liu
8 小时以前 da93fafc417b4f590b6ab639ccbbe67c14c8753c
feat(mes): 种子标签追溯配置改为字典驱动

追溯网址、生产经营者名称、进口商名称、是否进口种子四项配置原先只能改配置文件并重启服务,
而这几项恰是最常变动的运营信息。现改为优先取字典 mes_trace_config,管理员在「字典管理」
中改完约 1 分钟生效,无需改配置重启。配置文件保留为兜底默认值。

取值规则(四项各自独立判定):

- 字典有启用项且值非空且合法 → 用字典值
- 字典未建 / 无启用项 / 值为空 → 回退配置文件,属正常的「尚未配置」路径
- 追溯网址的字典值非空但不合法(占位符 / 本机 / 内网地址)→ 记 ERROR 并回退配置文件。
配置文件的值已被启动校验保证合法,回退不会印出不合规标签;反之若在此抛异常,
字典里一次手误就会让全平台标签导出停摆,代价更大
- 字典与配置文件都拿不到追溯网址 → 抛 PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED 拒绝导出

配套改动:

- 新增 MesTraceConfigResolver:读字典并合并配置文件兜底值,60 秒本地缓存
(数据库查询每 60 秒至多一次,与标签数量无关),任何路径都不向外抛异常——解析发生在
批量导出的高频循环里,一次数据库抖动不应中断整批导出。不用 DictFrameworkUtils 的原因:
它把字典投影成 List<String> 会丢弃 status 无法过滤禁用项,且其 dictDataApi 为自动配置
注入的静态字段,与本类首次调用无顺序保证
- 新增 MesTraceUrlValidator:配置文件与字典共用同一套合规校验,避免出现「配置文件拦得住、
字典绕得过」的缺口
- MesProQrLabelBuilder 增加配置来源 provider 钩子,静态调用点全部不变;袋码标签只取一次
快照,保证标签可读文本与二维码内容取到同一个网址(否则缓存刷新会让两者指向不同地址)
- MesTraceConfigController 改取生效快照:原先直读 MesProperties 绕过了标签构建器,
字典生效后管理端预览会与实际打印的标签不一致;响应新增 traceUrlSource 便于确认是否生效
- mes.allow-plain-qr-code 有意不纳入字典:它是「允许印出不含追溯网址的标签」的开关,
放进可后台编辑的字典等于给合规防线开后门
- 启动校验仍只针对配置文件,以保证数据库可用之前即可拦截不合规配置,且 @PostConstruct
阶段不访问数据库

另:新增字典初始化脚本 sql/mysql/mes_trace_config_init.sql(键值留空,上线零行为变化)、
前端联调方案 docs/mes_trace_config_dict_integration.md,补 25 个单元测试。
前端无需改动,管理端与 H5 溯源页均只消费接口下发值。

