Commit Graph

95 Commits

Author SHA1 Message Date
weicw 9b6c9fb90c merge: 审查修复(college 写入、逐级解析、文件默认收紧、注册表 all/has_college、层级校验下沉) 2026-11-03 23:16:21 +08:00
weicw 70213ab783 refactor(gxmu): 审批链层级校验下沉到 Service,删除路径也校验剩余链
- ReviewFlowController 里约 115 行的 validateLevels / validateUpdate /
  validateBatchUpdate / validateRemaining / merge 下沉为
  ReviewFlowService.validateLevelsOnSave / OnUpdate / OnBatchUpdate / OnRemove,
  controller 只在写操作前调用一次
- remove / removeBatch 新增删除后剩余链连续性校验;删除前就已不连续的链跳过校验,
  避免存量脏数据变成「永远删不掉」
- 顺手修掉一个真缺陷:merge 会就地改 existing,导致「改名后原链剩余校验」永远不触发
  (批量路径同样先取旧标题再合并)
- 模块标题回退三份收敛成 ModuleRegistryService.titleOf(Map, module) 一处,
  ReviewFlowConfigServiceImpl / ReviewListServiceImpl 直接调用
- ReviewFlowLevelValidator 的手写 join 换成 Collectors.joining(", ")
- 回填脚本头部补「表名 → 学院文本列」对照表,注明新增表按此表同步 4 处

refs #58
2026-11-03 23:14:24 +08:00
weicw 6bfdae5787 fix(security): 文件内联预览默认需登录,公开目录改为显式白名单
- file.public-preview 默认值由 true 改为 false(application.yml 与 @Value 同步),
  上传一律落在 /yyyyMMdd/ 日期目录,默认公开等于把所有上传文件(含盖章证明材料)
  变成匿名可读;公开页面引用的文件必须把所在目录列进 file.public-dirs
- 注释写清「公开页面引用的文件必须列进白名单,否则匿名访问会被拒」,同步更新
  FileAccessRules 类注释
- 删掉只被测试调用的 FileAccessRules.isPublicFileRequest(测试钉住的是没上线的
  平行判定);测试改为断言 downloadPatterns / permitAllPatterns /
  authenticatedPatterns 的内容与顺序
- 顺带把 gxmu.module-registry.strict 改成 ${GXMU_MODULE_REGISTRY_STRICT:true},
  与相邻配置一致

refs #58
2026-11-03 23:14:13 +08:00
weicw 43f0119b25 fix(gxmu): 审批人解析覆盖第 2..N 级,已处理节点再提交直接拒绝
- ReviewFlowStarterService 新增 advanceReview(module, pk, currentSortNumber):
  通过后推进下一级也走 ReviewerResolver,不再 setUserId(nextOne.getReviewUserId());
  解析为空抛可读 BusinessException 且不写节点,最后一级则不生成节点
- ReviewListController 的推进改为调用 advanceReview;ManuscriptController 的首级
  改为 startReview("cms_manuscript", id),cms 稿件不再复制 review_user_id
- 推进前校验当前节点 status == 0(ReviewStatus.PENDING),已处理节点再 PUT 直接拒绝,
  避免重复通过造出第二组活跃节点;批量审批同样逐行校验
- 新增 ReviewStatus 常量(gxmu_review_list.status:0待审 1通过 2驳回),
  把 controller / starter / DeclareGuard / TzbcyFormServiceImpl 里的裸 1、2 换成常量
- 解析失败的兜底消息补上学院与层级,与 ReviewerResolver 口径一致
- 审批动作注释里的「撤销」改成实际语义(通过 / 驳回)

refs #58
2026-11-03 23:14:00 +08:00
weicw be011dd4f1 fix(gxmu): 新申报按学籍补齐 college_id,不再只有存量回填
4 张在跑表(gxmu_sjqn / gxmu_tzbcy_form / gxmu_qmgc_form / gxmu_wxxzx_project)
的实体补 collegeId,save 路径在为空时按「登录用户 → gxmu_student.college_id」
补齐;解析不到(无学籍、名册未同步、学院不唯一)一律留空不报错。
更新路径不调用解析器,collegeId 为 null 时 MyBatis-Plus 忽略该字段,库里已有值不变。

解析逻辑收在一个共享组件 CollegeResolver 里,4 个 controller 只各调一行;
口径与一次性回填脚本一致(优先在校那条学籍,学院不唯一就留空)。

单测:有学籍时写入、无学籍时留空、学院多义时留空、更新不覆盖已有值。

refs #58
2026-11-03 23:13:41 +08:00
weicw 37cc8ee0d5 fix(gxmu): 注册表补 has_college 与团员管理种子,接口返回 DTO 并支持 all=true
- gxmu_module_registry 增加 has_college(4 张在跑表置 1,含已有库补列与补数据语句)
- 种子补 gxmu_tygl_form / gxmu_tygl(enabled=0,团员管理,sort 11/12),
  同步修正末尾「执行后校验」:12 行未删除、5 行启用、has_college 4 行
