配套方案:
docs/plans/SpringBoot后端转写方案.md。每阶段收尾追加一节, 记录「验证口径 → 实际结果 → 偏差与结论」。
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 版本线 |
backend/.env 不存在:本地真实运行配置位于 dist-package/dms-server/.env(远程 MySQL)。Java 版验证直接 source 该文件,凭据不落代码库。| 验证项 | 预期 | 结果 |
|---|---|---|
| 启动 | 无报错,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 已释放)。
sys_user 表:MyBatis Mapper、login/logout/me、JWT(HS256)、Argon2 存量密码校验(首个测试必须是「Python 版建号 → Java 版登录」)。| 文件 | 对齐 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 三键严格校验) |
NoClassDefFoundError。pom 显式加 bcprov-jdk18on:1.78.1。DMS_JWT_SECRET 为 28 字节(224 位),jjwt 0.12 在密钥构造/signWith 启发式/HMAC digest 三层均强制 HS256 密钥 ≥256 位且不可绕过;PyJWT 不限制密钥长度。为保证同库同密钥与 Python 版 token 互通,改用 com.auth0:java-jwt:4.4.0(不校验密钥长度,行为与 PyJWT 一致)。request.is_json 检查返回 400;Spring 默认抛 415,GlobalExceptionHandler 增加 HttpMediaTypeNotSupportedException → 400 INVALID_ARGUMENT「请求体必须是JSON对象」。ObjectMapper 替代字符串拼接(对齐 Python json.dumps,防特殊字符破坏 JSON)。脚本: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 版登录链路。
| 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
requests → JDK 内置 java.net.http.HttpClient(无新依赖);连接超时 10s,请求超时取 DMS_PORTAL_HTTP_TIMEOUT_SECONDS(默认10)。dig(data, successField) 缺失时按 Python str(None) 返回 "None" 再与配置值比较(digAsText),避免 NPE 且行为一致。split(":", -1) 保留空段,对齐 Python token.split(":")。LOGIN_IP_FORBIDDEN,Controller 层 catch 后记 recordLoginFailure(request, null, "(portal-setup)", "IP_NOT_ALLOWED")(对齐 Python 控制器层实现)。Passwords.hash 结果存变量复用(Argon2 慢哈希,避免双倍耗时)。脚本: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 版一致。
Invalid UTF-8 middle byte 0xc5 → 400。解决:中文 body 先 printf > 文件(UTF-8 原样)再 curl --data-binary @文件。Unknown column 'external_id',非 Java 代码问题;补 0016 迁移后全绿。结论:门户三形态(mock 已实证 / http 声明式已实现待真实对端 / custom 预留)、票据一次性、独立密码回落登录链路与 Python 版契约一致。
| 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 乐观锁、动态过滤分页) |
@Param 绑定 bug(用例11 500 根因):updateFull(UserEntity, @Param("authBump") int) 一旦任一参数加 @Param,MyBatis 停止对无注解参数做属性展开,SQL 里 #{realName} 抛 BindingException: Parameter 'realName' not found。修复:@Param("user") + SQL 全部改 #{user.xxx}。matrix 行数≠15 抛 400 INVALID_ARGUMENT「权限矩阵必须为15行」,与 Python _validate_matrix(InvalidArgumentError)一致,非 ConflictError。@RequestParam(required=false) 接收后走 Payloads.deleteRowVersion,缺失时返回对齐的 400 文案而非 Spring 默认 500。javatest4admin(脚本开头建号/结尾物理删除,可重复执行)。| 分组 | 用例(预期) | 结果 |
|---|---|---|
| 权限门禁 | 普通用户访问用户列表 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。
netstat -ano | grep :9346 找 PID → taskkill //F //PID。sys_organization 前须先删 sys_org_ip_assignment/sys_role_dept 引用行(doc_document/sys_user 同理不涉及测试数据)。User.is_deleted.is_(False),Java 一致——软删用户后组织可删。结论:组织/用户/角色/IP 四域 37 用例全绿,乐观锁 rowVersion、业务守卫(自保护/最后管理员/ROLE_IN_USE/ORGANIZATION_IN_USE/IP分配引用)、权限门禁与 Python 版契约一致。
按 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/MultipartHttpServletRequest 接口(javap 反编译确认)只有文件方法;StandardMultipartHttpServletRequest.getParameterMap 会混入 query 参数。为精确对齐 Flask request.form(仅 body 非文件 part),DocumentUploads 改用原生 Servlet request.getParts() + getSubmittedFileName()==null 判定表单字段。javatest5admin/javatest5user(结尾物理删除)。backend-java/dms-storage,互读验证通过启动时 export DMS_STORAGE_ROOT=backend/dms-storage(生产存储根,不改生产配置)对齐。三组脚本: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。
{createdCount, existingCount, attachmentCount, mainPlanRowVersion},非文档序列化(无嵌套 rowVersion);reorder 契约要求 orderedBindingIds 至少 2 个(单 ID → 400)。conn.rollback() 释放快照(cat_rv/doc_rv/cleanup 均如此)。doc_document_relation/doc_attachment_binding FK RESTRICT 不分软删——测试兜底清理须先删这两表行再删 doc_document(SUB_PLAN 自引用行最先删)。结论:文档全生命周期契约 44/44、存储互读 16/16 全绿,Java 版上传的文件 Python 版可直读(同 original/{uuid4}.{ext} 规则、同 sha256、同 DB 行语义),满足阶段5验收标准「与 Python 版互读对方存储文件」。遗留:全文检索/正文提取(阶段6)、权限矩阵查询与回收站(阶段7)、LibreOffice 预览转换与审计查询(阶段8)。
检索/正文域已随阶段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 版逐行对齐) |
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)。d=1 fatSector=4294967295 未 break 实锤类型提升缺陷)。脚本 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 |
POST /api/v1/attachments(Python 版同),与 POST /api/v1/documents(要求 documentType=MAIN/SUB_PLAN)是两个入口,复用统一 upload helper 时易混。结论:检索/正文域运行时验证 38/38 全绿(含与 Python fitz 提取逐字一致性对拍),OleContainer 类型 bug 已修复,阶段6验收标准「全文检索可用、正文提取状态流转正确」达成。遗留:权限矩阵查询与回收站(阶段7)、LibreOffice 预览转换与审计查询(阶段8)。
| 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) |
PermissionMapper.insert 多参数绑定,见下节),其余逐行对齐。session.get 语义保留):
category_id 受 FK(RESTRICT) + ck_doc_document_document_type_shape(非空)双约束,指向不存在行/置 NULL 均被 DB 拒绝;parent_document_id 同受 FK+非空约束。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 单值参数可裸引用)。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 |
@Param 前缀,单值参数可裸引用——与阶段5单参数(实体即首参可裸引用)规则不同。_recount_category 按 MAIN+SUB_PLAN 口径 COUNT 全量重数,与维护式快照存在漂移(删除 SUB_PLAN 不减计数但重数会算入),断言必须按重数口径算期望值(cnt=5->7 为正确行为)。ck_doc_document_document_type_shape):SUB_PLAN 的 category_id/parent/root 非空、MAIN 的 category_id 非空、ATTACHMENT 五字段全空——脚本置 NULL 漂移/指向不存在行均被拦截,两处 NOT_FOUND 防御分支由此不可构造;快照漂移场景改为仅漂移 category_name/path。evaluate_attachment_access 要求 USER 至少经一个有权有效主案访问(附件 visibility 仅元数据),Java 同语义 403 正确。sys_audit_log 列名 operation_detail;Jackson 序列化对齐 Python json.dumps 默认分隔符("key": "value" 带空格),断言需含空格。visibility_type='CUSTOM'(DB server_default),非 ALL_AUTHENTICATED。C:\Python311\python.exe(pymysql/argon2/requests 齐全)。结论:权限矩阵与回收站运行时验证 93/93 全绿(含恢复全约束 41 场景、级联删除/恢复批次 DB 断言、审计 JSON 逐键断言),PermissionMapper 绑定缺陷已修复,阶段7验收标准「权限矩阵查询/保存与回收站列表/恢复契约对齐」达成。遗留:LibreOffice 预览转换与审计查询/统计(阶段8)、工具与部署(阶段9)。
| 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} |
Set.of/Map.of)不允许 null 且 Map.of 最多 10 对:buildDictionaryEnums() 改 LinkedHashMap+Collections.unmodifiableMap(15 字典);枚举白名单判断前显式判空。CharacterCodingException 分支(「不是有效的UTF-8文件」)以 Files.readString 的 IOException/UncheckedIOException 捕获对齐 Python 显式 UTF-8 解码错误。AuditQueryService.positiveInteger 拆箱 NPE(audit/logs 全参数 500):返回 long 但默认值 null 直接 return → 拆箱炸。修复:改返回 Long,调用处 Math.toIntExact 转 int(3 处)。AuditLogMapper.countTodayViewers XML 实体未解码(statistics/documents 500 BadSqlGrammar):MyBatis 仅 <script> 模式经 XML 解析器(> 解码为 >),普通 @Select 字符串原样发送 → MySQL 语法错误。修复:普通注解比较符写原始 >=/<。trendStatistics granularity 缺失 NPE(缺参数 500 应 400):Set.of(...).contains(null) 抛 NPE(不可变集合禁 null),Python not in 对 None 安全。修复:先判空再 contains。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 |
<script> 模式才做 >/< 解码;普通 @Select 原样发送。动态片段常量(含 <if>)必须 <script>,纯静态 SQL 写原始比较符。Set.of(...).contains(null) 抛 NPE:Java 不可变集合禁 null 元素查询,白名单判断前先判空(Python in/not in 对 None 安全,逐行转写时的隐性差异)。.env 中 D:\\Program Files\\... 未加引号,bash 视作「前缀赋值+命令」执行(stderr Files\LibreOffice\...: No such file or directory),变量丢失 → 转换器 503;Python dotenv 整行解析无此问题。验证启动脚本显式 export 正斜杠路径规避。mvn spring-boot:run fork 的 java 存活继续占端口;后续实例端口冲突启动失败,但遗孤仍以旧代码服务请求——必须 netstat+taskkill 精确杀 PID 再重启,否则验证结果与代码状态错位(本阶段第 3 轮 503 假象即此)。Map.of 10 对上限:15 字典映射改 LinkedHashMap 构建(编译期即报错,易修)。结论:审计查询/统计、UI 字典、运行时配置与 LibreOffice 转换运行时验证 47/47 全绿(真实 soffice 进程转换+缓存落盘、业务时区今日窗口、WEEK 归一周一、窗口函数活跃榜、8 条审计专属校验消息逐字断言),3 处转写缺陷(拆箱 NPE/XML 实体/contains-null NPE)已修复,阶段8验收标准「审计日志查询、统计报表、UI 字典、运行时配置与预览转换契约对齐」达成。遗留:工具与部署(阶段9)。
| 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=部署+启动(合并部署规范) |
upgrade-db 的迁移列表/版本号格式来自 Flyway MigrationInfo(版本显示 1),Python 版来自 Alembic 头表;文案结构与退出码逐行对齐,引擎自有输出(如连接失败的 SQL State 块)随引擎。Standard Commons Logging discovery...):classpath 含 commons-logging 实现时的 JVM 级提示,warn 级别压制不掉,Python 版无此行;不影响退出码与文案主体。tools/export_full_demo_data.py 实际参数为 --env-file --storage --output,构建脚本只传 --output → 失败降级警告。Python 版 build.ps1 同样如此(既有宽降级行为,对齐不修)。%2f 编码斜杠请求:Tomcat 容器层默认拒绝(400),Flask 到应用层 404——两版均拒绝路径穿越、安全等价,仅状态码差异(见踩坑)。.properties() 是 defaultProperties(最低优先级),被 application.yml 的 spring.flyway.enabled: true 覆盖 → CLI 启动仍连 localhost 默认库崩溃(EXIT=1 而非预期退出 2)。修复:改 .run("--spring.flyway.enabled=false",...) 命令行参数源(最高优先级),日志压制参数同因。../backend/dms/resources/... 相对工作目录,发行包/临时目录下 DocumentRelationService 构造即崩溃。根因:Python BACKEND_ROOT 基于代码位置(PyInstaller 指向 _MEIPASS 内置副本)。修复:pom 无条件复制资源进 classpath:/dms-resources/ + DmsProperties.defaultResourcePath 探测链(见交付物)。@ExceptionHandler(Exception.class) 捕获了 Spring 的 HttpRequestMethodNotSupportedException → 500,Flask 对未注册方法默认 405。修复:GlobalExceptionHandler 增加 HttpRequestMethodNotSupportedException → 405 METHOD_NOT_ALLOWED。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 |
✅ |
SpringApplicationBuilder.properties() 是 defaultProperties(最低优先级,会被 application.yml 覆盖);CLI 模式的开关必须走 .run("--key=value",...) 命令行参数源(最高优先级)。Path(__file__) 是代码位置绝对路径(PyInstaller 下指向 _MEIPASS 内置副本);Java 无对应物,classpath:/ 需解出到真实文件才能供 Files API 使用——pom 打包复制+运行时探测链是 jar 内置资源的等价方案。@ExceptionHandler(Exception.class) 会吞 Spring MVC 框架异常:NoResourceFoundException(阶段5已处理)、HttpRequestMethodNotSupportedException(本阶段补)等在兜底之前需逐一显式映射,否则框架语义(404/405)变成 500。DMS_SEED_OVERWRITE_PASSWORDS=true,跑 seed 即覆盖 admin/user 密码(环境变量传入值优先于 .env)——验证后须以标准密码重跑恢复,并用对端(Python 版)登录 API 交叉确认。$? 偶发取空(hook 命令改写干扰):退出码断言写入独立 .sh 脚本执行最可靠;$() 命令替换同理。%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 转写收官。
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 空集语义 |
plan_allowed_actions 对 ADMIN 的 UNBIND_ATTACHMENT 均以"存在有效附件挂载"为前提(子案挂载不计入),仅有子案挂载的主案解挂子案会 403——Java 按 Python 现状逐行对齐,验证脚本先挂附件使位生效;该行为是否需两侧同步调整,留待统领会话决策。getHeightDir(),其相对差异较 fitz 字号被压缩(实测16pt/11pt≈0.195),故切分比例由 0.2 调至 0.15 以对齐效果。countVisibleMainByLevels/countVisibleMainForUser(旧口径按用户密级+PUBLISHED+可见性过滤),与 Python"括号数字=分类真实挂载规模"口径一致;USER 列表/详情接口继续独立控制内容访问。listSubPlans 挂载过滤误带 parentDocumentId:挂载子案(他主案子案)被同时要求满足本主案的父约束,恒被过滤——挂载列表永远为空。修复:挂载过滤副本移除 parentDocumentId,仅按 ids+公共过滤。rowVersion=null:insert 仅回填 id,DTO 直接序列化实体导致 null(Python 事务提交后过期重读为 0)。修复:创建路径显式置 0。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 ====
PYTHONIOENCODING=utf-8 读取正常。summary:MAIN/SUB_PLAN 创建字段集严格匹配,漏传即 400(与 Python _exact 同语义);附件 attachmentType 合法值为 SUPPORT_ACHIEVEMENT/TRAINING_MATERIAL/TYPICAL_CASE。SET FOREIGN_KEY_CHECKS=0 再按 J10 前缀清理,角色需先删 sys_role_dept/sys_role_permission。结论:阶段10(bad04bc 需求集同步)验证通过——0017/0018/0019 三条迁移的 Flyway 幂等等价物、顶级分类类型显示名称、子案跨主案挂载、正文提取失败可视化与重新解析、登录背景图、写路径 require_plan_action、动态 allowedModules、附件列表全量可见、分类树全量计数与 PDF 版面段落化提取全部落地,39/39 用例全绿(真实 LibreOffice 转换、真实远程库、真实并发口径)。Java 版与 Python 版重新回到契约一致状态。
统领会话决策:①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条),其余三类维持类型预过滤;子案上传/子案挂载/子案解挂弹窗退役;共享资料库类型筛选/徽章随字典自动四值 |
ck_doc_document_document_type_shape)隐含security_level='PUBLIC'且
要求父/根/分类为NULL:首次执行在类型转换步骤暴露(3819),由此确立"密级保留+约束放宽"
方案并调整迁移顺序(先补挂载、后转类型置空父/根/分类)。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/数据库三方收敛。