Co-Authored-By: Claude <noreply@anthropic.com>
已添加10个文件
已修改7个文件
1160 ■■■■■ 文件已修改
docs/mes_trace_config_dict_integration.md 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/seed_qr_label_gb_compliance_integration.md 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_trace_config_init.sql 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/trace/MesTraceConfigController.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/trace/vo/MesTraceQrConfigRespVO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesProperties.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesQrCodeConfig.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceConfigResolver.java 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceConfigSnapshot.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceUrlSource.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceUrlValidator.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesProQrLabelBuilder.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesTraceConfigProvider.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceConfigResolverTest.java 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceUrlValidatorTest.java 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesProQrLabelBuilderTest.java 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application.yaml 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/mes_trace_config_dict_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
# æº¯æºé…ç½®æ”¹ä¸ºå­—典驱动 - å‰ç«¯è”调方案
## èƒŒæ™¯
种子二维码标签的**追溯网址**、**生产经营者名称**此前只能改配置文件再重启服务。本次改为可在「系统管理 â†’ å­—典管理」的字典 `mes_trace_config` ä¸­ç›´æŽ¥ä¿®æ”¹ï¼Œæ”¹å®Œçº¦ 1 åˆ†é’Ÿå†…生效。
**接口路径与请求参数均无变化**,仅 `/mes/trace-config/qr-config` çš„响应新增一个字段 `traceUrlSource`。前端**不改造也能正常跑**(新字段可忽略),按需接入即可。
## æ¶‰åŠé¡µé¢
- ç”Ÿäº§æ‰¹æ¬¡ç®¡ç† - æ‰¹æ¬¡ã€ŒæŸ¥çœ‹äºŒç»´ç ã€å¼¹çª—
- è¢‹ç ç®¡ç†ï¼ˆèµ‹ç ç®¡ç†ï¼‰ - è¢‹ç ã€ŒæŸ¥çœ‹äºŒç»´ç ã€å¼¹çª—
- æ‰˜ç›˜ç®¡ç† - æ‰˜ç›˜ã€ŒæŸ¥çœ‹äºŒç»´ç ã€å¼¹çª—
- H5 æ‰«ç æº¯æºé¡µï¼ˆæ¶ˆè´¹è€…扫码后打开的页面)
- ç³»ç»Ÿç®¡ç† â†’ å­—典管理(运维/业务修改配置的入口)
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /admin-api/mes/trace-config/qr-config | èŽ·å¾—æº¯æº H5 é¡µé¢åŸºç¡€åœ°å€ï¼ˆäºŒç»´ç å†…容前缀) |
**请求参数:** æ— ï¼ˆç™»å½•态即可访问)
**响应字段新增:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| traceUrlSource | String | è¿½æº¯ç½‘址的实际生效来源,取值见下表 |
**`traceUrlSource` å–值说明:**
| å–值 | å«ä¹‰ | å‰ç«¯å»ºè®®æç¤º |
|------|------|--------------|
| DICT | å–自字典,管理员在字典管理里配置的值已生效 | æ— éœ€æç¤º |
| YAML | å­—典未配置该值,使用配置文件中的地址(正常回退) | æ— éœ€æç¤º |
| YAML_INVALID_DICT | å­—典里填了地址,但未通过合规校验(占位符 / æœ¬æœº / å†…网地址),已回退配置文件 | æç¤ºç®¡ç†å‘˜åŽ»å­—å…¸ç®¡ç†ä¿®æ­£ |
**响应示例:**
```json
{
  "code": 0,
  "data": {
    "traceBaseUrl": "http://1.95.78.124:9008/prod/trace.html",
    "traceUrlSource": "DICT"
  },
  "msg": ""
}
```
**免登录接口(H5 æº¯æºé¡µä½¿ç”¨ï¼‰å“åº”无变化:**
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | /public/mes/trace/config | æº¯æºé¡µé…ç½®ï¼ˆç”Ÿäº§ç»è¥è€…名称、追溯网址) |
| GET | /public/mes/trace/query | æ ¹æ®è¿½æº¯ç æŸ¥è¯¢å…¨ç¨‹æº¯æºä¿¡æ¯ |
> è¿™ä¸¤ä¸ªæŽ¥å£è¿”回的 `traceUrl` / `operatorName` å·²è‡ªåŠ¨è·Ÿéšå­—å…¸ï¼Œ**前端无需任何改动**。
## æ•°æ®å¸¦å…¥è¯´æ˜Ž
1. å­—典管理(运维修改「追溯网址」)→ åŽç«¯ç¼“å­˜ â†’ ç®¡ç†ç«¯ã€ŒæŸ¥çœ‹äºŒç»´ç ã€æŽ¥å£ï¼šä¿®æ”¹åŽçº¦ 1 åˆ†é’Ÿå†…,`traceBaseUrl` è‡ªåŠ¨å˜ä¸ºæ–°å€¼ï¼Œ`traceUrlSource` å˜ä¸º `DICT`。前端无需感知,每次打开弹窗拉一次即可。
2. ã€ŒæŸ¥çœ‹äºŒç»´ç ã€æŽ¥å£ â†’ äºŒç»´ç å¼¹çª—:`traceBaseUrl` æ‹¼æŽ¥è¿½æº¯ç ç”ŸæˆäºŒç»´ç å†…容,与后端导出打印的标签**完全一致**(同源同值)。
3. H5 æº¯æºé¡µé…ç½®æŽ¥å£ â†’ æº¯æºé¡µã€Œè¿½æº¯ç½‘址」展示:与标签印刷内容同源,扫码后页面展示的追溯网址与标签上印的一致。
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| traceBaseUrl | äºŒç»´ç å¼¹çª—(二维码内容前缀) | å€¼ä¸ºç©ºæ—¶äºŒç»´ç å›žé€€ä¸ºçº¯ç å€¼ï¼Œä¸æŠ¥é”™ |
| traceUrlSource | äºŒç»´ç å¼¹çª—(可选,建议仅异常时提示) | ä»…当值为 `YAML_INVALID_DICT` æ—¶å»ºè®®ç»™å‡ºå‘Šè­¦æ–‡æ¡ˆ |
| traceUrl | H5 æº¯æºé¡µã€Œè¿½æº¯ç½‘址」 | ä½œä¸ºé“¾æŽ¥å±•示,可点击跳转 |
| operatorName | H5 æº¯æºé¡µã€Œç”Ÿäº§ç»è¥è€…名称」 | è¿›å£ç§å­åœºæ™¯å±•示的是进口商名称 |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|------|------|
| å­—典里配置了合法的追溯网址 | æ ‡ç­¾ã€äºŒç»´ç ã€H5 é¡µé¢ã€ç®¡ç†ç«¯é¢„览全部使用字典值 |
| å­—典项为空 / è¢«ç¦ç”¨ / å­—典类型未创建 | å›žé€€ä½¿ç”¨é…ç½®æ–‡ä»¶ `mes.trace-base-url` çš„值,`traceUrlSource` ä¸º `YAML` |
| å­—典里的地址非法(占位符、localhost、内网 IP) | åŽç«¯è®° ERROR æ—¥å¿—并回退配置文件值,`traceUrlSource` ä¸º `YAML_INVALID_DICT`,**标签导出不中断** |
| å­—典与配置文件都拿不到追溯网址 | å¯¼å‡ºæ ‡ç­¾æ—¶æŠ¥é”™ã€Œæœªé…ç½®è¿½æº¯ç½‘址,无法生成二维码标签」,拒绝导出 |
| å­—典「字典标签」改名 | ä¼šå¯¼è‡´è¯¥é¡¹é…ç½®å¤±æ•ˆï¼ˆä»£ç æŒ‰æ ‡ç­¾ååŒ¹é…ï¼‰ã€‚请提醒运维只改「字典键值」,不要改「字典标签」 |
| ä¿®æ”¹å­—典后的生效时间 | çº¦ 1 åˆ†é’Ÿï¼ˆåŽç«¯ 60 ç§’本地缓存)。若为稀疏调用场景,可能需再触发一次查询或导出才切换 |
## æ³¨æ„äº‹é¡¹
1. **本次前端零必改项**:`traceUrlSource` æ˜¯å¯é€‰çš„增强字段,不接入不影响任何功能。
2. è¿½æº¯ç½‘址是种子标签国标四要素之一,一旦印出无法召回。前端**不要**在任何地方对追溯网址做兜底硬编码,一律以接口返回值为准。
3. äºŒç»´ç å†…容前缀必须与后端一致,前端拼接方式保持 `traceBaseUrl + "?code=" + ç¼–码后的码值` ä¸å˜ï¼›`traceBaseUrl` ä¸ºç©ºæ—¶å›žé€€ä¸ºçº¯ç å€¼ã€‚
4. æ‰«ç æžªæ‰«åˆ°æ‰“印标签的完整 H5 é“¾æŽ¥æ—¶ï¼ŒåŽç«¯ä¼šè‡ªåŠ¨ä»Žé“¾æŽ¥ä¸­æå– `code` å‚数,前端直接原样提交即可。
5. `allow-plain-qr-code`(允许二维码编码纯码值)**有意未纳入字典**,仍由配置文件控制,不在前端暴露入口。
6. å­—典管理中「字典标签」(如「追溯网址」)是代码固定标识,不可改名;可编辑的是「字典键值」。
docs/seed_qr_label_gb_compliance_integration.md
@@ -139,20 +139,42 @@
## é…ç½®é¡¹
后端配置项(`application-*.yaml` çš„ `mes` å‰ç¼€ï¼‰ï¼š
### å­—典配置(优先,可在管理后台修改)
追溯网址等四项配置的**运行时取值优先来自字典** `mes_trace_config`,在「系统管理 â†’ å­—典管理」中修改,改完约 1 åˆ†é’Ÿç”Ÿæ•ˆï¼Œæ— éœ€é‡å¯æœåŠ¡ã€‚å­—å…¸é¡¹ã€Œå­—å…¸æ ‡ç­¾ã€ä¸ºä»£ç å›ºå®šæ ‡è¯†ä¸å¯æ”¹åï¼Œå¯ç¼–è¾‘çš„æ˜¯ã€Œå­—å…¸é”®å€¼ã€ï¼›é”®å€¼ç•™ç©ºå³å›žé€€ä¸‹é¢çš„é…ç½®æ–‡ä»¶ã€‚
| å­—典标签 | è¯´æ˜Ž |
|----------|------|
| è¿½æº¯ç½‘址 | äºŒç»´ç å†…容前缀 + æ ‡ç­¾/H5 å±•示的追溯网址。填了值但非法(占位符/本机/内网)会记 ERROR å¹¶å›žé€€é…ç½®æ–‡ä»¶ |
| ç”Ÿäº§ç»è¥è€…名称 | ç§å­æ ‡ç­¾å››è¦ç´ ä¹‹ä¸€ |
| è¿›å£å•†åç§° | è¿›å£ç§å­åœºæ™¯å¡«å†™ |
| æ˜¯å¦è¿›å£ç§å­ | å¡« true / false |
初始化脚本:`sql/mysql/mes_trace_config_init.sql`(键值留空,上线零行为变化,由运维按环境填写)。
### é…ç½®æ–‡ä»¶ï¼ˆå…œåº•默认值)
后端配置项(`application-*.yaml` çš„ `mes` å‰ç¼€ï¼‰ã€‚前三项现为**字典未配置时的兜底值**:
| é…ç½®é¡¹ | å¿…å¡« | è¯´æ˜Ž |
|--------|------|------|
| `mes.trace-base-url` | æ˜¯ | è¿½æº¯ç½‘址,必须为公众可访问地址。非 local çŽ¯å¢ƒè‹¥ä¸ºå†…ç½‘ IP / localhost / å ä½ç¬¦å°†**拒绝启动** |
| `mes.operator-name` | æ˜¯ | ç”Ÿäº§ç»è¥è€…名称。未配置则无法导出标签(启动时告警) |
| `mes.importer-name` | å¦ | è¿›å£å•†åç§°ï¼Œè¿›å£ç§å­åœºæ™¯å¡«å†™ |
| `mes.imported` | å¦ | æ˜¯å¦è¿›å£ç§å­åœºæ™¯ï¼Œé»˜è®¤ false |
| `mes.allow-plain-qr-code` | å¦ | æ˜¯å¦å…è®¸äºŒç»´ç ç¼–码纯码值,仅供本地兼容历史码,默认 false |
| `mes.trace-base-url` | æ˜¯ | è¿½æº¯ç½‘址兜底值,必须为公众可访问地址。非 local çŽ¯å¢ƒè‹¥ä¸ºå†…ç½‘ IP / localhost / å ä½ç¬¦å°†**拒绝启动**(这道防线必须在数据库可用前生效,故不能只依赖字典) |
| `mes.operator-name` | æ˜¯ | ç”Ÿäº§ç»è¥è€…名称兜底值。未配置则无法导出标签(启动时告警) |
| `mes.importer-name` | å¦ | è¿›å£å•†åç§°å…œåº•值,进口种子场景填写 |
| `mes.imported` | å¦ | æ˜¯å¦è¿›å£ç§å­åœºæ™¯å…œåº•值,默认 false |
| `mes.allow-plain-qr-code` | å¦ | æ˜¯å¦å…è®¸äºŒç»´ç ç¼–码纯码值,仅供本地兼容历史码,默认 false。**有意不纳入字典**:它是绕过「标签必须含追溯网址」的开关,不应开放给后台随意编辑 |
### å–值优先级
1. å­—å…¸ `mes_trace_config` æœ‰å¯ç”¨é¡¹ä¸”「字典键值」非空 â†’ ç”¨å­—典值;
2. å­—典未建 / æ— å¯ç”¨é¡¹ / é”®å€¼ä¸ºç©º â†’ å›žé€€é…ç½®æ–‡ä»¶ï¼›
3. è¿½æº¯ç½‘址的字典值非空但非法 â†’ è®° ERROR æ—¥å¿—并回退配置文件(标签导出不中断);
4. å­—典与配置文件都拿不到追溯网址 â†’ æŠ›ã€Œæœªé…ç½®è¿½æº¯ç½‘址,无法生成二维码标签」,拒绝导出。
## æ³¨æ„äº‹é¡¹
- **前端禁止再硬编码生产经营者名称与追溯网址**,必须通过 `/public/mes/trace/config` èŽ·å–ï¼Œå¦åˆ™ä¼šä¸ŽäºŒç»´ç å†…å®¹ä¸ä¸€è‡´ã€‚
- è¿½æº¯ç½‘址在二维码内容与页面展示之间只有 `mes.trace-base-url` ä¸€ä¸ªçœŸå€¼æºï¼Œå‰ç«¯å±•示时不要自行拼接 `/prod` ç­‰è·¯å¾„。
- è¿½æº¯ç½‘址在二维码内容、标签印刷与页面展示之间是同一个真值源(字典 `mes_trace_config` ä¼˜å…ˆã€é…ç½®æ–‡ä»¶å…œåº•),前端展示时不要自行拼接 `/prod` ç­‰è·¯å¾„。
- å·²ä½œåºŸç çš„判定覆盖三种入口:袋码(状态 30)、托盘码(状态 20)、批次码(状态 40),三者的作废枚举值不同,前端不要共用常量判断。
- **存量标签需重新导出**:改造前导出的二维码内容已固化,其中追溯网址可能为内网地址且标签缺少四要素,需重新导出印刷。
- **袋序号改为原子分配**:原先由「查库内最大值再 +1」得到,并发生成会产生重复袋序号(袋序号会印在标签上);现已改为 Redis åŽŸå­è‡ªå¢žåˆ†é…ï¼Œå¹¶ç”¨åº“å†…æœ€å¤§å€¼æ ¡æ­£åŸºå‡†ã€‚åºå·**只保证不重复、不回退,不保证连续**(并发下不同请求拿到不同区段,属正常)。
sql/mysql/mes_trace_config_init.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
-- ============================================
-- MES ç§å­æ ‡ç­¾è¿½æº¯é…ç½®å­—典初始化脚本
-- ============================================
-- ç›®çš„:把种子二维码标签的「追溯网址」「生产经营者名称」等配置从配置文件搬到字典,
--       ç®¡ç†å‘˜å¯åœ¨ã€Œç³»ç»Ÿç®¡ç† â†’ å­—典管理」中直接修改,改完约 1 åˆ†é’Ÿå†…生效,无需改配置重启服务。
--
-- å–值规则(代码见 MesTraceConfigResolver):
--   1. å­—典项「启用」且「字典键值」非空 â†’ ç”¨å­—典值;
--   2. å­—典项不存在 / è¢«ç¦ç”¨ / é”®å€¼ä¸ºç©º â†’ å›žé€€é…ç½®æ–‡ä»¶ mes.trace-base-url、mes.operator-name ç­‰ï¼›
--   3. ã€Œè¿½æº¯ç½‘址」的字典值非空但不合法(占位符、本机、内网地址)→ è®° ERROR æ—¥å¿—并回退配置文件。
--
-- é‡è¦ï¼šæœ¬è„šæœ¬æœ‰æ„æŠŠã€Œå­—å…¸é”®å€¼ã€å…¨éƒ¨**留空**,这样各环境执行后行为与改造前完全一致(仍走配置文件),
--       å±žäºŽé›¶é£Žé™©ä¸Šçº¿ã€‚部署完成后由运维在字典管理中按环境填写实际值即可接管。
--
-- æ³¨æ„ï¼šã€Œå­—典标签」是代码里的固定标识,不可改名;可编辑的是「字典键值」。
--
-- è¯´æ˜Žï¼šä¸ä½¿ç”¨å¤šç§Ÿæˆ·ï¼Œè„šæœ¬ä¸å« tenant_id å­—段。脚本可重复执行。
-- ============================================
-- ----------------------------
-- 1. å­—典类型
-- ----------------------------
INSERT INTO `system_dict_type` (`name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 'MES ç§å­æ ‡ç­¾è¿½æº¯é…ç½®', 'mes_trace_config', 0,
       '二维码追溯网址、生产经营者名称等标签要素;留空则回退配置文件', '1', NOW(), '1', NOW(), b'0' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM system_dict_type WHERE type = 'mes_trace_config' AND deleted = 0) t);
-- ----------------------------
-- 2. å­—典数据
-- ã€Œå­—典键值」留空即回退配置文件;请按环境在字典管理中填写实际值。
-- ã€Œå­—典标签」为代码固定标识,请勿改名。
-- ----------------------------
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 1065370, 1, '追溯网址', '', 'mes_trace_config', 0, '', '', '1', NOW(), '1', NOW(), b'0' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM system_dict_data WHERE dict_type = 'mes_trace_config' AND label = '追溯网址' AND deleted = 0) t);
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 1065371, 2, '生产经营者名称', '', 'mes_trace_config', 0, '', '', '1', NOW(), '1', NOW(), b'0' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM system_dict_data WHERE dict_type = 'mes_trace_config' AND label = '生产经营者名称' AND deleted = 0) t);
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 1065372, 3, '进口商名称', '', 'mes_trace_config', 0, '', '', '1', NOW(), '1', NOW(), b'0' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM system_dict_data WHERE dict_type = 'mes_trace_config' AND label = '进口商名称' AND deleted = 0) t);
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 1065373, 4, '是否进口种子', '', 'mes_trace_config', 0, '', '', '1', NOW(), '1', NOW(), b'0' FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM (SELECT id FROM system_dict_data WHERE dict_type = 'mes_trace_config' AND label = '是否进口种子' AND deleted = 0) t);
-- ----------------------------
-- 3. å„环境填写示例(正式执行时请在管理后台字典管理中修改,勿直接套用)
--    è¿½æº¯ç½‘址        : http://1.95.78.124:9008/prod/trace.html
--    ç”Ÿäº§ç»è¥è€…名称  : æ–°ç–†å¡”里木河种业股份有限公司
--    è¿›å£å•†åç§°      : ä»…进口种子场景填写,否则留空
--    æ˜¯å¦è¿›å£ç§å­    : true / false
-- ----------------------------
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/trace/MesTraceConfigController.java
@@ -2,7 +2,8 @@
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mes.controller.admin.trace.vo.MesTraceQrConfigRespVO;
import cn.iocoder.yudao.module.mes.framework.config.MesProperties;
import cn.iocoder.yudao.module.mes.framework.config.MesTraceConfigResolver;
import cn.iocoder.yudao.module.mes.framework.config.MesTraceConfigSnapshot;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -17,6 +18,8 @@
 * ç®¡ç†åŽå° - MES æº¯æºé…ç½®
 * <p>
 * å‘管理端前端暴露溯源 H5 é¡µé¢åŸºç¡€åœ°å€ï¼Œç”¨äºŽ"查看二维码"预览/下载与打印标签保持一致的二维码内容。
 * å€¼å–自 {@link MesTraceConfigResolver} çš„**生效快照**而非配置文件——追溯网址可在字典
 * {@code mes_trace_config} ä¸­ä¿®æ”¹ï¼Œè‹¥æ­¤å¤„直读配置文件,管理端预览的二维码就会与实际打印的标签不一致。
 */
@Tag(name = "管理后台 - MES æº¯æºé…ç½®")
@RestController
@@ -25,13 +28,15 @@
public class MesTraceConfigController {
    @Resource
    private MesProperties mesProperties;
    private MesTraceConfigResolver mesTraceConfigResolver;
    @GetMapping("/qr-config")
    @Operation(summary = "获得溯源 H5 é¡µé¢åŸºç¡€åœ°å€ï¼ˆäºŒç»´ç å†…容前缀)")
    public CommonResult<MesTraceQrConfigRespVO> getQrConfig() {
        MesTraceConfigSnapshot snapshot = mesTraceConfigResolver.resolveSnapshot();
        MesTraceQrConfigRespVO respVO = new MesTraceQrConfigRespVO();
        respVO.setTraceBaseUrl(mesProperties.getTraceBaseUrl());
        respVO.setTraceBaseUrl(snapshot.traceUrl());
        respVO.setTraceUrlSource(snapshot.traceUrlSource().name());
        return success(respVO);
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/trace/vo/MesTraceQrConfigRespVO.java
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.mes.controller.admin.trace.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
@@ -10,7 +11,21 @@
    /**
     * æº¯æº H5 é¡µé¢åŸºç¡€åœ°å€ï¼ˆäºŒç»´ç å†…容前缀,如 http://host:port/trace.html)
     * <p>
     * ä¼˜å…ˆå–å­—å…¸ {@code mes_trace_config} ä¸­ã€Œè¿½æº¯ç½‘址」的配置值;字典未配置时回退配置文件。
     */
    @Schema(description = "溯源 H5 é¡µé¢åŸºç¡€åœ°å€ï¼ˆäºŒç»´ç å†…容前缀)",
            example = "http://1.95.78.124:9008/prod/trace.html")
    private String traceBaseUrl;
    /**
     * ä¸Šè¿°åœ°å€çš„实际生效来源,便于确认字典改动是否已生效
     * <p>
     * DICT:取自字典;YAML:字典未配置,回退配置文件;
     * YAML_INVALID_DICT:字典填了值但未通过合规校验,已回退配置文件(需修正字典)。
     */
    @Schema(description = "追溯网址生效来源:DICT / YAML / YAML_INVALID_DICT",
            example = "DICT")
    private String traceUrlSource;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesProperties.java
@@ -15,15 +15,20 @@
    /**
     * H5 æº¯æºé¡µé¢åœ°å€ï¼ˆtrace.html)基础 URL,用于生成携带追溯码参数的二维码内容。
     * <p>
     * è¯¥å€¼ä¸ºã€Œè¿½æº¯ç½‘址」的唯一真值源:二维码内容与 H5 é¡µé¢å±•示的追溯网址均取自此配置。
     * å¿…须为公众扫码可访问的地址,非本地环境配置为内网/占位符地址时启动将直接失败。
     * è¿™æ˜¯ã€Œè¿½æº¯ç½‘址」的**兜底默认值**:运行时优先取字典 {@link MesTraceConfigResolver#DICT_TYPE}
     * ä¸­ã€Œè¿½æº¯ç½‘址」的配置值,可在管理后台「字典管理」中修改而无需重启;字典未配置(或被禁用、
     * æˆ–填了非法地址)时才回退到此配置。
     * <p>
     * æœ¬é¡¹ä»ä¸ºå¿…填且参与启动校验:非本地环境配置为内网/占位符地址时启动将直接失败。
     * è¿™é“防线必须在数据库可用之前就能拦住不合规配置,因此不能只依赖字典。
     */
    private String traceBaseUrl = "";
    /**
     * ç”Ÿäº§ç»è¥è€…名称(种子标签四要素之一)。
     * <p>
     * ä½œä¸ºã€Œç”Ÿäº§ç»è¥è€…名称」的唯一真值源,标签印刷与 H5 æº¯æºé¡µå‡å–此值,禁止在前端硬编码。
     * åŒæ ·æ˜¯**兜底默认值**:运行时优先取字典 {@link MesTraceConfigResolver#DICT_TYPE} ä¸­
     * ã€Œç”Ÿäº§ç»è¥è€…名称」的配置值。标签印刷与 H5 æº¯æºé¡µå‡å–此值,禁止在前端硬编码。
     */
    private String operatorName = "";
@@ -31,11 +36,14 @@
     * è¿›å£å•†åç§°ï¼ˆè¿›å£ç§å­åœºæ™¯ï¼Œæ ‡ç­¾å››è¦ç´ ä¹‹ä¸€ï¼‰ã€‚
     * <p>
     * è¿›å£ç§å­åº”标注进口商名称;为空时回退使用 {@link #operatorName}。
     * è¿è¡Œæ—¶ä¼˜å…ˆå–字典中「进口商名称」的配置值。
     */
    private String importerName = "";
    /**
     * æ˜¯å¦å¤„于进口种子场景:为 true æ—¶æ ‡ç­¾ä¸Žæº¯æºé¡µå±•示「进口商名称」而非「生产经营者名称」
     * <p>
     * è¿è¡Œæ—¶ä¼˜å…ˆå–字典中「是否进口种子」的配置值。
     */
    private boolean imported = false;
@@ -43,6 +51,9 @@
     * å…è®¸äºŒç»´ç ç¼–码纯码值(不带溯源 URL)。
     * <p>
     * ä»…供本地/测试环境兼容历史码使用;生产环境必须为 false,否则会印出不含追溯网址的标签。
     * <p>
     * æœ¬é¡¹**有意不纳入字典**:它是「允许印出不含追溯网址的标签」的开关,放进可后台编辑的字典,
     * ç­‰äºŽç»™ç§å­æ ‡ç­¾çš„合规防线开了一个可被误操作的后门。因此只保留在配置文件中,由开发/运维把控。
     */
    private boolean allowPlainQrCode = false;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesQrCodeConfig.java
@@ -9,7 +9,6 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import java.net.URI;
import java.util.Arrays;
/**
@@ -22,6 +21,12 @@
 *     <li>生产经营者名称必填(标签四要素之一,缺失即为不合规标签);</li>
 *     <li>当前环境必须存在可渲染中文的字体(否则标签中文会变成方框)。</li>
 * </ul>
 * è¿½æº¯ç½‘址与生产经营者名称的**运行时**取值优先来自字典 {@code mes_trace_config},
 * å¯åœ¨ç®¡ç†åŽå°ã€Œå­—典管理」中修改而无需重启;配置文件的值退居为兜底默认值。
 * <p>
 * ä½†å¯åŠ¨æ ¡éªŒ**只针对配置文件**,原因有二:一是这道防线必须在数据库可用之前就能拦住不合规配置;
 * äºŒæ˜¯ @PostConstruct é˜¶æ®µä¸åº”访问数据库。字典值的合规校验由 {@link MesTraceConfigResolver}
 * åœ¨è¯»å–时完成(非法则记 ERROR å¹¶å›žé€€é…ç½®æ–‡ä»¶ï¼Œè§è¯¥ç±»æ³¨é‡Šï¼‰ã€‚
 */
@Slf4j
@Configuration(proxyBeanMethods = false)
@@ -38,9 +43,12 @@
    @Resource
    private Environment environment;
    @Resource
    private MesTraceConfigResolver mesTraceConfigResolver;
    @PostConstruct
    public void init() {
        // 1. æ³¨å…¥é…ç½®ï¼ˆå­—体校验独立于业务配置,本地环境同样必须通过,否则标签中文不可读)
        // 1. æ³¨å…¥é…ç½®æ–‡ä»¶çš„值(字体校验独立于业务配置,本地环境同样必须通过,否则标签中文不可读)
        MesProQrLabelBuilder.setTraceBaseUrl(mesProperties.getTraceBaseUrl());
        MesProQrLabelBuilder.setOperatorName(mesProperties.getOperatorName());
        MesProQrLabelBuilder.setImporterName(mesProperties.getImporterName());
@@ -48,16 +56,21 @@
        MesProQrLabelBuilder.setAllowPlainQrCode(mesProperties.isAllowPlainQrCode());
        QrCodeImageUtils.assertCjkFontAvailable();
        // 2. éžæœ¬åœ°çŽ¯å¢ƒæ‰§è¡Œä¸¥æ ¼æ ¡éªŒ
        // 2. æ ¡éªŒé…ç½®æ–‡ä»¶ã€‚下面的校验与日志都必须读配置文件的值,因此字典来源不能在此之前装配
        if (isLocalProfile()) {
            log.info("[init] å½“前为本地环境({}),跳过标签合规配置的严格校验;traceBaseUrl={}",
            log.info("[init] å½“前为本地环境({}),跳过标签合规配置的严格校验;配置文件 traceBaseUrl={}",
                    PROFILE_LOCAL, mesProperties.getTraceBaseUrl());
            return;
        }
        } else {
        validateTraceBaseUrl();
        validateOperatorName();
        log.info("[init] äºŒç»´ç æ ‡ç­¾é…ç½®æ ¡éªŒé€šè¿‡ï¼›traceBaseUrl={},生产经营者={}",
            log.info("[init] äºŒç»´ç æ ‡ç­¾é…ç½®æ ¡éªŒé€šè¿‡ï¼›é…ç½®æ–‡ä»¶ traceBaseUrl={},生产经营者={}",
                mesProperties.getTraceBaseUrl(), MesProQrLabelBuilder.resolveOperatorName());
        }
        // 3. æœ€åŽè£…配字典来源:此后 resolveTraceUrl()/resolveOperatorName() ä¼˜å…ˆèµ°å­—å…¸
        //    ï¼ˆå­—典未配置则自动回退配置文件)。此处只装配方法引用、绝不调用——
        //    è§£æžå­—典需要访问数据库,不能在 @PostConstruct é˜¶æ®µè§¦å‘。
        MesProQrLabelBuilder.setTraceConfigProvider(mesTraceConfigResolver::resolveSnapshot);
    }
    private boolean isLocalProfile() {
@@ -65,26 +78,17 @@
    }
    /**
     * æ ¡éªŒè¿½æº¯ç½‘址:必须为公众扫码可访问的地址
     * æ ¡éªŒé…ç½®æ–‡ä»¶ä¸­çš„追溯网址:必须为公众扫码可访问的地址
     * <p>
     * ä¸Žå­—典值共用 {@link MesTraceUrlValidator},保证两个来源的准入门槛完全一致,
     * ä¸ä¼šå‡ºçŽ°ã€Œé…ç½®æ–‡ä»¶æ‹¦å¾—ä½ã€å­—å…¸ç»•å¾—è¿‡ã€çš„ç¼ºå£ã€‚
     */
    private void validateTraceBaseUrl() {
        String url = StrUtil.trim(mesProperties.getTraceBaseUrl());
        if (StrUtil.isEmpty(url)) {
            throw new IllegalStateException("mes.trace-base-url æœªé…ç½®ã€‚二维码标签必须包含追溯网址,"
                    + "请在配置中填写公众扫码可访问的 H5 æº¯æºé¡µåœ°å€ã€‚");
        }
        if (StrUtil.containsAnyIgnoreCase(url, "your-domain", "example.com", "localhost")) {
            throw new IllegalStateException("mes.trace-base-url ä»ä¸ºå ä½ç¬¦æˆ–本机地址:" + url
                    + ",印出的标签消费者将无法访问,请填写真实的外网地址。");
        }
        String host = extractHost(url);
        if (StrUtil.isEmpty(host)) {
            throw new IllegalStateException("mes.trace-base-url æ ¼å¼éžæ³•,无法解析主机名:" + url);
        }
        if (isPrivateHost(host)) {
            throw new IllegalStateException("mes.trace-base-url æŒ‡å‘内网地址(" + host + "):" + url
                    + ",公众扫码无法访问,请填写外网可访问的地址。");
        }
        MesTraceUrlValidator.invalidReason(url).ifPresent(reason -> {
            throw new IllegalStateException("mes.trace-base-url " + reason
                    + "(该项是字典 " + MesTraceConfigResolver.DICT_TYPE + " æœªé…ç½®æ—¶çš„兜底值)");
        });
    }
    /**
@@ -96,47 +100,10 @@
     */
    private void validateOperatorName() {
        if (StrUtil.isBlank(MesProQrLabelBuilder.resolveOperatorName())) {
            log.warn("[init] mes.operator-name(生产经营者名称)未配置。该字段为种子标签四要素之一,"
                    + "缺失时将无法导出二维码标签,请尽快在配置中补充。");
            log.warn("[init] ç”Ÿäº§ç»è¥è€…名称未配置(配置文件 mes.operator-name ä¸Žå­—å…¸ {} å‡ä¸ºç©ºï¼‰ã€‚"
                            + "该字段为种子标签四要素之一,缺失时将无法导出二维码标签,请尽快补充。",
                    MesTraceConfigResolver.DICT_TYPE);
        }
    }
    /**
     * ä»Ž URL ä¸­æå–主机名
     */
    private String extractHost(String url) {
        try {
            URI uri = URI.create(url);
            return uri.getHost();
        } catch (IllegalArgumentException ex) {
            return null;
        }
    }
    /**
     * åˆ¤æ–­æ˜¯å¦ä¸ºæœ¬æœº / å†…网 / é“¾è·¯æœ¬åœ°åœ°å€
     */
    private boolean isPrivateHost(String host) {
        if ("localhost".equalsIgnoreCase(host) || host.endsWith(".local")) {
            return true;
        }
        String[] parts = host.split("\\.");
        if (parts.length != 4) {
            return false; // åŸŸåå½¢å¼ï¼Œäº¤ç”± DNS è§£æžï¼Œä¸åœ¨æ­¤å¤„拦截
        }
        int a, b;
        try {
            a = Integer.parseInt(parts[0]);
            b = Integer.parseInt(parts[1]);
        } catch (NumberFormatException ex) {
            return false;
        }
        // 127.0.0.0/8 æœ¬æœºã€10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 ç§ç½‘、169.254.0.0/16 é“¾è·¯æœ¬åœ°
        return a == 127
                || a == 10
                || (a == 172 && b >= 16 && b <= 31)
                || (a == 192 && b == 168)
                || (a == 169 && b == 254);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceConfigResolver.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,192 @@
package cn.iocoder.yudao.module.mes.framework.config;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.biz.system.dict.dto.DictDataRespDTO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.util.cache.CacheUtils;
import cn.iocoder.yudao.module.system.api.dict.DictDataApi;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
 * ç§å­æ ‡ç­¾é…ç½®è§£æžå™¨ï¼šæŠŠã€Œå­—典配置值 + é…ç½®æ–‡ä»¶å…œåº•值」合并为一份生效快照。
 * <p>
 * è¿½æº¯ç½‘址、生产经营者名称等标签要素此前只能改配置文件再重启服务,本次改为可在
 * ã€Œç³»ç»Ÿç®¡ç† â†’ å­—典管理」的字典 {@code mes_trace_config} ä¸­ç›´æŽ¥ä¿®æ”¹ï¼Œæ”¹å®Œæ— éœ€é‡å¯ã€‚
 * <p>
 * åˆå¹¶è§„则(四项配置各自独立判定):
 * <ol>
 *     <li>字典有启用项且值非空 â†’ ç”¨å­—典值;</li>
 *     <li>字典未建该字典类型 / æ— å¯ç”¨é¡¹ / å€¼ä¸ºç©º â†’ å›žé€€é…ç½®æ–‡ä»¶ï¼ˆæ­£å¸¸çš„「尚未配置」路径);</li>
 *     <li>追溯网址的字典值非空但**不合法**(占位符、本机、内网地址等)→ è®° ERROR æ—¥å¿—并回退配置文件。</li>
 * </ol>
 * ç¬¬ 3 æ¡æ˜¯æœ‰æ„ä¸ºä¹‹çš„「降级而不阻断」:配置文件的值已被启动校验保证合法,回退不会印出不合规标签,
 * æ ‡ç­¾åˆè§„底线不受影响;反之若在此抛异常,字典里一次手误就会让全平台的标签导出停摆,代价更大。
 * ã€Œå­—典与配置文件都拿不到追溯网址」仍由 {@code MesProQrLabelBuilder} æŠ›å¼‚常拒绝导出。
 * <p>
 * æœ¬ç±»**任何路径都不向外抛异常**:解析发生在二维码批量导出的高频循环中,一次数据库抖动
 * æˆ–字典读失败都不应中断整批导出。
 * <p>
 * æ€§èƒ½ï¼šå­—典读取带 60 ç§’本地缓存({@link CacheUtils#buildAsyncReloadingCache}),
 * å•次快照只需一次内存查表;数据库查询每 60 ç§’最多一次,与标签数量无关。
 * å› æ­¤å­—典修改后**约 1 åˆ†é’Ÿå†…生效**,且因异步刷新,稀疏调用下可能需再触发一次才切换。
 */
@Slf4j
@Component
public class MesTraceConfigResolver {
    /**
     * å­—典类型:种子标签追溯配置
     */
    public static final String DICT_TYPE = "mes_trace_config";
    /**
     * å­—典标签(固定标识,管理员可改的是「字典键值」,标签名不要改)
     */
    private static final String LABEL_TRACE_URL = "追溯网址";
    private static final String LABEL_OPERATOR_NAME = "生产经营者名称";
    private static final String LABEL_IMPORTER_NAME = "进口商名称";
    private static final String LABEL_IMPORTED = "是否进口种子";
    /**
     * å­—典配置缓存:dictType â†’ (字典标签 â†’ å­—典键值),仅含启用项。
     * <p>
     * ç”¨æœ¬ç±»æŒæœ‰çš„独立缓存而非 {@code DictFrameworkUtils},原因有二:一是后者把字典投影成
     * {@code List<String>} ä¼šä¸¢å¼ƒ {@code status},无法过滤被禁用的配置项;二是后者的
     * {@code dictDataApi} æ˜¯é™æ€å­—段、由自动配置注入,与本类首次调用无顺序保证,存在 NPE é£Žé™©ã€‚
     */
    private final LoadingCache<String, Map<String, String>> dictCache = CacheUtils.buildAsyncReloadingCache(
            Duration.ofSeconds(60L),
            new CacheLoader<String, Map<String, String>>() {
                @Override
                public Map<String, String> load(String dictType) {
                    return loadDictConfig(dictType);
                }
            });
    @Resource
    private DictDataApi dictDataApi;
    @Resource
    private MesProperties mesProperties;
    /**
     * è§£æžå½“前生效的种子标签配置快照
     *
     * @return ç”Ÿæ•ˆå¿«ç…§ï¼Œå„字段均已合并配置文件兜底值;绝不返回 null,也绝不抛异常
     */
    public MesTraceConfigSnapshot resolveSnapshot() {
        Map<String, String> dict = getDictConfig();
        // 1. è¿½æº¯ç½‘址:字典优先,但必须先过合规校验,否则回退配置文件
        String dictTraceUrl = dict.get(LABEL_TRACE_URL);
        String yamlTraceUrl = StrUtil.trimToEmpty(mesProperties.getTraceBaseUrl());
        String traceUrl;
        MesTraceUrlSource traceUrlSource;
        if (StrUtil.isEmpty(dictTraceUrl)) {
            // å­—典未配置(或留空/被禁用):正常的回退路径,不是错误
            traceUrl = yamlTraceUrl;
            traceUrlSource = MesTraceUrlSource.YAML;
        } else if (MesTraceUrlValidator.isValid(dictTraceUrl)) {
            traceUrl = dictTraceUrl;
            traceUrlSource = MesTraceUrlSource.DICT;
        } else {
            log.error("[resolveSnapshot][字典({})的「{}」配置了非法地址:{}({}),已回退配置文件的值 {},请尽快修正]",
                    DICT_TYPE, LABEL_TRACE_URL, dictTraceUrl,
                    MesTraceUrlValidator.invalidReason(dictTraceUrl).orElse(""), yamlTraceUrl);
            traceUrl = yamlTraceUrl;
            traceUrlSource = MesTraceUrlSource.YAML_INVALID_DICT;
        }
        // 2. ç”Ÿäº§ç»è¥è€…名称 / è¿›å£å•†åç§°ï¼šæ–‡æœ¬ç±»é…ç½®æ— éœ€åˆè§„校验,字典非空即生效
        String operatorName = firstNonBlank(dict.get(LABEL_OPERATOR_NAME), mesProperties.getOperatorName());
        String importerName = firstNonBlank(dict.get(LABEL_IMPORTER_NAME), mesProperties.getImporterName());
        return new MesTraceConfigSnapshot(traceUrl, traceUrlSource, operatorName, importerName,
                resolveImported(dict.get(LABEL_IMPORTED)));
    }
    /**
     * è¯»å–字典配置(带缓存),读取失败时返回空表以回退配置文件,绝不抛异常
     */
    private Map<String, String> getDictConfig() {
        try {
            Map<String, String> dictConfig = dictCache.get(DICT_TYPE);
            return dictConfig != null ? dictConfig : Collections.emptyMap();
        } catch (Exception ex) {
            log.error("[getDictConfig][读取字典({})失败,本次回退配置文件的值]", DICT_TYPE, ex);
            return Collections.emptyMap();
        }
    }
    /**
     * åŠ è½½å¹¶æ•´ç†å­—å…¸é…ç½®ï¼šåªä¿ç•™å¯ç”¨é¡¹ï¼Œæ•´ç†æˆã€Œå­—å…¸æ ‡ç­¾ â†’ å­—典键值」的映射
     * <p>
     * åŒä¸€æ ‡ç­¾é‡å¤é…ç½®æ—¶ä»¥å…ˆå‡ºçŽ°è€…ä¸ºå‡†â€”â€”{@code DictDataApi#getDictDataList} åº•层已按 {@code sort} æŽ’序,
     * å› æ­¤ã€Œå…ˆå‡ºçŽ°ã€å³ã€ŒæŽ’åºå€¼æ›´å°ã€ï¼Œæ­¤å¤„æ— éœ€å†æŽ’åºã€‚
     */
    private Map<String, String> loadDictConfig(String dictType) {
        try {
            List<DictDataRespDTO> list = dictDataApi.getDictDataList(dictType);
            if (CollUtil.isEmpty(list)) {
                return Collections.emptyMap();
            }
            Map<String, String> result = new HashMap<>();
            for (DictDataRespDTO data : list) {
                // è¢«ç¦ç”¨çš„字典项视为「未配置」:这是管理员在不删数据的前提下停用某项配置的手段
                if (!Objects.equals(CommonStatusEnum.ENABLE.getStatus(), data.getStatus())) {
                    continue;
                }
                if (StrUtil.isBlank(data.getLabel())) {
                    continue;
                }
                result.putIfAbsent(data.getLabel().trim(), StrUtil.trimToEmpty(data.getValue()));
            }
            return result;
        } catch (Exception ex) {
            // ç¼“存首次 get æ—¶åŠ è½½å¼‚å¸¸ä¼šç›´æŽ¥æŠ›ç»™è°ƒç”¨æ–¹ï¼Œå¿…é¡»åœ¨æ­¤åžæŽ‰ï¼Œå¦åˆ™ä¼šä¸­æ–­æ•´æ‰¹æ ‡ç­¾å¯¼å‡º
            log.error("[loadDictConfig][读取字典({})失败,本次回退配置文件的值]", dictType, ex);
            return Collections.emptyMap();
        }
    }
    /**
     * è§£æžã€Œæ˜¯å¦è¿›å£ç§å­ã€ï¼šå­—典项为空或取值无法识别时回退配置文件
     */
    private boolean resolveImported(String dictValue) {
        String value = StrUtil.trim(dictValue);
        if (StrUtil.isEmpty(value)) {
            return mesProperties.isImported();
        }
        if (StrUtil.equalsAnyIgnoreCase(value, "true", "1", "y", "yes", "是")) {
            return true;
        }
        if (StrUtil.equalsAnyIgnoreCase(value, "false", "0", "n", "no", "否")) {
            return false;
        }
        log.error("[resolveImported][字典({})的「{}」取值无法识别:{},已回退配置文件的值 {},请填写 true æˆ– false]",
                DICT_TYPE, LABEL_IMPORTED, value, mesProperties.isImported());
        return mesProperties.isImported();
    }
    /**
     * å–首个非空值:字典值优先,为空时回退配置文件值
     */
    private static String firstNonBlank(String preferred, String fallback) {
        return StrUtil.isNotBlank(preferred) ? preferred.trim() : StrUtil.trimToEmpty(fallback);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceConfigSnapshot.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.mes.framework.config;
/**
 * ç§å­æ ‡ç­¾é…ç½®çš„生效快照:字典值与配置文件兜底值合并后的结果。
 * <p>
 * ã€Œå­—典优先、配置文件兜底」的合并规则已在 {@link MesTraceConfigResolver} å†…完成,
 * æœ¬å¿«ç…§åªæ‰¿è½½**最终生效值**,消费方无需再关心来源。四项配置在快照中各自独立判定——
 * ä¾‹å¦‚追溯网址取自字典、而生产经营者名称字典未配置时仍可回退配置文件。
 * <p>
 * æ¯æ¬¡å–快照代表同一时刻的一致视图,标签构建时应**取一次快照、全程复用**,
 * é¿å…ç¼“存恰好在两次读取之间刷新,导致同一张标签上文字与二维码内容不一致。
 *
 * @param traceUrl       è¿½æº¯ç½‘址(二维码内容前缀,不含 code å‚数),可能为空
 * @param traceUrlSource è¿½æº¯ç½‘址的实际生效来源
 * @param operatorName   ç”Ÿäº§ç»è¥è€…名称,可能为空
 * @param importerName   è¿›å£å•†åç§°ï¼Œå¯èƒ½ä¸ºç©º
 * @param imported       æ˜¯å¦è¿›å£ç§å­åœºæ™¯ï¼šä¸º true æ—¶æ ‡ç­¾ä¸Žæº¯æºé¡µå±•示「进口商名称」
 */
public record MesTraceConfigSnapshot(String traceUrl,
                                     MesTraceUrlSource traceUrlSource,
                                     String operatorName,
                                     String importerName,
                                     boolean imported) {
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceUrlSource.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.mes.framework.config;
/**
 * è¿½æº¯ç½‘址的实际生效来源。
 * <p>
 * è¿½æº¯ç½‘址优先取字典 {@code mes_trace_config},字典未配置时回退配置文件 {@code mes.trace-base-url}。
 * ç®¡ç†å‘˜åœ¨å­—典里改完地址后,可凭此判断改动是否真的生效——尤其 {@link #YAML_INVALID_DICT}
 * è¡¨ç¤ºå­—典填了值但被合规校验拒绝,此时后台看到的是配置文件的旧地址,而不是自己刚填的。
 */
public enum MesTraceUrlSource {
    /**
     * å–自字典(管理员在「字典管理」中配置的值,已通过合规校验)
     */
    DICT,
    /**
     * å–自配置文件:字典未配置该值,属正常的回退路径
     */
    YAML,
    /**
     * å›žé€€è‡ªé…ç½®æ–‡ä»¶ï¼šå­—典填了值但未通过合规校验(占位符 / æœ¬æœº / å†…网地址等),已记 ERROR æ—¥å¿—
     */
    YAML_INVALID_DICT;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceUrlValidator.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,97 @@
package cn.iocoder.yudao.module.mes.framework.config;
import cn.hutool.core.util.StrUtil;
import java.net.URI;
import java.util.Optional;
/**
 * è¿½æº¯ç½‘址校验器:判断一个追溯网址是否能印在种子标签上让消费者扫得开。
 * <p>
 * è¿½æº¯ç½‘址有两个来源——配置文件 {@code mes.trace-base-url} ä¸Žå­—å…¸ {@code mes_trace_config}。
 * ä¸¤è€…必须走**同一套**校验规则,否则从字典进来的值就能绕过标签合规检查,
 * å°å‡ºæ¶ˆè´¹è€…扫不开的标签。因此把规则收敛到这里,供启动校验与字典解析共同复用。
 * <p>
 * æ³¨æ„ï¼šæœ¬ç±»åªåšã€Œåœ°å€æœ¬èº«æ˜¯å¦å¯ç”¨ã€çš„静态判断,不含任何配置读取与异常抛出,便于单测。
 */
public final class MesTraceUrlValidator {
    private MesTraceUrlValidator() {
    }
    /**
     * è¯¥åœ°å€æ˜¯å¦å¯ç”¨äºŽæ ‡ç­¾ï¼šç©ºã€å ä½ç¬¦ã€æœ¬æœºåœ°å€ã€å†…网地址、无法解析的地址均视为不可用。
     *
     * @param url è¿½æº¯ç½‘址
     * @return true è¡¨ç¤ºå¯å°åœ¨æ ‡ç­¾ä¸Š
     */
    public static boolean isValid(String url) {
        return invalidReason(url).isEmpty();
    }
    /**
     * åˆ¤æ–­è¿½æº¯ç½‘址是否可用,不可用时给出人类可读的原因。
     *
     * @param url è¿½æº¯ç½‘址
     * @return ç©ºè¡¨ç¤ºå¯ç”¨ï¼›æœ‰å€¼å³ä¸ºä¸å¯ç”¨çš„原因
     */
    public static Optional<String> invalidReason(String url) {
        String trimmed = StrUtil.trim(url);
        if (StrUtil.isEmpty(trimmed)) {
            return Optional.of("未配置。二维码标签必须包含追溯网址,"
                    + "请填写公众扫码可访问的 H5 æº¯æºé¡µåœ°å€ã€‚");
        }
        // å ä½ç¬¦ä¸Žæœ¬æœºåœ°å€ï¼šå¤šä¸ºä»Žç¤ºä¾‹é…ç½®ç›´æŽ¥æ‹·æ¥ã€å¿˜è®°æ›¿æ¢ï¼Œå°å‡ºåŽ»æ¶ˆè´¹è€…å¿…ç„¶æ‰«ä¸å¼€
        if (StrUtil.containsAnyIgnoreCase(trimmed, "your-domain", "example.com", "localhost")) {
            return Optional.of("仍为占位符或本机地址:" + trimmed
                    + ",印出的标签消费者将无法访问,请填写真实的外网地址。");
        }
        String host = extractHost(trimmed);
        if (StrUtil.isEmpty(host)) {
            return Optional.of("格式非法,无法解析主机名:" + trimmed);
        }
        if (isPrivateHost(host)) {
            return Optional.of("指向内网地址(" + host + "):" + trimmed
                    + ",公众扫码无法访问,请填写外网可访问的地址。");
        }
        return Optional.empty();
    }
    /**
     * ä»Ž URL ä¸­æå–主机名,无法解析时返回 null
     */
    private static String extractHost(String url) {
        try {
            return URI.create(url).getHost();
        } catch (IllegalArgumentException ex) {
            return null;
        }
    }
    /**
     * åˆ¤æ–­æ˜¯å¦ä¸ºæœ¬æœº / å†…网 / é“¾è·¯æœ¬åœ°åœ°å€
     */
    private static boolean isPrivateHost(String host) {
        if ("localhost".equalsIgnoreCase(host) || host.endsWith(".local")) {
            return true;
        }
        String[] parts = host.split("\\.");
        if (parts.length != 4) {
            return false; // åŸŸåå½¢å¼ï¼Œäº¤ç”± DNS è§£æžï¼Œä¸åœ¨æ­¤å¤„拦截
        }
        int a, b;
        try {
            a = Integer.parseInt(parts[0]);
            b = Integer.parseInt(parts[1]);
        } catch (NumberFormatException ex) {
            return false;
        }
        // 127.0.0.0/8 æœ¬æœºã€10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 ç§ç½‘、169.254.0.0/16 é“¾è·¯æœ¬åœ°
        return a == 127
                || a == 10
                || (a == 172 && b >= 16 && b <= 31)
                || (a == 192 && b == 168)
                || (a == 169 && b == 254);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesProQrLabelBuilder.java
@@ -7,6 +7,7 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.framework.config.MesTraceConfigSnapshot;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import java.net.URLEncoder;
@@ -69,16 +70,30 @@
     */
    private static final Pattern UNIT_CODE_PATTERN = Pattern.compile("^[0-9A-Za-z]{1,20}$");
    /** H5 æº¯æºé¡µé¢åŸºç¡€åœ°å€ï¼Œç”± {@code MesQrCodeConfig} å¯åŠ¨æ—¶æ³¨å…¥ï¼›ä¸ºç©ºæ—¶äºŒç»´ç ç¼–ç çº¯ç å€¼ */
    /**
     * é…ç½®æ¥æºï¼šç”± {@code MesQrCodeConfig} å¯åŠ¨æ—¶è£…é…ï¼ŒæŒ‡å‘å­—å…¸è§£æžå™¨ã€‚
     * <p>
     * ä¸º null æ—¶ï¼ˆå•测、非 Spring ä¸Šä¸‹æ–‡ï¼‰å›žé€€ä¸‹é¢å‡ ä¸ªé™æ€å­—段,也就是配置文件的值。
     */
    private static volatile MesTraceConfigProvider configProvider;
    /** H5 æº¯æºé¡µé¢åŸºç¡€åœ°å€ï¼Œé…ç½®æ–‡ä»¶ {@code mes.trace-base-url} çš„值;字典未配置时的兜底 */
    private static volatile String traceBaseUrl = "";
    /** ç”Ÿäº§ç»è¥è€…名称,由 {@code MesQrCodeConfig} å¯åŠ¨æ—¶æ³¨å…¥ */
    /** ç”Ÿäº§ç»è¥è€…名称,配置文件 {@code mes.operator-name} çš„值;字典未配置时的兜底 */
    private static volatile String operatorName = "";
    /** è¿›å£å•†åç§°ï¼Œè¿›å£ç§å­åœºæ™¯ä½¿ç”¨ */
    /** è¿›å£å•†åç§°ï¼Œé…ç½®æ–‡ä»¶ {@code mes.importer-name} çš„值;字典未配置时的兜底 */
    private static volatile String importerName = "";
    /** æ˜¯å¦è¿›å£ç§å­åœºæ™¯ */
    /** æ˜¯å¦è¿›å£ç§å­åœºæ™¯ï¼Œé…ç½®æ–‡ä»¶ {@code mes.imported} çš„值;字典未配置时的兜底 */
    private static volatile boolean imported = false;
    /** æ˜¯å¦å…è®¸äºŒç»´ç é™çº§ç¼–码纯码值(仅限本地兼容历史码) */
    private static volatile boolean allowPlainQrCode = false;
    /**
     * è£…配配置来源。传 null å¯æ¢å¤ä¸ºã€Œåªè¯»é…ç½®æ–‡ä»¶é™æ€å€¼ã€ï¼Œå•测借此避免静态状态串味。
     */
    public static void setTraceConfigProvider(MesTraceConfigProvider provider) {
        MesProQrLabelBuilder.configProvider = provider;
    }
    public static void setTraceBaseUrl(String traceBaseUrl) {
        MesProQrLabelBuilder.traceBaseUrl = StrUtil.blankToDefault(traceBaseUrl, "");
@@ -101,21 +116,46 @@
    }
    /**
     * å–当前生效的配置快照
     *
     * @return å¿«ç…§ï¼›æœªè£…配配置来源时返回 null,调用方回退静态字段(配置文件的值)
     */
    private static MesTraceConfigSnapshot currentSnapshot() {
        MesTraceConfigProvider provider = configProvider;
        return provider != null ? provider.get() : null;
    }
    /**
     * å½“前应标注的生产经营者名称:进口种子场景优先取进口商名称,否则取生产经营者名称
     *
     * @return åç§°ï¼Œæœªé…ç½®æ—¶è¿”回空串
     */
    public static String resolveOperatorName() {
        if (imported && StrUtil.isNotBlank(importerName)) {
            return importerName;
        return resolveOperatorName(currentSnapshot());
        }
        return operatorName;
    private static String resolveOperatorName(MesTraceConfigSnapshot snapshot) {
        boolean importedScene = snapshot != null ? snapshot.imported() : imported;
        String currentImporterName = snapshot != null ? snapshot.importerName() : importerName;
        String currentOperatorName = snapshot != null ? snapshot.operatorName() : operatorName;
        if (importedScene && StrUtil.isNotBlank(currentImporterName)) {
            return currentImporterName;
        }
        return currentOperatorName;
    }
    /**
     * è¿½æº¯ç½‘址(对外展示用,不含 code å‚数)
     */
    public static String resolveTraceUrl() {
        return resolveTraceUrl(currentSnapshot());
    }
    private static String resolveTraceUrl(MesTraceConfigSnapshot snapshot) {
        // å¿«ç…§ä¸ºç©ºå€¼æ—¶ä¸è¦†ç›–配置文件的值:配置来源只应「补充」而不应把兜底值抹成空
        if (snapshot != null && StrUtil.isNotBlank(snapshot.traceUrl())) {
            return snapshot.traceUrl().trim();
        }
        return traceBaseUrl;
    }
@@ -123,7 +163,11 @@
     * æ˜¯å¦è¿›å£ç§å­åœºæ™¯ï¼šä¸º true æ—¶æ ‡ç­¾ä¸Žæº¯æºé¡µåº”展示「进口商名称」
     */
    public static boolean isImported() {
        return imported;
        return isImported(currentSnapshot());
    }
    private static boolean isImported(MesTraceConfigSnapshot snapshot) {
        return snapshot != null ? snapshot.imported() : imported;
    }
    /**
@@ -132,13 +176,26 @@
     * æœªé…ç½®æº¯æºåœ°å€ä¸”未开启降级开关时直接报错,避免印出不含追溯网址的标签。
     */
    private static String buildQrCodeContent(String code) {
        if (StrUtil.isEmpty(traceBaseUrl)) {
        return buildQrCodeContent(code, resolveTraceUrl());
    }
    /**
     * ç»„装二维码内容(使用调用方给定的追溯网址)
     * <p>
     * æ ‡ç­¾ä¸Šæ—¢æœ‰äºŒç»´ç å†…容、又有可读的「追溯网址」文本时,两处必须传同一个网址:
     * åˆ†åˆ«å–值可能跨越缓存刷新,导致同一张标签上文字与二维码指向不同地址。
     *
     * @param code     ç å€¼
     * @param traceUrl æœ¬æ¬¡ç”Ÿæ•ˆçš„追溯网址,为空表示未配置
     */
    private static String buildQrCodeContent(String code, String traceUrl) {
        if (StrUtil.isEmpty(traceUrl)) {
            if (allowPlainQrCode) {
                return code;
            }
            throw exception(PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED);
        }
        return traceBaseUrl + "?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8);
        return traceUrl + "?code=" + URLEncoder.encode(code, StandardCharsets.UTF_8);
    }
    /**
@@ -217,16 +274,21 @@
                                                MesProBatchDO batch,
                                                Map<Long, MesMdmItemDO> itemMap,
                                                Map<Long, AdminUserRespDTO> userMap) {
        // æ•´å¼ æ ‡ç­¾åªå–一次配置快照:可读文本与二维码内容共用同一个追溯网址。
        // è‹¥ä¸¤å¤„各自取值,缓存可能恰好在其间刷新,同一张标签上的文字与二维码就会指向不同地址。
        MesTraceConfigSnapshot snapshot = currentSnapshot();
        String traceUrl = resolveTraceUrl(snapshot);
        LinkedHashMap<String, String> keyValues = new LinkedHashMap<>();
        // ===== å››è¦ç´ ï¼ˆå›½æ ‡è¦æ±‚,顺序固定,缺失即阻断导出)=====
        putRequired(keyValues, "品种名称", resolveItemName(bagCode, batch, itemMap),
                PRO_QR_LABEL_ITEM_NAME_MISSING);
        putRequired(keyValues, imported ? "进口商名称" : "生产经营者名称", resolveOperatorName(),
                PRO_QR_LABEL_OPERATOR_MISSING);
        putRequired(keyValues, isImported(snapshot) ? "进口商名称" : "生产经营者名称",
                resolveOperatorName(snapshot), PRO_QR_LABEL_OPERATOR_MISSING);
        putRequired(keyValues, "单元识别代码", resolveUnitCode(bagCode.getCode()),
                PRO_QR_LABEL_UNIT_CODE_MISSING);
        putRequired(keyValues, "追溯网址", resolveTraceUrl(), PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED);
        putRequired(keyValues, "追溯网址", traceUrl, PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED);
        // ===== ä»¥ä¸‹ä¸ºè¾…助生产信息 =====
        if (bagCode.getBagNo() != null) {
@@ -243,7 +305,7 @@
        }
        return QrLabelInfo.builder()
                .fileName(bagCode.getCode())
                .code(buildQrCodeContent(bagCode.getCode()))
                .code(buildQrCodeContent(bagCode.getCode(), traceUrl))
                .keyValues(keyValues)
                .build();
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesTraceConfigProvider.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.mes.framework.qrcode;
import cn.iocoder.yudao.module.mes.framework.config.MesTraceConfigSnapshot;
/**
 * ç§å­æ ‡ç­¾é…ç½®æ¥æºï¼šå‘ {@link MesProQrLabelBuilder} æä¾›ã€Œå­—å…¸ + é…ç½®æ–‡ä»¶ã€åˆå¹¶åŽçš„生效配置。
 * <p>
 * {@code MesProQrLabelBuilder} æ˜¯é™æ€å·¥å…·ç±»ï¼Œè¢«äºŒç»´ç æ‰¹é‡å¯¼å‡ºé«˜é¢‘循环调用,无法直接做依赖注入;
 * å› æ­¤åœ¨å¯åŠ¨æ—¶ç”± {@code MesQrCodeConfig} è£…配一个本接口的实现(指向 {@code MesTraceConfigResolver}),
 * è¿è¡Œæ—¶ç”±æž„建器回调取值。这样既保留了静态调用点不变,又能让配置随字典改动生效。
 * <p>
 * å®žçŽ°æ–¹**必须保证不抛异常**,否则会中断整批标签导出。
 */
@FunctionalInterface
public interface MesTraceConfigProvider {
    /**
     * å–当前生效的配置快照
     *
     * @return ç”Ÿæ•ˆå¿«ç…§ï¼Œä¸åº”返回 null
     */
    MesTraceConfigSnapshot get();
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceConfigResolverTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
package cn.iocoder.yudao.module.mes.framework.config;
import cn.iocoder.yudao.framework.common.biz.system.dict.dto.DictDataRespDTO;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.module.system.api.dict.DictDataApi;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
 * å­—典解析器测试:守住「字典优先、配置文件兜底」的合并规则与「绝不抛异常」的约定。
 */
class MesTraceConfigResolverTest {
    private static final String YAML_URL = "http://yaml.example.org/trace.html";
    private static final String DICT_URL = "http://1.95.78.124:9008/prod/trace.html";
    private final DictDataApi dictDataApi = mock(DictDataApi.class);
    @Test
    void dictValueWinsOverYaml() {
        stubDict(dict("追溯网址", DICT_URL));
        MesTraceConfigSnapshot snapshot = newResolver().resolveSnapshot();
        assertEquals(DICT_URL, snapshot.traceUrl());
        assertEquals(MesTraceUrlSource.DICT, snapshot.traceUrlSource());
    }
    @Test
    void fallsBackToYamlWhenDictTypeMissing() {
        stubDict(); // å­—典类型尚未初始化,属于正常的「尚未配置」路径
        MesTraceConfigSnapshot snapshot = newResolver().resolveSnapshot();
        assertEquals(YAML_URL, snapshot.traceUrl());
        assertEquals(MesTraceUrlSource.YAML, snapshot.traceUrlSource());
    }
    @Test
    void disabledDictItemIsTreatedAsAbsent() {
        // ç¦ç”¨æ˜¯ä¸åˆ æ•°æ®å³åœç”¨æŸé¡¹é…ç½®çš„æ‰‹æ®µï¼Œå¿…须按「未配置」处理
        DictDataRespDTO disabled = dict("追溯网址", DICT_URL);
        disabled.setStatus(CommonStatusEnum.DISABLE.getStatus());
        stubDict(disabled);
        MesTraceConfigSnapshot snapshot = newResolver().resolveSnapshot();
        assertEquals(YAML_URL, snapshot.traceUrl());
        assertEquals(MesTraceUrlSource.YAML, snapshot.traceUrlSource());
    }
    @Test
    void blankDictValueFallsBackToYaml() {
        stubDict(dict("追溯网址", "   "));
        MesTraceConfigSnapshot snapshot = newResolver().resolveSnapshot();
        assertEquals(YAML_URL, snapshot.traceUrl());
        assertEquals(MesTraceUrlSource.YAML, snapshot.traceUrlSource());
    }
    @Test
    void invalidDictValueFallsBackToYamlAndFlagsSource() {
        // å­—典里填了内网地址:降级回配置文件而不是抛异常,避免一次手误让全平台导出停摆
        stubDict(dict("追溯网址", "http://192.168.1.1/trace.html"));
        MesTraceConfigSnapshot snapshot = newResolver().resolveSnapshot();
        assertEquals(YAML_URL, snapshot.traceUrl());
        assertEquals(MesTraceUrlSource.YAML_INVALID_DICT, snapshot.traceUrlSource());
    }
    @Test
    void dictReadFailureFallsBackToYamlWithoutThrowing() {
        when(dictDataApi.getDictDataList(MesTraceConfigResolver.DICT_TYPE))
                .thenThrow(new RuntimeException("数据库抖动"));
        MesTraceConfigSnapshot snapshot = assertDoesNotThrow(() -> newResolver().resolveSnapshot());
        assertEquals(YAML_URL, snapshot.traceUrl());
        assertEquals(MesTraceUrlSource.YAML, snapshot.traceUrlSource());
    }
    @Test
    void dictIsReadOncePerCacheWindow() {
        stubDict(dict("追溯网址", DICT_URL));
        MesTraceConfigResolver resolver = newResolver();
        resolver.resolveSnapshot();
        resolver.resolveSnapshot();
        resolver.resolveSnapshot();
        // æ ‡ç­¾å¯¼å‡ºä¼šæˆåƒä¸Šä¸‡æ¬¡å–快照,绝不能每次都查库
        verify(dictDataApi, times(1)).getDictDataList(MesTraceConfigResolver.DICT_TYPE);
    }
    @Test
    void resolvesOtherFieldsIndependentlyFromDict() {
        stubDict(dict("追溯网址", DICT_URL),
                dict("生产经营者名称", "新疆塔里木河种业股份有限公司"),
                dict("进口商名称", "某进口商"),
                dict("是否进口种子", "true"));
        MesTraceConfigSnapshot snapshot = newResolver().resolveSnapshot();
        assertEquals("新疆塔里木河种业股份有限公司", snapshot.operatorName());
        assertEquals("某进口商", snapshot.importerName());
        assertTrue(snapshot.imported());
    }
    @Test
    void fieldsFallBackIndependentlyWhenOnlyUrlIsConfigured() {
        // åªé…äº†è¿½æº¯ç½‘址:其余三项仍应回退配置文件,而不是被清空
        stubDict(dict("追溯网址", DICT_URL));
        MesTraceConfigSnapshot snapshot = newResolver().resolveSnapshot();
        assertEquals(DICT_URL, snapshot.traceUrl());
        assertEquals("YAML ç”Ÿäº§ç»è¥è€…", snapshot.operatorName());
        assertEquals("YAML è¿›å£å•†", snapshot.importerName());
        assertFalse(snapshot.imported());
    }
    @Test
    void unrecognizedImportedValueFallsBackToYaml() {
        stubDict(dict("是否进口种子", "也许吧"));
        assertFalse(newResolver().resolveSnapshot().imported());
    }
    @Test
    void firstEnabledItemWinsWhenDuplicated() {
        // åŒä¸€æ ‡ç­¾é‡å¤é…ç½®æ—¶ä»¥æŽ’序靠前者为准(底层查询已按 sort æŽ’序)
        stubDict(dict("追溯网址", DICT_URL), dict("追溯网址", "http://second.example.org/trace.html"));
        assertEquals(DICT_URL, newResolver().resolveSnapshot().traceUrl());
    }
    private MesTraceConfigResolver newResolver() {
        MesTraceConfigResolver resolver = new MesTraceConfigResolver();
        MesProperties properties = new MesProperties();
        properties.setTraceBaseUrl(YAML_URL);
        properties.setOperatorName("YAML ç”Ÿäº§ç»è¥è€…");
        properties.setImporterName("YAML è¿›å£å•†");
        properties.setImported(false);
        ReflectionTestUtils.setField(resolver, "dictDataApi", dictDataApi);
        ReflectionTestUtils.setField(resolver, "mesProperties", properties);
        return resolver;
    }
    private void stubDict(DictDataRespDTO... data) {
        when(dictDataApi.getDictDataList(MesTraceConfigResolver.DICT_TYPE)).thenReturn(List.of(data));
    }
    private static DictDataRespDTO dict(String label, String value) {
        DictDataRespDTO dto = new DictDataRespDTO();
        dto.setDictType(MesTraceConfigResolver.DICT_TYPE);
        dto.setLabel(label);
        dto.setValue(value);
        dto.setStatus(CommonStatusEnum.ENABLE.getStatus());
        return dto;
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/framework/config/MesTraceUrlValidatorTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,81 @@
package cn.iocoder.yudao.module.mes.framework.config;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
 * è¿½æº¯ç½‘址校验器测试。
 * <p>
 * è¯¥æ ¡éªŒåŒæ—¶æŠŠå…³é…ç½®æ–‡ä»¶ä¸Žå­—典两个来源,若规则被放松,消费者扫不开的标签就会印出去且无法召回,
 * å› æ­¤è¿™é‡Œé€ä¸ªå®ˆä½æ¯ä¸ªæ‹’绝分支。
 */
class MesTraceUrlValidatorTest {
    @Test
    void acceptsPublicAccessibleUrls() {
        assertValid("http://1.95.78.124:9008/prod/trace.html");
        assertValid("https://trace.example.org/trace.html");
        assertValid("http://42.63.70.90:9003/trace.html");
        assertValid("  http://1.95.78.124:9008/prod/trace.html  "); // ä¸¤ä¾§ç©ºæ ¼åº”被忽略
    }
    @Test
    void rejectsBlankUrl() {
        assertInvalid("");
        assertInvalid("   ");
        assertInvalid(null);
    }
    @Test
    void rejectsUnreplacedPlaceholdersAndLocalhost() {
        // ä»Žç¤ºä¾‹é…ç½®ç›´æŽ¥æ‹·æ¥å¿˜è®°æ›¿æ¢ï¼Œæ˜¯æ ‡ç­¾å°é”™åœ°å€æœ€å¸¸è§çš„原因
        assertInvalid("https://dev.your-domain.com/trace.html");
        assertInvalid("http://example.com/trace.html");
        assertInvalid("http://localhost:48080/trace.html");
        assertInvalid("http://LOCALHOST/trace.html");
    }
    @Test
    void rejectsPrivateAndLoopbackHosts() {
        assertInvalid("http://127.0.0.1:9008/trace.html");
        assertInvalid("http://10.0.0.5/trace.html");
        assertInvalid("http://172.16.0.9/trace.html");
        assertInvalid("http://172.31.255.254/trace.html");
        assertInvalid("http://192.168.0.10:48080/trace.html");
        assertInvalid("http://169.254.1.1/trace.html");
        assertInvalid("http://printer.local/trace.html");
    }
    @Test
    void acceptsHostsJustOutsidePrivateRanges() {
        // 172.15/172.32 ä¸åœ¨ 172.16.0.0/12 ç§ç½‘段内,不应被误杀
        assertValid("http://172.15.0.1/trace.html");
        assertValid("http://172.32.0.1/trace.html");
        assertValid("http://11.0.0.1/trace.html");
    }
    @Test
    void rejectsUnparseableUrl() {
        assertInvalid("这不是一个地址");
        assertInvalid("/trace.html");
        assertInvalid("trace.html");
    }
    @Test
    void invalidReasonExplainsWhy() {
        assertTrue(MesTraceUrlValidator.invalidReason("http://192.168.1.1/x").orElseThrow().contains("内网"));
        assertTrue(MesTraceUrlValidator.invalidReason("").orElseThrow().contains("未配置"));
        assertTrue(MesTraceUrlValidator.invalidReason("http://localhost/x").orElseThrow().contains("占位符"));
    }
    private void assertValid(String url) {
        assertTrue(MesTraceUrlValidator.isValid(url), "应判定为合法:" + url);
    }
    private void assertInvalid(String url) {
        assertFalse(MesTraceUrlValidator.isValid(url), "应判定为非法:" + url);
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/framework/qrcode/MesProQrLabelBuilderTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,117 @@
package cn.iocoder.yudao.module.mes.framework.qrcode;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.framework.config.MesTraceConfigSnapshot;
import cn.iocoder.yudao.module.mes.framework.config.MesTraceUrlSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
 * æ ‡ç­¾æž„建器的配置来源测试。
 * <p>
 * æž„建器是静态工具类,配置来源通过 {@link MesTraceConfigProvider} è£…配。这里重点验证
 * ã€Œé…ç½®æ¥æºä¼˜å…ˆã€é…ç½®æ–‡ä»¶å…œåº•、两者皆无则拒绝导出」这条链路,以及标签可读文本与
 * äºŒç»´ç å†…容取到的是同一个追溯网址。
 */
class MesProQrLabelBuilderTest {
    private static final String YAML_URL = "http://yaml.example.org/trace.html";
    private static final String DICT_URL = "http://1.95.78.124:9008/prod/trace.html";
    @AfterEach
    void resetStaticState() {
        // æž„建器的配置是静态字段,不清干净会串味到其它测试
        MesProQrLabelBuilder.setTraceConfigProvider(null);
        MesProQrLabelBuilder.setTraceBaseUrl("");
        MesProQrLabelBuilder.setOperatorName("");
        MesProQrLabelBuilder.setImporterName("");
        MesProQrLabelBuilder.setImported(false);
        MesProQrLabelBuilder.setAllowPlainQrCode(false);
    }
    @Test
    void providerValueWinsOverConfiguredFile() {
        MesProQrLabelBuilder.setTraceBaseUrl(YAML_URL);
        MesProQrLabelBuilder.setOperatorName("生产经营者");
        useProvider(snapshot(DICT_URL, "生产经营者", "", false));
        assertEquals(DICT_URL, MesProQrLabelBuilder.resolveTraceUrl());
    }
    @Test
    void fallsBackToConfiguredFileWhenProviderAbsent() {
        MesProQrLabelBuilder.setTraceBaseUrl(YAML_URL);
        // æœªè£…配配置来源(单测 / éž Spring ä¸Šä¸‹æ–‡ï¼‰æ—¶åº”回退配置文件
        assertEquals(YAML_URL, MesProQrLabelBuilder.resolveTraceUrl());
    }
    @Test
    void blankProviderValueDoesNotEraseConfiguredFile() {
        MesProQrLabelBuilder.setTraceBaseUrl(YAML_URL);
        useProvider(snapshot("", "生产经营者", "", false));
        // é…ç½®æ¥æºåªåº”「补充」,不应把兜底值抹成空
        assertEquals(YAML_URL, MesProQrLabelBuilder.resolveTraceUrl());
    }
    @Test
    void bagLabelPrintsSameUrlAsItsQrCode() {
        MesProQrLabelBuilder.setOperatorName("生产经营者");
        useProvider(snapshot(DICT_URL, "生产经营者", "", false));
        QrLabelInfo label = MesProQrLabelBuilder.buildBagCodeLabel(bagCode(), null, Map.of(), Map.of());
        // å¯è¯»æ–‡æœ¬ä¸ŽäºŒç»´ç å†…容必须是同一个网址,否则标签会自相矛盾
        assertEquals(DICT_URL, label.getKeyValues().get("追溯网址"));
        assertEquals(DICT_URL + "?code=12345678901234567890", label.getCode());
    }
    @Test
    void importedSceneLabelsImporterName() {
        useProvider(snapshot(DICT_URL, "生产经营者", "某进口商", true));
        QrLabelInfo label = MesProQrLabelBuilder.buildBagCodeLabel(bagCode(), null, Map.of(), Map.of());
        assertEquals("某进口商", label.getKeyValues().get("进口商名称"));
        assertNull(label.getKeyValues().get("生产经营者名称"));
    }
    @Test
    void rejectsExportWhenNeitherSourceHasUrl() {
        MesProQrLabelBuilder.setOperatorName("生产经营者");
        useProvider(snapshot("", "生产经营者", "", false));
        // å››è¦ç´ ç¼ºå¤±å³æ‹’绝导出:这是标签合规的最后一道闸门,绝不能静默降级
        assertThrows(ServiceException.class,
                () -> MesProQrLabelBuilder.buildBagCodeLabel(bagCode(), null, Map.of(), Map.of()));
    }
    @Test
    void providerCannotBypassElementValidation() {
        // å³ä½¿é…ç½®æ¥æºç»™äº†ç½‘址,生产经营者名称缺失仍须拒绝导出
        useProvider(snapshot(DICT_URL, "", "", false));
        assertThrows(ServiceException.class,
                () -> MesProQrLabelBuilder.buildBagCodeLabel(bagCode(), null, Map.of(), Map.of()));
    }
    private static void useProvider(MesTraceConfigSnapshot snapshot) {
        MesProQrLabelBuilder.setTraceConfigProvider(() -> snapshot);
    }
    private static MesTraceConfigSnapshot snapshot(String traceUrl, String operatorName,
                                                   String importerName, boolean imported) {
        return new MesTraceConfigSnapshot(traceUrl, MesTraceUrlSource.DICT, operatorName, importerName, imported);
    }
    private static MesProBagCodeDO bagCode() {
        return new MesProBagCodeDO().setCode("12345678901234567890").setItemName("新陆早78号");
    }
}
yudao-server/src/main/resources/application.yaml
@@ -1,16 +1,19 @@
# ç§å­æ ‡ç­¾ä¸Ž H5 æº¯æºé¡µé…ç½®ï¼ˆå„ profile å¯è¦†ç›–)
# æ³¨æ„ï¼štrace-base-url ä¸Ž operator-name æ˜¯äºŒç»´ç æ ‡ç­¾å››è¦ç´ ä¸­çš„「追溯网址」「生产经营者名称」,
# éž local çŽ¯å¢ƒä¸‹ trace-base-url è‹¥ä¸ºå†…网/占位符地址将拒绝启动,避免印出消费者扫不开的标签。
# æ³¨æ„ï¼štrace-base-url、operator-name、importer-name、imported æ˜¯äºŒç»´ç æ ‡ç­¾å››è¦ç´ ç›¸å…³çš„配置。
# è¿è¡Œæ—¶è¿™å‡ é¡¹**优先取字典 mes_trace_config**(管理后台「字典管理」可改,改完无需重启),
# æ­¤å¤„配置退居为兜底默认值:字典未配置(或被禁用、或填了非法地址)时才生效。
# éž local çŽ¯å¢ƒä¸‹ trace-base-url è‹¥ä¸ºå†…网/占位符地址仍将拒绝启动——这道防线必须在数据库可用前生效。
mes:
  # è¿½æº¯ç½‘址(二维码内容前缀,形如 http://域名:端口/trace.html),需为公众扫码可访问的地址
  # è¿½æº¯ç½‘址兜底值(二维码内容前缀,形如 http://域名:端口/trace.html),需为公众扫码可访问的地址
  trace-base-url: http://localhost:48080/trace.html
  # ç”Ÿäº§ç»è¥è€…名称(标签四要素之一,标签与溯源页统一取此值;未配置则无法导出标签)
  # ç”Ÿäº§ç»è¥è€…名称兜底值(标签四要素之一,标签与溯源页统一取此值;未配置则无法导出标签)
  operator-name: ''
  # è¿›å£å•†åç§°ï¼ˆè¿›å£ç§å­åœºæ™¯å¡«å†™ï¼›imported=true æ—¶æ ‡ç­¾ä¸Žæº¯æºé¡µå±•示进口商名称)
  # è¿›å£å•†åç§°å…œåº•值(进口种子场景填写;imported=true æ—¶æ ‡ç­¾ä¸Žæº¯æºé¡µå±•示进口商名称)
  importer-name: ''
  # æ˜¯å¦è¿›å£ç§å­åœºæ™¯
  # æ˜¯å¦è¿›å£ç§å­åœºæ™¯å…œåº•值
  imported: false
  # æ˜¯å¦å…è®¸äºŒç»´ç ç¼–码纯码值(不带溯源 URL),仅供本地兼容历史码,生产环境必须为 false
  # æ˜¯å¦å…è®¸äºŒç»´ç ç¼–码纯码值(不带溯源 URL),仅供本地兼容历史码,生产环境必须为 false。
  # è¯¥é¡¹æœ‰æ„ä¸çº³å…¥å­—典:它是绕过「标签必须含追溯网址」的开关,不应开放给后台随意编辑。
  allow-plain-qr-code: false
  # äºŒç»´ç æ‰¹é‡å¯¼å‡ºï¼šå°æ‰¹é‡åŒæ­¥ç§’出,大批量转后台任务(无数量上限)