SpringBoot转写验证记录.md 67 KB

SpringBoot 转写验证记录

配套方案:docs/plans/SpringBoot后端转写方案.md。每阶段收尾追加一节, 记录「验证口径 → 实际结果 → 偏差与结论」。

阶段 1:工程骨架(2026-08-23)

交付物

backend-java/ Maven 工程(Spring Boot 3.5.3 / Java 17 / MyBatis 3.0.5):

文件 对齐 Python 版
pom.xml 依赖按阶段引入(阶段1:web/validation/mybatis/mysql/flyway/lombok)
config/DmsProperties dms/config.py(含 pymysql URL→JDBC URL 自动转换,同一份 .env 两版可用)
config/DataSourceConfig SQLAlchemy engine 选项(READ COMMITTED / 会话时区 UTC / 1800s 回收)
config/CorsConfig flask-cors 默认行为 + blueprint.py 的 Expose-Headers
common/ApiResponse + ErrorEnvelope response.py(成功 data 空时序列化 {})
common/DmsException + Errors errors.py 全部 47 个错误码(code/status/中文消息逐条对应)
common/RequestIdFilter + RequestContext request_context.py(UUID 透传/生成/非法拒绝)
common/GlobalExceptionHandler blueprint.py 的错误处理语义
api/v1/HealthController health.py
db/migration/V1__baseline_alembic_0016.sql 空 DDL 占位,锚定 Flyway 版本线

实施偏差说明

  • Spring Boot 版本:方案文档写 3.3.x,实施改用 3.5.3。原因:3.3 的 OSS 维护期已于 2025-06 结束,3.5 为当前维护线且同样要求 Java 17,对本项目无 API 影响。
  • Python 版 backend/.env 不存在:本地真实运行配置位于 dist-package/dms-server/.env(远程 MySQL)。Java 版验证直接 source 该文件,凭据不落代码库。

验证结果(远程库 dms @82.156.x.x:3306)

验证项 预期 结果
启动 无报错,9346 端口 ✅ Started in 4.752s
Flyway baseline 不动 alembic_version,仅建自身版本表 ✅ baseline 0 → migrate V1
GET /api/v1/health 200,{code:OK, data:{service:dms,status:UP}, requestId} + X-Request-Id 头 ✅
GET /api/v1/not-exist 404,{code:RESOURCE_NOT_FOUND, message:请求的资源不存在, details:null, requestId} ✅
非法 X-Request-Id 400 INVALID_ARGUMENT「X-Request-Id必须是有效UUID」 ✅
CORS Origin 回显 + Access-Control-Expose-Headers: Content-Disposition, X-Request-Id ✅

验证后服务已关闭(按端口精准终止 java.exe,9346 已释放)。

遗留事项(转阶段 2 处理)

  • 阶段 2 起接入 sys_user 表:MyBatis Mapper、login/logout/me、JWT(HS256)、Argon2 存量密码校验(首个测试必须是「Python 版建号 → Java 版登录」)。

阶段 2:认证与用户基础(2026-08-23)

交付物

文件 对齐 Python 版
model/UserEntity + mapper/UserMapper models/user.py(findActiveByUsername/findActiveById(ForUpdate)/updateLoginSuccess/incrementAuthVersion 等)
security/Passwords security/passwords.py(Argon2id m=65536,t=3,p=4,spring-security-crypto Argon2PasswordEncoder(16,32,4,65536,3),DUMMY_HASH 时序安全)
security/JwtTokens security/jwt_tokens.py(HS256 / iss=dms / claims: sub,username,roleCode,authVersion,iat,exp,jti;2h 与 7 天两档)
security/AuthRequired + AuthContext + AuthInterceptor security/decorators.py(bearer_auth_required / admin_required:验签→用户态→auth_version→身份一致性)
service/AuditService services/audit_service.py(LOGIN/LOGOUT/LOGIN_FAILED 三类审计 + clientIp 提取 + detail JSON 序列化)
service/LoginIpService services/login_ip_service.py(IPv4/IPv6 CIDR 白名单)
service/UserSummaryService services/user_service.py user_summary(effective 组织/角色名回退、lastLoginAt 毫秒ISO、allowedModules)
service/AuthService services/auth.py(login 全分支 + logout auth_version 递增)
api/v1/AuthController + UsersController api/v1/auth.py + api/v1/users.py current_user(login 三键严格校验)

实施偏差说明

  • BouncyCastle 显式引入:spring-security-crypto 的 Argon2 实现依赖 BC 且声明为 optional 不传递,运行期抛 NoClassDefFoundError。pom 显式加 bcprov-jdk18on:1.78.1。
  • JWT 库 jjwt → java-jwt:存量 DMS_JWT_SECRET 为 28 字节(224 位),jjwt 0.12 在密钥构造/signWith 启发式/HMAC digest 三层均强制 HS256 密钥 ≥256 位且不可绕过;PyJWT 不限制密钥长度。为保证同库同密钥与 Python 版 token 互通,改用 com.auth0:java-jwt:4.4.0(不校验密钥长度,行为与 PyJWT 一致)。
  • 415 → 400 映射:非 JSON Content-Type 时 Python Flask request.is_json 检查返回 400;Spring 默认抛 415,GlobalExceptionHandler 增加 HttpMediaTypeNotSupportedException → 400 INVALID_ARGUMENT「请求体必须是JSON对象」。
  • 审计 detail 序列化:用 Jackson ObjectMapper 替代字符串拼接(对齐 Python json.dumps,防特殊字符破坏 JSON)。

验证结果(远程库,测试账号 javatest001 由 Python argon2-cffi 建号)

脚本:docs/temp/verify_stage2.sh + verify_stage2b.sh(字面量 token)。

# 用例 预期 结果
1 错误密码 401 INVALID_CREDENTIALS ✅
2 不存在用户(时序安全,DummyHash 校验) 401 INVALID_CREDENTIALS ✅
3 正确登录(Python Argon2 哈希 → Java 校验互通) 200 + accessToken/tokenType/expiresIn/user 全字段 ✅ id 字符串化、allowedModules、lastLoginAt 毫秒ISO
4 /users/me 无 token 401 TOKEN_INVALID ✅
5 /users/me 伪造 token 401 TOKEN_INVALID ✅
6 /users/me 合法 token 200 user summary ✅
7 logout 200 data:{} ✅
8 logout 后旧 token 401 AUTH_VERSION_MISMATCH ✅

附加通过项:login 多余字段/keepSignedIn 非布尔/缺字段 → 400 INVALID_ARGUMENT;非 JSON Content-Type → 400。

结论:Argon2 密码哈希与 JWT(HS256) 双向互通实证通过,同一数据库/同一密钥下 Java 版可直接替换 Python 版登录链路。

遗留事项(转阶段 3 处理)

  • 测试账号 javatest001 保留在库中(阶段3-8 继续用作验证账号,阶段9收尾统一清理)。
  • admin_required 分支(角色管理接口)待阶段4验证。

阶段3:门户跳转登录(2026-08-23)

交付物

Java 文件(backend-java/src/main/java/com/dms/) 对齐 Python 源 职责
service/PortalConfigService.java services/portal_config.py .env 门户配置加载(provider/tokenParam/verifyUrl/fieldMap/defaultOrgId 等),PortalConfigException
service/PortalProvider.java services/portal_provider.py mock/http/custom 三形态验签与身份拉取;PortalIdentity 中间结构;PortalTokenException/PortalIdentityException
service/PortalAuthService.java services/portal_auth_service.py verify(用户匹配/按需建户/组织匹配/票据签发)+ setup-password(票据消费/一次性失效/IP限制/直接登录)
api/v1/PortalAuthController.java api/v1/portal_auth.py POST /auth/portal/verify、/auth/portal/setup-password,严格请求体校验;IP禁止审计(user=None, "(portal-setup)")
mapper/UserMapper.java 扩展 SQLAlchemy User 查询 findByExternalId/insert(useGeneratedKeys)/updatePortalSync/updatePortalPassword
mapper/OrganizationMapper.java 扩展 — findByOrgName/findByIdNotDeleted

票据机制:JWT HS256(同 JWT 密钥),claims sub=userId / purpose=PORTAL_SETUP / authVersion / iat / exp(+300s) / iss=dms;设密成功后 auth_version+1 使票据一次性失效。

数据库前置(重要)

远程库当时停在 0015_login_ip_restriction,缺 0016_portal_user_provision(sys_user.external_id + 生成列 active_marker + 软删唯一约束 + 索引)。用全局 Python(backend venv 基解释器已失效)执行官方迁移补齐:

cd backend/migrations && python -m alembic upgrade head   # 0015 → 0016