- GET /api/gxmu/module-registry/list 支持 all=true(默认只返回启用),
  改为返回 ModuleRegistryDTO,不再外泄 tenantId / userId / 时间戳
- ModuleRegistryService:删掉无生产调用的 titleOf(String)/allModules,
  标题回退收敛成唯一的静态 titleOf(Map, module);getByModule 改由 hasCollege 使用
- listAll 只保留 SQL 排序(sort_number 升序,NULL 排最前,与 MySQL ASC 口径一致),
  删掉内存二次排序
- ModuleRegistryIntegrityChecker 增加反向告警:review_flow_config 里有、注册表里没有的
  module 打 warn,不阻断启动

refs #58
2026-11-03 23:13:29 +08:00
weicw 9c4476a395 fix(sql): 注册表种子启用稿件模块(cms_manuscript 有在跑的审核链) 2026-11-03 22:36:52 +08:00
weicw 4c2cf5542e merge: #60 模块注册表后端与启动期一致性校验 2026-11-03 22:31:28 +08:00
weicw 2838bc0d9e merge: #61 审批人规则解析并接入发起与重提 2026-11-03 22:31:19 +08:00
weicw 5c998f39d0 merge: #62 驳回清理旧节点与审批链层级校验
冲突解决:ReviewFlowStarterServiceImpl 保留 #62 的驳回清理与事务注解,
发起/重提仍由 ReviewerResolver 解析取人;合并两份 ReviewFlowStarterServiceImplTest,
置于同一文件(驳回清理 4 个用例 + 审批人解析 4 个用例)。
2026-11-03 22:30:39 +08:00
weicw 51b8dddd22 merge: #64 学院标识一次性回填脚本 2026-11-03 22:29:48 +08:00
weicw 02b2e4b56b merge: spec58/integration(带进 #59 schema 与已合并的集成顶部) 2026-11-03 22:29:27 +08:00
weicw 11f0f90f7a feat(spec58): 模块注册表后端与模块硬编码收敛(#60)
- 新增 gxmu/module 包:ModuleRegistry 实体/Mapper/Service,listEnabled 按 sort_number 升序,titleOf 未注册回退 module 原串
- 新增 GET /api/gxmu/module-registry/list(需登录),返回启用模块元数据
- ReviewFlowConfigServiceImpl / ReviewListServiceImpl 的模块名映射改读注册表,查不到回退原串
- DeclareGuardServiceImpl 的模块集合改为注册表驱动,含 enabled=0(停用模块存量仍可撤回/作废)
- 启动期一致性校验:table_name≠module 或业务表缺失即启动失败,gxmu.module-registry.strict=false 可关闭
- 单测 13 例:注册表加载与排序、titleOf 回退、启动校验、module 名回退
2026-11-03 22:29:27 +08:00
weicw 446a56db72 Merge branch 'spec58/integration' into spec58/t6 2026-11-03 22:28:55 +08:00
weicw 5b7ddd48c7 feat(spec58): 4 张业务表 college_id 一次性回填脚本
以学生档案 gxmu_student.college_id 为准,按 user_id 回填 sjqn / tzbcy_form /
qmgc_form / wxxzx_project 的 college_id;仍为空的行再用表单里的学院文本
按组织名 LIKE 兜底,只有唯一命中才写入,无法匹配留空。

- A 段学生档案主路径,优先取在校记录的学院,档案学院不唯一时留空不猜
- B 段文本兜底,候选学院 = 有平台单位号的节点 ∪ 学生档案引用过的节点
- 只填空不覆盖,可重复执行;不接定时任务
- 附回填率、空值清单、未匹配文本一览与一致性抽查(3a-3d),供人工核对
2026-11-03 22:28:49 +08:00
weicw 58b09b029a merge: #63 审批动作鉴权与文件访问白名单 2026-11-03 22:28:29 +08:00
weicw f96613a0ee feat(spec58): 审批人解析组件 ReviewerResolver 与发起接线
- 新增 ReviewerResolver:USER:<userId> / COLLEGE_ROLE:<roleCode>,; 分隔多段取并集升序
- reviewer_rule 为空回退 review_user_id;按学院解析空集合抛 BusinessException,消息含学院名/层级/缺什么
- ReviewFlowStarterService 发起与驳回重提改由解析取人,解析为空不写任何节点
- ReviewFlow 增加 reviewerRule 字段;DeclareRecordMapper 增加 selectReviewContext(仅 4 张在跑表)
2026-11-03 22:28:02 +08:00
weicw c732cdb037 Merge branch 'spec58/integration' into spec58/t5 2026-11-03 22:26:58 +08:00
Codex d65e8453f5 fix(file): 下载需登录,内联预览与缩略图默认公开
- /api/file/download/** 恒需登录,注册顺序排在公开模式之前
- 新增 file.public-preview(默认 true):内联预览 /api/file/*/* 与缩略图默认匿名
- file.public-dirs 保留为额外公开目录,语义限定在原图与缩略图
- 测试补三条:下载无白名单也需登录、内联默认公开、public-preview=false 时内联需登录
2026-11-03 22:26:52 +08:00
weicw 4b50891fd4 merge: #62 驳回清理旧节点与审批链层级校验 2026-11-03 22:25:44 +08:00
weicw 91b3007939 Merge branch 'spec58/integration' into spec58/t4 2026-11-03 22:25:01 +08:00
weicw 17499cb526 fix(review): 驳回重走时清理旧节点,审批链层级连续性保存校验
- restartReviewWhenLastRejected 先删同一申报记录下旧节点再建第一级,同一事务
- 新增 ReviewFlowLevelValidator,按 title 校验层级从 1 连续升序
- ReviewFlowController 的 save/update/saveBatch/updateBatch 接入校验
- 单测覆盖驳回清理、层级缺级/重复/多链独立校验
2026-11-03 22:24:56 +08:00
weicw b8c9c6c853 Merge branch 'spec58/integration' into spec58/t5 2026-11-03 22:23:42 +08:00
Codex 94071092e3 fix(review): 审批动作校验归属、驳回必须写意见、文件下载需登录
- PUT /api/gxmu/review-list 与 /batch:操作者必须是待办行 user_id,否则 code 403
- status=2 且意见为空时拒绝并提示「驳回必须填写审核意见」
- GET /api/file/** 由匿名改为需登录,公开目录走 file.public-dirs 白名单
- 新增 FileAccessRules 纯规则组件,SecurityConfig 按其产出注册 antMatchers
- 新增 ReviewListControllerAuthTest(8 例)与 FileAccessRulesTest(7 例)
2026-11-03 22:23:35 +08:00
weicw 1f2a837609 merge: #59 schema 脚本(审批人规则列、学院标识列、模块注册表) 2026-11-03 22:21:56 +08:00
weicw fa7633b35e feat(spec58): 审批人规则列、学院标识列与模块注册表
- gxmu_review_flow 增加 reviewer_rule(为空回退 review_user_id)
- 4 张在跑业务表增加可空 college_id,供按学院解析审批人
- 新建 gxmu_module_registry 并写入 10 个模块,仅 4 个在跑模块启用
- information_schema 判存 + INSERT IGNORE,脚本可重复执行
2026-11-03 22:21:22 +08:00
weicw 0e4986330a feat(declare): 同一申报项目进行中只允许一条申报
- 新增 DeclareGuardService:按已通过/未处理驳回/待审核数判重,申报项目行加锁防连点
- 9 张申报表补 declare_id / declare_status / declare_remark,提交时写入并随审核结果回写
- 新增撤回、作废(强制放行)接口;挑战杯团队成员按手机号判重
- 申报项目支持配置允许重复提交与最多同时条数
- 迁移脚本 sql/20261102_add_declare_uniqueness.sql(含存量回填与重复清单)
2026-11-02 19:28:56 +08:00
weicw 76c9edd4be feat(file): 上传准入白名单 + 素材媒体大类筛选
素材库要从「只传图片」放开到文档与音视频,后端必须先有一道类型防线:
/api/file/upload 此前对文件类型零校验,任意文件都能落到公开可读目录。

- UploadRules:image/* 按探测 MIME 放行(历史头像/图标格式多,不做后缀白名单);
  其余按后缀白名单(pdf/doc/docx/xls/xlsx/ppt/pptx/txt/md/csv、
  mp3/wav/m4a/aac、mp4/mov/webm),再用危险 MIME 黑名单兜住 exe 改名。
  不用「扩展名必须等于探测类型」的严格比对:docx/xlsx/pptx 经 Tika 常得到
  application/zip,md/csv 得到 text/plain,严格比对会误杀正常办公文档。
- FileStorageService.store 落盘后校验,本地分支失败删除刚写的文件,不留孤儿。
- FileController 单独捕获 UploadRejectedException,把原因原样回给前端。
- FileRecordParam.mediaCategory + Mapper 展开成 image/video/audio/doc 四类条件:
  「文档」在 MIME 里没有统一前缀,只能落到 application/% | text/%。

设计取舍见 docs/adr/0004(本仓库外,tuanwei-home)。
2026-11-01 21:03:56 +08:00
weicw 9e88c53052 fix: 数据源会话时区强制 +08:00,修复创建时间早 8 小时
生产数据库容器(1Panel-mariadb-ZOpD)的 system_time_zone 是 UTC,而数据源 URL 写着

serverTimezone=Asia/Shanghai:JDBC 会话时区是 UTC、驱动却按 +08 解析服务端返回的字面量,

于是 timestamp 列(分类管理、文章、系统日志、审核流、AI 对话等)读出来整体早 8 小时。

数据源 URL 增加 connectionTimeZone=%2B08:00&forceConnectionTimeZoneToSession=true,

连接建立时把会话时区设为 +08:00,与 JVM 默认时区、spring.jackson.time-zone 保持一致。

用偏移量而非命名时区,避免 MariaDB 容器未加载时区表时报 Unknown or incorrect time zone。

附 sql/20261026_timezone_8h_check.sql:部署后的时区自检、分类管理核对,

以及 datetime 列历史 UTC 字面量与教师建号行的时间回填模板(默认注释未执行)。
2026-10-26 11:09:15 +08:00
weicw 0252672997 fix(ai): 短视频/图片产物归一并转存为对外地址(禅道 #1)
- 归一按内容识别 video_result(此前只认图片):地址字符串、markdown 链接、
  文件对象都统一成 {url,name,type:video},端上四个页面才取得到播放地址,
  修复「上游接口已通、我方未输出视频、复制链接为空」;
- answer 不再残留裸地址:媒体地址命中时改写成「文件名 + 链接」,
  生成记录也补上媒体直链,纯视频分支不再因正文为空而整条不入库;
- 新增 CreativeMediaRelay:上游产物在内网,端上打不开,由后端下载、
  存入本项目文件存储,下发 /api/file 对外地址(Range 续传现成);
  失败只降级保留原地址,不影响本次生成;
- FileStorageService.storeFile 支持把磁盘文件落入本地/对象存储;
- 开发环境关闭转存(公网 demo 地址本就可直接访问),生产默认开启。
2026-10-25 15:55:49 +08:00
b2894lxlx 5626cdd679 fix: 申报可用时间按东八区判断,写入时显式使用北京时间
避免容器 JVM/库会话仍为 UTC 时 createTime 偏 8 小时,以及 usable 查询把刚开放的申报滤掉。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-25 12:20:04 +08:00
weicw 0a2801850d fix(sync): 班级人数与辅导员不再被空值覆盖,人数按名册兜底
开放平台对 2025 级起的班级不返回 BJRS,对 2026 级连 FDYH 都不返回,
而 batchUpsert 用 VALUES() 直写这两列,每晚同步都会把人工补录的辅导员和
学生人数刷成 NULL;新生班级的人数因此一直空着,班级管理页首屏(2026 级)
两列全空。

- ClassInfoMapper.xml: counselor_name / student_count 改为
  COALESCE(VALUES(x), x),平台有值仍以平台为准,给空时保留存量
- 新增 refreshStudentCountFromRoster:学生同步结束时按 gxmu_student
  全量在册统计(不过滤 status,与平台 BJRS 口径一致)回填人数,
  只补「为空或 0」的班级,名册里没学生的班级不动
- StudentSyncHandler: 学生段写完后调用兜底

开发库已用同一语句回填 728 个班级。
2026-10-24 20:41:35 +08:00
weicw 4c2a787d81 feat(ai): 写作/生图/创意/活动策划也保存历史记录
- ai_chat_list 新增 chat_type / params,旧数据默认 chat
- 新增 AiGenerationRecorder:成功后同事务写 list + history,末帧回传 chatId
- 聊天改为成功收尾才建行,失败/中止不再留下空记录
- 写作(含自由创作)、生图、创意助手、活动策划四处接入,软失败与断流不入库
- 历史列表只取最近 30 条(SQL LIMIT),并强制按登录用户过滤
- 修复历史排序用了不存在的 sort_number 列(多行会 NPE)
- 新增 4 个测试类:记录器规则、排序回归、创意成功/软失败、30 条上限
2026-10-24 17:37:39 +08:00
b2894lxlx d54f559d34 fix: 审核列表与申报可见性,并统一 Docker 北京时区
修复团支部等角色待审漏数、PageParam 对 Collection 的 IN 条件失效,以及容器默认 UTC 导致时间偏移 8 小时的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 14:55:11 +08:00
weicw-dshsa da5c5c1e09 fix(ai): SSE 帧优先投给在途流式请求,修「生成后按钮永久不可点」
WebSocketServer.send 原本 WS 优先:只要同一账号还有 WS 会话(问答助手页会建
连接),文稿工作台那条 /stream 的帧就全被投到 WS 上,SSE 一帧收不到——正文只能
等末帧,收尾的 __END__ 永远不来,前端 loading 不复位,生成按钮卡死。

改为 SSE 优先:ChatStreamRegistry 是按请求登记的,登记期间发出的帧都属于那条
请求;没有在途请求时 emit 返回 false,照旧走 WS。握手回执「连接成功」改由
register() 直接写给该会话,避免被派发到别的请求。

测试:补两条回归(有在途 SSE 时即使 WS 在线也投 SSE;没有在途 SSE 时仍走 WS),
ai 包 158 条全绿。
2026-10-23 12:57:03 +08:00
weicw-dshsa 206adbff6c chore(build): 项目 JDK 固定为 17,修正 .sdkmanrc 的过期结论
原先钉的是 JDK 8,注释里写「lombok 1.18.20 不支持 JDK 17」。实测不成立:
JDK 17 下 `mvn -o clean compile` 与 158 条 ai 测试全绿;真正炸的是 JDK 21
(NoSuchFieldError: JCTree$JCImport does not have member field 'qualid')。

本机 sdkman 全局默认已切到 21,所以按项目固定 17,不动 sdk default。
2026-10-23 12:57:03 +08:00
weicw-dshsa 1ae2ab580d feat(manuscript): 稿件正文增加 content_format 格式标记
- cms_manuscript 新增 content_format(html/markdown/text),默认 html
- Manuscript 实体加 contentFormat;保存与修改时缺省按 html 落库,下游不再靠猜
- 迁移脚本幂等,并把明显是 markdown 的历史行标出来
2026-10-22 14:37:34 +08:00
weicw-dshsa f5a89634c4 feat(file): 文件存储接入 RustFS(S3),修正文件外链域名与路径
文件上传从本地磁盘改为可切换的对象存储(默认 s3,type 改回 local 即退回原行为):

- 新增 common.core.storage 包:ObjectStorage 接口 + S3ObjectStorage(minio 8.5.17)
  + NoopObjectStorage + FileStorageService + StorageConfig + StoredFile;
  key 规则沿用 file/yyyyMMdd/uuid.ext、缩略图 file/thumbnail/...,桶内结构对齐旧磁盘目录
- 读取仍走后端代理 GET /api/file/**:s3 模式下首次读取物化到本地 cache-path 再返回,
  对外 URL 不变;历史本地文件一律作废,不做回退兼容
- ConfigProperties 增加 storage 配置块,endpoint/access-key/secret-key 支持环境变量覆盖
  (RUSTFS_ENDPOINT / RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY),配置非法时启动即失败
- FileRecordService.deleteFileAsync 改为按 path 删除,本地与对象存储同时清理
- pom 固定 okhttp3.version=4.12.0、kotlin.version=1.9.25:Spring Boot 2.5.4 默认的
  okhttp 3.14.9 与 minio 所需的 okhttp 4.12.0 冲突,会导致启动即
  NoClassDefFoundError: okhttp3/ConnectionPool

同时修正文件外链拼接(以下均为改造前就存在的问题):

- application-prod.yml:server-url / file-server 由失效的 tuanwei-api.gxwebsoft.com
  改为生产实际域名 https://twai-api.gxmu.edu.cn(与 admin .env.production、
  uniapp request.js 一致),此前上传返回的 url 指向旧域名导致访问不到
- FileController 抽出 fileBaseUrl(),分页/列表原来漏了 /api/file 前缀
- OpenFileController 同样补 /api/open/file 前缀
- TzbcyMaterialServiceImpl 下载链接补 /api/file/download
2026-10-21 23:11:45 +08:00
weicw-dshsa bf6813e71d feat(ai): 聊天 WebSocket 加 /api 端点与心跳,WS 不可用时退到 HTTP SSE
生产网关在 /api 前丢掉 Upgrade 头,wss://…/chat/{userId} 到不了后端,
端上一直卡在「正在建立 WebSocket 连接…」。

- 新增 ApiChatWebSocketServer(/api/chat/{userId}),与老端点共用会话表;
  会话表改存 Session 而不是端点实例,两条路径都能收到推送
- 登记会话时关闭空闲超时(Tomcat 默认 20s,AI 首字慢时会被掐断),
  并补 @OnMessage 丢弃心跳(缺了它容器会因不支持的文本帧直接断连)
- send() 找不到 WS 会话时改投 ChatStreamRegistry,把帧送到 HTTP SSE 连接,
  推送侧仍只有 sendMessage 一个入口,三千多次调用点不用改
- prod 放开 com.gxwebsoft.ai.config 到 INFO:只保留连接/断开,推送路径仍静默
- 补 ChatStreamRegistryTest / WebSocketEndpointPathTest / AiControllerSseFrameTest

顺带补上 31eb75c 漏加的 ChatStreamRegistry.java:AiController 已经 import 它,
缺文件时 HEAD 编译不过。
2026-10-21 19:18:02 +08:00
weicw-dshsa 31eb75cdad fix(ai): 创意助手繁忙归类补 503/50508 与流式中断,失败日志保留尾部
线上日志(2026-10-21 18:10/18:50)显示失败节点已从出图节点移到最前面的
「问题分类器」(SiliconFlow):503 code 50508 System is too busy,以及
requests/urllib3 read_chunked 流式读取中断。旧归类只认 50500,用户看到的是
「生成失败」而不是「生成服务繁忙」;且 briefUpstreamError 只留头部,
Python traceback 的异常类型在尾部被截掉。

- isUpstreamCapacityFailure 增补 50508 / 503 / 流式中断等模式
- briefUpstreamError 改为头 2/3 + 标记 + 尾 1/3(仍单行、仍 <900 字符)
- buildCreativeAssistantResult 在成功但无可展示内容时记 WARN(含 outputKeys
  与 photo_files 条数),让解析兜底路径下一次可诊断
- 新增 AiCreativeBusyClassificationTest(6 条)

issue 19;不恢复自动重试。
2026-10-21 19:09:03 +08:00
weicw-dshsa f4590001d8 feat(ai): 创意助手出图链路收口——去重试、图片模式包装提示词、输出形状兼容(issue 19)
- 删除 CREATIVE_ASSISTANT_MAX_RETRY / executeCreativeAssistantWithRetry /
  isRetryableCreativeFailure,失败即失败;日志仍带 failedNode 与上游 error。
- 图片模式(mode=image)把 user_content 包装成「【任务类型】生成图片…」意图声明,
  避免上游「问题分类器」把画面描述判成文案/脚本分支;不带 mode 的入口保持原话直送。
- outputs 形状兼容:image7 这类数组里的 markdown 图片按内容归一到 photo_files
  ({url,name,type}),findTextValue 支持 List 中的图片字符串;raw 保持上游原样。
- 测试:新增 AiCreativeNoRetryIntegrationTest(2)、AiCreativeImagePromptTest(4)、
  AiCreativeImageOutputTest(5),删除两个重试用例;全量 148 跑 0 failures。
2026-10-21 15:42:54 +08:00
weicw-dshsa 42655ecd83 chore(build): 用 .sdkmanrc 固定 JDK 8 与 Maven 3.9.16
父 POM 管的 lombok 1.18.20 不支持 JDK 17,而本机 sdkman 全局默认是 java 17.0.20-tem,编译必然失败。按项目固定工具链,不改 sdk default。
2026-10-21 12:40:13 +08:00
weicw-dshsa aed2870b27 chore(dev): 开发环境 Dify 指向公网演示实例
只覆盖 dify.base-url,dify.apps / dataset / workflows 仍取 application.yml 默认值,避免两份配置漂移。base-url 必须带 /v1(/v1/parameters 返回缺 Bearer 的 401 JSON,/parameters 返回控制台 404)。演示实例与内网实例共用同一批应用 Key 与知识库。
2026-10-21 12:40:13 +08:00
weicw-dshsa 1b61fe5586 feat(ai): 自由模式转创意助手,失败日志补失败节点与上游 error(issue 17 / 18)
自由模式(issue 18):
- docType 为空时不调「内容创作与宣传助手」——它开始节点的 doc_type 是必填 select,
  空值直接被上游判 failed(error="doc_type is required in input form")。改为转
  「AI 创意助手」,只发它唯一的必填入参 user_content。
- ManuscriptGenInputs 增加 userContent;doc_type / Number 仍无条件带键(空串能过
  Dify 的必填校验,缺键不行)。
- 用例:AiManuscriptFreeModeRouteTest、AiManuscriptInputsContractTest。

失败日志(issue 17):
- 稿件流程、大纲流程、executeWorkflowForResult 三处失败日志补 status + failedNode +
  upstreamError + elapsed。失败信息拆在 node_finished 与 workflow_finished 两类事件,
  入参校验类失败只有后者,只取节点事件等于没取。
- 补 event=error 与「静默断流」(没有 workflow_finished / message_end)的日志,
  两者都带 taskId,可与请求对上。
- 日志级用 error 而非 warn:生产 com.gxwebsoft 只开 ERROR。
- 用例:AiWorkflowFailureDetailTest。
2026-10-21 12:40:05 +08:00
weicw-dshsa 9e20014cd6 fix(cms): 分类写入前校验上级分类,拦截自引用与成环
分类是自关联结构,一旦出现「自己指向自己」或「祖先指向后代」的环,前端构建分类树时
会直接抛错,导致整个页面无法渲染(报 data error: {categoryId: x, parentId: x})。

- ArticleCategoryService 新增 checkParentValid:顶级分类放行;修改时沿父级链向上回溯,
  父级链中不允许出现自己;带回溯深度上限兜底,避免历史脏数据造成死循环。
- Controller 的保存与更新入口都调用该校验,不通过时抛 BusinessException(中文提示)。

注:本提交同时包含这三个文件的整体重新格式化(忽略空白后实际逻辑改动为 52 行插入)。
2026-10-13 20:00:25 +08:00
weicw-dshsa 21e49c6a1a fix(ai): WebSocket 推送静默化,并补重试与静默推送用例(issue 11 / 12)
- WebSocketServer.sendMessage 里两行 System.out.println 删除。该方法被**每个 SSE
  事件**调用一次(大纲单次生成实测 3062 个事件,即一次请求刷 6000+ 行),而且绕过
  日志框架——既不受 logging.level 控制(生产把 com.gxwebsoft 压到 ERROR 也拦不住),
  也不进日志文件,真故障时需要的几行会被顶掉。日志改到低频的 onOpen / onClose;
  同时把 onOpen 里静默吞掉的握手回应异常改成 WARN(那是「连上了却收不到内容」的
  第一现场)。
- 新增用例:AiCreativeRetryTest、AiCreativeRetryIntegrationTest(issue 11,
  后者用本地 HTTP server 扮演 Dify,断言瞬时故障确实重跑一次、凭证失效只请求一次)、
  WebSocketServerSilentPushTest(issue 12,捕获 System.out 断言推送路径零输出)。
2026-10-13 20:00:01 +08:00
weicw-dshsa 8406c90cf3 fix(ai): 文件上传按应用选择凭证;并入创意助手重试与失败日志(issue 11 / 14)
同一文件内包含两块内容,无法拆分提交:

一、文件上传按应用选择凭证(issue 14,本次)
    Dify 的 /files/upload 是按应用鉴权的:实测同一张图、同一个 end user 下,chat Key
    上传得到的 file id 喂给创意助手工作流的 file-list 变量,0.2 秒即报
    Invalid upload file;用 creativeAssistant Key 上传的同一文件则 succeeded。
    而原实现只有两个分支(type 为空/0 用 chat,否则用 manuscript),从不使用创意助手
    的 Key,因此任何喂给创意助手的附件都必然失败 —— views/gxmu/creativeAssistant 表单页
    用的是同一个接口,它的附件功能同样是坏的(既有缺陷)。
    新增 app 参数按应用代号取凭证(复用 DifyProperties.AppKeys#byCode),不传时保持既有
    行为;未知代号回退旧逻辑并打 WARN。新增 resolveUploadToken。

二、创意助手出图失败的重试与可观测(issue 11,前次会话已完成验收、此前未提交)
    executeCreativeAssistantWithRetry / runCreativeAssistantOnce / CreativeRun /
    CreativeApiFailureException / creativeFailureReason / creativeFailureAnswer /
    isUpstreamCapacityFailure / isRetryableCreativeFailure / briefUpstreamError:
    上游 50500 算力不足时自动重跑一次,凭证失效、模型停用等不重试;每次失败按 ERROR
    打结构化日志(含失败节点名与上游原文截断)。
2026-10-13 19:57:41 +08:00
weicw-dshsa f54e28cffc fix(ai): 失败态在返回体里带 status=failed,前端才能区分失败文案与正文(issue 10)
上游工作流 failed 时,后端把 reply 置为失败文案("生成失败,请稍后重试")
并照常走成功路径返回(HTTP 200 / code=0),而返回体里**没有 status**:

    data = {"answer":"生成失败,请稍后重试", "conversationId":"…", "messageId":"…"}

两端前端的 isFailedPayload(读 payload.status / payload.data.status)因此
永远落空,只能把失败文案当正文渲染(实测三个 admin 页面都会这样)。
修 issue 09 时上游每次都 failed,这条是必现的。

改动:
- buildManuscriptResult 增加 failed 参数(保留三参重载,等价 failed=false,
  以免破坏 issue 08 既有测试的调用形态),失败时 put("status","failed")。
- 新增 buildActivityOutlineResult(同构),activityOutlineGen 收尾改用之。
- 两个方法都在外层作用域记录 workflowFailed:失败分支里有 continue,
  不能用分支内的局部布尔。

刻意不改的东西:
- 失败仍走 HTTP 200 / code=0。改成 code!=0 会与既有约定冲突、影响所有调用方
  的 catch 分支,本条只加一个字段即可让两端现有判定生效。
- answer 键在失败态下依然存在(issue 08 契约),只是现在有了 status 可区分。

测试:新增 AiWorkflowFailureHttpStatusTest 8/8,含反向用例——
成功态**不得**带 status(否则 isFailedPayload 会误伤正常结果),
以及「成功态正文恰好是失败文案」时唯一判据是 status。
全量 210 跑 0 失败(2 个 ServerContainer not available 为预存问题)。

验证:用桩上游(probe/stub-dify-failure.py,回 workflow_finished{status:failed})
起第二个后端实例,实测两条链路的返回体都带 status='failed' 且
answer 仍为可读文案,前端 isFailedPayload 判定为 true —— 全链路闭合。
2026-10-13 02:12:46 +08:00
weicw-dshsa fbc9774e19 fix(ai): 大纲链路补齐上游必填变量,资金归一化为数值(issue 09)
活动策划大纲当时必然失败,界面只显示「生成失败,请稍后重试」。
两条根因(单变量对照实测确证):

1. Dify 开始节点 event_funding 是 number,而实体与两端前端都是字符串,
   小程序问答助手页默认值还是中文「待定」→ must be a valid number,0.2s 即 failed。
2. event_member / tel_people 是上游必填变量,实体里根本没有字段,从不发送
   → event_member is required in input form。

实施中发现的关键事实,推翻了 issue 初稿的一条建议:
「is required in input form」检查的是键是否存在,不是值是否非空 ——

  缺 event_member 键 / null  → failed: is required in input form
  "event_member": ""        → succeeded(4752 条 message)
  "event_funding": ""       → succeeded(7268 条 message)
  "event_funding": "待定"    → failed: must be a valid number

所以「干脆不发该字段」行不通(不发 = 报 required),正确做法是键始终下发、
值空串兜底。

改动:
- ActivityOutlineInputs 补 eventMember / peopleManage / telPeople 三字段
  (snake + 驼峰别名);eventFunding 保持 String 承载用户原文。
- buildActivityOutlineInputs:6 个上游必填变量无条件占位(空值走空白转空串,
  不能给 null —— null 会被上游当缺失);event_type / event_location /
  people_manage / event_meme 实测非必填,保持「为空不发」。
- 新增 normalizeEventFunding:抽出首个金额并归一化为 BigDecimal。
  两个必须先处理的坑,否则静默发出错误金额(比留空更糟):
    · 日期:'2026年4月办活动,预算5000元' 的 2026 会被当金额 → 先剔除日期
    · 单位前空格:'预算 1 万元' 只认紧邻的「万」会抽成 1 元 → \s* 容忍
  另支持 万/亿/千/百 倍率与 '3万5千' 连续降序单位相加;抽不出即降级空串。
- 前置校验抽成 validateActivityOutline,只校验主题/受众/时间三项。
  刻意不校验另外三个上游必填项 —— 上游必填不等于用户必填,加校验会把两端
  默认入参挡在门外(改前端默认值为空串后,旧校验会直接返回
  'event_funding不能为空',比原缺陷更糟)。
- buildLoadingText 加 120 字上限:agent_thought 的思维链实测可达数 KB,
  整段塞进 loading 气泡等于把思维链推给端上。
- 去掉 isDisplayableText 之后的恒假条件(空串已被排除),并注释说明
  reply.indexOf("") 恒为 0 会误判已存在,故两道判断都不能少。

配套前端:
- uniapp 问答助手页资金默认值 '待定' → ''(未提及 != 待定)
- admin 入参类型补注释与 3 个可选键

测试:新增 AiActivityOutlineContractTest 23/23,含「凡校验放行的入参必带上游
全部必填键」的不变量用例;AiManuscriptUuidLeakTest 增至 21 条,补 loading
长度上限与真实 message_end 形状。全量 202 跑 0 失败
(2 个 ServerContainer not available 为预存问题)。

验收:probe/outline-e2e.py 走真实后端,用两端前端默认入参与 issue 原文要求的
'待定' 一档,复刻 apiRequest 回落 + formatOutlineResult + parseAssistantContent
判定,29/29 PASS。
2026-10-13 00:37:15 +08:00
weicw-dshsa a8c8dd8643 feat(dify): 全部迁移到内网实例,统一为单份配置
VPN 打通后开发机可直连内网,原先「默认公网 + prod 覆盖内网」的双份配置
已无必要,且两套 Key/知识库 ID 完全不通用(实测互打一律 401),
留着只会造成误用。公网那套整体移除,回退到 git 历史取。

- application.yml:base-url 与 8 个应用 Key、全部知识库密钥/ID 改指内网;
  原默认值来自公网,其注释同步改写
- application-dev.yml / application-prod.yml:删除 dify 覆盖段。
  YAML map 是按键合并而非整体替换,保留一份与默认值相同的覆盖
  只会在将来漏改时混出「一半内网一半公网」的状态
- DifyProperties:默认 baseUrl 改内网,类注释同步

修复一个上线起就坏的接口:

- admin 端「竞赛历史」文档列表按 challenge-cup 取库,而该 ID
  b342f096-… 在**公网实例上也返回 404**(公网真实的挑战杯库是
  7c812029-…,从未登记),故该接口一直不可用。改指内网「竞赛案例」
  库(181d58e3-…,145 篇),实测已能列出文档。对外路径保持
  /challenge-cup/documents 不变,以免影响 admin 端调用

清理失效的工作流变量(内网实测已从开始节点移除):

- document-process 的 query / pdf_url,declaration 的 parse_value。
  三者配置为空串以显式丢弃 —— 直接删行会退回「按原名透传」,
  反而把 pdfUrl / parseValue 这类驼峰名发给 Dify。实体字段保留,
  不破坏 /documentProcess/upload 的入参契约(pdfUrl 仍参与
  「必须提供文件或 pdfUrl」的校验,只是不再转发)
- 注:Dify 对多传变量静默忽略,故此前不报错、功能未受影响,
  属「看着对其实无效」的配置

测试:改断言为内网值,并新增「不残留任何公网配置」「失效变量必须
配成空串」等防回归用例;变异验证(base-url 改回公网 / 空串改回生效名)
确认用例有真实判别力。Dify 相关 44 例全绿。

Refs: .scratch/dify-probe/README.md, .scratch/ai-assistant/map.md
2026-10-12 12:37:33 +08:00