Explorar o código

Merge branch 'fix_ai_bug' into pre

zoujiajian hai 5 horas
pai
achega
4d6eaf73f0

+ 13 - 0
docs/exchange-code-security.md

@@ -41,3 +41,16 @@
 ```powershell
 & 'D:\IntelliJ IDEA 2025.2.3\plugins\maven\lib\maven3\bin\mvn.cmd' -pl netflix-web -am test '-Dtest=ExchangeCodeSecurityTest,CouponWriteSecurityTest' '-Dsurefire.failIfNoSpecifiedTests=false'
 ```
+
+
+## 2026-10-21 用户端兑换码详情加固
+- `GET /applets/user/get/ticket/code/detail` 只接受完整 code,缺失、空串、空白、非法字符和超过 256 字符均拒绝。兼容字母数字、下划线和连字符。
+- 使用框架一次 URL 解码后的值,不再二次解码。改用 MyBatis 参数绑定的 code 等值查询,额外 skuIds/useStatus/排序/操作符不参与查询。
+- 返回前用 Java 严格比较原码,避免数据库不区分大小写的匹配;响应不再包含 code 字段。前端应使用自己的输入码执行兑换,不依赖详情响应回填。
+- 不修改兑换核销流程,不增加数据库迁移。此改动未新增限流,不代表全部兑换安全问题已完成。
+- 部署验收:空码与错误码不返回数据;完整有效测试码正常展示;附加参数不能改变查询目标;响应不含完整兑换码。
+
+## 2026-10-21 外部镜像查询访问策略调整
+按用户要求,`GET /external/app/get/mirror` 恢复公开查询,移除该查询的后台登录/root/兑换码查询权限限制。固定规格、参数白名单和分页上限保留。
+`PUT /external/app/refund/{id}` 仍要求后台登录、root 和 exchangeCode:refund;类级权限移至退款方法,未取消退款授权。
+此为公开访问策略,不代表返回的完整镜像兑换码属于保密数据;未修改用户端兑换码详情的精确码校验。

+ 31 - 0
docs/frontend-security-integration.md

@@ -172,3 +172,34 @@ ip 筛选目前只精确匹配 remoteIp 或 realIp,不查询 forwardedFor 链
 4. 余额金额为 0、负数、小数、超过 10 位或备注为空时拒绝;合法请求金额和流水一致,余额竞争更新失败时不产生流水。
 5. 修改优惠券同时提交伪造 createdTime,确认原创建时间不变。
 6. 执行 SQL、部署后重新登录刷新权限;历史异常优惠券/兑换码不会因部署自动作废,需要单独核查处理。