实施偏差说明

  • HttpClient:Python requests → JDK 内置 java.net.http.HttpClient(无新依赖);连接超时 10s,请求超时取 DMS_PORTAL_HTTP_TIMEOUT_SECONDS(默认10)。
  • 成功判定语义对齐:dig(data, successField) 缺失时按 Python str(None) 返回 "None" 再与配置值比较(digAsText),避免 NPE 且行为一致。
  • mock token 解析:split(":", -1) 保留空段,对齐 Python token.split(":")。
  • IP 禁止审计位置:service 抛 LOGIN_IP_FORBIDDEN,Controller 层 catch 后记 recordLoginFailure(request, null, "(portal-setup)", "IP_NOT_ALLOWED")(对齐 Python 控制器层实现)。
  • 密码只 hash 一次:Passwords.hash 结果存变量复用(Argon2 慢哈希,避免双倍耗时)。

验证结果(provider=mock,端口 9346)

脚本:docs/temp/verify_stage3.sh(时间戳用户名,可重复执行)。

# 用例 预期 结果
1 verify 多余字段 400 INVALID_ARGUMENT「请求体必须且只能包含query」 ✅
2 verify query 缺 token 参数 400「跳转链接缺少token参数…」 ✅
3 非法 mock token 401 PORTAL_TOKEN_INVALID ✅
4 新用户首次跳转 200 phase=SET_PASSWORD + username/realName/portalTicket(212字符JWT) ✅
5 setup 密码过短 400「密码长度必须在8~64个字符之间」 ✅
6 setup 伪造票据 401 PORTAL_TICKET_INVALID ✅
7 正确设密并登录 200 accessToken/tokenType/expiresIn/user(与 /auth/login 同构) ✅
8 票据复用 401「设置密码凭证已使用,请从门户重新跳转」 ✅
9 二次跳转 200 phase=LOGIN(无票据) ✅
10 独立密码普通登录 200 accessToken ✅

落库核验:sys_user id=5 portaluser*,external_id/role_code=USER/status=ENABLED/auth_version=1/last_login_at 非空;审计三条 CREATE_USER{portal,externalId} → LOGIN{portal,setupPassword} → LOGIN{keepSignedIn},与 Python 版一致。

踩坑记录(Windows 验证环境)

  • curl 命令行传中文被 GBK 码页破坏:Windows curl.exe 接收命令行参数经代码页转换,UTF-8 中文 → 服务端 Invalid UTF-8 middle byte 0xc5 → 400。解决:中文 body 先 printf > 文件(UTF-8 原样)再 curl --data-binary @文件。
  • 首现象 500 的根因是远程库缺列:Unknown column 'external_id',非 Java 代码问题;补 0016 迁移后全绿。

结论:门户三形态(mock 已实证 / http 声明式已实现待真实对端 / custom 预留)、票据一次性、独立密码回落登录链路与 Python 版契约一致。

阶段4:组织/用户/角色/IP 管理(2026-08-23)

交付物

Java 文件(backend-java/src/main/java/com/dms/) 对齐 Python 源 职责
service/OrganizationService.java services/organization_service.py 组织树(内存递归排序/status过滤/含停用开关)、创建/更新/删除(子孙环检测、userCount守卫、IP分配行同事务软删)
service/UserService.java services/user_service.py 用户 CRUD:列表(组织含 descendants 展开)、创建(初始密码 Argon2 + mustChangePassword)、更新(自保护/最后管理员守卫/authBump)、软删、重置密码
service/RoleService.java services/role_service.py 角色 CRUD + 15行权限矩阵(canView等7布尔位)+ 部门关联 + 用户关联(memberCount、ROLE_IN_USE守卫)
service/IpEntryService.java services/ip_entry_service.py IP条目池 CRUD(单IP/CIDR规范化、主机位校验、分配守卫)+ 组织IP分配
api/v1/OrganizationsController.java api/v1/organizations.py 6路由(tree/POST/PUT/DELETE/ip-assignments GET+PUT)
api/v1/UsersController.java 扩展 api/v1/users.py /me(仅认证)+ list/create/update/delete/reset-password(均 admin)
api/v1/RolesController.java api/v1/roles.py 10路由(CRUD + departments/permissions/users 子资源 GET+PUT)
api/v1/IpEntriesController.java api/v1/ip_entries.py 4路由(list/create/update/delete)
model/RoleEntity/IpEntryEntity + mapper/RoleMapper/IpEntryMapper/OrganizationMapper 扩展 对应 SQLAlchemy 模型 注解 SQL(FOR UPDATE 乐观锁、动态过滤分页)

实施偏差说明

  • MyBatis 多参数 @Param 绑定 bug(用例11 500 根因):updateFull(UserEntity, @Param("authBump") int) 一旦任一参数加 @Param,MyBatis 停止对无注解参数做属性展开,SQL 里 #{realName} 抛 BindingException: Parameter 'realName' not found。修复:@Param("user") + SQL 全部改 #{user.xxx}。
  • 权限矩阵行数校验语义:Java 对 matrix 行数≠15 抛 400 INVALID_ARGUMENT「权限矩阵必须为15行」,与 Python _validate_matrix(InvalidArgumentError)一致,非 ConflictError。
  • DELETE rowVersion 参数:用 @RequestParam(required=false) 接收后走 Payloads.deleteRowVersion,缺失时返回对齐的 400 文案而非 Spring 默认 500。
  • 临时管理员方案:生产 admin(1) 密码与 .env 种子密码不一致(不可用),验证脚本用 Python pymysql+argon2 直插临时 ADMIN javatest4admin(脚本开头建号/结尾物理删除,可重复执行)。

验证结果(provider=mock,端口 9346,脚本 docs/temp/verify_stage4.sh 共 37 用例)

分组 用例(预期) 结果
权限门禁 普通用户访问用户列表 403 ✅
组织 树查询 200;创建 201;重复orgCode 409;改名 200;parentId=自己 400;错误rowVersion 409;删除 200 ✅ 7/7
用户 列表 200;创建(中文名+组织) 201;用户名重复 409;更新 200;admin改自己 403 USER_SELF_PROTECTION;重置密码 200;新密码登录 200;删除 200 ✅ 8/8
角色 列表 200;创建 201;角色名重复 409;矩阵种子15行;矩阵更新 200;矩阵不完整 400;关联部门 200+回读;关联用户 200+回读;删有成员角色 409 ROLE_IN_USE;解除后删除 200 ✅ 10/10
IP条目 列表 200;CIDR创建201规范化(192.168.x.0/24);重复IP 409;非法IP 400;主机位非0 400;分配组织 200+回读;删被引用IP 409;解除后删除 200 ✅ 8/8

rowVersion 联动实证:登录 touch 用户 rv(创建0→更新1→重置2→登录3→删除传3);角色子资源更新 touch 角色 rv(矩阵1→部门2→用户3→解除4);组织IP分配/解除各 touch 组织 rv。

踩坑记录

  • KillShell 杀不死 Java 子进程:Windows 下结束 mvn shell 后 java.exe 仍占 9346,新实例 "Port already in use" 静默退出,请求打到旧进程返回 404。必须 netstat -ano | grep :9346 找 PID → taskkill //F //PID。
  • 物理清理顺序受 FK RESTRICT 限制:删 sys_organization 前须先删 sys_org_ip_assignment/sys_role_dept 引用行(doc_document/sys_user 同理不涉及测试数据)。
  • 软删用户不占组织守卫:Python 组织删除守卫 User.is_deleted.is_(False),Java 一致——软删用户后组织可删。

结论:组织/用户/角色/IP 四域 37 用例全绿,乐观锁 rowVersion、业务守卫(自保护/最后管理员/ROLE_IN_USE/ORGANIZATION_IN_USE/IP分配引用)、权限门禁与 Python 版契约一致。

阶段5:分类/文档/附件/挂载域(2026-08-23)

按 8 个子阶段推进(5.1 存储层 → 5.8 契约验证),本阶段完成后文档全生命周期(上传/覆盖/更新/关联/挂载/密码锁/删除)可用。

交付物

