Files
IM-web-new/dist/api/profile.php
T
2026-10-09 22:49:53 +07:00

322 lines
11 KiB
PHP

<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
const PROFILE_PORTRAIT_FLAG = 0x02;
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
function profileRequestValue(string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
}
function normalizeProfileUser(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
return [
'uid' => $uid,
'userId' => $uid,
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
];
}
function tryProfileUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
return $result ?: null;
} catch (Throwable) {
return null;
}
}
function loadProfileUserFromDb(string $identity): ?array
{
$statement = imDb()->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
. ' from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ?'
. ' order by case when `_uid` = ? then 0 when `_mobile` = ? then 1 when `_name` = ? then 2 else 3 end'
. ' limit 1'
);
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
$row = $statement->fetch();
return $row ? normalizeProfileUser($row) : null;
}
function mergeProfileUser(array $primary, ?array $fallback): array
{
if (!$fallback) {
return $primary;
}
$fallback = normalizeProfileUser($fallback);
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
$primary[$key] = (string)$fallback[$key];
}
}
return $primary;
}
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
{
if ($portrait !== '') {
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
return;
}
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, webImNowMs(), $userId]);
}
function loadProfileUser(string $identity, bool $preferMobile = true): array
{
$identity = trim($identity);
if ($identity === '') {
respond(400, '缺少用户ID');
}
$dbUser = loadProfileUserFromDb($identity);
if ($dbUser) {
$fallback = null;
if ((string)($dbUser['portrait'] ?? '') === '') {
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
}
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
}
}
return mergeProfileUser($dbUser, $fallback);
}
$user = null;
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
$user = tryProfileUserInfo(['mobile' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['userId' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['name' => $identity]);
}
if (!$user) {
respond(404, '用户资料不存在');
}
return normalizeProfileUser($user);
}
function readProfileJavaSerializedString(string $data, int $offset): ?array
{
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
return null;
}
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
$start = $offset + 3;
if ($start + $length > strlen($data)) {
return null;
}
return [substr($data, $start, $length), $start + $length];
}
function profileSessionUserId(string $sessionData): string
{
$marker = "\x74\x00\x06userId";
$offset = strpos($sessionData, $marker);
if ($offset === false) {
return '';
}
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
$userId = is_array($decoded) ? (string)$decoded[0] : '';
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
return '';
}
return $userId;
}
function requireProfileUpdateAuth(string $targetUserId): void
{
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
if ($authToken === '') {
respond(401, '请重新登录后再修改资料');
}
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
$statement->execute([$authToken]);
$row = $statement->fetch();
if (!$row) {
respond(401, '登录状态已失效,请重新登录');
}
$sessionUserId = profileSessionUserId((string)$row['session_data']);
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
respond(403, '只能修改自己的资料');
}
}
function profileUploadedFileExtension(string $fileName, string $mime): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
if (in_array($extension, $allowedExtensions, true)) {
return $extension;
}
return match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp', 'image/x-ms-bmp' => 'bmp',
'image/x-icon', 'image/vnd.microsoft.icon' => 'ico',
default => 'jpg',
};
}
function profilePublicMediaBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
}
function uploadProfilePortrait(array $file): string
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('头像上传失败');
}
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new RuntimeException('头像不能超过5MB');
}
$tmpName = (string)($file['tmp_name'] ?? '');
$mime = mime_content_type($tmpName) ?: '';
if (!str_starts_with($mime, 'image/')) {
throw new RuntimeException('头像必须是图片文件');
}
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
throw new RuntimeException('头像存储目录不可写');
}
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('头像存储目录创建失败');
}
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('头像文件保存失败');
}
@chmod($targetPath, 0644);
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
}
try {
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
$payload = $isMultipart ? [] : readJsonPayload();
$action = $isMultipart
? profileRequestValue('action', 'update')
: trim((string)($payload['action'] ?? 'get'));
$userId = $isMultipart
? profileRequestValue('userId')
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
$currentProfile = loadProfileUser($userId);
if ($action === 'get') {
respond(0, 'success', ['profile' => $currentProfile]);
}
if ($action !== 'update') {
respond(400, '不支持的资料操作');
}
requireProfileUpdateAuth((string)$currentProfile['userId']);
$displayName = $isMultipart
? profileRequestValue('displayName')
: trim((string)($payload['displayName'] ?? ''));
if ($displayName === '') {
respond(400, '昵称不能为空');
}
if (mb_strlen($displayName, 'UTF-8') > 64) {
respond(400, '昵称不能超过64个字');
}
$portrait = '';
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
}
$flag = PROFILE_DISPLAY_NAME_FLAG;
$userInfo = [
'userId' => $currentProfile['userId'],
'displayName' => $displayName,
];
if ($portrait !== '') {
$flag |= PROFILE_PORTRAIT_FLAG;
$userInfo['portrait'] = $portrait;
}
requireImSuccess(
imAdminPost('/admin/user/update', [
'flag' => $flag,
'userInfo' => $userInfo,
]),
'用户资料保存失败'
);
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
}
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
$updatedProfile['portrait'] = $portrait;
}
if ((string)$updatedProfile['displayName'] === '') {
$updatedProfile['displayName'] = $displayName;
}
respond(0, 'success', ['profile' => $updatedProfile]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
}