+
+## 登录日志菜单权限(2026-10-21 更新)
+- 入口改为系统管理下的登录日志,移除顶部快捷按钮。
+- 路由 `/setting/loginAudit`,旧地址 `/security/login-audit` 保留别名。
+- 查询接口 `GET /manage/login/audit/get` 由 root 限制改为登录且持有 `loginAudit:view`;响应格式不变。
+- 前端路由、页面和请求拦截同步校验该权限。该权限可查询全部登录日志,应仅授权可信审计人员。
+- 执行 `docs/sql/20261021-login-audit-menu.sql`,核对系统管理父菜单 ID;仅默认授权 root,其他角色手工勾选菜单并刷新权限、重新登录。
+- 验收:授权角色可查询;无权限角色无法进入且直接调用接口被拒绝;未登录拒绝;旧地址同样受控。
+
+## 2026-10-21 联调反馈调整
+- `/sys/get?key=sys_default_shop_key` 撤销额外商户读取权限检查;配置修改接口权限保留。
+- 兑换码列表和导出固定 `ec.id desc`,生成后前端回第一页(保留筛选条件)。
+- `/manage/user/add/balance` 单笔金额暂按截图边界设为小于 30000 元,接口单位为分,`deductBalance >= 3000000` 拒绝;前端同步限制。用户尚未另行指定业务上限。
+- 此上限仅限人工加余额,不是账户总余额上限,不限制扣款,也不替代累计风控。
+- 无数据库迁移;发布前后端。边界:29999.99 元可通过额度检查,30000/30000.01 元拒绝(其他权限/参数条件仍需满足)。
+
+## 管理端错误提示(2026-10-21)
+- 普通请求及兑换码下载显示后端业务 msg;返回 data=requestId=... 时追加错误编号。
+- 普通请求错误提示保留 8 秒、允许关闭;登录失效、HTTP 错误、网络失败、超时均有提示。
+- 后端补充缺少参数、参数类型和 JSON 格式错误提示;字段绑定校验显示字段名,不回显错误输入值。
+- 未知服务异常仍使用通用提示,完整堆栈通过 requestId 在服务日志定位,不向前端开放 SQL/密钥。
+- 新增前端工具 src/utils/apiError.mjs,发布时需一起提交。无 SQL 变更。
+
+后续调整:按用户要求撤回此次前端错误提示增强,删除 apiError.mjs,恢复 axios 和下载工具原有 msg 展示逻辑;前面的前端提示增强说明不再适用。其他安全修复保留。
+
+## 后端管理接口错误提示分流(2026-10-21)
+- 仅应用内 `/manage` 和 `/manage/**` 使用管理提示策略,去除 servlet contextPath 后匹配;不匹配 `/manager`、`/management`,不信任请求头伪造路径。
+- 管理接口的业务/未知异常、参数校验错误在 msg 中附加错误编号,前端沿用原有 msg 展示即可。
+- 缺少参数、参数类型错误、JSON 格式错误的细化提示只用于管理路径;字段校验管理路径增加字段名。
+- 登录/角色/权限异常沿用原业务码及提示,不改变登录跳转协议。未知错误仍不返回原始异常、SQL或堆栈。
+- 其他路径保留原业务提示及错误编号 data 约定,不新增管理字段细节。无需 SQL、无需前端提示改动。

+ 13 - 8
docs/sql/20261021-coupon-exchange-permissions.sql

@@ -1,10 +1,15 @@
+-- Explicit comparison collation avoids MySQL 1267 across database/session defaults.
 -- 优惠券 + 商品兑换码权限。请在应用数据库执行;重复串行执行不会重复插入。
 -- 如自动识别失败/存在同名菜单,请将下面两个 NULL 改成实际父菜单 id。
 -- 不创建前端路由,不改已有权限,不给普通角色扩权。
 SET @coupon_parent_override = NULL;
 SET @exchange_parent_override = NULL;
--- Set this to the actual user-management menu ID before running balance grants.
+-- 自动识别用户列表;匹配不唯一时填写实际用户列表菜单 ID。
 SET @balance_parent_override = NULL;
