diff --git a/admin.php b/admin.php index ee0a56c..3bf0382 100644 --- a/admin.php +++ b/admin.php @@ -3105,6 +3105,12 @@ function showAddUserGroup() { +
+
+ + +
+
@@ -3143,6 +3149,7 @@ async function doAddUserGroup() { const priority = document.getElementById('ugPriority')?.value || '0'; const price = document.getElementById('ugPrice')?.value || '-1'; const maxLands = document.getElementById('ugMaxLands')?.value || '-1'; + const maxEffects = document.getElementById('ugMaxEffects')?.value || '5'; const homeLimit = document.getElementById('ugHomeLimit')?.value || '0'; const joinPrice = document.getElementById('ugJoinPrice')?.value || '0'; const renewPrice = document.getElementById('ugRenewPrice')?.value || '0'; @@ -3157,7 +3164,7 @@ async function doAddUserGroup() { try { const res = await apiCall('update_user_group', { name, display_name: displayName, display_color: color, - priority, land_price_per_sqm: price, max_lands: maxLands, + priority, land_price_per_sqm: price, max_lands: maxLands, max_effects: maxEffects, home_limit: homeLimit, join_price: joinPrice, renew_price: renewPrice, duration_minutes: duration, auto_renew: autoRenew, default_perms: '{}' @@ -3203,6 +3210,10 @@ async function showEditUserGroup(groupName) {
`; + html += `
+
+
+
`; html += `
@@ -3247,6 +3258,7 @@ async function doEditUserGroup(groupName) { const priority = document.getElementById('eugPriority')?.value || '0'; const price = document.getElementById('eugPrice')?.value || '-1'; const maxLands = document.getElementById('eugMaxLands')?.value || '-1'; + const maxEffects = document.getElementById('eugMaxEffects')?.value || '5'; const homeLimit = document.getElementById('eugHomeLimit')?.value || '0'; const duration = document.getElementById('eugDuration')?.value || '0'; const joinPrice = document.getElementById('eugJoinPrice')?.value || '0'; @@ -3262,7 +3274,7 @@ async function doEditUserGroup(groupName) { try { const res = await apiCall('update_user_group', { name: groupName, display_name: displayName, display_color: color, - priority, land_price_per_sqm: price, max_lands: maxLands, + priority, land_price_per_sqm: price, max_lands: maxLands, max_effects: maxEffects, home_limit: homeLimit, duration_minutes: duration, join_price: joinPrice, renew_price: renewPrice, auto_renew: autoRenew, default_perms: JSON.stringify(perms) diff --git a/api/land_api.php b/api/land_api.php index b1220b3..474eed0 100644 --- a/api/land_api.php +++ b/api/land_api.php @@ -1662,6 +1662,55 @@ function handleValidationCallback($db, $data) { // ==================== 玩家端:更新领地字段(效果管理)==================== +/** + * ★ 领地增益效果数量上限(2026-10-04 任务3) + * 取领地所有者所属最高优先级用户组的 max_effects,默认 5;<=0 表示不限。 + */ +function getLandMaxEffects($db, $land) { + $fallback = 5; + $owner = $land['owner'] ?? ''; + if (empty($owner)) return $fallback; + try { + $db->exec("CREATE TABLE IF NOT EXISTS web_user_groups ( + group_name TEXT PRIMARY KEY, + display_name TEXT DEFAULT '', + display_color TEXT DEFAULT '§f', + display_emoji TEXT DEFAULT '', + priority INTEGER DEFAULT 0, + land_price_per_sqm INTEGER DEFAULT -1, + max_lands INTEGER DEFAULT -1, + max_effects INTEGER DEFAULT 5, + home_limit INTEGER DEFAULT 0, + join_price INTEGER DEFAULT 0, + auto_renew INTEGER DEFAULT 0, + renew_price INTEGER DEFAULT 0, + duration_minutes INTEGER DEFAULT 0, + default_perms TEXT DEFAULT '{}', + synced_at INTEGER DEFAULT 0 + )"); + $db->exec("CREATE TABLE IF NOT EXISTS web_user_group_members ( + player_name TEXT NOT NULL, + group_name TEXT NOT NULL, + added_by TEXT DEFAULT 'system', + added_time INTEGER DEFAULT 0, + expiry_time INTEGER DEFAULT 0, + PRIMARY KEY(player_name, group_name) + )"); + $st = $db->prepare("SELECT g.max_effects FROM web_user_group_members m + LEFT JOIN web_user_groups g ON m.group_name = g.group_name + WHERE m.player_name = :p AND (m.expiry_time = 0 OR m.expiry_time > :now) + ORDER BY g.priority DESC LIMIT 1"); + $st->bindValue(':p', $owner, SQLITE3_TEXT); + $st->bindValue(':now', time(), SQLITE3_INTEGER); + $row = $st->execute()->fetchArray(SQLITE3_ASSOC); + if ($row && $row['max_effects'] !== null) { + $v = (int)$row['max_effects']; + return $v <= 0 ? PHP_INT_MAX : $v; + } + } catch (\Throwable $e) { /* 表缺失等 */ } + return $fallback; +} + function handleUpdateLandField($db, $playerName, $post) { $name = $post['name'] ?? ''; $field = $post['field'] ?? ''; @@ -1723,10 +1772,19 @@ function handleUpdateLandField($db, $playerName, $post) { $arr = json_decode($value, true); if (is_array($arr)) { if ($field === 'give_effects') { + // ★ 2026-10-04 任务3:数量上限(按领地所有者用户组 max_effects,默认5)+ 等级封顶1~255 + $maxEff = getLandMaxEffects($db, $land); + if (count($arr) > $maxEff) { + $arr = array_slice($arr, 0, $maxEff); + } // [["夜视","1","99999"]] → "夜视:1:99999" $parts = []; foreach ($arr as $e) { if (is_array($e)) { + if (isset($e[1]) && $e[1] !== '') { + $lv = (int)$e[1]; + $e[1] = (string)max(1, min(255, $lv)); + } $parts[] = implode(':', $e); } else { $parts[] = (string)$e; @@ -2312,6 +2370,7 @@ function handleListUserGroups($db) { priority INTEGER DEFAULT 0, land_price_per_sqm INTEGER DEFAULT -1, max_lands INTEGER DEFAULT -1, + max_effects INTEGER DEFAULT 5, home_limit INTEGER DEFAULT 0, join_price INTEGER DEFAULT 0, auto_renew INTEGER DEFAULT 0, @@ -2332,6 +2391,7 @@ function handleListUserGroups($db) { 'auto_renew' => 'ALTER TABLE web_user_groups ADD COLUMN auto_renew INTEGER DEFAULT 0', 'renew_price' => 'ALTER TABLE web_user_groups ADD COLUMN renew_price INTEGER DEFAULT 0', 'duration_minutes' => 'ALTER TABLE web_user_groups ADD COLUMN duration_minutes INTEGER DEFAULT 0', + 'max_effects' => 'ALTER TABLE web_user_groups ADD COLUMN max_effects INTEGER DEFAULT 5', ]; foreach ($migrations as $col => $sql) { if (!in_array($col, $columns)) { @@ -2369,6 +2429,7 @@ function handleUpdateUserGroup($db, $data) { priority INTEGER DEFAULT 0, land_price_per_sqm INTEGER DEFAULT -1, max_lands INTEGER DEFAULT -1, + max_effects INTEGER DEFAULT 5, home_limit INTEGER DEFAULT 0, join_price INTEGER DEFAULT 0, auto_renew INTEGER DEFAULT 0, @@ -2384,6 +2445,7 @@ function handleUpdateUserGroup($db, $data) { $priority = (int)($data['priority'] ?? 0); $pricePerSqm = (int)($data['land_price_per_sqm'] ?? -1); $maxLands = (int)($data['max_lands'] ?? -1); + $maxEffects = (int)($data['max_effects'] ?? 5); // 领地增益效果上限,<=0=不限 $homeLimit = (int)($data['home_limit'] ?? 0); $joinPrice = (int)($data['join_price'] ?? 0); $renewPrice = (int)($data['renew_price'] ?? 0); @@ -2393,9 +2455,9 @@ function handleUpdateUserGroup($db, $data) { $stmt = $db->prepare("INSERT OR REPLACE INTO web_user_groups (group_name, display_name, display_color, display_emoji, priority, - land_price_per_sqm, max_lands, home_limit, join_price, auto_renew, renew_price, duration_minutes, default_perms, synced_at) + land_price_per_sqm, max_lands, max_effects, home_limit, join_price, auto_renew, renew_price, duration_minutes, default_perms, synced_at) VALUES (:name, :display, :color, :emoji, :priority, - :price, :maxlands, :homeLimit, :joinPrice, :autoRenew, :renewPrice, :duration, :perms, :synced)"); + :price, :maxlands, :maxEffects, :homeLimit, :joinPrice, :autoRenew, :renewPrice, :duration, :perms, :synced)"); $stmt->bindValue(':name', $name, SQLITE3_TEXT); $stmt->bindValue(':display', $displayName, SQLITE3_TEXT); $stmt->bindValue(':color', $displayColor, SQLITE3_TEXT); @@ -2403,6 +2465,7 @@ function handleUpdateUserGroup($db, $data) { $stmt->bindValue(':priority', $priority, SQLITE3_INTEGER); $stmt->bindValue(':price', $pricePerSqm, SQLITE3_INTEGER); $stmt->bindValue(':maxlands', $maxLands, SQLITE3_INTEGER); + $stmt->bindValue(':maxEffects', $maxEffects, SQLITE3_INTEGER); $stmt->bindValue(':homeLimit', $homeLimit, SQLITE3_INTEGER); $stmt->bindValue(':joinPrice', $joinPrice, SQLITE3_INTEGER); $stmt->bindValue(':autoRenew', $autoRenew, SQLITE3_INTEGER); @@ -2590,6 +2653,7 @@ function handleGetPlayerGroups($db, $player) { priority INTEGER DEFAULT 0, land_price_per_sqm INTEGER DEFAULT -1, max_lands INTEGER DEFAULT -1, + max_effects INTEGER DEFAULT 5, home_limit INTEGER DEFAULT 0, join_price INTEGER DEFAULT 0, auto_renew INTEGER DEFAULT 0, @@ -2608,6 +2672,7 @@ function handleGetPlayerGroups($db, $player) { 'auto_renew' => 'ALTER TABLE web_user_groups ADD COLUMN auto_renew INTEGER DEFAULT 0', 'renew_price' => 'ALTER TABLE web_user_groups ADD COLUMN renew_price INTEGER DEFAULT 0', 'duration_minutes' => 'ALTER TABLE web_user_groups ADD COLUMN duration_minutes INTEGER DEFAULT 0', + 'max_effects' => 'ALTER TABLE web_user_groups ADD COLUMN max_effects INTEGER DEFAULT 5', ]; foreach ($migrations as $col => $sql) { if (!in_array($col, $columns)) { @@ -2631,6 +2696,7 @@ function handleGetPlayerGroups($db, $player) { priority INTEGER DEFAULT 0, land_price_per_sqm INTEGER DEFAULT -1, max_lands INTEGER DEFAULT -1, + max_effects INTEGER DEFAULT 5, home_limit INTEGER DEFAULT 0, join_price INTEGER DEFAULT 0, auto_renew INTEGER DEFAULT 0, @@ -2666,6 +2732,7 @@ function handleListAvailableGroups($db) { priority INTEGER DEFAULT 0, land_price_per_sqm INTEGER DEFAULT -1, max_lands INTEGER DEFAULT -1, + max_effects INTEGER DEFAULT 5, home_limit INTEGER DEFAULT 0, join_price INTEGER DEFAULT 0, auto_renew INTEGER DEFAULT 0, @@ -2682,6 +2749,7 @@ function handleListAvailableGroups($db) { 'auto_renew' => 'ALTER TABLE web_user_groups ADD COLUMN auto_renew INTEGER DEFAULT 0', 'renew_price' => 'ALTER TABLE web_user_groups ADD COLUMN renew_price INTEGER DEFAULT 0', 'duration_minutes' => 'ALTER TABLE web_user_groups ADD COLUMN duration_minutes INTEGER DEFAULT 0', + 'max_effects' => 'ALTER TABLE web_user_groups ADD COLUMN max_effects INTEGER DEFAULT 5', ]; foreach ($migrations as $col => $sql) { if (!in_array($col, $columns)) { diff --git a/api/sync.php b/api/sync.php index d3afadf..0336d41 100644 --- a/api/sync.php +++ b/api/sync.php @@ -913,6 +913,7 @@ function syncUserGroups() { priority INTEGER DEFAULT 0, land_price_per_sqm INTEGER DEFAULT -1, max_lands INTEGER DEFAULT -1, + max_effects INTEGER DEFAULT 5, home_limit INTEGER DEFAULT 0, join_price INTEGER DEFAULT 0, auto_renew INTEGER DEFAULT 0, @@ -934,6 +935,7 @@ function syncUserGroups() { 'auto_renew' => 'ALTER TABLE web_user_groups ADD COLUMN auto_renew INTEGER DEFAULT 0', 'renew_price' => 'ALTER TABLE web_user_groups ADD COLUMN renew_price INTEGER DEFAULT 0', 'duration_minutes' => 'ALTER TABLE web_user_groups ADD COLUMN duration_minutes INTEGER DEFAULT 0', + 'max_effects' => 'ALTER TABLE web_user_groups ADD COLUMN max_effects INTEGER DEFAULT 5', ]; foreach ($migrations as $col => $sql) { if (!in_array($col, $columns)) { @@ -944,10 +946,10 @@ function syncUserGroups() { $now = time(); $stmt = $db->prepare("INSERT OR REPLACE INTO web_user_groups (group_name, display_name, display_color, display_emoji, priority, - land_price_per_sqm, max_lands, home_limit, join_price, auto_renew, + land_price_per_sqm, max_lands, max_effects, home_limit, join_price, auto_renew, renew_price, duration_minutes, default_perms, synced_at) VALUES (:name, :display, :color, :emoji, :priority, - :price, :maxlands, :homelimit, :joinprice, :autorenew, + :price, :maxlands, :maxeffects, :homelimit, :joinprice, :autorenew, :renewprice, :duration, :perms, :synced)"); $count = 0; foreach ($groups as $g) { @@ -958,6 +960,7 @@ function syncUserGroups() { $stmt->bindValue(':priority', (int)($g['priority'] ?? 0), SQLITE3_INTEGER); $stmt->bindValue(':price', (int)($g['land_price_per_sqm'] ?? -1), SQLITE3_INTEGER); $stmt->bindValue(':maxlands', (int)($g['max_lands'] ?? -1), SQLITE3_INTEGER); + $stmt->bindValue(':maxeffects', (int)($g['max_effects'] ?? 5), SQLITE3_INTEGER); $stmt->bindValue(':homelimit', (int)($g['home_limit'] ?? 0), SQLITE3_INTEGER); $stmt->bindValue(':joinprice', (int)($g['join_price'] ?? 0), SQLITE3_INTEGER); $stmt->bindValue(':autorenew', (int)($g['auto_renew'] ?? 0), SQLITE3_INTEGER); diff --git a/api/ticket.php b/api/ticket.php index e47b7e4..dbdbb92 100644 --- a/api/ticket.php +++ b/api/ticket.php @@ -189,8 +189,50 @@ function decodeTicketImages($raw) { /** * 上传工单附件图片:转存自家兰空图床,返回可直接引用的 URL。 * 认证:管理后台 session 或用户 token(getAuthUser)。 - * 限制:仅 png/jpg、单张 ≤2048K、内容必须与后缀一致;同会话每分钟最多 12 次(防刷)。 + * 限制:仅 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(); @@ -218,19 +260,27 @@ function ticketUploadImage() { $m = isset($msgs[$f['error']]) ? $msgs[$f['error']] : ('上传错误码 ' . $f['error']); error($m); } - // 后缀:仅 png / jpg - $orig = basename((string)$f['name']); - $ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION)); - if ($ext !== 'png' && $ext !== 'jpg') error('仅支持 png / jpg 格式'); + // 文件名清洗:全角句点→半角、去零宽字符、去首尾空白 + $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('无法识别的图片内容'); + if ($info === false) error('无法识别的图片内容(请确认文件确实是 png 或 jpg)'); $itype = (int)$info[2]; - if ($ext === 'png' && $itype !== IMAGETYPE_PNG) error('文件内容与 .png 后缀不符'); - if ($ext === 'jpg' && $itype !== IMAGETYPE_JPEG) error('文件内容与 .jpg 后缀不符'); + 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'] : '未知错误')); diff --git a/core.php b/core.php index b5b9f8d..e1d7ff0 100644 --- a/core.php +++ b/core.php @@ -644,6 +644,7 @@ function initTables(SQLite3 $db) { priority INTEGER DEFAULT 0, land_price_per_sqm INTEGER DEFAULT -1, max_lands INTEGER DEFAULT -1, + max_effects INTEGER DEFAULT 5, home_limit INTEGER DEFAULT 0, join_price INTEGER DEFAULT 0, auto_renew INTEGER DEFAULT 0, diff --git a/player.php b/player.php index dfa61b6..0060ab4 100644 --- a/player.php +++ b/player.php @@ -2843,7 +2843,7 @@ if ($currentVersion !== $BUILD_VERSION) {
- +

仅支持 png / jpg,单张 ≤ 2048K;选中即上传,并在上方描述光标处插入 [图片N] 占位符

@@ -2866,8 +2866,14 @@ if ($currentVersion !== $BUILD_VERSION) { input.value = ''; for (const file of files) { if ((ticketState.images || []).length >= 3) { setImgMsg(msg, '最多只能上传 3 张图片', true); break; } - const ext = (file.name.split('.').pop() || '').toLowerCase(); - if (ext !== 'png' && ext !== 'jpg') { setImgMsg(msg, '仅支持 png / jpg:' + file.name, true); continue; } + // ★ 2026-10-04 修复:不再用文件名后缀判类型 + // (.jpeg / 全角句点 / 无后缀的真实 png、jpg 曾被误拦) + // 只有明显不是图片类型(.gif/.webp/.heic/文档等)才提前拦下, + // 其余一律上传,由服务端按图片真实内容判定。 + const ext = (file.name.split('.').pop() || '').toLowerCase().trim(); + const okExt = ['', 'png', 'jpg', 'jpeg', 'jpe', 'jfif']; + if (!okExt.includes(ext)) { setImgMsg(msg, '仅支持 png / jpg(.jpeg 也算):' + file.name, true); continue; } + if (file.type && !file.type.startsWith('image/')) { setImgMsg(msg, '这不是图片文件:' + file.name, true); continue; } if (file.size > 2048 * 1024) { setImgMsg(msg, '超过 2048K:' + file.name, true); continue; } setImgMsg(msg, '上传中… ' + file.name, false); try {