分组 Java 文件(backend-java/src/main/java/com/dms/) 对齐 Python 源 职责
存储层 storage/StoragePaths storage/UploadStorage storage/OleContainer dms/uploads.py original/{uuid4}.{ext} 落盘、SHA256、temporary/ 暂存、doc/docx/pdf/wps 白名单、PDF/OLE 魔数校验
分类域 model/CategoryEntity + mapper/CategoryMapper + service/CategoryService + api/v1/CategoriesController models/category.py + services/category_service.py + api/v1/categories.py 分类树(递归/扁平 flatMode)、创建/更新/删除(CATEGORY_NOT_LEAF/CATEGORY_IN_USE 守卫、document_count 联动)
权限评估 service/AuthorizationService + mapper/PermissionMapper + model/PermissionEntity services/authorization_service.py 密级×角色矩阵可访问性评估(CUSTOM 可见性默认拒绝普通用户,权限矩阵查询接口留阶段7)
密码锁 service/DocumentPasswordService services/document_password_service.py 设密/清密/改密、X-DMS-Document-Password 头校验、admin 除删除外全操作需密码语义
文档查询 service/DocumentQueryService services/document_query_service.py 列表(分页/过滤/密级过滤)、详情(contentText 屏蔽、viewPasswordVerified、绑定/子方案聚合)
文档写入 service/DocumentMutationService + service/DocumentContentService + mapper/DocumentMapper + model/DocumentEntity + model/DocumentRelationEntity + mapper/DocumentRelationMapper services/document_mutation_service.py + services/document_content_service.py 上传(新建/覆盖/子方案/批量导入)、元数据更新、description、关联关系、重排、软删(父子守卫)、正文提取状态流转
附件/挂载 service/AttachmentQueryService + service/AttachmentBindingService + model/AttachmentBindingEntity + mapper/AttachmentBindingMapper services/attachment_query_service.py + services/attachment_binding_service.py 附件 CRUD(attachmentType 四类)、批量挂载(去重/createdCount)、槽位重排(≥2)、解绑、attachmentCount 联动
下载/预览 service/FileReadService + service/PreviewService + service/FileVerificationService + service/DocumentToPdfConverter services/file_read_service.py 等 下载(Content-Disposition 原文件名)、完整性校验(sha256)、预览占位(LibreOffice 转换留阶段8)
控制器 api/v1/DocumentsController + api/v1/AttachmentsController + api/v1/MainPlansController + api/v1/DocumentUploads api/v1/documents.py 全部文档域路由;multipart 部件严格校验(file+metadata / files+items / 查询参数白名单)
公共 common/Page common/Payloads common/TimeRanges common/DeadlockRetry dms/paging.py 等 分页信封、字段严格校验、时间范围解析、死锁重试(对齐 execute_with_deadlock_retry)
审计 model/AuditLogEntity + mapper/AuditLogMapper services/audit_service.py business_audit 文档域操作审计落库

实施偏差说明

  • MultipartRequest 无 form 字段方法:Spring 6.2.8 的 MultipartRequest/MultipartHttpServletRequest 接口(javap 反编译确认)只有文件方法;StandardMultipartHttpServletRequest.getParameterMap 会混入 query 参数。为精确对齐 Flask request.form(仅 body 非文件 part),DocumentUploads 改用原生 Servlet request.getParts() + getSubmittedFileName()==null 判定表单字段。
  • 临时管理员:延续阶段4模式,验证脚本用 Python argon2-cffi 直插 javatest5admin/javatest5user(结尾物理删除)。
  • 互读存储根:Java 默认 backend-java/dms-storage,互读验证通过启动时 export DMS_STORAGE_ROOT=backend/dms-storage(生产存储根,不改生产配置)对齐。

验证结果(端口 9347,与 Python 版同库同存储根)

三组脚本:docs/temp/verify_stage5_7.sh(冒烟 15 用例)、stage5_interop.py(存储互读 16 用例)、stage5_contract.py(综合契约 44 用例),均可重复执行(自建自清)。

① 路由冒烟 15/15:relation-presets 三预设+no-store、列表类接口、404 错误码(MAIN_PLAN_NOT_FOUND/RESOURCE_NOT_FOUND)、multipart 部件严格校验(非 multipart/缺字段/多余查询参数 → 400)、happy-path 进入文件类型校验链(.txt → 415 UNSUPPORTED_FILE_TYPE)。

② 存储互读 16/16(Python 建 → Java 读 / Java 建 → Python 读):

方向 用例 结果
Python 落盘+插库 → Java 读 详情 fileHash/fileSize/originalFileName 一致;下载字节 sha256 一致;Content-Disposition 携带原文件名 ✅ 5/5
Java API 上传 → Python 核验 响应 fileHash==DB 行;file_size/扩展名/mime/类型/状态/密级/分类挂载一致;落盘 sha256==file_hash;PDF 头;相对路径 original/{uuid4}.pdf 规则 ✅ 8/8
Java 自读闭环 下载 sha256 一致 ✅

③ 综合契约 44/44(分类 5 + 文档生命周期 12 + 关联 2 + 附件/挂载 10 + 密码锁 7 + 删除守卫 8): 分类树/创建/重复 409/CATEGORY_NOT_LEAF 409;上传 201/同名 409/覆盖错版本 409/覆盖 201 且 fileHash 更新/子方案 touch 父 rv+1/元数据+description 更新/乐观锁 409;关联保存+列表;附件双上传/批量挂载 createdCount=2/重排单 ID 400/重排 200/解绑;CUSTOM 可见性普通用户 403;设密 hasPassword/详情屏蔽 viewPasswordVerified=false/无密码下载 403 DOCUMENT_PASSWORD_REQUIRED/带密码头下载 200/错误密码仍屏蔽/清密;有子方案删主案 409 MAIN_PLAN_HAS_CHILDREN/删子/删主/删附件/删分类守卫 CATEGORY_IN_USE。

rowVersion 联动全链实证(本次修正"description 不 touch"的错误认知,update_document_description 同样 row_version += 1):创建=0 → 覆盖+1 → 子方案上传 touch 父+1 → 元数据+1 → description+1 → 关联+1 → 挂载/重排/解绑各+1 → 设密/清密各+1 → 删子方案 touch 父+1;分类 rv 随 document_count 联动 +1。

踩坑记录

  • Multipart form 字段提取(见偏差说明):接口能力边界靠 javap 反编译确认,避免臆测 API。
  • 挂载域响应契约:bind/reorder/unbind 返回扁平计数字典 {createdCount, existingCount, attachmentCount, mainPlanRowVersion},非文档序列化(无嵌套 rowVersion);reorder 契约要求 orderedBindingIds 至少 2 个(单 ID → 400)。
  • pymysql REPEATABLE READ 快照陷阱:验证脚本连接的首个 SELECT 建立读视图,此后 Java 连接提交的行不可见 → cleanup 误判"行不存在"跳过删除 → FK 报错。解决:关键读取前 conn.rollback() 释放快照(cat_rv/doc_rv/cleanup 均如此)。
  • 软删行阻塞硬删:doc_document_relation/doc_attachment_binding FK RESTRICT 不分软删——测试兜底清理须先删这两表行再删 doc_document(SUB_PLAN 自引用行最先删)。
  • Windows curl 不识别 /tmp 路径(-F file=@/tmp → HTTP 000):上传文件放脚本目录。

结论:文档全生命周期契约 44/44、存储互读 16/16 全绿,Java 版上传的文件 Python 版可直读(同 original/{uuid4}.{ext} 规则、同 sha256、同 DB 行语义),满足阶段5验收标准「与 Python 版互读对方存储文件」。遗留:全文检索/正文提取(阶段6)、权限矩阵查询与回收站(阶段7)、LibreOffice 预览转换与审计查询(阶段8)。


阶段6:全文检索与正文提取(验证)

交付物盘点(6.1,零新增代码)

检索/正文域已随阶段5.4/5.5全量转写,本阶段静态核对确认后仅做运行时验证:

能力 Java 交付物(已有) Python 对应 说明
正文提取 service/DocumentContentService services/document_content_service.py PDFBox pdfText + POI wordText(段落/表格递归/页眉页脚)+ buildSearchText + 状态流转(UNSUPPORTED/FAILED/SUCCESS/EMPTY)
检索全参数 service/DocumentQueryService services/document_query_service.py keyword(标题 LIKE 转义 %_\)/contentKeyword(content_text OR search_text)/organizationKeyword/securityLevels/tags(+tagsMatch any/all)/updatedFrom/updatedTo/aggregateHits/categoryScope/sortBy/includeDescendants
命中片段 contentSnippet 同名 keyword 命中正文时截取,前后 40/80 字符,越界侧加「……」
聚合命中 aggregate 过滤 同名 仅 MAIN:titleHit/subHitCount(子案标题命中数)/attachmentHitCount
DOC/WPS 提取 DocumentMutationService 转换分支 document_mutation_service.py 转换分支 doc/wps 走 DocumentToPdfConverter(LibreOffice),不可用/异常 → FAILED(与 Python 版逐行对齐)

本阶段修复的 Java 缺陷(1 处)

storage/OleContainer.java CFB 链终止常量类型 bug(合法 .doc 全部被 415 误拒):

  • 根因①:END_OF_CHAIN/FREESECT 声明为 int(0xFFFFFFFE/0xFFFFFFFF 即 -2/-1),与 u32 返回的 long 无符号值(4294967294/4294967295)比较时 int 符号扩展后永不相等 → DIFAT 遇 FREESECT 不 break、目录链遇 ENDOFCHAIN 不终止。
  • 根因②(连环):fat 为 int[],fat[(int) sector] 读出后符号扩展回 -2L,同样与常量不等。
  • 修复:常量改 long(0xFFFFFFFEL/0xFFFFFFFFL)+ 链值读出处 & 0xFFFFFFFFL 无符号扩展(OleContainer.java:21-24、74-75)。
  • 定位方法:Python 复刻解析逻辑对拍(文件本体解析成功)→ 独立 Java main 复跑(稳定复现)→ 插桩打印(d=1 fatSector=4294967295 未 break 实锤类型提升缺陷)。

验证结果(端口 9347,38/38 全绿)

脚本 docs/temp/stage6_search.py(自建自清,可重复执行):