+SET @balance_parent = COALESCE(@balance_parent_override, (
+    SELECT CASE WHEN COUNT(*) = 1 THEN MIN(id) ELSE NULL END
+    FROM cms_menu WHERE type = 'menu' AND (title = '用户列表' OR path = '/userManage/user')
+));
 
 SET @coupon_parent = COALESCE(@coupon_parent_override, (
     SELECT CASE WHEN COUNT(*) = 1 THEN MIN(id) ELSE NULL END
@@ -32,9 +37,9 @@ INSERT INTO tmp_coupon_exchange_permissions VALUES
 ('兑换码生成', 'exchangeCode:add', @exchange_parent, 2),
 ('兑换码导出', 'exchangeCode:export', @exchange_parent, 3),
 ('兑换码退款重发', 'exchangeCode:refund', @exchange_parent, 4),
-('增加用户余额', 'userBalance:add', @balance_parent_override, 1),
-('扣除用户余额', 'userBalance:deduct', @balance_parent_override, 2),
-('余额操作记录', 'userBalance:view', @balance_parent_override, 3);
+('增加用户余额', 'userBalance:add', @balance_parent, 1),
+('扣除用户余额', 'userBalance:deduct', @balance_parent, 2),
+('余额操作记录', 'userBalance:view', @balance_parent, 3);
 
 START TRANSACTION;
 INSERT INTO cms_menu
@@ -42,14 +47,14 @@ INSERT INTO cms_menu
 SELECT p.title, p.perms, p.perms, 'button', '', p.sort_num, p.parent_id, NOW(), NOW()
 FROM tmp_coupon_exchange_permissions p
 INNER JOIN cms_menu parent ON parent.id = p.parent_id AND parent.type <> 'button'
-WHERE NOT EXISTS (SELECT 1 FROM cms_menu existing WHERE existing.perms = p.perms);
+WHERE NOT EXISTS (SELECT 1 FROM cms_menu existing WHERE existing.perms = CONVERT(p.perms USING utf8mb4) COLLATE utf8mb4_unicode_ci);
 
 -- 只授权名称精确为 root 的角色;其他角色在后台角色管理中显式授权。
 INSERT INTO cms_role_menu (role_id, menu_id, created_time, update_time)
 SELECT r.id, m.id, NOW(), NOW()
 FROM cms_role r
 INNER JOIN cms_menu m ON 1 = 1
-INNER JOIN tmp_coupon_exchange_permissions p ON p.perms = m.perms
+INNER JOIN tmp_coupon_exchange_permissions p ON CONVERT(p.perms USING utf8mb4) COLLATE utf8mb4_unicode_ci = m.perms
 WHERE r.role_name = 'root'
   AND NOT EXISTS (
       SELECT 1 FROM cms_role_menu existing
@@ -61,14 +66,14 @@ COMMIT;
 SELECT p.perms, m.id AS menu_id, m.parent_id,
        CASE WHEN m.id IS NULL THEN 'MISSING: set parent override and rerun' ELSE 'OK' END AS result
 FROM tmp_coupon_exchange_permissions p
-LEFT JOIN cms_menu m ON m.perms = p.perms
+LEFT JOIN cms_menu m ON m.perms = CONVERT(p.perms USING utf8mb4) COLLATE utf8mb4_unicode_ci
 ORDER BY p.perms;
 
 SELECT r.id AS role_id, r.role_name, m.perms
 FROM cms_role_menu rm
 INNER JOIN cms_role r ON r.id = rm.role_id
 INNER JOIN cms_menu m ON m.id = rm.menu_id
-INNER JOIN tmp_coupon_exchange_permissions p ON p.perms = m.perms
+INNER JOIN tmp_coupon_exchange_permissions p ON CONVERT(p.perms USING utf8mb4) COLLATE utf8mb4_unicode_ci = m.perms
 WHERE r.role_name = 'root'
 ORDER BY m.perms;
 

+ 3 - 2
docs/sql/20261021-merchant-permissions.sql

@@ -1,3 +1,4 @@
+-- Explicit comparison collation avoids MySQL 1267 across database/session defaults.
 -- Set the existing merchant-management parent menu ID before running.
 SET @merchant_parent = NULL;
 START TRANSACTION;
@@ -5,11 +6,11 @@ INSERT INTO cms_menu (title,menu_name,perms,type,path,order_by,parent_id,created
 SELECT p.title,p.perms,p.perms,'button','',p.sort_num,m.id,NOW(),NOW()
 FROM (SELECT '商户查询' title,'merchant:view' perms,1 sort_num UNION ALL SELECT '新增商户','merchant:add',2 UNION ALL SELECT '编辑商户和收款配置','merchant:edit',3 UNION ALL SELECT '删除商户配置','merchant:delete',4 UNION ALL SELECT '渠道商户切换','merchant:updateChannelShop',5) p
 JOIN cms_menu m ON m.id=@merchant_parent AND m.type<>'button'
-WHERE NOT EXISTS (SELECT 1 FROM cms_menu e WHERE e.perms=p.perms);
+WHERE NOT EXISTS (SELECT 1 FROM cms_menu e WHERE e.perms = CONVERT(p.perms USING utf8mb4) COLLATE utf8mb4_unicode_ci);
 INSERT INTO cms_role_menu (role_id,menu_id,created_time,update_time)
 SELECT r.id,m.id,NOW(),NOW() FROM cms_role r JOIN cms_menu m ON m.perms IN ('merchant:view','merchant:add','merchant:edit','merchant:delete','merchant:updateChannelShop')
 WHERE r.role_name='root' AND NOT EXISTS (SELECT 1 FROM cms_role_menu e WHERE e.role_id=r.id AND e.menu_id=m.id);
 COMMIT;
 SELECT p.perms,m.id,IF(m.id IS NULL,'MISSING','OK') result
-FROM (SELECT 'merchant:view' perms UNION ALL SELECT 'merchant:add' UNION ALL SELECT 'merchant:edit' UNION ALL SELECT 'merchant:delete' UNION ALL SELECT 'merchant:updateChannelShop') p LEFT JOIN cms_menu m ON m.perms=p.perms;
+FROM (SELECT 'merchant:view' perms UNION ALL SELECT 'merchant:add' UNION ALL SELECT 'merchant:edit' UNION ALL SELECT 'merchant:delete' UNION ALL SELECT 'merchant:updateChannelShop') p LEFT JOIN cms_menu m ON m.perms = CONVERT(p.perms USING utf8mb4) COLLATE utf8mb4_unicode_ci;
 -- All five rows must be OK. Assign other roles explicitly, refresh permissions and re-login.

+ 3 - 2
docs/sql/20261021-refund-permissions.sql

@@ -1,3 +1,4 @@
+-- Explicit comparison collation avoids MySQL 1267 across database/session defaults.
 -- Set the existing order-management parent menu ID before running.
 SET @refund_parent = NULL;
 START TRANSACTION;
@@ -5,11 +6,11 @@ INSERT INTO cms_menu (title,menu_name,perms,type,path,order_by,parent_id,created
 SELECT p.title,p.perms,p.perms,'button','',p.sort_num,m.id,NOW(),NOW()
 FROM (SELECT '人工退款' title,'order:refund' perms,1 sort_num UNION ALL SELECT '余额转原渠道退款','order:refundTransfer',2) p
 JOIN cms_menu m ON m.id=@refund_parent AND m.type<>'button'
-WHERE NOT EXISTS (SELECT 1 FROM cms_menu e WHERE e.perms=p.perms);
+WHERE NOT EXISTS (SELECT 1 FROM cms_menu e WHERE e.perms = CONVERT(p.perms USING utf8mb4) COLLATE utf8mb4_unicode_ci);
 INSERT INTO cms_role_menu (role_id,menu_id,created_time,update_time)
 SELECT r.id,m.id,NOW(),NOW() FROM cms_role r JOIN cms_menu m ON m.perms IN ('order:refund','order:refundTransfer')
 WHERE r.role_name='root' AND NOT EXISTS (SELECT 1 FROM cms_role_menu e WHERE e.role_id=r.id AND e.menu_id=m.id);
 COMMIT;
 SELECT p.perms,m.id,IF(m.id IS NULL,'MISSING','OK') result
-FROM (SELECT 'order:refund' perms UNION ALL SELECT 'order:refundTransfer') p LEFT JOIN cms_menu m ON m.perms=p.perms;
+FROM (SELECT 'order:refund' perms UNION ALL SELECT 'order:refundTransfer') p LEFT JOIN cms_menu m ON m.perms = CONVERT(p.perms USING utf8mb4) COLLATE utf8mb4_unicode_ci;
 -- Both rows must be OK. Assign other roles explicitly, refresh permissions and re-login.

+ 1 - 1
netflix-dao/src/main/java/com/cyksj/model/excel/ExcelExchangeCodeData.java

@@ -21,7 +21,7 @@ import java.util.Date;
 @Getter
 @Setter
 @SearchBean(tables = "exchange_code ec left join user u on u.id = ec.user_id",
-		where = "ec.type = 0 :condition:")
+		where = "ec.type = 0 :condition:", orderBy = "ec.id desc")
 public class ExcelExchangeCodeData {
 
 	/**

+ 2 - 0
netflix-dao/src/main/java/com/cyksj/model/views/ExchangeCodeDetailView.java

@@ -3,6 +3,7 @@ package com.cyksj.model.views;
 import com.ejlchina.searcher.bean.DbField;
 import com.ejlchina.searcher.bean.DbIgnore;
 import com.ejlchina.searcher.bean.SearchBean;
+import com.fasterxml.jackson.annotation.JsonIgnore;
 import lombok.Getter;
 import lombok.Setter;
 
@@ -19,6 +20,7 @@ import java.util.List;
 @Setter
 @SearchBean(tables = "exchange_code ec")
 public class ExchangeCodeDetailView {
+	@JsonIgnore
 	@DbField("ec.code")
 	private String code;
 

+ 1 - 1
netflix-dao/src/main/java/com/cyksj/model/views/ExchangeCodeView.java

@@ -17,7 +17,7 @@ import java.util.Date;
 @Getter
 @Setter
 @SearchBean(tables = "exchange_code ec left join user u on u.id = ec.user_id",
-		where = "ec.type = 0 :condition:")
+		where = "ec.type = 0 :condition:", orderBy = "ec.id desc")
 public class ExchangeCodeView {
 	@DbField("ec.id")
 	private Long id;

+ 38 - 7
netflix-web/src/main/java/com/cyksj/web/GlobalExceptionHandler.java

@@ -71,10 +71,17 @@ public class GlobalExceptionHandler {
             apiCode = GatewayApiCode.CMS_USER_NO_PERMISSION;
         }
 
+        if (isManageRequest() && e instanceof org.springframework.web.bind.MissingServletRequestParameterException) {
+            message = "缺少必填参数:" + ((org.springframework.web.bind.MissingServletRequestParameterException) e).getParameterName();
+        } else if (isManageRequest() && e instanceof org.springframework.web.method.annotation.MethodArgumentTypeMismatchException) {
+            message = "参数格式不正确:" + ((org.springframework.web.method.annotation.MethodArgumentTypeMismatchException) e).getName();
+        } else if (isManageRequest() && e instanceof org.springframework.http.converter.HttpMessageNotReadableException) {
+            message = "请求体格式不正确,请检查 JSON 和字段类型";
+        }
         if (apiCode != null) {
             return GatewayResponse.FAIL.newBuilder().buildGatewayCode(apiCode).toResult();
         }
-        return GatewayResponse.FAIL.newBuilder().setMsg(message).toResult("requestId=" + requestId);
+        return GatewayResponse.FAIL.newBuilder().setMsg(manageMessage(message, requestId)).toResult("requestId=" + requestId);
     }
 
     @ExceptionHandler(value = ConstraintViolationException.class)
@@ -85,9 +92,8 @@ public class GlobalExceptionHandler {
         Set<ConstraintViolation<?>> violations = ex.getConstraintViolations();
         if (!violations.isEmpty()) {
             for (ConstraintViolation<?> item : violations) {
-                System.out.println(item.getPropertyPath());
                 // 遍历错误字段信息
-                errorInfoSet.add(item.getMessage());
+                errorInfoSet.add(isManageRequest() ? item.getPropertyPath() + ":" + item.getMessage() : item.getMessage());
             }
 
             StringBuilder sbf = new StringBuilder();
@@ -100,7 +106,7 @@ public class GlobalExceptionHandler {
 
         log.error("错误字段 信息: \n Value: {}", message);
 
-        return GatewayResponse.FAIL.newBuilder().buildGatewayCode(GatewayApiCode.PARAMETER_MISSING_ERROR).setMsg(message).toResult();
+        return validationFailure(message);
     }
 
     @ExceptionHandler(BindException.class)
@@ -115,7 +121,7 @@ public class GlobalExceptionHandler {
             Set<String> errorInfoSet = new TreeSet<String>();
             for (FieldError fieldError : fieldErrorList) {
                 // 遍历错误字段信息
-                errorInfoSet.add(fieldError.getDefaultMessage());
+                errorInfoSet.add(isManageRequest() ? fieldError.getField() + ":" + (fieldError.isBindingFailure() ? "格式或类型不正确" : fieldError.getDefaultMessage()) : fieldError.getDefaultMessage());
             }
 
             StringBuilder sbf = new StringBuilder();
@@ -123,13 +129,38 @@ public class GlobalExceptionHandler {
                 sbf.append(errorInfo);
                 sbf.append(",");
             }
-            String message = sbf.substring(0, sbf.length() - 1);
+            String message = sbf.length() == 0 ? "参数校验失败" : sbf.substring(0, sbf.length() - 1);
 
             log.error("验证参数信息是否有效 错误信息: \n Value: {}", message);
 
-            return GatewayResponse.FAIL.newBuilder().buildGatewayCode(GatewayApiCode.PARAMETER_MISSING_ERROR).setMsg(message).toResult();
+            return validationFailure(message);
 
         }
         return GatewayResponse.SUCCESS.newBuilder().buildGatewayCode(GatewayApiCode.SUCCESS).setMsg("参数校验通过").toResult();
     }
+    private boolean isManageRequest() {
+        String path = request.getRequestURI();
+        String context = request.getContextPath();
+        if (context != null && !context.isEmpty() && path.startsWith(context + "/")) {
+            path = path.substring(context.length());
+        }
+        // Do not trust client-supplied forwarded path headers.
+        return "/manage".equals(path) || path.startsWith("/manage/");
+    }
+
+    private String manageMessage(String message, String requestId) {
+        return isManageRequest() ? message + "(错误编号:" + requestId + ")" : message;
+    }
+
+    private Result<String> validationFailure(String message) {
+        if (!isManageRequest()) {
+            return GatewayResponse.FAIL.newBuilder().buildGatewayCode(GatewayApiCode.PARAMETER_MISSING_ERROR)
+                    .setMsg(message).toResult();
+        }
+        String requestId = java.util.UUID.randomUUID().toString();
+        log.warn("Validation failed requestId={} path={} message={}", requestId, request.getRequestURI(), message);
+        return GatewayResponse.FAIL.newBuilder().buildGatewayCode(GatewayApiCode.PARAMETER_MISSING_ERROR)
+                .setMsg(manageMessage(message, requestId)).toResult("requestId=" + requestId);
+    }
+
 }

+ 2 - 3
netflix-web/src/main/java/com/cyksj/web/controller/external/ExternalController.java

@@ -48,8 +48,6 @@ import java.util.List;
  * 创建时间:2024/6/11 15:01
  */
 @RestController
-@SaCheckLogin
-@SaCheckRole("root")
 @RequestMapping("/external/app")
 @RequiredArgsConstructor
 public class ExternalController {
@@ -75,7 +73,6 @@ public class ExternalController {
      * 知乎 特殊镜像规格 兑换码列表
      */
     @GetMapping("/get/mirror")
-    @SaCheckPermission("exchangeCode:view")
     public Result<SearchResult<ExternalExchangeCodeView>> getMirror(@RequestParam(required = true, defaultValue = "18") Long gid) {
         //目前只展示规格id 444 GPT基础镜像2月
         List<Long> showSkuId = null;
@@ -104,6 +101,8 @@ public class ExternalController {
 	/**
 	 * 退款 成功 重新生成兑换码
 	 */
+	@SaCheckLogin
+	@SaCheckRole("root")
 	@PutMapping("/refund/{id}")
 	@SaCheckPermission("exchangeCode:refund")
 	@Log(module = "兑换码/外部退款重发", businessType = BusinessType.PUT, isSaveRequestData = true)

+ 4 - 0
netflix-web/src/main/java/com/cyksj/web/controller/manage/CmsUserController.java

@@ -480,6 +480,10 @@ public class CmsUserController{
         if (deductBalance.compareTo(BigDecimal.ZERO) <= 0) {
             throw BusinessRuntimeException.getInstance("请增加正确的余额数值");
         }
+        // Stored and requested amounts are integer cents: 30000 yuan = 3000000 cents.
+        if (deductBalance.compareTo(new BigDecimal("3000000")) >= 0) {
+            throw BusinessRuntimeException.getInstance("单笔增加余额必须小于30000元");
+        }
         if (!balanceIdempotency.begin(adminId, "add", requestId, balanceReq)) return GatewayResponse.SUCCESS.newBuilder().toResult();
         Long userId = balanceReq.getUserId();
         User user = userMapper.selectById(userId);

+ 2 - 2
netflix-web/src/main/java/com/cyksj/web/controller/manage/LoginAuditController.java

@@ -1,7 +1,7 @@
 package com.cyksj.web.controller.manage;
 
 import cn.dev33.satoken.annotation.SaCheckLogin;
-import cn.dev33.satoken.annotation.SaCheckRole;
+import cn.dev33.satoken.annotation.SaCheckPermission;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.cyksj.common.exception.BusinessRuntimeException;
@@ -17,7 +17,7 @@ import java.util.Date;
 @RestController
 @RequestMapping("/manage/login/audit")
 @SaCheckLogin
-@SaCheckRole("root")
+@SaCheckPermission("loginAudit:view")
 @RequiredArgsConstructor
 public class LoginAuditController {
     private final LoginAuditMapper mapper;

+ 1 - 1
netflix-web/src/main/java/com/cyksj/web/controller/sys/SysConfigController.java

@@ -76,7 +76,7 @@ public class SysConfigController {
 
 	@GetMapping("/get")
 	public Result<SysConfig> get(String key) {
-        checkMerchantConfig(key, "merchant:view");
+        // Configuration read is public; write operations retain merchant authorization.
 		try {
 			String cacheKey = RedisService.key.SYS_CONFIG_CACHE_KEY.getName() + key;
 			Object o = redisService.get(cacheKey);

+ 21 - 9
netflix-web/src/main/java/com/cyksj/web/controller/user/UserController.java

@@ -3,7 +3,6 @@ package com.cyksj.web.controller.user;
 import cn.hutool.core.date.DateTime;
 import cn.hutool.core.date.DateUtil;
 import cn.hutool.core.util.StrUtil;
-import cn.hutool.core.util.URLUtil;
 import cn.hutool.extra.servlet.ServletUtil;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.cyksj.common.annotation.NoSubmit;
@@ -14,6 +13,7 @@ import com.cyksj.enums.GatewayApiCode;
 import com.cyksj.enums.GatewayResponse;
 import com.cyksj.mapper.*;
 import com.cyksj.mapper.manage.coupon.CouponUserMapper;
+import com.cyksj.mapper.manage.exchange.ExchangeCodeMapper;
 import com.cyksj.mapper.manage.distribute.*;
 import com.cyksj.mapper.mirrorshop.UserMirrorShopMapper;
 import com.cyksj.model.dto.UserWhoami;
@@ -62,6 +62,8 @@ public class UserController {
 
     private final ExchangeCodeService exchangeCodeService;
 
+    private final ExchangeCodeMapper exchangeCodeMapper;
+
     private final CouponUserMapper couponUserMapper;
 
     private final OrderDonMapper orderDonMapper;
@@ -128,18 +130,28 @@ public class UserController {
      * 车票兑换码详情
      */
     @GetMapping("/get/ticket/code/detail")
-    public Result<ExchangeCodeDetailView> getTicketCodeDetail(String code) throws Exception {
-        if (StrUtil.isBlank(code) || code.length() > 256) {
+    public Result<ExchangeCodeDetailView> getTicketCodeDetail(
+            @RequestParam(value = "code", required = false) String code) throws Exception {
+        // Spring already decodes query parameters. Never decode a second time.
+        // Accept legacy underscore/hyphen codes, but reject whitespace and encoded payloads.
+        if (code == null || !code.matches("[A-Za-z0-9_-]{1,256}")) {
             throw BusinessRuntimeException.getInstance("错误兑换码");
         }
-        code = URLUtil.decode(code);
-        ExchangeCodeDetailView exchangeCodeDetailView = beanSearcher.searchFirst(ExchangeCodeDetailView.class, MapUtils.builder().field(ExchangeCodeDetailView::getCode, code).build());
-        if (exchangeCodeDetailView == null) {
+        // Do not pass request parameters to BeanSearcher: all filters are server-owned.
+        ExchangeCode record = exchangeCodeMapper.selectOne(Wrappers.lambdaQuery(ExchangeCode.class)
+                .eq(ExchangeCode::getCode, code).last("LIMIT 1"));
+        // Also reject case-insensitive matches from a *_ci database collation.
+        if (record == null || !code.equals(record.getCode())) {
             throw BusinessRuntimeException.getInstance("错误兑换码");
         }
-        List<Long> skuIds = Jsons.parseList(exchangeCodeDetailView.getSkuIds(), Long.class);
-        exchangeCodeDetailView.setSkuDetails(skuMapper.selectExchangeTitleSkus(skuIds));
-        return GatewayResponse.SUCCESS.newBuilder().toResult(exchangeCodeDetailView);
+        ExchangeCodeDetailView detail = new ExchangeCodeDetailView();
+        detail.setSkuIds(record.getSkuIds());
+        detail.setUseStatus(record.getUseStatus());
+        detail.setUseTime(record.getUseTime());
+        detail.setCodeType(record.getCodeType());
+        List<Long> skuIds = Jsons.parseList(record.getSkuIds(), Long.class);
+        detail.setSkuDetails(skuMapper.selectExchangeTitleSkus(skuIds));
+        return GatewayResponse.SUCCESS.newBuilder().toResult(detail);
     }
 
     /**