zhizhijie
2026-09-14 d1cfd849c6a61800dcd86ffb45f80882ae20b2e7
security: 接口鉴权 + 跨域收紧 + AI 数据脱敏 + 日志脱敏

- 新增 JwtAuthFilter:/api/** 除登录与验证码白名单外必须携带 Bearer 令牌(JSON 401);
jwt.secret 由固定串改为 64 位随机串(在线用户需重新登录)
- CORS:由 allowedOriginPattern("*") + allowCredentials(true) 改为配置化来源白名单并关闭凭证携带
- AI 脱敏:去掉 columns:"*" 放开整表的口子;屏蔽统一社会信用代码/填表人/联系电话/单位负责人/
统计负责人/填报单位/企业代码(可查列、过滤列、排序列与表结构提示一并剔除);
外发前企业名称换 Company1… 别名、自由文本中的企业名与手机号一并处理,回答返回前还原
- 日志:MyBatis 由默认 StdOutImpl 改为 Slf4jImpl,不再把整行业务数据打进 backend.log
- 前端:审核结果导出、模板下载由 window.open 改为 axios 带令牌取 blob 下载
- 文档:database.md 第七章补充脱敏实现说明(llm_desensitize_map 为预留表)
- 附《功能测试报告_接口鉴权与AI数据脱敏加固_2026-09-14》《工作日结_2026-09-14》
9个文件已修改
3个文件已添加
383 ■■■■■ 已修改文件
docs/database.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/功能测试报告/功能测试报告_接口鉴权与AI数据脱敏加固_2026-09-14.md 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/工作日结/工作日结_2026-09-14.md 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/common/config/CorsConfig.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/llmintegration/service/DbQueryExecutor.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/llmintegration/service/DesensitizeService.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/llmintegration/service/LLMService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/resources/application.yml.example 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/AuditResult.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataImport.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/database.md
@@ -411,6 +411,10 @@
## ä¸ƒã€LLM脱敏映射
> **实现说明(2026-09-14 æ›´æ–°ï¼‰**:当前实际生效的脱敏是**内存态**的——`DesensitizeService` åœ¨æ•°æ®å¤–发前把企业名称替换为 `Company1`、`Company2`…,回答返回前再还原,映射只存在进程内存中、不落库。
> å…·ä½“做法:AI æŸ¥åº“(`query_database`)结果回填给大模型前,企业名称统一换别名,自由文本里的企业名称与手机号一并处理;`contact_person`(填表人)、`contact_phone`(联系电话)、`unified_credit_code`(统一社会信用代码)、`unit_leader`、`stats_leader`、`report_unit`、`enterprise_code` ç›´æŽ¥ä»Ž"可查列"中剔除,AI æ—¢æŸ¥ä¸åˆ°ä¹Ÿä¸èƒ½ä½œä¸ºè¿‡æ»¤/排序条件。
> ä¸‹è¡¨ `llm_desensitize_map` ä¸º**预留**的落库版映射表,目前代码未引用、表为空;若后续需要留存脱敏审计痕迹再启用。
### llm_desensitize_map è„±æ•æ˜ å°„表
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
docs/¹¦ÄܲâÊÔ±¨¸æ/¹¦ÄܲâÊÔ±¨¸æ_½Ó¿Ú¼øÈ¨ÓëAIÊý¾ÝÍÑÃô¼Ó¹Ì_2026-09-14.md
New file
@@ -0,0 +1,60 @@
# åŠŸèƒ½æµ‹è¯•æŠ¥å‘Š_接口鉴权与AI数据脱敏加固_2026-09-14
> æ¨¡å—:系统安全(后端鉴权 / è·¨åŸŸ / AI æ•°æ®è„±æ• / æ—¥å¿—脱敏) 测试人:Codex 日期:2026-09-14
## ä¸€ã€åŠŸèƒ½è¯´æ˜Ž
针对本日安全自查发现的四类风险做加固(**这是安全整改,不是新功能**):
| åºå· | åŽŸé£Žé™© | æœ¬æ¬¡å¤„ç½® |
| --- | --- | --- |
| 1 | åŽç«¯ `/api/**` å…¨éƒ¨ `permitAll`,任何人无需登录即可调用,含"按报表期清空数据"等敏感接口 | æ–°å¢ž `JwtAuthFilter`,除登录/验证码白名单外,`/api/**` å¿…须携带有效 `Bearer` ä»¤ç‰Œï¼Œå¦åˆ™è¿”回 JSON 401;`application.yml` çš„ `jwt.secret` ç”±å¯çŒœæµ‹çš„固定串改为 64 ä½éšæœºä¸² |
| 2 | CORS `allowedOriginPattern("*")` ä¸” `allowCredentials(true)`,任意网站可携带凭证调用本机接口 | æ”¹ä¸ºåªå…è®¸é…ç½®é¡¹ `app.cors.allowed-origins` ä¸­åˆ—出的来源(默认 `http://localhost:8080,http://127.0.0.1:8080`),并关闭 `allowCredentials` |
| 3 | AI æŸ¥åº“接口可传 `columns:"*"` æ”¾å¼€æ•´è¡¨ï¼Œä¼ä¸šåç§°ã€ç»Ÿä¸€ç¤¾ä¼šä¿¡ç”¨ä»£ç ã€å•位负责人、填表人、联系电话明文发往 DeepSeek | â‘  åŽ»æŽ‰ `"*"` æ”¾å¼€æ•´è¡¨çš„口子(改为只返回默认列);② å±è”½ 7 ä¸ªå­—段(`unified_credit_code`/`contact_person`/`contact_phone`/`unit_leader`/`stats_leader`/`report_unit`/`enterprise_code`),既不可查也不可作过滤/排序条件,且不再出现在给模型的表结构提示里;③ å¤–发前把企业名称换成 `Company1…` åˆ«åï¼Œè‡ªç”±æ–‡æœ¬ä¸­çš„企业名称与 11 ä½æ‰‹æœºå·ä¸€å¹¶å¤„理,回答返回前端前还原 |
| 4 | MyBatis-Plus é»˜è®¤ `StdOutImpl` æŠŠæ•´è¡Œæ•°æ®ï¼ˆå«ä¼ä¸šåç§°ã€è”系电话)打进 `backend.log` | æ˜¾å¼æ”¹ä¸º `Slf4jImpl`,SQL æ—¥å¿—降为 DEBUG çº§åˆ«ï¼Œé»˜è®¤ä¸è¾“出 |
改动文件(8 æ”¹ 1 æ–°å¢žï¼‰ï¼š`security/filter/JwtAuthFilter.java`(新增)、`security/config/SecurityConfig.java`、`common/config/CorsConfig.java`、`llmintegration/service/DbQueryExecutor.java`、`llmintegration/service/DesensitizeService.java`、`llmintegration/service/LLMService.java`、`traffic-audit-web/src/views/AuditResult.vue`、`traffic-audit-web/src/views/DataImport.vue`、`application.yml.example`、`docs/database.md`。
**连带修复**:前端 `AuditResult.vue`(审核结果导出)与 `DataImport.vue`(下载导入模板)原本用 `window.open('/api/...')` ç›´è¿žæŽ¥å£ï¼Œæµè§ˆå™¨è·³è½¬ä¸ä¼šå¸¦ `Authorization` å¤´ï¼Œå¼€å¯é‰´æƒåŽä¼š 401;已改为走 axios å®žä¾‹å– blob å†è§¦å‘下载(报表生成页原本就是这种写法)。
## äºŒã€æµ‹è¯•环境
- åŽç«¯ 8090(`mvn -o -q compile` é€šè¿‡ï¼ŒEXIT=0,重启后就绪)、前端 8080(dev ç¼–译成功)。
- æ•°æ®åº“:本机 `traffic_audit`(MySQL 3308)。
- æŠ“包手段:把 `deepseek.api-url` ä¸´æ—¶æŒ‡å‘本地假 DeepSeek æœåŠ¡ï¼ˆ`127.0.0.1:18080`),完整捕获每一次发往大模型的请求体,**测试后已恢复为真实地址并重启**。
## ä¸‰ã€ç”¨ä¾‹ä¸Žç»“æžœ
| ç¼–号 | ç”¨ä¾‹ | è¾“å…¥ | é¢„期 | å®žé™… | ç»“论 |
| --- | --- | --- | --- | --- | --- |
| S1 | æœªç™»å½•访问业务接口 | æ— ä»¤ç‰Œ GET `/api/user/list`、`/api/data/list`、`/api/audit/export`、`/api/report/export/turnoverRank`、`/api/templates/...`、`/api/llm/chat` | å…¨éƒ¨ 401 | 6 ç»„接口全部返回 401 JSON | é€šè¿‡ |
| S2 | ç™»å½•白名单不受影响 | æ— ä»¤ç‰Œ GET `/api/auth/captcha-config`、POST `/api/auth/login` | å¯è®¿é—® | ç™»å½•返回 200,令牌 156 ä½ | é€šè¿‡ï¼ˆæ­£å‘) |
| S3 | æºå¸¦ä»¤ç‰Œæ­£å¸¸ä½¿ç”¨ | `admin/123456` å–令牌后调 `/api/user/list`、`/api/audit/export`、`/api/report/export/turnoverRank` | 200 | 200 / 200(59965 å­—节)/ 200(14589 å­—节) | é€šè¿‡ |
| S4 | ä»¤ç‰Œæ— æ•ˆæ—¶æ‹’绝 | ä¼ªé€ /过期 `Bearer` | 401 | 401 | é€šè¿‡ï¼ˆè´Ÿå‘) |
| S5 | è·¨åŸŸç™½åå•生效 | å¸¦ `Origin: http://localhost:8080` è¯·æ±‚ | è¿”回该来源 | 200,`Access-Control-Allow-Origin: http://localhost:8080` | é€šè¿‡ |
| S6 | è·¨åŸŸéžç™½åå•被拦 | `Origin: http://evil.example.com`、`http://127.0.0.1:9999` | æ‹’绝 | å‡ 403,未返回放行头 | é€šè¿‡ï¼ˆè´Ÿå‘) |
| S7 | é¢„检请求 | æ— ä»¤ç‰Œ `OPTIONS` + `Origin: localhost:8080` | 200 ä¸”带放行头 | 200,允许来源正确 | é€šè¿‡ |
| S8 | AI æŸ¥å…¨è¡¨è¢«é™åˆ¶ | è®©æ¨¡åž‹å‘èµ· `columns:"*"` æŸ¥è¯¢ `h2032_enterprise_monthly` | åªè¿”回默认列 | æŠ“包显示返回列仅含默认列,**不含**任何联系人/电话/信用代码字段 | é€šè¿‡ |
| S9 | AI æŒ‡å®šæ•æ„Ÿåˆ—被拒 | `columns:["enterpriseName","contactPhone","unifiedCreditCode"]` | æ‹’绝 | è¿”回 `该字段不允许查询: contactPhone` | é€šè¿‡ï¼ˆè´Ÿå‘) |
| S10 | AI ç”¨æ•æ„Ÿåˆ—做过滤被拒 | `filters:{"contactPhone":"180****8880"}` | æ‹’绝 | è¿”回"列名不合法",可用列清单中已无联系人/电话/信用代码 | é€šè¿‡ï¼ˆè´Ÿå‘) |
| S11 | ä¼ä¸šåç§°è„±æ•å¤–发 | è§‚察抓包中 tool ç»“果的 `enterpriseName` | åº”为别名 | å®žé™…为 `Company1`、`Company2` | é€šè¿‡ |
| S12 | å›žç­”返回前还原 | AI å›žç­”文案 | æ˜¾ç¤ºçœŸå®žä¼ä¸šå | `unmaskAll` è¿˜åŽŸï¼ˆä»£ç è·¯å¾„ + æŠ“包比对) | é€šè¿‡ |
| S13 | æ—¥å¿—不再落整行数据 | é‡å¯åŽæ‰§è¡Œç™»å½•、列表、导出、审核结果导出 | æ— è¡Œè½¬å‚¨ | `<=` è¡Œ 0 æ¡ã€"Total:" 0 æ¡ã€æ‰‹æœºå·æ ·å¼ 0 æ¡ï¼ˆæ•´æ”¹å‰å•次查询即数百条) | é€šè¿‡ |
| S14 | å‰ç«¯ç¼–译 | æ”¹åЍ 2 ä¸ª .vue åŽ dev ç¼–译 | æ— é”™è¯¯ | `Compiled successfully` Ã—2 | é€šè¿‡ |
| S15 | å‰ç«¯å¸¦ä»¤ç‰Œä¸‹è½½ | å®¡æ ¸ç»“果导出、模板下载改为 blob æ–¹å¼ | ä¸å† 401 | æŽ¥å£ä¾§å¸¦ä»¤ç‰Œè¿”回 200;前端编译通过(浏览器终验见"遗留") | éƒ¨åˆ†ï¼ˆé¡µé¢å¾…人工点验) |
## å››ã€å‘现的问题与处置
1. **MyBatis æ—¥å¿—第一轮没改掉**:MyBatis-Plus åœ¨æ²¡æœ‰æ˜¾å¼é…ç½®æ—¶**默认就是 `StdOutImpl`**,仅删除配置项无效,仍把整行数据打进日志。已显式改为 `Slf4jImpl` å¹¶å¤æµ‹ï¼ˆS13)。
2. **前端两处 `window.open` ä¼šå› é‰´æƒå¤±æ•ˆ**:`AuditResult.vue` çš„审核结果导出、`DataImport.vue` çš„æ¨¡æ¿ä¸‹è½½ã€‚已改为 axios å– blob åŽä¸‹è½½ï¼ˆåŽŸæŠ¥è¡¨ç”Ÿæˆé¡µçš„åŒæ¬¾å†™æ³•ï¼‰ã€‚
3. **`jwt.secret` å¯çŒœæµ‹**:鉴权一旦真正启用,固定且写死的密钥等于可伪造令牌。已换成 64 ä½éšæœºä¸²ï¼ˆ`application.yml` æœªå…¥åº“,仓库内只有占位符示例)。**副作用:所有已登录用户的令牌失效,需重新登录。**
4. **`llm_desensitize_map` è¡¨ä¸Žå®žé™…实现不符**:该表设计为可逆脱敏映射落库,但代码从未引用(0 è¡Œï¼‰ã€‚本次采用内存态脱敏,已在 `docs/database.md` ç¬¬ä¸ƒç« è¡¥å……"实现说明",注明该表为预留、当前未启用,避免文档与实现不一致。
## äº”、结论
四类风险已全部处置并通过接口级、产物级与"报文级"验证:未登录无法访问任何业务接口;跨域仅放行配置来源;发往 DeepSeek çš„æŠ¥æ–‡é‡Œä¼ä¸šåç§°å·²æ¢ä¸ºåˆ«åï¼Œè”系人、电话、统一社会信用代码等字段完全无法被查询或外发;后端日志不再落整行业务数据。
遗留(不影响本次结论):
- 3D éªŒè¯ç ä»é»˜è®¤å…³é—­ï¼ˆ`app.captcha.required: false`),生产部署前建议开启;
- `sys_role` / `sys_user_role` ä»ä¸ºç©ºè¡¨ï¼Œè§’色权限未落地,目前"权限"仍只等于"报表页显示哪几个模块";
- å®¡æ ¸ç»“果导出、模板下载两处前端改动建议在浏览器点验一遍。
docs/¹¤×÷ÈÕ½á/¹¤×÷ÈÕ½á_2026-09-14.md
New file
@@ -0,0 +1,47 @@
# å·¥ä½œæ—¥ç»“_2026-09-14
## ä¸€ã€ä»Šå¤©åšäº†ä»€ä¹ˆ
### 1. çŽ¯å¢ƒ
- ä¸Šåˆå¯åŠ¨å‰åŽç«¯ï¼ˆåŽç«¯ 8090、前端 8080,10:02 å°±ç»ªï¼‰ã€‚
- 22:23 åŽç«¯é‡å¯ï¼ˆé‰´æƒåŠ å›ºåŽï¼‰ï¼Œ22:26/22:29 å‰åŽç«¯å†æ¬¡é‡å¯ï¼Œå‡éªŒè¯å°±ç»ªã€‚
### 2. ç³»ç»ŸçŽ°çŠ¶æ ¸æŸ¥ï¼ˆå›žç­”"还有哪些问题""报表到底完成几张")
- **报表口径纠正**:25 æ˜¯**导出接口数**,实际=**20 å¼ æŠ¥è¡¨ + 5 ä¸ªæ‰“包接口**(货运/旅客/城市客运/投资 4 ä¸ªåˆ†ç»„ zip + 1 ä¸ª"生成选中"zip)。
- **实测 2026-08 å…¨éƒ¨ 20 å¼ **:19 å¼ èƒ½å‡ºæ–‡ä»¶ï¼Œ1 å¼ æŠ¥é”™ï¼ˆç½‘约车客运量拆分表,500,缺外部输入 `docs/城市客运/网约车订单及全省总量.xlsx`)。
- **"能不能真上报"再筛一遍**(同一张报表导 2026-07 ä¸Ž 2026-08 é€æ ¼æ¯”对):**14 å¼ éšæŠ¥è¡¨æœŸå˜åŒ–**(汇总大表 1、货运 3、旅客 3、城市客运 3、投资 4),**5 å¼ ä¸¤æœŸå®Œå…¨ä¸€æ ·**(中口径明细、城市公交明细、巡游出租明细、轨道轮渡明细、能运汇总表),**1 å¼ æŠ¥é”™**。
- **根因定位**:那 4 å¼ æ˜Žç»†çš„æ¨¡æ¿åªæœ‰"2026å¹´1月…7月"7 ä¸ªæœˆä»½åˆ—,8 æœˆçš„值被写到"累计"列后又被累计公式重算覆盖,所以 8 æœˆæ•°æ®è¿›ä¸åŽ»ï¼ˆåº“é‡Œæœ‰ 8 æœˆæ•°æ®ï¼Œå¦‚武汉市 8 æœˆå…¬äº¤å®¢è¿é‡ 3856.83 ä¸‡äººæ¬¡ï¼Œæœªå–到)。
- **审核实测**:2026-08 å…­ç±»å…¨éƒ¨è·‘通(H2032 483 æ¡ã€H2031 83 æ¡ã€H204 2029 æ¡ã€INVEST 0、CITY_BUS 34、CITY_TAXI 18);2026-09 åªæœ‰ 34/0/2027/0/0/0——9 æœˆæœˆæŠ¥ä¸Žæ¯”对源(轨迹里程、运政车辆)均未导入,等于"假审核"。**H204 8 æœˆ 2029 æ¡é‡Œ 1284 æ¡ä¸ºå‡é˜³æ€§**(比对源 `h204_auth_vehicle` åªæœ‰ 7 æœˆæ•°æ®ï¼Œ8 æœˆ 1284 å°è½¦è¢«å…¨åˆ¤å¼‚常)。
- **操作日志核实**:`sys_operation_log` 440 æ¡**只记报表导出**,导入/审核/登录均未记录;且失败也照记(网约车拆分表 500 ä»è®°ä¸º"导出报表"),不能当成功凭据。
### 3. å®‰å…¨æ•´æ”¹ï¼ˆä»Šæ—¥ä¸»è¦ä»£ç å·¥ä½œï¼‰
按四项风险逐一处置并验证,详见《功能测试报告_接口鉴权与AI数据脱敏加固_2026-09-14.md》:
1. **接口鉴权**:新增 `JwtAuthFilter`,`/api/**` é™¤ç™»å½•/验证码白名单外一律要求 `Bearer` ä»¤ç‰Œï¼ˆè¿”回 JSON 401);`jwt.secret` æ¢æˆ 64 ä½éšæœºä¸²ã€‚
2. **跨域收紧**:由 `*` + `allowCredentials(true)` æ”¹ä¸ºé…ç½®åŒ–来源白名单、关闭凭证携带。
3. **AI æ•°æ®è„±æ•**:去掉 `columns:"*"` æ”¾å¼€æ•´è¡¨çš„口子;屏蔽 7 ä¸ªèº«ä»½/联系类字段(可查列、过滤列、排序列、给模型的表结构提示一并剔除);外发前企业名称换 `Company1…` åˆ«åï¼Œè¿”回前还原。
4. **日志脱敏**:MyBatis ç”±é»˜è®¤ `StdOutImpl` æ”¹ä¸º `Slf4jImpl`,不再把整行业务数据(含企业名/电话)打进日志。
5. **连带修复**:前端 `AuditResult.vue`(审核结果导出)、`DataImport.vue`(模板下载)由 `window.open` æ”¹ä¸ºèµ° axios å¸¦ä»¤ç‰Œä¸‹è½½ã€‚
6. æ–‡æ¡£ï¼š`docs/database.md` ç¬¬ä¸ƒç« è¡¥å……"实现说明",注明 `llm_desensitize_map` ä¸ºé¢„留表、当前为内存态脱敏。
## äºŒã€æµ‹è¯•结果摘要
- æœªç™»å½•访问 6 ç»„业务接口 â†’ å…¨éƒ¨ 401;登录白名单正常;带令牌 200(审核结果导出 59965 å­—节、周转量排名 14589 å­—节)。
- è·¨åŸŸï¼šç™½åå•来源放行、`evil.example.com` ç­‰è¢« 403;预检 OPTIONS æ­£å¸¸ã€‚
- **报文级验证**:把 `deepseek.api-url` ä¸´æ—¶æŒ‡å‘本地假服务抓包,确认①`columns:"*"` åªå›žé»˜è®¤åˆ—;②指定 `contactPhone`/`unifiedCreditCode` è¢«æ‹’;③用 `contactPhone` è¿‡æ»¤è¢«æ‹’;④外发报文里企业名称是 `Company1`/`Company2`。测试后已恢复真实地址。
- æ—¥å¿—:重启后 `backend.log` è¡Œè½¬å‚¨ 0 æ¡ã€æ‰‹æœºå·æ ·å¼ 0 æ¡ï¼ˆæ•´æ”¹å‰å•次查询即数百条)。
## ä¸‰ã€é—留问题与待办
1. **接口鉴权后所有在线用户需重新登录**(`jwt.secret` å·²æ›´æ¢ï¼‰ã€‚
2. å‰ç«¯å®¡æ ¸ç»“果导出、模板下载两处改动**待浏览器点验**。
3. 3D éªŒè¯ç ä»é»˜è®¤å…³é—­ï¼ˆ`app.captcha.required: false`),生产部署前建议开启。
4. `sys_role`/`sys_user_role` ç©ºè¡¨ï¼Œè§’色权限未落地。
5. **8 æœˆå®¡æ ¸è¦çœŸæ­£å¯ç”¨**,需先导入 8 æœˆèƒ½è€—运政车辆数据(消掉 1284 æ¡å‡é˜³æ€§ï¼‰ï¼›å»ºè®®è¡¥"比对基准缺失保护"(0.5 å¤©ï¼‰ã€‚
6. **9 æœˆå®¡æ ¸è¦å¯ç”¨**,缺月报与比对源,需业务上报后导入。
7. 4 å¼ æ˜Žç»†æŠ¥è¡¨æ¨¡æ¿æ‰©åˆ—(8 æœˆèµ·å¯ç”¨ï¼‰ï¼Œä¼° 2 ä¸ªå·¥ä½œæ—¥ã€‚
8. èƒ½è¿æ±‡æ€»è¡¨ã€ç½‘约车拆分表各有数据/来源卡点,待业务确认。
9. `backend.log` åŽ†å²å†…å®¹é‡Œä»æœ‰æ•´æ”¹å‰çš„æ•´è¡Œä¸šåŠ¡æ•°æ®ï¼ˆæ˜Žæ–‡ä¼ä¸šåä¸Žç”µè¯ï¼‰ï¼Œæ–‡ä»¶æœªå…¥åº“ï¼›æ˜¯å¦éœ€è¦æ¸…ç†å¾…ç”¨æˆ·ç¡®è®¤ã€‚
## å››ã€æ˜Žå¤©è®¡åˆ’
1. 4 å¼ æ˜Žç»†æŠ¥è¡¨æ¨¡æ¿æ‰©åˆ—与取数(城市公交、巡游出租、轨道轮渡、中口径明细),完成后逐格核对并写测试报告。
2. è¡¥"审核比对基准缺失保护",让 8 æœˆèƒ½è€—审核的 1284 æ¡å‡é˜³æ€§æ¶ˆå¤±ã€‚
3. å¯¼å…¥æ ¡éªŒå¢žå¼ºï¼šæ ¡éªŒæ–‡ä»¶æœˆä»½ä¸Žæ‰€é€‰æŠ¥è¡¨æœŸä¸€è‡´ã€é‡å¤å¯¼å…¥æç¤ºã€æ‰¹æ¬¡æº¯æºå›žå¡«ã€‚
4. è§†ç”¨æˆ·ç¡®è®¤ï¼Œæ¸…理 `backend.log` ä¸­åŽ†å²æ˜Žæ–‡æ•°æ®ã€‚
traffic-audit-server/src/main/java/com/trafficaudit/common/config/CorsConfig.java
@@ -1,22 +1,46 @@
package com.trafficaudit.common.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
import java.util.List;
/**
 * è·¨åŸŸé…ç½®ï¼šåªå…è®¸é…ç½®ä¸­åˆ—出的来源访问接口。
 * å‰åŽç«¯åˆ†ç¦»æœ¬åœ°å¼€å‘默认放行 8080;前端经 dev-server ä»£ç†æˆ–与后端同源部署时均不需要额外放行。
 * æ³¨æ„ï¼šä¸å¼€å¯ allowCredentials(接口用 Bearer ä»¤ç‰Œé‰´æƒï¼Œä¸ä¾èµ– Cookie),避免任意站点携带凭证调用。
 */