分组 用例 结果
提取状态流转 正文 PDF SUCCESS/含 AlphaSignal+BetaSignal;DOCX SUCCESS/段落+表格信号;空 PDF EMPTY 且正文 NULL;DOC FAILED(转换器不可用,对齐 Python 版转换分支语义) ✅ 8/8
search_text 含名称+摘要+正文三源;与 Python(fitz) 提取逐字一致 ✅ 2/2
contentKeyword 命中正文主案、命中 DOCX 主案、snippet 含关键词且「……」包裹、未命中 0 条 ✅ 4/4
aggregateHits 命中主案(titleHit=False/subHitCount=1/attachmentHitCount=1)、未命中 0 条、非 MAIN 400 ✅ 3/3
检索参数 标题 keyword、百分号 keyword 精确命中(LIKE 转义)、securityLevels 含/不含、tags 单命中、tagsMatch=all、tags 未命中 0、updatedFrom 过去命中、updatedTo 过去 0、from 晚于 to 400、sortBy=createdAt、子案列表 keyword ✅ 12/12
上传链路 5 个素材上传 201(PDF×3/DOCX/DOC)+ 登录/建分类/子方案/附件/挂载 ✅ 11/11

踩坑记录

  • Java 有符号/无符号类型提升陷阱(见上文 OleContainer 修复):u32 语义值与 int 十六进制字面量比较是重灾区;CFB 魔数校验阶段5已通过的原因是当时只测了 pdf/docx,doc 的 OLE 路径本次才首次运行。
  • 附件上传端点:附件走独立 POST /api/v1/attachments(Python 版同),与 POST /api/v1/documents(要求 documentType=MAIN/SUB_PLAN)是两个入口,复用统一 upload helper 时易混。
  • pymysql 快照与 FK 顺序(延续阶段5经验):残留兜底删除须先 SUB_PLAN(parent FK)→ relation/binding 行 → 主案;cleanup 对空列表/None id 做防御。
  • doc 提取状态预期:非 pdf/docx → UNSUPPORTED 仅适用于 wps 等白名单外延;doc 在 mutation 层有专门转换分支,本机无 LibreOffice → FAILED 是正确对齐(非 bug)。

结论:检索/正文域运行时验证 38/38 全绿(含与 Python fitz 提取逐字一致性对拍),OleContainer 类型 bug 已修复,阶段6验收标准「全文检索可用、正文提取状态流转正确」达成。遗留:权限矩阵查询与回收站(阶段7)、LibreOffice 预览转换与审计查询(阶段8)。


阶段7:权限矩阵与回收站(2026-08-23)

交付物

Java 交付物 Python 对应 说明
service/PermissionService services/permission_service.py GET 可见性视图(默认 CUSTOM 空集/附件 400/子案 409 继承标记)、PUT 全链校验(未知字段/缺字段/枚举/重复主体 details{subjectType,subjectId}/ALL_AUTH 带 entries/ORG 空/ORG 带 USER/主体 404/rv 冲突/子案继承/附件)、_active_permissions(lock=True) 排序锁读差异保存(逐行软删/整行刷新/插入,rv+1)
service/RecycleBinService services/recycle_bin_service.py 列表(保留原始多值参数做重复检测、documentType 枚举、deletedFrom/To 时间与顺序、sortField/sortDirection 白名单、page/pageSize 下界)、单条恢复全约束(文件完整性 HASH/SIZE、分类 DELETED/DISABLED/NOT_LEAF、父案 DELETED/WRONG_TYPE/ROOT_MISMATCH、权限 ORG 禁用/ALL_AUTH 带权限/键冲突、挂载冲突、rv 乐观锁)、恢复成功批次(权限/挂载逐行复活+名称刷新、分类快照继承父案、_recount_category 重数、审计 RESTORE_DOCUMENT、明细去 fileHash)
api/v1/PermissionController api/v1/documents.py 权限路由 GET/PUT /documents/{id}/permissions(附件 id 同路径触发 400 ATTACHMENT_HAS_NO_ACL)
api/v1/RecycleBinController api/v1/recycle_bin.py GET /recycle-bin/documents、POST /recycle-bin/documents/{id}/restore(均 requireAdmin)
mapper/PermissionMapper 扩展 — listActiveByDocumentForUpdate(FOR UPDATE 排序锁读)/softDeleteById/updateEntry/insert/listDeletedBatchForUpdate(同 deleted_at+updated_by 批次定位)/restoreById
mapper/CategoryMapper 扩展 — restoreDocumentCount(重数回写,值变化才 touch)
mapper/DocumentMapper 扩展 — countActivePlansByCategory(MAIN+SUB_PLAN 口径,对齐 _recount_category)

实施偏差说明

  • Java 自身 bug 修复 1 处(PermissionMapper.insert 多参数绑定,见下节),其余逐行对齐。
  • 恢复约束中两个防御分支不可运行时构造(DB 层 FK+CHECK 双约束前置拦截,Java 与 Python 同样不可达,代码分支已按 Python session.get 语义保留):
    • 分类 NOT_FOUND:MAIN 的 category_id 受 FK(RESTRICT) + ck_doc_document_document_type_shape(非空)双约束,指向不存在行/置 NULL 均被 DB 拒绝;
    • 父案 NOT_FOUND:SUB_PLAN 的 parent_document_id 同受 FK+非空约束。

本阶段修复的 Java 缺陷(1 处)

PermissionMapper.insert MyBatis 多参数绑定(PUT 新增权限 500 INTERNAL_ERROR):

  • 根因:方法签名 (PermissionEntity permission, LocalDateTime now) 为多参数,SQL 引用 #{documentId} 无前缀 → BindingException: Parameter 'documentId' not found. Available parameters are [now, permission, param1, param2]。
  • 修复:@Param("permission") + SQL 全字段改 #{permission.xxx}(now 单值参数可裸引用)。

验证结果(端口 9347,93/93 全绿,脚本 docs/temp/stage7_contract.py 自建自清可重复执行)

分组 覆盖 结果
A 权限矩阵(26) GET 默认空集(visibilityType=CUSTOM)/404/附件 400/子案继承标记;PUT 校验链 11 场景(含重复主体/ALL_AUTH 带 entries/ORG 空/主体 404/rv 冲突/子案 409/附件 400);差异保存 新增 2→改 1 删 1→恢复 2(rv 逐行递增断言);审计 CHANGE_PERMISSION(operation_detail JSON 逐键断言) ✅ 26/26
B 列表参数(10) 未知参数 details.parameters/重复参数 details.parameter/documentType 枚举/deletedFrom 非 Z 时间/from≥to/sortField 白名单/sortDirection/page=0/pageSize=0/恢复不存在 404 ✅ 10/10
C 恢复约束(41) 请求体缺/多字段/负数/rv 冲突;HASH_MISMATCH(同长度覆写文件)+SIZE_MISMATCH(DB file_size+1);分类 DELETED/DISABLED/NOT_LEAF(DB 直插活跃子分类构造,API 建子分类被有文档分类前置 409 拒绝);父案 ROOT_MISMATCH/WRONG_TYPE(parent 指向活跃 ATTACHMENT 行——指向软删自身命中 DELETED,isDeleted 检查先于类型)/DELETED;ORG 禁用/ALL_AUTH 带权限/权限键冲突→清障复活 1;附件挂载冲突→清障恢复;M5/M7/S7 恢复成功 + 分类快照继承(category_name/path 漂移后被父案快照覆盖)+父 child_count 回填 ✅ 41/41
D M1 主链路(13) 删除级联(权限活跃条全软删+挂载 2 软删+attachment_count=0+分类计数-1,权限断言用前后差值规避阶段A历史软删干扰);列表 keyword/deletedBy 快照/时间窗含排除/名称排序;恢复 200(restoredPermissionCount=2/restoredBindingCount=2)+DB 回填+审计+再恢复 409 ✅ 13/13
E USER 视角(3) ACL 可见 M1 详情;经 M1 挂载可见附件 ATT1;无挂载附件 403(对齐 evaluate_attachment_access:USER 只能经有权主案访问附件,visibility=ALL_AUTHENTICATED 不放行) ✅ 3/3

踩坑记录

  • MyBatis 多参数实体绑定:多参数方法引用实体属性必须 @Param 前缀,单值参数可裸引用——与阶段5单参数(实体即首参可裸引用)规则不同。
  • 恢复分类计数是重数不是快照递增:_recount_category 按 MAIN+SUB_PLAN 口径 COUNT 全量重数,与维护式快照存在漂移(删除 SUB_PLAN 不减计数但重数会算入),断言必须按重数口径算期望值(cnt=5->7 为正确行为)。
  • DB CHECK 约束对场景构造的限制(ck_doc_document_document_type_shape):SUB_PLAN 的 category_id/parent/root 非空、MAIN 的 category_id 非空、ATTACHMENT 五字段全空——脚本置 NULL 漂移/指向不存在行均被拦截,两处 NOT_FOUND 防御分支由此不可构造;快照漂移场景改为仅漂移 category_name/path。
  • 附件 USER 可见性语义:初版脚本误设「无挂载公开附件 USER 可见」,实际 Python evaluate_attachment_access 要求 USER 至少经一个有权有效主案访问(附件 visibility 仅元数据),Java 同语义 403 正确。
  • 审计格式事实:sys_audit_log 列名 operation_detail;Jackson 序列化对齐 Python json.dumps 默认分隔符("key": "value" 带空格),断言需含空格。
  • 上传默认可见性:MAIN 上传后 visibility_type='CUSTOM'(DB server_default),非 ALL_AUTHENTICATED。
  • Python 解释器:backend/.venv 基础解释器路径失效,验证统一用系统 C:\Python311\python.exe(pymysql/argon2/requests 齐全)。

