0) { @ob_end_clean(); } ob_start(); header('Content-Type: application/json; charset=utf-8'); header('X-Content-Type-Options: nosniff'); header('Cache-Control: no-store, no-cache, must-revalidate'); error_reporting(E_ALL); ini_set('display_errors', '0'); ini_set('log_errors', '1'); // 仅刷新当前文件的OPcache(不要opcache_reset——会重置整个服务器缓存导致竞态条件) if (function_exists('opcache_invalidate')) { @opcache_invalidate(__FILE__); } register_shutdown_function(function() { while (ob_get_level() > 0) { @ob_end_flush(); } }); require_once __DIR__ . '/../core.php'; // 工单附件图:兰空图床客户端(本文件不含敏感值,凭据在 lsky_keys.php) require_once __DIR__ . '/../lsky.php'; if (session_status() === PHP_SESSION_NONE) { session_start(); } $action = getParam('action', ''); try { switch ($action) { // ===== 用户端 ===== case 'create': ticketCreate(); break; case 'my_list': ticketMyList(); break; case 'detail': ticketDetail(); break; case 'reply': ticketReply(); break; case 'withdraw': ticketWithdraw(); break; // ===== 管理员端 ===== case 'list_all': ticketListAll(); break; case 'admin_reply': ticketAdminReply(); break; case 'reject': ticketReject(); break; case 'assign': ticketAssign(); break; case 'admin_create': ticketAdminCreate(); break; case 'admin_complete': ticketAdminComplete(); break; // ===== 服务商端 ===== case 'provider_list': ticketProviderList(); break; case 'available': ticketAvailable(); break; case 'grab': ticketGrab(); break; case 'provider_reply': ticketProviderReply(); break; case 'complete': ticketProviderComplete(); break; // ===== 通用 ===== case 'upload_image': ticketUploadImage(); break; case 'providers': ticketProviders(); break; case 'provider_check': ticketProviderCheck(); break; default: error('未知操作: ' . $action); } // ★ WAL checkpoint:释放WAL锁,减少database is locked概率 try { walCheckpoint(); } catch (\Throwable $ignored) {} } catch (\Throwable $e) { error('服务器错误: ' . $e->getMessage()); } // ============================================================ // 工具函数 // ============================================================ function requireWebToken() { $token = getParam('token'); if (!$token) error('缺少token', 401); $info = validateTokenSilent($token); if (!$info) error('token无效或已过期', 401); return $info; } function requireAdmin() { if (!isAdminLoggedIn()) error('未登录管理后台', 401); } /** * 检查是否为管理员或授权的服务商用户 * 管理员通过session认证,用户通过token认证 */ function getAuthUser() { if (isAdminLoggedIn()) return ['player' => 'admin', 'role' => 'admin']; return requireWebToken(); } function isServiceProviderInGame($playerName) { // 优先从Web.db的web_service_providers表读取(Java同步数据) $db = getDB(); try { $stmt = $db->prepare("SELECT 1 FROM web_service_providers WHERE player_name = :p AND active = 1"); $stmt->bindValue(':p', $playerName, SQLITE3_TEXT); $row = $stmt->execute()->fetchArray(); if ($row !== false) return true; } catch (\Throwable $e) { /* 表可能不存在 */ } // 回退:尝试读取Java的login.db(本地开发环境) $loginDbPath = GAME_LOGIN_DB; if (!file_exists($loginDbPath)) return false; try { $loginDb = new SQLite3($loginDbPath); $loginDb->enableExceptions(true); $stmt = $loginDb->prepare("SELECT 1 FROM service_providers WHERE player_name = :p AND active = 1"); $stmt->bindValue(':p', $playerName, SQLITE3_TEXT); $result = $stmt->execute(); $row = $result->fetchArray(); $loginDb->close(); return $row !== false; } catch (Exception $e) { return false; } } function getServiceProviderRole($playerName) { // 优先从Web.db读取 $db = getDB(); try { $stmt = $db->prepare("SELECT role FROM web_service_providers WHERE player_name = :p AND active = 1"); $stmt->bindValue(':p', $playerName, SQLITE3_TEXT); $row = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if ($row) return $row['role']; } catch (\Throwable $e) { /* 表可能不存在 */ } // 回退:读取Java的login.db $loginDbPath = GAME_LOGIN_DB; if (!file_exists($loginDbPath)) return null; try { $loginDb = new SQLite3($loginDbPath); $loginDb->enableExceptions(true); $stmt = $loginDb->prepare("SELECT role FROM service_providers WHERE player_name = :p AND active = 1"); $stmt->bindValue(':p', $playerName, SQLITE3_TEXT); $result = $stmt->execute(); $row = $result->fetchArray(SQLITE3_ASSOC); $loginDb->close(); return $row ? $row['role'] : null; } catch (Exception $e) { return null; } } // ★ 单张工单附件图上限 define('TICKET_MAX_IMAGES', 3); /** * 读取并校验请求里的 images 参数(数组或 JSON 字符串均可),返回 URL 列表。 * 每个 URL 必须以自家图床前缀开头(LSKY_ALLOWED_HOST_PREFIX),最多 3 张。 */ function normalizeTicketImages() { $raw = getParam('images', []); if (is_string($raw)) $raw = json_decode($raw, true); if ($raw === null || $raw === '') $raw = []; if (!is_array($raw)) error('images 参数格式错误'); $prefix = LSKY_ALLOWED_HOST_PREFIX; $out = []; foreach ($raw as $u) { if (!is_string($u)) error('图片地址格式错误'); $u = trim($u); if ($u === '') continue; if (strpos($u, $prefix) !== 0) error('不支持的图片地址(仅接受自家图床)'); $out[] = $u; if (count($out) >= TICKET_MAX_IMAGES) break; } return $out; } function encodeTicketImages(array $list) { return json_encode(array_values($list), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } function decodeTicketImages($raw) { if (!is_string($raw) || $raw === '') return []; $d = json_decode($raw, true); return is_array($d) ? $d : []; } /** * 上传工单附件图片:转存自家兰空图床,返回可直接引用的 URL。 * 认证:管理后台 session 或用户 token(getAuthUser)。 * 限制:仅 png/jpg(★ 2026-10-04 起按图片真实内容判定,不看文件名后缀)、单张 ≤2048K;同会话每分钟最多 12 次(防刷)。 */ /** * ★ 清洗上传文件名(2026-10-04 任务4) * 全角句点→半角、零宽字符剔除、去老浏览器带的完整路径、去首尾空白。 */ function sanitizeImageFileName($name) { // 全角/非法字符 -> 半角合法字符(Windows 会拒收含全角字符的文件名) $name = str_replace(array( "\xe3\x80\x8e", // 全角句点 "\xef\xbd\xa1", // 半角浊点 "\xe3\x80\x8f", // 全角斜线 "\xe2\x80\x8b", // 零宽空格 "\xe2\x80\x8c", // 零宽非连接符 "\xe2\x80\x8d", // 零宽连接符 "\xef\xbb\xbf", // BOM "\xef\xbc\x8c", // 全角 小于号 "\xef\xbc\x9e", // 全角 大于号 "\xef\xbc\x9a", // 全角 冒号 "\xef\xbc\x9f", // 全角 问号 "\xef\xbc\x8a", // 全角 星号 "\xef\xbc\x87", // 全角 双引号 "\xef\xbc\x9c", // 全角 竖线 "\xef\xbc\xa0", // 全角空格 ), array( '.', '.', '/', '', '', '', '', '_', '_', '_', '_', '_', '_', '_', ' ', ), $name); // 老浏览器会带完整路径 C:\fakepath\a.png $name = str_replace('\\', '/', $name); $pos = strrpos($name, '/'); if ($pos !== false) $name = substr($name, $pos + 1); return trim($name); } /** 后缀归一:jpeg / jpe / jfif -> jpg,其余转小写去点 */ function normalizeImageExt($ext) { $ext = strtolower(trim((string)$ext)); $ext = str_replace('.', '', $ext); if (in_array($ext, array('jpeg', 'jpe', 'jfif', 'jif'), true)) return 'jpg'; return $ext; } function ticketUploadImage() { getAuthUser(); // 简单限流:同一会话 1 分钟内最多 12 张 $now = time(); $slot = isset($_SESSION['lsky_up']) && is_array($_SESSION['lsky_up']) ? $_SESSION['lsky_up'] : ['t' => 0, 'n' => 0]; if ($now - (int)$slot['t'] > 60) $slot = ['t' => $now, 'n' => 0]; if ((int)$slot['n'] >= 12) error('图片上传太频繁,请稍后再试'); $slot['n']++; $_SESSION['lsky_up'] = $slot; if (empty($_FILES) || empty($_FILES['image'])) { error('未收到图片文件(表单字段名必须是 image)'); } $f = $_FILES['image']; if ((int)$f['error'] !== UPLOAD_ERR_OK) { $msgs = [ UPLOAD_ERR_INI_SIZE => '图片超过服务器单文件大小上限', UPLOAD_ERR_FORM_SIZE => '图片超过表单大小上限', UPLOAD_ERR_PARTIAL => '图片上传中断,请重试', UPLOAD_ERR_NO_FILE => '未选择文件', UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录缺失', UPLOAD_ERR_CANT_WRITE => '服务器写入临时文件失败', ]; $m = isset($msgs[$f['error']]) ? $msgs[$f['error']] : ('上传错误码 ' . $f['error']); error($m); } // 文件名清洗:全角句点→半角、去零宽字符、去首尾空白 $orig = sanitizeImageFileName(basename((string)$f['name'])); // 大小:≤2048K if ((int)$f['size'] > 2048 * 1024) error('图片不能超过 2048K'); if (!is_uploaded_file($f['tmp_name'])) error('非法上传'); // ★ 2026-10-04 修复:判定「是什么图」只看图片真实内容,不看文件名后缀。 // 旧逻辑用 pathinfo 后缀判类型,导致 .jpeg / 全角句点 / 尾部空格 / // 无后缀的真实 png、jpg 全被误判成「不是 png/jpg 图片」。 // 这里 getimagesize 认真实格式,防伪造后缀的安全目的依然成立。 $info = @getimagesize($f['tmp_name']); if ($info === false) error('无法识别的图片内容(请确认文件确实是 png 或 jpg)'); $itype = (int)$info[2]; if ($itype === IMAGETYPE_PNG) $realExt = 'png'; elseif ($itype === IMAGETYPE_JPEG) $realExt = 'jpg'; else error('仅支持 png / jpg 格式'); // 后缀归一:jpeg/jpe/jfif 一律当 jpg $ext = normalizeImageExt(pathinfo($orig, PATHINFO_EXTENSION)); // 用真实后缀重命名,去掉 .jpeg / 空格 / 全角点等乱七八糟的原名 $base = pathinfo($orig, PATHINFO_FILENAME); if ($base === '') $base = 'ticket_image'; $orig = mb_substr($base, 0, 60) . '.' . $realExt; $r = lskyUploadFile($f['tmp_name'], $orig); if (empty($r['ok'])) error('图片上传失败: ' . (isset($r['msg']) && $r['msg'] !== '' ? $r['msg'] : '未知错误')); success(['url' => $r['url'], 'thumbnail' => isset($r['thumb']) ? $r['thumb'] : $r['url']], '上传成功'); } function ticketCreate() { $info = requireWebToken(); $player = $info['player']; $type = trim(getParam('type', '')); $title = trim(getParam('title', '')); $description = trim(getParam('description', '')); if (empty($type)) error('请选择工单类型'); if (empty($title)) error('请输入工单标题'); if (mb_strlen($title) > 100) error('标题不能超过100字'); if (mb_strlen($description) > 5000) error('描述不能超过5000字'); $validTypes = ['bug', 'help', 'report', 'apply', 'other']; if (!in_array($type, $validTypes)) error('无效的工单类型'); $images = normalizeTicketImages(); // 附件图(≤3,必须来自自家图床) $db = getDB(); $now = time(); $db->exec('BEGIN IMMEDIATE'); try { $stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, title, description, images, created_at, updated_at) VALUES (:type, 'submitted', :player, :title, :desc, :images, :now, :now)"); $stmt->bindValue(':type', $type, SQLITE3_TEXT); $stmt->bindValue(':player', $player, SQLITE3_TEXT); $stmt->bindValue(':title', $title, SQLITE3_TEXT); $stmt->bindValue(':desc', $description, SQLITE3_TEXT); $stmt->bindValue(':images', encodeTicketImages($images), SQLITE3_TEXT); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->execute(); $ticketId = $db->lastInsertRowID(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('创建失败: ' . $e->getMessage()); } success(['id' => $ticketId], '工单提交成功'); } function ticketMyList() { $info = requireWebToken(); $player = $info['player']; $db = getDB(); $status = getParam('status', ''); $page = max(1, (int)getParam('page', 1)); $limit = 20; $offset = ($page - 1) * $limit; $where = "WHERE requester = :player"; $params = [':player' => $player]; if ($status && $status !== 'all') { $where .= " AND status = :status"; $params[':status'] = $status; } $countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where"); foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT); $total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total']; $stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset"); foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT); $stmt->bindValue(':limit', $limit, SQLITE3_INTEGER); $stmt->bindValue(':offset', $offset, SQLITE3_INTEGER); $result = $stmt->execute(); $list = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $list[] = $row; } success([ 'list' => $list, 'total' => (int)$total, 'page' => $page, 'pages' => ceil($total / $limit) ]); } function ticketDetail() { $id = (int)getParam('id', 0); if ($id <= 0) error('无效的工单ID'); $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $row = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if (!$row) error('工单不存在'); // 权限检查:管理员可以直接查看,普通用户只能看自己的工单 if (isAdminLoggedIn()) { // 管理员,直接放行 } else { $info = requireWebToken(); $player = $info['player']; if ($row['requester'] !== $player && $row['assigned_to'] !== $player) { error('无权查看此工单'); } } // 获取回复列表 $replies = getTicketReplies($id); $row['replies'] = $replies; // 附件图:老库/老工单没有 images 列时兜底为空数组 $row['images'] = decodeTicketImages(isset($row['images']) ? $row['images'] : null); success($row); } function ticketReply() { $info = requireWebToken(); $player = $info['player']; $id = (int)getParam('id', 0); $message = trim(getParam('message', '')); if ($id <= 0) error('无效的工单ID'); if (empty($message)) error('回复内容不能为空'); if (mb_strlen($message) > 5000) error('回复内容不能超过5000字'); $db = getDB(); // 检查工单状态和权限 $stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if (!$ticket) error('工单不存在'); if ($ticket['requester'] !== $player) error('只能回复自己的工单'); if (in_array($ticket['status'], ['withdrawn', 'rejected'])) error('该工单已关闭,无法回复'); $now = time(); $db->exec('BEGIN IMMEDIATE'); try { // 插入回复 $stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'user', :msg, :now)"); $stmt->bindValue(':tid', $id, SQLITE3_INTEGER); $stmt->bindValue(':sender', $player, SQLITE3_TEXT); $stmt->bindValue(':msg', $message, SQLITE3_TEXT); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->execute(); // 更新工单状态为已回复 $stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id"); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->execute(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('回复失败'); } success(null, '回复成功'); } function ticketWithdraw() { $info = requireWebToken(); $player = $info['player']; $id = (int)getParam('id', 0); if ($id <= 0) error('无效的工单ID'); $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if (!$ticket) error('工单不存在'); if ($ticket['requester'] !== $player) error('无权操作此工单'); if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭'); $db->exec('BEGIN IMMEDIATE'); try { $stmt = $db->prepare("UPDATE web_tickets SET status = 'withdrawn', updated_at = :now WHERE id = :id"); $stmt->bindValue(':now', time(), SQLITE3_INTEGER); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->execute(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('撤销失败'); } success(null, '工单已撤销'); } // ===== 管理员端 ===== function ticketListAll() { requireAdmin(); $db = getDB(); // 兜底建表(服务器core.php可能版本旧) try { $db->exec("CREATE TABLE IF NOT EXISTS web_tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, status TEXT DEFAULT 'submitted', requester TEXT NOT NULL, assigned_to TEXT DEFAULT '', title TEXT NOT NULL, description TEXT DEFAULT '', images TEXT DEFAULT '[]', reject_reason TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )"); // ★ 迁移:老库补 images 列(工单附件图 URL 数组 JSON) try { $db->exec("ALTER TABLE web_tickets ADD COLUMN images TEXT DEFAULT '[]'"); } catch (Exception $e) { /* 列已存在 */ } $db->exec("CREATE TABLE IF NOT EXISTS web_ticket_replies ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL, sender TEXT NOT NULL, role TEXT DEFAULT 'user', message TEXT NOT NULL, created_at INTEGER NOT NULL )"); $db->exec("CREATE INDEX IF NOT EXISTS idx_wt_requester ON web_tickets(requester)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_wt_status ON web_tickets(status)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_wt_assigned ON web_tickets(assigned_to)"); $db->exec("CREATE INDEX IF NOT EXISTS idx_wtr_ticket ON web_ticket_replies(ticket_id)"); } catch (\Throwable $e) { error('工单表初始化失败: ' . $e->getMessage()); } try { $status = getParam('status', 'all'); $page = max(1, (int)getParam('page', 1)); $search = trim(getParam('search', '')); $limit = 20; $offset = ($page - 1) * $limit; $where = "WHERE 1=1"; $params = []; if ($status && $status !== 'all') { $where .= " AND status = :status"; $params[':status'] = $status; } if ($search) { $where .= " AND (requester LIKE :search OR title LIKE :search2)"; $params[':search'] = "%$search%"; $params[':search2'] = "%$search%"; } $countStmt = $db->prepare("SELECT COUNT(*) as total FROM web_tickets $where"); foreach ($params as $k => $v) $countStmt->bindValue($k, $v, SQLITE3_TEXT); $total = $countStmt->execute()->fetchArray(SQLITE3_ASSOC)['total']; $stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY created_at DESC LIMIT :limit OFFSET :offset"); foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT); $stmt->bindValue(':limit', $limit, SQLITE3_INTEGER); $stmt->bindValue(':offset', $offset, SQLITE3_INTEGER); $result = $stmt->execute(); $list = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $list[] = $row; } // 统计各状态数量 $stats = []; foreach (['submitted', 'replied', 'completed', 'withdrawn', 'rejected'] as $s) { $cnt = $db->querySingle("SELECT COUNT(*) FROM web_tickets WHERE status = '$s'"); $stats[$s] = (int)$cnt; } $stats['all'] = (int)$db->querySingle("SELECT COUNT(*) FROM web_tickets"); success([ 'list' => $list, 'total' => (int)$total, 'page' => $page, 'pages' => ceil($total / $limit), 'stats' => $stats ]); } catch (\Throwable $e) { error('查询工单失败: ' . $e->getMessage()); } } function ticketAdminReply() { requireAdmin(); $id = (int)getParam('id', 0); $message = trim(getParam('message', '')); if ($id <= 0) error('无效的工单ID'); if (empty($message)) error('回复内容不能为空'); if (mb_strlen($message) > 5000) error('回复内容不能超过5000字'); $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if (!$ticket) error('工单不存在'); if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭'); $now = time(); $db->exec('BEGIN IMMEDIATE'); try { $stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', :msg, :now)"); $stmt->bindValue(':tid', $id, SQLITE3_INTEGER); $stmt->bindValue(':msg', $message, SQLITE3_TEXT); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->execute(); // 管理员回复 → 状态更新为已回复(不改变assigned_to,保留原服务商所有权) $stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id"); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->execute(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('回复失败'); } success(null, '回复成功'); } function ticketReject() { requireAdmin(); $id = (int)getParam('id', 0); $reason = trim(getParam('reason', '')); if ($id <= 0) error('无效的工单ID'); if (empty($reason)) error('请输入驳回原因'); if (mb_strlen($reason) > 500) error('驳回原因不能超过500字'); $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if (!$ticket) error('工单不存在'); if ($ticket['status'] === 'withdrawn' || $ticket['status'] === 'rejected') error('该工单已关闭'); $now = time(); $db->exec('BEGIN IMMEDIATE'); try { $stmt = $db->prepare("UPDATE web_tickets SET status = 'rejected', reject_reason = :reason, updated_at = :now WHERE id = :id"); $stmt->bindValue(':reason', $reason, SQLITE3_TEXT); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->execute(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('驳回失败'); } success(null, '工单已驳回'); } function ticketAssign() { requireAdmin(); $id = (int)getParam('id', 0); $provider = trim(getParam('provider', '')); if ($id <= 0) error('无效的工单ID'); if (empty($provider)) error('请输入服务商名称'); // 验证是否为服务商 if (!isServiceProviderInGame($provider)) error("$provider 不是服务商"); $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if (!$ticket) error('工单不存在'); if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭'); $now = time(); $db->exec('BEGIN IMMEDIATE'); try { $stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, status = 'replied', updated_at = :now WHERE id = :id"); $stmt->bindValue(':provider', $provider, SQLITE3_TEXT); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->execute(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('分配失败'); } success(null, "已分配给 $provider"); } function ticketAdminCreate() { requireAdmin(); $type = trim(getParam('type', '')); $title = trim(getParam('title', '')); $description = trim(getParam('description', '')); $provider = trim(getParam('provider', '')); if (empty($type)) error('请选择工单类型'); if (empty($title)) error('请输入工单标题'); if (mb_strlen($title) > 100) error('标题不能超过100字'); if (mb_strlen($description) > 5000) error('描述不能超过5000字'); $validTypes = ['bug', 'help', 'report', 'apply', 'other']; if (!in_array($type, $validTypes)) error('无效的工单类型'); // 如果指定了服务商,验证身份 if ($provider && !isServiceProviderInGame($provider)) { error("$provider 不是服务商"); } $images = normalizeTicketImages(); // 附件图(≤3,必须来自自家图床) $db = getDB(); $now = time(); $db->exec('BEGIN IMMEDIATE'); try { $stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, assigned_to, title, description, images, created_at, updated_at) VALUES (:type, 'replied', 'admin', :provider, :title, :desc, :images, :now, :now)"); $stmt->bindValue(':type', $type, SQLITE3_TEXT); $stmt->bindValue(':provider', $provider, SQLITE3_TEXT); $stmt->bindValue(':title', $title, SQLITE3_TEXT); $stmt->bindValue(':desc', $description, SQLITE3_TEXT); $stmt->bindValue(':images', encodeTicketImages($images), SQLITE3_TEXT); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->execute(); $ticketId = $db->lastInsertRowID(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('创建失败: ' . $e->getMessage()); } $msg = '工单已创建'; if ($provider) $msg .= ",已派发给 $provider"; success(['id' => $ticketId], $msg); } function ticketAdminComplete() { requireAdmin(); $id = (int)getParam('id', 0); if ($id <= 0) error('无效的工单ID'); $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if (!$ticket) error('工单不存在'); if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭'); $now = time(); $db->exec('BEGIN IMMEDIATE'); try { $stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, 'admin', 'admin', '✅ 工单已由管理员标记完结', :now)"); $stmt->bindValue(':tid', $id, SQLITE3_INTEGER); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->execute(); $stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id"); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->execute(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('操作失败'); } success(null, '已标记完结'); } // ===== 服务商端 ===== function ticketProviderList() { $info = requireWebToken(); $player = $info['player']; // 验证服务商身份 $role = getServiceProviderRole($player); if (!$role) error('你不是服务商'); $db = getDB(); $type = getParam('type', 'all'); // processing: 活跃(已提交/已回复) // completed: 已完结/已撤销/已驳回 // all: 全部跟过的工单 $where = "WHERE assigned_to = :player"; $params = [':player' => $player]; if ($type === 'processing') { $where .= " AND status IN ('submitted', 'replied')"; } elseif ($type === 'completed') { $where .= " AND status IN ('completed', 'withdrawn', 'rejected')"; } // 'all' 不加status过滤 $stmt = $db->prepare("SELECT * FROM web_tickets $where ORDER BY updated_at DESC LIMIT 100"); foreach ($params as $k => $v) $stmt->bindValue($k, $v, SQLITE3_TEXT); $result = $stmt->execute(); $list = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $list[] = $row; } success(['list' => $list, 'role' => $role]); } function ticketAvailable() { $info = requireWebToken(); $player = $info['player']; if (!isServiceProviderInGame($player)) error('你不是服务商'); $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_tickets WHERE status = 'submitted' AND assigned_to = '' ORDER BY created_at DESC LIMIT 50"); $result = $stmt->execute(); $list = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $list[] = $row; } success(['list' => $list]); } function ticketGrab() { $info = requireWebToken(); $player = $info['player']; $id = (int)getParam('id', 0); if ($id <= 0) error('无效的工单ID'); if (!isServiceProviderInGame($player)) error('你不是服务商'); $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if (!$ticket) error('工单不存在'); if ($ticket['status'] !== 'submitted') error('该工单已被接单'); if (!empty($ticket['assigned_to'])) error('该工单已被分配'); $now = time(); $db->exec('BEGIN IMMEDIATE'); try { $stmt = $db->prepare("UPDATE web_tickets SET assigned_to = :provider, updated_at = :now WHERE id = :id"); $stmt->bindValue(':provider', $player, SQLITE3_TEXT); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->execute(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('抢单失败'); } success(null, '抢单成功'); } function ticketProviderReply() { $info = requireWebToken(); $player = $info['player']; $id = (int)getParam('id', 0); $message = trim(getParam('message', '')); if ($id <= 0) error('无效的工单ID'); if (empty($message)) error('回复内容不能为空'); if (mb_strlen($message) > 5000) error('回复内容不能超过5000字'); if (!isServiceProviderInGame($player)) error('你不是服务商'); $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if (!$ticket) error('工单不存在'); if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单'); if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭'); $now = time(); $db->exec('BEGIN IMMEDIATE'); try { $stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', :msg, :now)"); $stmt->bindValue(':tid', $id, SQLITE3_INTEGER); $stmt->bindValue(':sender', $player, SQLITE3_TEXT); $stmt->bindValue(':msg', $message, SQLITE3_TEXT); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->execute(); $stmt = $db->prepare("UPDATE web_tickets SET status = 'replied', updated_at = :now WHERE id = :id"); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->execute(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('回复失败'); } success(null, '回复成功'); } function ticketProviderComplete() { $info = requireWebToken(); $player = $info['player']; $id = (int)getParam('id', 0); if ($id <= 0) error('无效的工单ID'); if (!isServiceProviderInGame($player)) error('你不是服务商'); $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_tickets WHERE id = :id"); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $ticket = $stmt->execute()->fetchArray(SQLITE3_ASSOC); if (!$ticket) error('工单不存在'); if ($ticket['assigned_to'] !== $player) error('这不是分配给你的工单'); if (in_array($ticket['status'], ['completed', 'withdrawn', 'rejected'])) error('该工单已关闭'); $now = time(); $db->exec('BEGIN IMMEDIATE'); try { // 插入完结回复 $stmt = $db->prepare("INSERT INTO web_ticket_replies (ticket_id, sender, role, message, created_at) VALUES (:tid, :sender, 'provider', '✅ 工单已处理完毕', :now)"); $stmt->bindValue(':tid', $id, SQLITE3_INTEGER); $stmt->bindValue(':sender', $player, SQLITE3_TEXT); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->execute(); // 状态改为completed $stmt = $db->prepare("UPDATE web_tickets SET status = 'completed', updated_at = :now WHERE id = :id"); $stmt->bindValue(':now', $now, SQLITE3_INTEGER); $stmt->bindValue(':id', $id, SQLITE3_INTEGER); $stmt->execute(); $db->exec('COMMIT'); } catch (Exception $e) { $db->exec('ROLLBACK'); error('操作失败'); } success(null, '已标记完结'); } // ===== 通用 ===== function ticketProviders() { if (!isAdminLoggedIn()) error('未登录管理后台'); $loginDbPath = GAME_LOGIN_DB; if (!file_exists($loginDbPath)) success(['list' => []]); try { $loginDb = new SQLite3($loginDbPath); $result = $loginDb->query("SELECT player_name, role, join_time FROM service_providers WHERE active = 1"); $list = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $list[] = $row; } $loginDb->close(); success(['list' => $list]); } catch (Exception $e) { success(['list' => []]); } } function ticketProviderCheck() { $info = requireWebToken(); $player = $info['player']; $isProvider = isServiceProviderInGame($player); $role = $isProvider ? getServiceProviderRole($player) : null; success(['is_provider' => $isProvider, 'role' => $role]); } // ===== 辅助函数 ===== function getTicketReplies($ticketId) { $db = getDB(); $stmt = $db->prepare("SELECT * FROM web_ticket_replies WHERE ticket_id = :tid ORDER BY created_at ASC"); $stmt->bindValue(':tid', $ticketId, SQLITE3_INTEGER); $result = $stmt->execute(); $list = []; while ($row = $result->fetchArray(SQLITE3_ASSOC)) { $list[] = $row; } return $list; }