@Configuration
public class CorsConfig {
    @Value("${app.cors.allowed-origins:http://localhost:8080,http://127.0.0.1:8080}")
    private String allowedOrigins;
    private List<String> originList() {
        return Arrays.asList(allowedOrigins.split(","));
    }
    @Bean
    public CorsFilter corsFilter() {
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOriginPattern("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowCredentials(true);
        config.setAllowedOrigins(originList());
        config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept", "X-Requested-With"));
        config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowCredentials(false);
        config.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
        return source;
    }
}
    @Bean
    public CorsFilter corsFilter() {
        return new CorsFilter(corsConfigurationSource());
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/llmintegration/service/DbQueryExecutor.java
@@ -28,6 +28,17 @@
    @Resource
    private JdbcTemplate jdbcTemplate;
    @Resource
    private DesensitizeService desensitizeService;
    /** ç¦æ­¢å¤–发到大模型的字段:个人与主体身份类信息(在可查列、过滤列、排序列中一并屏蔽) */
    private static final java.util.Set<String> BLOCKED_COLUMNS = new java.util.TreeSet<>(Arrays.asList(
            "unified_credit_code", "contact_person", "contact_phone",
            "unit_leader", "stats_leader", "report_unit", "enterprise_code"));
    /** è‡ªç”±æ–‡æœ¬é‡Œæ®‹ç•™çš„æ‰‹æœºå· */
    private static final Pattern PHONE = Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
    public static final Set<String> ALLOWED_TABLES = new TreeSet<>();
    private static final Map<String, TableMeta> TABLES = new LinkedHashMap<>();
@@ -146,7 +157,7 @@
        for (TableMeta meta : TABLES.values()) {
            sb.append(meta.table).append("【").append(meta.desc).append("】: ");
            List<String> parts = new ArrayList<>();
            for (String c : meta.cols) {
            for (String c : selectable(meta)) {
                parts.add(snakeToCamel(c) + "(" + meta.labels.getOrDefault(c, snakeToCamel(c)) + ")");
            }
            sb.append(String.join(", ", parts)).append("\n");
@@ -179,6 +190,7 @@
            sql.append(" LIMIT ").append(lim);
            List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), w.args.toArray());
            maskRows(rows);
            out.set("success", true);
            out.set("rows", rows);
            out.set("count", rows.size());
@@ -196,21 +208,68 @@
        if (columnsParam == null) {
            return aliased(meta.defaults);
        }
        // ä¼ å…¥ "*" æ—¶åªè¿”回默认列,不再放开整表(敏感字段不参与查询)
        if (columnsParam instanceof String && "*".equals(columnsParam)) {
            return aliased(meta.cols);
            return aliased(meta.defaults);
        }
        if (columnsParam instanceof List) {
            List<String> out = new ArrayList<>();
            List<String> usable = selectable(meta);
            for (Object o : (List<?>) columnsParam) {
                String c = normalizeCol(String.valueOf(o));
                if (!meta.cols.contains(c)) {
                    throw new IllegalArgumentException("列名不合法: " + o + ",该表可用列: " + camelList(meta.cols));
                if (BLOCKED_COLUMNS.contains(c)) {
                    throw new IllegalArgumentException("该字段不允许查询: " + o);
                }
                if (!usable.contains(c)) {
                    throw new IllegalArgumentException("列名不合法: " + o + ",该表可用列: " + camelList(usable));
                }
                out.add(c);
            }
            return aliased(out.isEmpty() ? meta.defaults : out);
        }
        return aliased(meta.defaults);
    }
    /** å¤–发前脱敏:企业名称替换为别名,自由文本中的企业名称与手机号一并处理 */
    private void maskRows(List<Map<String, Object>> rows) {
        if (rows == null) {
            return;
        }
        for (Map<String, Object> row : rows) {
            Object nameObj = row.get("enterpriseName");
            String realName = nameObj == null ? null : String.valueOf(nameObj);
            String alias = null;
            if (realName != null && !realName.isEmpty()) {
                alias = desensitizeService.maskEnterprise(realName);
                row.put("enterpriseName", alias);
            }
            for (Map.Entry<String, Object> e : row.entrySet()) {
                Object v = e.getValue();
                if (!(v instanceof String)) {
                    continue;
                }
                String s = (String) v;
                String outText = s;
                if (realName != null && !realName.isEmpty() && outText.contains(realName)) {
                    outText = outText.replace(realName, alias);
                }
                outText = PHONE.matcher(outText).replaceAll("手机号已脱敏");
                if (!outText.equals(s)) {
                    e.setValue(outText);
                }
            }
        }
    }
    /** è¯¥è¡¨å¯æŸ¥å­—段 = å…¨éƒ¨å­—段 - æ•æ„Ÿå­—段 */
    private static List<String> selectable(TableMeta meta) {
        List<String> out = new ArrayList<>();
        for (String c : meta.cols) {
            if (!BLOCKED_COLUMNS.contains(c)) {
                out.add(c);
            }
        }
        return out;
    }
    private List<String> aliased(List<String> cols) {
@@ -239,8 +298,9 @@
        }
        for (Map.Entry<String, Object> e : filters.entrySet()) {
            String col = normalizeCol(String.valueOf(e.getKey()));
            if (!meta.cols.contains(col)) {
                throw new IllegalArgumentException("列名不合法: " + e.getKey() + ",该表可用列: " + camelList(meta.cols));
            List<String> usable = selectable(meta);
            if (BLOCKED_COLUMNS.contains(col) || !usable.contains(col)) {
                throw new IllegalArgumentException("列名不合法: " + e.getKey() + ",该表可用列: " + camelList(usable));
            }
            Object v = e.getValue();
            if (v instanceof List) {
@@ -281,7 +341,7 @@
            s = s.substring(0, s.length() - 4).trim();
        }
        String col = normalizeCol(s);
        if (!meta.cols.contains(col)) {
        if (BLOCKED_COLUMNS.contains(col) || !selectable(meta).contains(col)) {
            throw new IllegalArgumentException("排序列不合法: " + orderBy);
        }
        return col + (desc ? " DESC" : " ASC");
traffic-audit-server/src/main/java/com/trafficaudit/llmintegration/service/DesensitizeService.java
@@ -7,6 +7,10 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * ä¼ä¸šåç§°è„±æ•ï¼šæŠŠä¼ä¸šåç§°æ›¿æ¢ä¸º Company1、Company2… åŽå‘给大模型,回答返回前再还原。
 * ä»…保存在内存中,不落库。
 */
@Slf4j
@Service
public class DesensitizeService {
@@ -16,6 +20,9 @@
    private final AtomicInteger counter = new AtomicInteger(1);
    public String maskEnterprise(String name) {
        if (name == null) {
            return null;
        }
        return enterpriseMap.computeIfAbsent(name, k -> {
            String alias = "Company" + counter.getAndIncrement();
            reverseMap.put(alias, name);
@@ -27,9 +34,23 @@
        return reverseMap.getOrDefault(alias, alias);
    }
    /** æŠŠæ•´æ®µæ–‡æœ¬é‡Œå‡ºçŽ°è¿‡çš„è„±æ•åˆ«åè¿˜åŽŸä¸ºçœŸå®žä¼ä¸šåç§°ï¼ˆå›žç­”è¿”å›žç»™å‰ç«¯å‰è°ƒç”¨ï¼‰ */
    public String unmaskAll(String text) {
        if (text == null || text.isEmpty() || reverseMap.isEmpty()) {
            return text;
        }
        String out = text;
        for (Map.Entry<String, String> e : reverseMap.entrySet()) {
            if (out.contains(e.getKey())) {
                out = out.replace(e.getKey(), e.getValue());
            }
        }
        return out;
    }
    public void clear() {
        enterpriseMap.clear();
        reverseMap.clear();
        counter.set(1);
    }
}
}
traffic-audit-server/src/main/java/com/trafficaudit/llmintegration/service/LLMService.java
@@ -24,6 +24,8 @@
    private DeepSeekConfig config;
    @Resource
    private DbQueryExecutor dbQueryExecutor;
    @Resource
    private DesensitizeService desensitizeService;
    public String chat(String prompt) {
        JSONObject body = JSONUtil.createObj()
@@ -84,7 +86,11 @@
            JSONArray toolCalls = message.getJSONArray("tool_calls");
            if (toolCalls == null || toolCalls.isEmpty()) {
                String content = message.getStr("content");
                return content == null ? "(AI æœªè¿”回内容)" : content;
                if (content == null) {
                    return "(AI æœªè¿”回内容)";
                }
                // å›žå¡«ç»™å‰ç«¯å‰ï¼ŒæŠŠä¼ä¸šåç§°åˆ«åè¿˜åŽŸä¸ºçœŸå®žåç§°
                return desensitizeService.unmaskAll(content);
            }
            // å›žä¼  assistant æ¶ˆæ¯ï¼ˆå«å·¥å…·è°ƒç”¨ï¼‰ï¼Œå†é€ä¸ªæ‰§è¡Œå¹¶å›žå¡« tool ç»“æžœ
traffic-audit-server/src/main/java/com/trafficaudit/security/config/SecurityConfig.java
@@ -1,5 +1,6 @@
package com.trafficaudit.security.config;
import com.trafficaudit.security.filter.JwtAuthFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -8,10 +9,16 @@
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.annotation.Resource;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Resource
    private JwtAuthFilter jwtAuthFilter;
    @Bean
    public PasswordEncoder passwordEncoder() {
@@ -22,12 +29,19 @@
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .cors().and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/api/**").permitAll()
            .antMatchers("/", "/index.html", "/js/**", "/css/**", "/fonts/**", "/img/**", "/favicon.ico").permitAll()
            .anyRequest().authenticated();
            // ç™»å½•前必须可用的接口
            .antMatchers("/api/auth/login", "/api/auth/captcha", "/api/auth/captcha/verify",
                         "/api/auth/captcha-config").permitAll()
            // å‰ç«¯é™æ€èµ„源
            .antMatchers("/", "/index.html", "/favicon.ico", "/js/**", "/css/**", "/fonts/**", "/img/**", "/static/**").permitAll()
            .anyRequest().permitAll()
            .and()
            // æŽ¥å£é‰´æƒåœ¨ JwtAuthFilter å†…完成(返回 JSON 401)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}
}
traffic-audit-server/src/main/java/com/trafficaudit/security/filter/JwtAuthFilter.java
New file
@@ -0,0 +1,64 @@
package com.trafficaudit.security.filter;
import com.trafficaudit.security.utils.JwtUtils;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
/**
 * æŽ¥å£ç™»å½•校验过滤器:/api/** é™¤ç™½åå•外必须携带有效 Bearer ä»¤ç‰Œï¼Œå¦åˆ™ç›´æŽ¥è¿”回 401。
 * ç™½åå•只保留登录前必须调用的接口(登录、行为验证码)。
 */
@Component
@Order(1)
public class JwtAuthFilter extends OncePerRequestFilter {
    /** å…ç™»å½•接口(登录页在拿到令牌前必须能调用) */
    private static final List<String> WHITELIST = Arrays.asList(
            "/api/auth/login",
            "/api/auth/captcha",
            "/api/auth/captcha/verify",
            "/api/auth/captcha-config"
    );
    @Resource
    private JwtUtils jwtUtils;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String uri = request.getRequestURI();
        // éžæŽ¥å£è¯·æ±‚(前端静态资源、首页)放行
        if (uri == null || !uri.startsWith("/api")) {
            chain.doFilter(request, response);
            return;
        }
        // é¢„检请求放行,交由 CORS å¤„理
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            chain.doFilter(request, response);
            return;
        }
        if (WHITELIST.contains(uri)) {
            chain.doFilter(request, response);
            return;
        }
        String username = jwtUtils.resolveUsername(request.getHeader("Authorization"));
        if (username == null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\",\"data\":null}");
            return;
        }
        request.setAttribute("loginUser", username);
        chain.doFilter(request, response);
    }
}
traffic-audit-server/src/main/resources/application.yml.example
@@ -31,7 +31,8 @@
  type-aliases-package: com.trafficaudit
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
    # æ˜¾å¼æŒ‡å®š Slf4j:MyBatis-Plus é»˜è®¤ StdOutImpl ä¼šæŠŠæ•´è¡Œæ•°æ®ï¼ˆå«ä¼ä¸šå/电话)打进日志
    log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
  global-config:
    db-config:
      id-type: auto
@@ -68,5 +69,8 @@
# Behavior captcha on login. Set to false to skip verification during test phase; enable before production release.
app:
  cors:
    # å…è®¸è·¨åŸŸè®¿é—®æŽ¥å£çš„æ¥æºï¼ˆé€—号分隔)
    allowed-origins: http://localhost:8080,http://127.0.0.1:8080
  captcha:
    required: false
traffic-audit-web/src/views/AuditResult.vue
@@ -609,8 +609,26 @@
    },
    exportExcel() {
      if (!this.period) { this.$message.warning('请选择报表期'); return }
      const q = 'period=' + this.period + '&reportType=' + this.reportType + '&onlyVerify=' + (this.issueScope !== 'all') + '&status=' + (this.statusScope || 'all')
      window.open('/api/audit/export?' + q)
      const params = {
        period: this.period,
        reportType: this.reportType,
        onlyVerify: this.issueScope !== 'all',
        status: this.statusScope || 'all'
      }
      api.get('/audit/export', { params, responseType: 'blob' })
        .then(res => this.downloadBlob(res, '审核结果_' + this.period + '.xlsx'))
        .catch(() => this.$message.error('导出失败:请确认该报表期已有审核结果'))
    },
    downloadBlob(res, fileName) {
      const blob = res instanceof Blob ? res : new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
      const url = window.URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = fileName
      document.body.appendChild(a)
      a.click()
      window.URL.revokeObjectURL(url)
      document.body.removeChild(a)
    },
    review(row, status) {
      if (status === 'IGNORED') {
traffic-audit-web/src/views/DataImport.vue
@@ -334,7 +334,20 @@
    typeLabel(t) { return TYPE_LABELS[t] || t },
    downloadTemplate() {
      if (!this.templateUrl) return
      window.open('/api/templates/' + this.templateUrl)
      const name = this.templateUrl.split('/').pop()
      api.get('/templates/' + this.templateUrl, { responseType: 'blob' })
        .then(res => {
          const blob = res instanceof Blob ? res : new Blob([res])
          const url = window.URL.createObjectURL(blob)
          const a = document.createElement('a')
          a.href = url
          a.download = name
          document.body.appendChild(a)
          a.click()
          window.URL.revokeObjectURL(url)
          document.body.removeChild(a)
        })
        .catch(() => this.$message.error('模板下载失败,请稍后重试'))
    },
    onModuleChange(key) {
      this.importType = this.moduleSel[key] || ''