结论:权限矩阵与回收站运行时验证 93/93 全绿(含恢复全约束 41 场景、级联删除/恢复批次 DB 断言、审计 JSON 逐键断言),PermissionMapper 绑定缺陷已修复,阶段7验收标准「权限矩阵查询/保存与回收站列表/恢复契约对齐」达成。遗留:LibreOffice 预览转换与审计查询/统计(阶段8)、工具与部署(阶段9)。

阶段8:审计查询/统计、UI 字典、运行时配置与 LibreOffice 转换(2026-08-24)

交付物

Java 交付物 Python 对应 说明
service/LibreOfficeConverter services/document_converter.py DocumentToPdfConverter 唯一实现:同 hash join-or-register 并发去重(CountDownLatch+volatile 结果)、Semaphore(max(1,concurrency)) 限流、work/user 双临时目录+独立 -env:UserInstallation profile、退出码/预期文件/%PDF- 魔数三重校验、超时 terminate→destroyForcibly、finally 递归清理;构造期打印 enabled/executable 初始化日志
config/DmsProperties 扩展 config.py 后半 新增 10 键:预置关系/UI 字典路径默认指向 ../backend/dms/resources/*、DMS_BUSINESS_TIMEZONE(非法 IANA 启动失败)、开关键四值解析、firstOnPath 探测 soffice/libreoffice、DMS_PDF_VIEWER_ENGINE iframe/pdfjs 白名单回退
common/DmsEnums enums.py 15 个 Set<String> 常量(含 AUDIT_ACTIONS 36/AUDIT_TARGETS 10),供 UI 字典校验与审计枚举白名单共用
mapper/AuditLogMapper 扩展 models/audit_log.py 查询 LIST_FILTER/TIME_FILTER 接口常量注解拼接复用;countByFilters/listByFilters(sortDirection 白名单后 ${} 插值+LIMIT offset,limit)、countDocumentTypes(SUM CASE 三计数 is_deleted=0)、countTodayViewers、listTrendPeriods(DAY/MONTH 直取、WEEK FROM_DAYS(TO_DAYS-WEEKDAY) 归一周一)、listActionCounts、listActiveUsers(COUNT(*) OVER(PARTITION)+ROW_NUMBER 取每人最新快照)
service/AuditQueryService services/audit_query_service.py 审计分页(12 参数白名单/keyword strip+escapeLike 六列 LIKE/14 字段摘要 id·targetId 字符串化/createdAt 毫秒 Z)、documents 五字段统计(业务时区今日 UTC 窗口)、trend/actions/active_users;audit 版专属校验消息(unknown=字典序最小无 details、duplicate 单值、UTC 单条消息、正整数「必须是正整数字符串/不得大于」、枚举「不是有效枚举值」)
api/v1/AuditController api/v1/audit.py 5 GET 全部 requireAdmin:/audit/logs、/statistics/documents、`/audit/statistics/trend
service/UiDictionaryService services/ui_dictionary_service.py 构造期加载 JSON+严格校验(根字段/version yyyy.N/locale 正则/15 字典名/项字段 {code,label,sortNo} 精确集合/code 唯一/label strip 非空/sortNo isInt 排 bool·float/code 集合与服务端枚举一致/(sortNo,code) 排序),失败即阻止启动;toMap 保持字典声明顺序
api/v1/UiDictionariesController api/v1/ui_dictionaries.py GET /config/ui-dictionaries(bearer 即可、Cache-Control: private, no-store)
api/v1/RuntimeConfigController api/v1/runtime_config.py GET /config/runtime 公开无鉴权、Cache-Control: public, max-age=60、返回 {pdfViewerEngine}

实施偏差说明

  • Java 不可变集合(Set.of/Map.of)不允许 null 且 Map.of 最多 10 对:buildDictionaryEnums() 改 LinkedHashMap+Collections.unmodifiableMap(15 字典);枚举白名单判断前显式判空。
  • CharacterCodingException 分支(「不是有效的UTF-8文件」)以 Files.readString 的 IOException/UncheckedIOException 捕获对齐 Python 显式 UTF-8 解码错误。
  • 其余逐行对齐;3 处转写缺陷运行时暴露后修复(见下节)。

本阶段修复的 Java 缺陷(3 处)

  1. AuditQueryService.positiveInteger 拆箱 NPE(audit/logs 全参数 500):返回 long 但默认值 null 直接 return → 拆箱炸。修复:改返回 Long,调用处 Math.toIntExact 转 int(3 处)。
  2. AuditLogMapper.countTodayViewers XML 实体未解码(statistics/documents 500 BadSqlGrammar):MyBatis 仅 <script> 模式经 XML 解析器(&gt; 解码为 >),普通 @Select 字符串原样发送 → MySQL 语法错误。修复:普通注解比较符写原始 >=/<。
  3. trendStatistics granularity 缺失 NPE(缺参数 500 应 400):Set.of(...).contains(null) 抛 NPE(不可变集合禁 null),Python not in 对 None 安全。修复:先判空再 contains。

验证结果(端口 9347,47/47 全绿,脚本 docs/temp/stage8_contract.py 自建自清可重复执行,输出存档 docs/temp/stage8_contract_out.txt)

分组 覆盖 结果
A 运行时配置(2) 双账号登录前置;/config/runtime 公开访问(无 token 200)+ iframe 引擎 + Cache-Control: public, max-age=60 ✅ 2/2
B UI 字典(7) 未认证 401;普通 USER 可读;version/locale 值;15 字典名齐全;项结构 {code,label,sortNo}+(sortNo,code) 排序;auditActions/auditTargets/auditResults 枚举计数 36/10/2;Cache-Control: private, no-store ✅ 7/7
C 审计分页(17) 默认分页(page=1/pageSize=20)+id/targetId 字符串化+createdAt 毫秒 Z;keyword 命中双账号;actionType/userId/targetType/时间窗/asc 过滤;8 条校验消息(未知参数字典序最小/重复参数/page 非数字/pageSize>100/actionType 非法/sortDirection 非法/createdFrom 无 Z/时间序颠倒)全部 400 逐字断言;USER 越权 403 ✅ 17/17
D 统计(18) documents 五字段(planDocumentCount=main+sub 拆解);docx 主案上传→PUT 放开 ALL_AUTHENTICATED→USER 查看→VIEW_DOCUMENT 审计落库→今日查看人数≥1(业务时区窗口);documents 统计 USER 403/未知参数 400;trend DAY/WEEK 归一周一/MONTH/granularity 缺失 400/时间序 400;actions 分组含 LOGIN/未知参数 400;active_users 含目标 USER/limit=0/limit=101 400 ✅ 18/18
E LibreOffice(3) python-docx 生成真实 docx:转换 200+响应体 %PDF- 魔数;二次预览命中缓存(不再转换);缓存文件落盘 preview/{hash前2}/{hash}.pdf ✅ 3/3

踩坑记录

  • MyBatis 注解 SQL 的 XML 实体解码边界:<script> 模式才做 &gt;/&lt; 解码;普通 @Select 原样发送。动态片段常量(含 <if>)必须 <script>,纯静态 SQL 写原始比较符。
  • Set.of(...).contains(null) 抛 NPE:Java 不可变集合禁 null 元素查询,白名单判断前先判空(Python in/not in 对 None 安全,逐行转写时的隐性差异)。
  • bash source 含空格 .env 值被截断:.env 中 D:\\Program Files\\... 未加引号,bash 视作「前缀赋值+命令」执行(stderr Files\LibreOffice\...: No such file or directory),变量丢失 → 转换器 503;Python dotenv 整行解析无此问题。验证启动脚本显式 export 正斜杠路径规避。
  • 遗孤 Java 进程:KillShell 仅杀 bash 包装,mvn spring-boot:run fork 的 java 存活继续占端口;后续实例端口冲突启动失败,但遗孤仍以旧代码服务请求——必须 netstat+taskkill 精确杀 PID 再重启,否则验证结果与代码状态错位(本阶段第 3 轮 503 假象即此)。
  • 上传默认可见性(承接阶段7):metadata 白名单无 visibilityType → 默认 CUSTOM 空集,USER 403 为正确契约;验证脚本需 PUT permissions 放开 ALL_AUTHENTICATED 后构造 USER 查看审计。
  • Map.of 10 对上限:15 字典映射改 LinkedHashMap 构建(编译期即报错,易修)。

结论:审计查询/统计、UI 字典、运行时配置与 LibreOffice 转换运行时验证 47/47 全绿(真实 soffice 进程转换+缓存落盘、业务时区今日窗口、WEEK 归一周一、窗口函数活跃榜、8 条审计专属校验消息逐字断言),3 处转写缺陷(拆箱 NPE/XML 实体/contains-null NPE)已修复,阶段8验收标准「审计日志查询、统计报表、UI 字典、运行时配置与预览转换契约对齐」达成。遗留:工具与部署(阶段9)。

阶段9:CLI 工具、SPA 托管与一体化打包(2026-08-24)

交付物

Java 交付物 Python 对应 说明
DmsApplication.main 改造 launcher.py main 先 LauncherBootstrap.bootstrap()(环境变量必须先于 Spring 上下文),再分发子命令:upgrade-db [--yes]/seed 走 SpringApplicationBuilder.web(NONE)+bannerMode(OFF) 命令行参数源(禁 Flyway+压日志),否则 SpringApplication.run 起 Web
config/LauncherBootstrap launcher.py _bootstrap_env/_load_dotenv 首启复制 .env.example→.env 并五段提示;逐行 key=value 解析(strip 引号先 " 后 ')、setdefault 语义(系统 env 与 -D 优先)经 System.setProperty 注入。不用 spring.config.import:properties 语法把单反斜杠当转义符(D:\Program Files→D:Program Files),dotenv 原样保留,两版共用 .env 会分叉
cli/UpgradeDbCommand db_upgrade.py Flyway 编程式(baselineOnMigrate+baselineVersion 0+classpath:db/migration);空 URL 两行错误退出 2、连接失败退出 2、文案逐行对齐(数据库地址脱敏 scheme://user:***@host/当前版本/目标版本/待执行迁移/确认提示/已取消退出 1)
cli/SeedCommand seed_dev.py 密码变量缺失退出 1、DMS_SEED_OVERWRITE_PASSWORDS 仅 true/false 否则退出 1;TransactionTemplate 单事务;3 组织(ORG_ROOT 机关/ORG_OPS 作战部/ORG_COMMS 通信部)+2 用户(admin TOP_SECRET 机关/user SECRET 作战部);查询不过滤软删(findByUsername/findByOrgCodeAny 专用 Mapper 方法);密码覆盖仅 password_hash+auth_version+1+row_version+1(不动 updated_by/at)
cli/CliRunner launcher 子命令分发 @ConditionalOnProperty("dms.cli.command") 的 CommandLineRunner+ExitCodeGenerator
web/FrontendAssets app.py serve_spa 前端定位 四级探测:DMS_FRONTEND_DIST > ./frontend-dist > ../frontend/dist > classpath:/static;resolveFile 防穿越(normalize+startsWith,classpath 模式拒 ../ 与开头 /)
web/SpaController app.py serve_spa @GetMapping("/**") 兜底;api/ 前缀 404 JSON {"error":"Not found"};文件按 MediaTypeFactory 猜类型;否则 index.html;decodePercent 仅解 %XX(不用 URLDecoder:+→空格是 query 语义)
config/DmsProperties.defaultResourcePath config.py BACKEND_ROOT 默认预置资源探测链:显式配置 > ../backend/dms/resources/ 存在则用(仓库内与 Python 共用源文件)> jar 内 classpath:/dms-resources/ 解出临时目录(发行包场景)
pom:finalName dms-server + frontend profile + 无条件 copy-dms-resources dms-server.spec(PyInstaller datas/exe) mvn package -Pfrontend 把 ../frontend/dist 复制进 static、../backend/dms/resources 复制进 dms-resources(等价 _internal/dms/resources);产物 dms-server.jar 名称对齐 dms-server.exe
build/build-java.ps1、build/deploy-java.{bat,sh}、build/run-java.{bat,sh}、build/java-templates/{run.bat,run.sh,upgrade-db.bat,upgrade-db.sh,README.txt} build/build.ps1、run.ps1、upgrade-db.bat 四步:前端构建→mvn package -Pfrontend→整理发行包(jar+.env.example+5 模板+database-init.sql 导出)→从 dist-package-java-bak 恢复 .env/dms-storage;run=部署+启动(合并部署规范)

实施偏差说明

  • Flyway 替代 Alembic 迁移引擎(沿用阶段1决策):upgrade-db 的迁移列表/版本号格式来自 Flyway MigrationInfo(版本显示 1),Python 版来自 Alembic 头表;文案结构与退出码逐行对齐,引擎自有输出(如连接失败的 SQL State 块)随引擎。
  • CLI 输出一行 spring-jcl 警告(Standard Commons Logging discovery...):classpath 含 commons-logging 实现时的 JVM 级提示,warn 级别压制不掉,Python 版无此行;不影响退出码与文案主体。
  • 控制台中文编码:Java stdout 跟随系统 GBK(与 Python Windows 控制台一致),git bash UTF-8 终端显示乱码但内容正确(iconv 验证)。
  • tools/export_full_demo_data.py 实际参数为 --env-file --storage --output,构建脚本只传 --output → 失败降级警告。Python 版 build.ps1 同样如此(既有宽降级行为,对齐不修)。
  • %2f 编码斜杠请求:Tomcat 容器层默认拒绝(400),Flask 到应用层 404——两版均拒绝路径穿越、安全等价,仅状态码差异(见踩坑)。

本阶段修复的 Java 缺陷(3 处)

  1. CLI 子命令模式 Flyway 仍自动执行(用例1 暴露):.properties() 是 defaultProperties(最低优先级),被 application.yml 的 spring.flyway.enabled: true 覆盖 → CLI 启动仍连 localhost 默认库崩溃(EXIT=1 而非预期退出 2)。修复:改 .run("--spring.flyway.enabled=false",...) 命令行参数源(最高优先级),日志压制参数同因。
  2. 发行包运行时默认资源路径不存在(用例1 重跑暴露):默认 ../backend/dms/resources/... 相对工作目录,发行包/临时目录下 DocumentRelationService 构造即崩溃。根因:Python BACKEND_ROOT 基于代码位置(PyInstaller 指向 _MEIPASS 内置副本)。修复:pom 无条件复制资源进 classpath:/dms-resources/ + DmsProperties.defaultResourcePath 探测链(见交付物)。
  3. POST/PUT 兜底路由返回 500(SPA 矩阵暴露):@ExceptionHandler(Exception.class) 捕获了 Spring 的 HttpRequestMethodNotSupportedException → 500,Flask 对未注册方法默认 405。修复:GlobalExceptionHandler 增加 HttpRequestMethodNotSupportedException → 405 METHOD_NOT_ALLOWED。

验证结果(CLI 五用例 + SPA 矩阵 11 项 + 外置 dist,发行包 jar dist-package-java/dms-server/dms-server.jar 61MB 内置前端与资源)

用例 预期 结果
CLI-1 空 .env 临时目录 upgrade-db 两行错误(未配置 DMS_DATABASE_URL)+退出 2 ✅ EXIT=2
CLI-2 .env.example 存在 + seed 无密码变量 首启复制提示+.env 生成+缺密码错误+退出 1 ✅ EXIT=1,二次运行不再重复首启提示
CLI-3 真实 .env(开发库)upgrade-db --yes 数据库地址(脱敏)/当前版本/目标版本+「已是最新」+退出 0 ✅ EXIT=0,版本 1→1
CLI-4 seed 连跑两次(.env 自带 OVERWRITE=true) 幂等(新增组织 0/用户 0)+覆盖警告 ✅ 两次均「新增组织0个,新增用户0个」;期间密码被环境变量临时覆盖后已用标准密码(Admin@1234)恢复,并经 Python 版 9345 登录验证通过(跨版 Argon2id hash 互通实证)
CLI-5 DMS_SEED_OVERWRITE_PASSWORDS=abc 「必须是true或false」+退出 1 ✅ EXIT=1
SPA /、/assets/*.js、SPA 路由 /documents、深层路由 /admin/users?page=1 200 html/js ✅ 4/4
SPA /api/v1/health、未知 API /api/v1/nonexistent 200 / 404 JSON {"error":"Not found"} ✅ 2/2
SPA POST /、PUT /a 405 ✅ 2/2(修复 3 后)
SPA GET /a%20b 200 html(decodePercent 不转 +) ✅
SPA GET /..%2f..%2fetc%2fpasswd 拒绝 ✅ 400(Tomcat 容器层拒绝,Flask 为 404 应用层拒绝,安全等价,已知差异)
DMS_FRONTEND_DIST 外置目录 / 与 SPA 路由均返回外置 index.html ✅

踩坑记录

  • Spring 属性优先级:SpringApplicationBuilder.properties() 是 defaultProperties(最低优先级,会被 application.yml 覆盖);CLI 模式的开关必须走 .run("--key=value",...) 命令行参数源(最高优先级)。
  • Python 资源路径 vs Java:Path(__file__) 是代码位置绝对路径(PyInstaller 下指向 _MEIPASS 内置副本);Java 无对应物,classpath:/ 需解出到真实文件才能供 Files API 使用——pom 打包复制+运行时探测链是 jar 内置资源的等价方案。
  • @ExceptionHandler(Exception.class) 会吞 Spring MVC 框架异常:NoResourceFoundException(阶段5已处理)、HttpRequestMethodNotSupportedException(本阶段补)等在兜底之前需逐一显式映射,否则框架语义(404/405)变成 500。
  • seed 验证的共享库副作用:开发库 .env 自带 DMS_SEED_OVERWRITE_PASSWORDS=true,跑 seed 即覆盖 admin/user 密码(环境变量传入值优先于 .env)——验证后须以标准密码重跑恢复,并用对端(Python 版)登录 API 交叉确认。
  • Windows git bash 的 $? 偶发取空(hook 命令改写干扰):退出码断言写入独立 .sh 脚本执行最可靠;$() 命令替换同理。
  • Tomcat 默认拒绝编码斜杠 %2f(400 于容器层):与应用层 decodePercent 防穿越(404)双保险;开启 ALLOW_ENCODED_SLASH 对齐状态码需放松容器安全限制,得不偿失,记录为已知差异。

结论:阶段9(工具与部署物转写)验证通过——CLI 双命令(upgrade-db/seed)文案/退出码/幂等语义对齐 Python 版且与 Python 版共用同一 .env 与同一 MySQL 库(含跨版 Argon2id 密码互通、跨版 JWT 鉴权);SPA 托管对齐 serve_spa 契约(静态资源/前端路由回退/api 404 JSON/405/DMS_FRONTEND_DIST 外置覆盖);一体化打包(jar 内置前端+资源,build/deploy/run 四件套脚本)完成,发行包 61MB。本阶段修复 3 处缺陷(CLI 属性优先级/默认资源路径/405 兜底)。至此 9 个阶段全部完成,Python→SpringBoot 转写收官。

阶段10:bad04bc 需求集同步(2026-08-29)

阶段1~9收官后,Python 版在 bad04bc(2026-08-29 提交,工作跨度 08-24~08-29)中继续落地了一批需求, Java 版快照停留在其间,形成 4 个缺口接口、3 条缺失迁移、约 13 个路由权限语义过时与 2 处口径不一致。 本阶段按 P0→P1→P2 将其全量同步。

交付物

分组 Java 交付物 Python 对应 / 迁移 说明
迁移 db/migration/V2__category_type_display_name.sql Alembic 0017 doc_category.type_display_name VARCHAR(128) NULL;information_schema 探测后 PREPARE 执行,幂等
迁移 db/migration/V3__sub_plan_binding.sql Alembic 0018 doc_sub_plan_binding 建表(生成列 active_marker + uq 唯一键 + 双索引 + FK RESTRICT);CREATE TABLE IF NOT EXISTS 幂等
迁移 db/migration/V4__content_extract_error.sql Alembic 0019 doc_document.content_extract_error VARCHAR(512) NULL;同 V2 幂等模式
分类 CategoryEntity/Mapper/Service 扩展 0017 typeDisplayName 进出(创建/编辑必填校验、子分类强制 NULL、DTO/审计快照);树计数改为全量子树主案数(countActiveMainGroupByCategory,不按密级/权限过滤,子树累加穿过停用节点);flat_mode 切换守卫增加 hasSubPlanMount;删除原可见性计数查询;创建响应 rowVersion 补 0(对齐 Python 事务提交后按库值刷新的口径)
子案挂载 SubPlanBindingEntity + SubPlanBindingMapper + SubPlanBindingService + MainPlansController 两路由 0018 POST /main-plans/{id}/sub-plans/bind、DELETE /main-plans/{id}/sub-plans/{subPlanId}:主案行锁+密级+requirePlanAction(BIND/UNBIND)+密码锁+乐观锁+步长10排序+审计(scope=subPlanMount);自有子案剔除;listSubPlans 合并挂载子案(自有在前、按 binding.sort_no 追加、mountId 标记,挂载过滤去掉 parentDocumentId)
重新解析 DocumentMutationService.reExtractContent + DocumentMapper.updateContentExtraction + DocumentsController 路由 0019 POST /documents/{id}/re-extract(仅管理员):按当前存储文件覆盖写回提取结果(含失败原因)并重建 search_text,审计 EDIT_DOCUMENT(scope=reExtractContent);上传/覆盖/批量共用 extractFileContent(Office 类转换失败携带脱敏原因,转换器不可用固定文案);文档与附件详情对 ADMIN 追加 contentExtractStatus/contentExtractError/contentExtractedAt
提取算法 DocumentContentService 重写 _pdf_page_text 系列 PDF 版面块段落化:LineCollector(sortByPosition 逐字符收集行/主字号/bbox)→ 自适应块分组(中位行距×1.4 且至少+2pt 为块边界)→ mergeLineItems(条目编号/字号突变≥0.15/句末标点切分,段内软换行合并,ASCII 字母数字间补空格)→ 退化网格(每行一块+等距+同字号)回退纯文本启发式;页间空行分隔;sanitizeExtractError 脱敏(绝对路径→<path>,480 字符截断)
登录背景 api/v1/LoginBackgroundController v1.13 GET /config/login-background 公开:background.jpg 优先 png,nosniff+public, max-age=60+ETag/Last-Modified/304;无图 404 FILE_NOT_FOUND;DMS_LOGIN_BACKGROUND_DIR 覆盖运行目录(LauncherBootstrap.appRoot() 改 public)
写路径鉴权 AuthorizationService.requirePlanAction + 各服务 require_plan_action 新错误码 DOCUMENT_ACTION_FORBIDDEN;密级前置→共享附件仅 ADMIN→evaluatePlanAccess→操作位校验;接入:方案编辑/说明/删除/重排(逐文档 EDIT)/关联(EDIT)/密码锁(CONFIG_PERMISSION)/ACL 读写(CONFIG_PERMISSION+密码)/附件编辑删除/挂载三路由;对应 13 个控制器路由由 requireAdmin 降为仅认证
动态菜单 AuthorizationService.hasAnyManageAction + UserSummaryService.allowedModulesForUser has_any_manage_action MANAGE_MATRIX_COLUMNS 五管理位;OWN/ASSIGNED/OTHER 三区部门集合(空集 [-1] 占位)+查看权三来源(矩阵/全员可见/ACL)任一已发布主案命中即真;login/users/me/门户设密三处下发动态 allowedModules
附件列表 AttachmentsController.list + AttachmentQueryService list_attachments 门禁降为仅认证;USER 全量可见但 description=null、无 contentSnippet、allowedActions 空集
Mapper DocumentMapper.existsManageHit、CategoryMapper.countActiveMainGroupByCategory/existsSubPlanMountUnderCategories、insert/updateOverwrite 增列 — content_extracterror 进出;空集合占位对齐 Python in/not_in 空集语义

实施偏差说明

  • UNBIND 位的既有口径:两侧 plan_allowed_actions 对 ADMIN 的 UNBIND_ATTACHMENT 均以"存在有效附件挂载"为前提(子案挂载不计入),仅有子案挂载的主案解挂子案会 403——Java 按 Python 现状逐行对齐,验证脚本先挂附件使位生效;该行为是否需两侧同步调整,留待统领会话决策。
  • PDF 块分组/字号阈值:fitz 直接给出版面块与字号,PDFBox 只有逐字符 TextPosition。块分组以"中位行距×1.4(+2pt 下限)"自适应阈值近似邻近度聚块;字号取行内最大 getHeightDir(),其相对差异较 fitz 字号被压缩(实测16pt/11pt≈0.195),故切分比例由 0.2 调至 0.15 以对齐效果。
  • 分类树计数:删除 countVisibleMainByLevels/countVisibleMainForUser(旧口径按用户密级+PUBLISHED+可见性过滤),与 Python"括号数字=分类真实挂载规模"口径一致;USER 列表/详情接口继续独立控制内容访问。
  • 迁移引擎:Python 版在并行期违反 §4.3 冻结新增了 Alembic 0017~0019;Java 侧以幂等 V2~V4 等价承接(已由 Alembic 升级到 0019 的库上执行为 no-op,仅登记 Flyway 版本线),此后仍以 Flyway 为唯一出口。

本阶段修复的 Java 缺陷(2 处)

  1. listSubPlans 挂载过滤误带 parentDocumentId:挂载子案(他主案子案)被同时要求满足本主案的父约束,恒被过滤——挂载列表永远为空。修复:挂载过滤副本移除 parentDocumentId,仅按 ids+公共过滤。
  2. 分类创建响应 rowVersion=null:insert 仅回填 id,DTO 直接序列化实体导致 null(Python 事务提交后过期重读为 0)。修复:创建路径显式置 0。

验证结果(端口 9347,远程测试库 dms@82.156.x.x:3306,脚本 docs/temp/stage10_contract.py 自建自清可重复执行)

分组 用例 结果
A 登录背景(2) 无图 404 FILE_NOT_FOUND 标准错误体(无需认证);运行目录有图时 200+image/*+nosniff+max-age=60 ✅ 2/2
B 分类(4) 一级分类携带 typeDisplayName 创建/修改;子分类强制 NULL;缺字段 400 逐字拒绝 ✅ 4/4
C 迁移(2) flyway 版本线 0/1/2/3/4;schema 含 type_display_name/content_extract_error/doc_sub_plan_binding(在 Alembic 0019 库上 V2~V4 幂等 no-op 实证) ✅ 2/2
D 计数与可见性(5) 子分类计数=3 主案(ADMIN=USER,含 SECRET);父分类沿链汇总=3;USER 列表全量可见 SECRET 主案且 allowedActions 空集 ✅ 5/5
E 子案挂载(9) bind created=1/subPlanCount 口径;重复挂载 existing;自有子案剔除;列表合并(自有在前+mountId 标记);USER 无权 403 DOCUMENT_ACTION_FORBIDDEN;解挂 subPlanCount=0;重复解挂 404;恢复仅自有;(附件挂载使 UNBIND 位生效) ✅ 9/9
F 提取与重解析(8) 坏 PDF→FAILED+脱敏 error(管理员详情三字段,USER 详情无);re-extract 200 覆盖写回+USER 403;图片 UNSUPPORTED 无 error;docx SUCCESS;PDF 版面段落化专项(LibreOffice 实转 PDF:标题独立成段+两段软换行合并=3 段) ✅ 8/8
G 附件列表(2) USER 可访问(不再 403);description=null+allowedActions 空集 ✅ 2/2
H 写路径与动态菜单(7) 无角色编辑/看 ACL 403;角色矩阵(PUBLIC 三范围管理4位)挂用户;登录 allowedModules 动态含 BACKEND_MANAGEMENT;有 EDIT 位 USER 编辑主案 200;未获授权位设密码锁 403 ✅ 7/7

==== stage10: 39/39 PASS ====

踩坑记录

  • Windows 控制台 GBK:脚本输出中文乱码不影响断言;JAVA 侧 PYTHONIOENCODING=utf-8 读取正常。
  • 上传 metadata 必含 summary:MAIN/SUB_PLAN 创建字段集严格匹配,漏传即 400(与 Python _exact 同语义);附件 attachmentType 合法值为 SUPPORT_ACHIEVEMENT/TRAINING_MATERIAL/TYPICAL_CASE。
  • 测试素材避开切分规则:验证软换行合并的段落不能含条目编号开头/句末标点,否则分别命中规则1/规则3 被正确切分(首次用"第N条…。"素材得到 16 段属算法按设计工作,非缺陷)。
  • 清理需临时关闭外键检查:doc_document 父子 FK RESTRICT,脚本 cleanup 先 SET FOREIGN_KEY_CHECKS=0 再按 J10 前缀清理,角色需先删 sys_role_dept/sys_role_permission。
  • soffice 路径含空格:启动脚本经 python 解析 .env 并转正斜杠(bash 直接 source 会截断含空格值,同阶段8踩坑)。

结论:阶段10(bad04bc 需求集同步)验证通过——0017/0018/0019 三条迁移的 Flyway 幂等等价物、顶级分类类型显示名称、子案跨主案挂载、正文提取失败可视化与重新解析、登录背景图、写路径 require_plan_action、动态 allowedModules、附件列表全量可见、分类树全量计数与 PDF 版面段落化提取全部落地,39/39 用例全绿(真实 LibreOffice 转换、真实远程库、真实并发口径)。Java 版与 Python 版重新回到契约一致状态。

阶段11:UNBIND方案B + 配套方案并入共享资料库(2026-08-29)

统领会话决策:①ADMIN无条件拥有UNBIND_ATTACHMENT(方案B);②"配套方案"与支撑成果/ 方案训考资料/典型案例并轨为共享资料库附件第四种类型ACCOMPANYING_PLAN,入库/挂载/ 解绑/留存语义与其余三类一致。方案详见docs/plans/配套方案并入共享资料库方案.md。

交付物

分组 交付物 说明
迁移 Alembic 0020_sub_plan_to_attachment + Flyway V5__sub_plan_to_attachment.sql 附件类型CHECK扩四值(新名v2,旧删新加按名幂等);附件shape约束放宽(ck_doc_document_type_shape_v2,允许任意密级);同名消歧(临时表规避MySQL 1093);自有子案关系先补doc_attachment_binding(沿用sort_no);doc_sub_plan_binding活跃行转挂载后软删;SUB_PLAN全量转ATTACHMENT+ACCOMPANYING_PLAN(置空父/根/分类,保留密级,visibility=ALL_AUTHENTICATED);attachment_count重算、child_count清零;降级保护(存在并轨数据即拒绝)
后端(两侧) plan_allowed_actions ADMIN分支 方案B:恒含UNBIND(与BIND对称),消除主案列表每行一次的count查询;"仅有子案挂载的主案管理员无法解挂"的倒挂随之消除
后端(两侧) SUB_PLAN创建拒绝 单传/批量/覆盖一致:400"配套方案已并入共享资料库,请以附件类型ACCOMPANYING_PLAN上传"
后端(两侧) evaluate_attachment_access/require_attachment_access 附件读路径密级前置:密级不足优先抛SECURITY_LEVEL_FORBIDDEN(任何角色含ADMIN),通过后仍按"至少一个挂载主案权限"判定
枚举/字典 AttachmentType.ACCOMPANYING_PLAN(Python enums / Java DmsEnums / AttachmentQueryService) UI字典attachmentTypes四值(配套方案sortNo 10居首),服务端启动枚举一致性校验两版同步生效
前端 App.tsx + AttachmentBindingModal + enums.ts 关系Tab首项改为ACCOMPANYING_PLAN("sub"退役);浏览/管理两处关系面板统一走挂载附件列表(类型=当前Tab)+上传自动挂载+解绑/拖动走binding接口;挂载弹窗配套方案Tab为软排序(preferredType居前不过滤,单页200条),其余三类维持类型预过滤;子案上传/子案挂载/子案解挂弹窗退役;共享资料库类型筛选/徽章随字典自动四值

实施偏差说明

  • 密级保留而非归一PUBLIC:原附件shape约束强制PUBLIC,为不静默降密存量INTERNAL/SECRET 子案,放宽约束+读路径密级前置(新建附件仍默认PUBLIC);密级前置的拒绝码为 SECURITY_LEVEL_FORBIDDEN,前端"密级不足"提示语义成立。
  • 挂载弹窗配套方案Tab为软排序而非过滤:需求原文"配套方案排在前面,其他的排在后面", preferredType模式取pageSize 200单页并客户端稳定排序(偏好类型居前),隐藏分页器。
  • 共享测试库存在带授权的默认角色:stage11的"主案详情USER访问回归"按200/403双合法口径断言。

本阶段踩坑

  • MySQL 1093:UPDATE目标表不可出现在派生表子查询——同名消歧候选id先落临时表再JOIN更新。
  • 附件shape约束(ck_doc_document_document_type_shape)隐含security_level='PUBLIC'且 要求父/根/分类为NULL:首次执行在类型转换步骤暴露(3819),由此确立"密级保留+约束放宽" 方案并调整迁移顺序(先补挂载、后转类型置空父/根/分类)。
  • Flyway V5在已被Alembic 0020升级的库上执行:全部步骤按名/按数据态幂等no-op (flyway版本线0~5,SUB_PLAN仍为0、挂载29条无重复)。

验证结果(端口9347,远程测试库已升0020,脚本docs/temp/stage11_contract.py自建自清)

分组 用例 结果
A 方案B(2) 零挂载主案ADMIN allowedActions恒含UNBIND/BIND;USER访问回归(200/403双合法) ✅ 2/2
B 创建拒绝(1) SUB_PLAN创建400逐字消息 ✅ 1/1
C 配套方案闭环(6) ACCOMPANYING_PLAN上传入库→USER按类型检索可见→挂载→Tab列表(bindingId)→解绑→库中留存 ✅ 6/6
D 存量迁移(5) SUB_PLAN清零/12条就位;密级INTERNAL10+SECRET2保留;child_count清零;v2双约束在位旧约束已除;快照对照父主案挂载全部补齐 ✅ 5/5
E 密级前置(2) PUBLIC密级USER访问INTERNAL配套方案403 SECURITY_LEVEL_FORBIDDEN;TOP_SECRET ADMIN 200 ✅ 2/2
F UI字典(1) attachmentTypes四值且配套方案居首 ✅ 1/1

==== stage11: 18/18 PASS ====;前端tsc零错误、vitest412/412、vite build通过; Java mvn package通过(61MB)。契约变更记录于DMS_API_CONTRACT.md版本1.14。

结论:阶段11验证通过——方案B与管理员解挂倒挂消除;配套方案全链路(上传入库/自动挂载/ Tab列表/解绑留存/跨类型挂载弹窗软排序)与其余三类附件同构;存量12条子案无损并轨 (密级保留、挂载补齐、计数重算);两版后端与前端契约一致,Java/Python/数据库三方收敛。