@@ -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) {
@@ -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 {