# 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 基解释器已失效)执行官方迁移补齐: ```bash 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` 常量(含 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|actions|users` | | `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 **仅 `