diff --git a/docs/code-review-issues.md b/docs/code-review-issues.md index d86cb5a..dd488fb 100644 --- a/docs/code-review-issues.md +++ b/docs/code-review-issues.md @@ -43,7 +43,7 @@ | ~~`config.CloneData`~~ | ~~已删除:全仓零调用,且 `Clone`/`MergeRuntimeConfig` 已覆盖实际快照复制入口。已完成(2026-08-28)。~~ | ~~internal/config/clone.go~~ | | ~~`paymentkit.XMLValues`/`XMLEncode`~~ | ~~已删除:仅测试调用;XML 编解码辅助已移入 payment 集成测试文件,生产 XML 继续走 gopay。已完成(2026-08-28)。~~ | ~~internal/paymentkit/xml.go~~ | | ~~`paymentkit.NestedString`~~ | ~~已删除:生产零调用;旧测试已改为覆盖现存 `JSONObject`/`StringAtPath`。已完成(2026-08-28)。~~ | ~~internal/paymentkit/json.go~~ | -| `logging.NewZapLogger` | internal/logging/zap.go:542 | 仅测试调用,生产用 NewReloadableZapLogger | +| ~~`logging.NewZapLogger`~~ | ~~已删除:唯一测试调用改用生产构造器 `NewReloadableZapLogger`,避免维护双入口。~~ | ~~internal/logging/zap.go;data/gorm_logger_test.go~~ | | ~~`data/payment.contains`~~ | ~~已改用 `paymentkit.ContainsFold`,本地实现已删除。~~ | ~~data/payment/payment.go~~ | | ~~`paymentkit status.go 的 ConfiguredInt64/ConfiguredValues/Text/FirstText/FirstString` 定位漂移~~ | ~~经复核保留:这些函数仍被 payment data/integration 生产路径消费,属于配置解析边界;问题是 README 描述过窄而非死代码。评估完成,保留(2026-08-28)。~~ | ~~internal/paymentkit/status.go:36-90~~ | @@ -68,7 +68,7 @@ | # | 问题 | 位置 | 轮次 | |---|------|------|------| -| D-11 | authority 树构建算法两份【既定不采用,保留】 | biz authority.go:48-78 vs menu.go:80-99 | 一轮 | +| ~~D-11~~ | ~~经复核保留:authority 树与 menu 树输入模型、过滤规则和输出结构不同,共享算法会引入泛型/回调抽象而降低可读性。~~ | ~~biz authority.go vs menu.go~~ | ~~评估完成,保留(2026-08-28)~~ | | ~~D-15~~ | ~~defaults 合并逻辑三层三份。~~ **已修复:统一使用 `integrationbiz.MergeIntegrationDefaults`。** | ~~相关文件~~ | ~~已完成~~ | | ~~D-17 剩余~~ | ~~`values()` 与 `testRow()` 已收敛为共享读取逻辑,并保留启用状态差异。~~ | ~~data/payment/payment.go~~ | ~~已完成~~ | | ~~D-19(评估后保留)~~ | ~~payment 金额守恒校验四处重复。~~ **经复核保留:四处输入字段与供应商容错语义不同,强行合并会破坏分层;biz 最终守恒校验作为跨边界不变式。** | ~~biz/payment 与各供应商适配器~~ | ~~不改动~~ | @@ -79,15 +79,15 @@ | # | 问题 | 位置 | 轮次 | |---|------|------|------| -| F-2 | integration/payment/result.go 现为 15 个单行转发 shim 层(注释自称 compatibility shims,重构后的过渡债务形态) | integration/payment/result.go:12-84 | 一轮(四轮形态更新) | -| F-4 | handler/http.go 便捷门面【既定暂留决策】 | server/handler/http.go:14-29 | 一轮 | -| F-6 | task 双 usecase 并存:TaskUsecase 嵌入 TaskRepo 透传 9 方法给 worker;TaskApplicationUsecase 再包一层、其 6 方法纯转发 | biz/task/task.go:76-79,159-237 | 一/三轮 | -| F-7 | Backend 三层缝合(biz InitializationRepo → initialize.Repo → data.Data)【既定不采用,保留编排】 | initialize/initialize.go:17-46 | 一轮 | -| F-8 | data-scope 审计回调 dataScopeAuditEnqueue 穿透 6 层签名(newReloadableDB 已不再注册回调,但签名仍残留 `_ ...dataScopeAuditEnqueue` 匿名变参垫片——清理垫片即闭环) | data/runtime_clients.go:168;data_scope.go:19-136 | 一轮(四轮近闭环) | -| F-9 | 纯透传壳 usecase 12 个【既定不采用,判定为分层契约保留】 | biz/system/* | 三轮 | -| F-10 | security_session.go 14 方法全透传(8 个已确认死,见 2.1——按死代码处理而非合并) | service/system/security_session.go | 三轮 | -| F-11 | biz 接口嵌入透传 12 处 usecase【既定不采用,契约保留】 | biz/system/* | 二轮 | -| F-13 | adapter.go 双入口与别名残留:`type Adapter = bizpayment.PaymentAdapter` 别名;包级 `New()` 与 `Factory.New` 并存(Factory 仅是为满足 wire 的壳) | integration/payment/adapter.go:11,44,53-55 | 四轮 | +| ~~F-2~~ | ~~经复核保留:文件除 paymentkit 转发外还承载 `callbackFields`、`firstNonEmpty` 与 `parseConfiguredAmount` 等包内协议语义;高频短别名被 100+ 处渠道代码消费,整体展开只会放大改动而不减少规则源。~~ | ~~integration/payment/result.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~F-4~~ | ~~经复核保留:handler/http.go 只提供响应类型与函数别名,避免各 handler 重复导入 httpx,不承载业务规则。~~ | ~~server/handler/http.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~F-6~~ | ~~经复核保留:TaskUsecase 面向 worker 的 repo/校验能力,TaskApplicationUsecase 负责运行时同步与恢复;两者生命周期和依赖不同。~~ | ~~biz/task/task.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~F-7~~ | ~~经复核保留:InitializationRepo → initialize.Repo → data.Data 是启动编排的接口倒置链,避免 initialize 直接依赖 data 实现。~~ | ~~initialize/initialize.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~F-8~~ | ~~已删除 `newReloadableDB` 的匿名变参垫片;审计回调仍只在数据库激活/替换时由 `registerDataScopeCallbacks` 绑定,职责链闭环。~~ | ~~data/runtime_clients.go;data_scope.go~~ | ~~已完成(2026-08-28)~~ | +| ~~F-9~~ | ~~经复核保留:system usecase 透传壳是 biz 对外契约与 Wire 注入面的稳定边界,删除会把 service/worker 直接绑定到 repo。~~ | ~~biz/system/*~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~F-10~~ | ~~经复核保留剩余活跃方法:security session 负责 token/cache 语义,不能与 service handler 合并;已确认的 8 个无消费者方法按死代码处理。~~ | ~~service/system/security_session.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~F-11~~ | ~~经复核保留:biz 接口嵌入透传用于组合窄 repo 能力,调用方依赖稳定 usecase 契约,机械拆分会扩大 Wire 与测试替身改动。~~ | ~~biz/system/*~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~F-13~~ | ~~已删除 `Adapter` 类型别名和包级 `New` 双入口;`Factory.New` 直接实现 biz 的 `PaymentAdapterFactory`,工厂表使用 biz 接口类型。~~ | ~~integration/payment/adapter.go;vendor.go~~ | ~~已完成(2026-08-28)~~ | ## 五、过分拆分 / 文件组织 @@ -109,16 +109,16 @@ | # | 问题 | 位置 | 轮次 | |---|------|------|------| -| P-3 | pkg/module 内移建议【既定不采用,维持现状】 | pkg/module | 三轮 | -| P-5 | pkg/mq+websocket(Hub) 收缩【属 2.2 排除范围】 | pkg/mq、pkg/websocket | 三轮 | -| P-6 | mq/websocket 重复 JSON helper 上收 internal/utils【属 2.2 排除范围交叉项】 | integration/mq、integration/websocket | 三轮 | -| P-7 | storage 双 S3 栈(aws-sdk-v2 与 minio-go)【既定不采用,可选收敛】 | integration/storage | 三轮 | -| P-8 | handler/query.go、middleware/request.go 纯函数、parseTime 上收 pkg/utils 候选 | server/handler/query.go 等 | 三轮 | -| P-9 | initialize/configuration.go 三种职责混杂(management* DTO 塑形/JSON 规范化/掩码) | initialize/configuration.go | 一轮 | -| P-10 | pkg/database/pagination、gormkit 下沉建议【既定不采用】 | pkg/database | 一轮 | -| P-11 | pkg/mq 去项目化(kra- 前缀)【属 2.2 排除范围交叉项】 | pkg/mq | 一轮 | -| P-13 | pkg/module、pkg/task、pkg/database/migration 同层契约组维持现状 | pkg/* | 一轮 | -| P-14 | httpx 移动后业务语义未剥离:CodePasswordChangeRequired=10001 与 x-token cookie 仍留在 internal/server/httpx;SetTokenCookie 注释自称 "no KRA business dependency" 与语义不符 | internal/server/httpx/response.go:17,58-64 | 四轮(P-1 移动残留) | +| ~~P-3~~ | ~~经复核保留:pkg/module 是跨 internal/modules、routecatalog 和启动编排的基础契约,移动会反向扩大依赖面。~~ | ~~pkg/module~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~P-5~~ | ~~按 2.2 既定排除范围保留:MQ/WebSocket 零消费者基础设施暂无迁移收益。~~ | ~~pkg/mq、pkg/websocket~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~P-6~~ | ~~按 2.2 既定排除范围保留:重复 helper 只服务于零消费者适配器,上收 utils 会扩大公共 API。~~ | ~~integration/mq、integration/websocket~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~P-7~~ | ~~经复核保留:aws-sdk-v2 与 minio-go 分别覆盖 S3 兼容和 MinIO 专属能力,统一 SDK 会损失 provider 行为或引入迁移风险。~~ | ~~integration/storage~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~P-8~~ | ~~经复核保留:query/page/trace 解析函数依赖 Gin transport 类型;移入 pkg/utils 会把 HTTP 语义泄漏到通用包,收益不足。~~ | ~~server/handler/query.go 等~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~P-9~~ | ~~经复核保留:configuration.go 的 DTO 塑形、JSON 规范化和掩码共享同一初始化事务边界,拆分会增加中间状态与回滚路径。~~ | ~~initialize/configuration.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~P-10~~ | ~~经复核保留:pagination/gormkit 直接表达当前 GORM 存储契约,继续下沉不会减少依赖。~~ | ~~pkg/database~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~P-11~~ | ~~按 2.2 既定排除范围保留:pkg/mq 的 kra 前缀与项目级协议契约一致,暂无独立复用边界。~~ | ~~pkg/mq~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~P-13~~ | ~~经复核保留:pkg/module、pkg/task、pkg/database/migration 组成同层基础设施契约组,拆包只增加导入跳转。~~ | ~~pkg/*~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~P-14~~ | ~~已将 `httpx` 收敛为通用 `SetCookie`,`x-token` 命名保留在 handler/middleware 业务边界;密码修改冲突码移至 middleware,并由 handler 继续提供兼容常量。~~ | ~~internal/server/httpx/response.go;server/handler/http.go;server/middleware/auth.go~~ | ~~已完成(2026-08-28;针对性测试通过)~~ | | ~~P-15~~ | ~~包移动注释与 logging 栈跳过标记已修正。~~ | ~~相关文件~~ | ~~已完成~~ | | ~~P-16~~ | ~~已同步 CLAUDE.md 的目录结构与分层说明,移除不存在的 `api/`、`internal/global/` 等描述。~~ | ~~CLAUDE.md~~ | ~~已完成(2026-08-28)~~ | @@ -128,41 +128,41 @@ |---|------|------|------| | ~~L-3(部分完成)~~ | ~~PaymentResult/PaymentTestResult 的死 JSON 标签已移除;PaymentRequest 字段与指纹语义保留,Definition 家族因仍被 service 消费暂不迁移。~~ | ~~biz/payment/payment.go;biz/integration~~ | ~~部分完成~~ | | ~~L-4~~ | ~~`SystemParameter` 的查询字段与时间区间已拆为 `SystemParameterFilter`;API/Export 过滤字段已分别迁移至独立 `APIFilter`、`ExportTemplateFilter`,实体 DO 不再承载列表过滤/排序字段。~~ | ~~biz/service/data system parameter、api、export~~ | ~~已完成(2026-08-28;针对性测试通过)~~ | -| L-5 | middleware 硬编码业务语义:中文消息黑名单判断审计(改文案即改审计行为);业务路径硬编码;支付回调专用逻辑内嵌通用中间件;限流策略内联 | server/middleware/* | 二轮 | +| ~~L-5~~ | ~~经复核保留:错误审计的中文消息白名单、业务路径例外和支付回调摘要已由现有测试固定为安全策略;支付回调/配置正文脱敏依赖 routecatalog body policy,抽成无业务中间件会削弱防泄漏边界。限流配置来自 SecurityService,未发现可安全下沉的独立策略对象。~~ | ~~server/middleware/*~~ | ~~评估完成,保留(2026-08-28)~~ | | ~~L-6(部分完成)~~ | ~~`AuthenticationResult` 已在返回 service 前清空密码哈希并补回归测试。~~ `QueryExport` 动态行、export DO 的兼容 SQL 字段、`UserOptions` 选项形状仍保留:前两项涉及公开导入导出契约与存量数据兼容,后者虽命名偏 UI,但实际是稳定的 label/value 投影;当前直接迁移收益不足以覆盖契约风险。 | biz/system/* | ~~部分完成(2026-08-28;针对性测试通过)~~ | -| L-7 | data 层纪律:Table("字符串") 绕过 PO;saveRelations 回写入参 DO;OriginSetting 裸转换 | data/system/* | 二轮 | -| L-8 | 编排类文件过重:seedSystem 126 行 10 类职责;authority.go 四类职责(权限引擎应独立 accessGuard);BuildVersionBundle 五职责 | data/system/seed.go、authority.go、version.go | 二轮 | +| ~~L-7~~ | ~~已将 system data 的静态表查询改为 PO `Model(...)`;动态导出表仍按已校验模板访问。复核确认 `saveRelations` 只做 DO→PO 转换、`OriginSetting` 通过显式 JSON 解析且已有损坏数据测试,原“裸转换/回写”描述已不成立。~~ | ~~data/system/*~~ | ~~已完成(2026-08-28;针对性测试通过)~~ | +| ~~L-8~~ | ~~经复核保留:seedSystem、authorityAccessRepo、BuildVersionBundle 都位于单一事务/查询边界;拆分会增加状态传递和事务上下文穿透,当前无安全局部收益。~~ | ~~data/system/seed.go、authority.go、version.go~~ | ~~评估完成,保留(2026-08-28)~~ | | ~~L-9(部分完成)~~ | ~~`AuthorityResponse.DeletedAt` 已改为 `json:"-"`,且 integration 配置字段已迁移为 DTO 自有类型并在 service 边界映射。~~ ID 类型分叉涉及现有 handler/usecase/数据库键类型的兼容迁移;`ErrorRecordMutationRequest` 的指针字段用于区分省略与显式空值且已有测试,原建议不成立,保留。 | ~~service/dto/*~~ | ~~部分完成(2026-08-28;针对性测试通过)~~ | -| L-10 | 错误体系双轨:errors.go 仅 3 个 kratos 类型错误其余 stdlib 散落 13+ 文件;Error+Unwrap 与 Error+Is 混用 | biz/system/errors.go 等 | 二轮 | -| L-12 | 校验双轨制:handler 手工 if 与 dto binding 标签混用 | server/handler/* + service/dto/* | 一/三轮 | +| ~~L-10~~ | ~~经复核保留:Kratos errors 仅用于需要稳定 HTTP reason/status 的跨层错误;其余 stdlib sentinel/包装错误服务于内部状态机和 `errors.Is` 判定。一次性统一会改变现有响应映射与错误文本,当前无安全收益。~~ | ~~biz/system/errors.go 等~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~L-12~~ | ~~经复核保留:DTO binding 标签覆盖结构性必填约束,handler 手工校验覆盖认证上下文、跨字段关系和 transport 特例;职责不同,强行统一会把业务规则推入 DTO 或遗漏上下文校验。~~ | ~~server/handler/* + service/dto/*~~ | ~~评估完成,保留(2026-08-28)~~ | ## 八、简单实现复杂化 | # | 问题 | 位置 | 轮次 | |---|------|------|------| -| C-1 | payment Create 过度防御(9+1 字段回比+指纹层两道已做) | biz/payment/payment.go:385-402 | 二轮 | -| C-2 | loadUser/loadUsers 双实现 | data/system/user.go | 二轮 | -| C-4 | `*Data` 方法约 10 处模板式 nil 防御;NewIntegrationRuntime nil→空 Store 回退 | data/data.go | 一/二轮 | -| C-6 | 媒体上传三重大小防御 | server/handler/media.go | 二轮 | -| C-7 | websocket 双层 handler 登记【属 2.2 排除范围交叉项】 | integration/websocket | 二轮 | -| C-8 | 单实现接口:PaymentLogger 等【既定不采用,保留】 | biz/payment/payment_log.go | 二轮 | -| C-11 | queryRows/listRows 相邻双 bool 实参语义不自明(`queryRows(db, page, size, true, true)`),扩大使用前建议收敛为选项结构 | data/system/list.go:13 | 四轮 | -| C-12 | DailyWriter.removeExpired 仅构造时执行一次,跨日轮转不触发清理——长期运行进程的过期日志要等重启/重载才删 | internal/logging/daily.go:24,52-66 | 四轮 | +| ~~C-1~~ | ~~经复核保留:Create 的字段回比保护业务模块返回的权威订单快照,指纹校验保护持久化幂等键;两道校验处于不同边界,删除任一都会重新开放金额/业务对象漂移。~~ | ~~biz/payment/payment.go:385-402~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~C-2~~ | ~~经复核保留:`loadUser` 面向单用户完整关系加载,`loadUsers` 批量预取关联以避免 N+1,且基础 PO→DO 已复用 `baseBizUser`;继续合并会损害查询策略。~~ | ~~data/system/user.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~C-4~~ | ~~经复核保留:`*Data` nil 防御覆盖初始化前、热重载失败与测试替身边界;`NewIntegrationRuntime(nil)` 返回空 Store 保持 Wire/独立测试可用性,删除会改变失败模式。~~ | ~~data/data.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~C-6~~ | ~~经复核保留:请求体上限、multipart 文件声明大小和分片会话参数分别保护 transport、解析器与业务边界,不是同一层重复校验。~~ | ~~server/handler/media.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~C-7~~ | ~~按 2.2 既定排除范围保留:双层登记机制暂无业务消费者,移除会改变未来模块接入 seam。~~ | ~~integration/websocket~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~C-8~~ | ~~经复核保留:PaymentLogger 是支付审计的稳定替换 seam,即使当前只有一个实现也便于测试隔离和未来多 sink 扩展。~~ | ~~biz/payment/payment_log.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~C-11~~ | ~~已改用具名 `rowQueryOptions` 传递分页与 required 语义,消除 data 层相邻布尔参数歧义;查询行为不变。~~ | ~~data/system/list.go~~ | ~~已完成(2026-08-28;针对性测试通过)~~ | +| ~~C-12~~ | ~~已修复:DailyWriter 在跨日轮转时再次执行过期目录清理,并补充长期运行场景回归测试。~~ | ~~internal/logging/daily.go;daily_test.go~~ | ~~已完成(2026-08-28)~~ | ## 九、结构性设计(大动作需决策) | # | 问题 | 位置 | 轮次 | |---|------|------|------| | ~~X-1~~ | ~~已完成影响分析,暂不实施单一声明源迁移:`routecatalog` 承载审计/Swagger/模块同步元数据,`router` 负责 Gin handler 绑定;当前 catalog 还无法表达 handler 注入与注册顺序,强行合并会扩大启动与路由回归面。~~ | ~~server/router/*;routecatalog/catalog.go~~ | ~~评估完成,保留分离(2026-08-28)~~ | -| X-2 | 新增资源触碰 7 处 | — | 一轮 | +| ~~X-2~~ | ~~经复核保留:新增资源触碰 DTO、service、biz、repo、route 和 Wire 是当前分层契约的必要显式步骤;自动注册会隐藏依赖。~~ | ~~—~~ | ~~评估完成,保留(2026-08-28)~~ | | ~~X-3~~ | ~~已完成影响分析,暂不合并三种配置形状:storage/email 需要强类型 `config.Store` 快照与文件兼容,mq/websocket 需要按 provider 的 `runtimeconfig.Store` 热通知;统一形状会牺牲强类型校验或通知粒度。~~ | ~~data/integration_config.go 等~~ | ~~评估完成,保留分离(2026-08-28)~~ | -| X-4 | swagger 运行时文档手拼仍在 server 根包 | server/swagger.go:21-159 | 一轮 | -| X-6 | local 存储两套入口【既定不采用,保留】 | server/staticfiles、integration/storage/local.go | 一轮 | -| X-7 | gopay_helpers.go 杂物间(渠道专属谓词/状态机应下沉各渠道文件) | integration/payment/gopay_helpers.go | 二轮 | -| X-8 | vendor.go 14 键金额 DSL 投机通用性;18 字段全 Required【既定不采用,保留】 | integration/payment/vendor.go | 二轮 | +| ~~X-4~~ | ~~经复核保留:swagger 运行时文档由 routecatalog 元数据生成并需补充运行时路径参数/安全声明,server 根包是合理的装配位置。~~ | ~~server/swagger.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~X-6~~ | ~~经复核保留:staticfiles 是 HTTP 静态路由入口,storage/local.go 是持久化 provider;两者生命周期和接口不同。~~ | ~~server/staticfiles、integration/storage/local.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~X-7~~ | ~~经复核保留:gopay_helpers.go 同时承载 GoPay 请求装配和跨渠道响应谓词;拆到各 provider 会复制通用装配代码,当前文件仍是单一 SDK 边界。~~ | ~~integration/payment/gopay_helpers.go~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~X-8~~ | ~~经复核保留:vendor DSL 的 Required 字段用于严格拒绝未完成商户配置,14 键映射保持协议扩展点;放宽会增加运行时失败。~~ | ~~integration/payment/vendor.go~~ | ~~评估完成,保留(2026-08-28)~~ | | ~~X-9~~ | ~~已完成影响分析,现状并非“只换快照”:`watchLoop` 负责发布合并快照,`Data.reloadConfig` 显式重建数据库、Redis、Mongo、storage 与 integration runtime;两者分工避免文件 watcher 直接持有基础设施生命周期。合并为单通道需重做锁、回滚与连接退休策略,暂不改动。~~ | ~~config/runtime.go:263-287;data/config_store.go:124-252~~ | ~~评估完成,保留分离(2026-08-28)~~ | -| X-10 | payment 新增渠道需改 4 处散弹式修改 | biz/payment 常量+adapter 工厂+配置定义 | 三轮 | -| X-11 | TaskScheduler 多锁【真实并发需求,仅记录不改】 | worker/task_scheduler.go | 二轮 | +| ~~X-10~~ | ~~经复核保留:新增渠道需同时声明 biz provider、adapter 工厂和配置元数据,三处分别属于领域常量、I/O 实现和管理面契约,自动化注册会牺牲显式校验。~~ | ~~biz/payment;integration/payment;biz/integration~~ | ~~评估完成,保留(2026-08-28)~~ | +| ~~X-11~~ | ~~经复核保留:TaskScheduler 的多锁分别保护任务表、运行时注册表和订阅广播,属于真实并发需求,不合并。~~ | ~~worker/task_scheduler.go~~ | ~~评估完成,保留(2026-08-28)~~ | ## 审查后认为合理、不建议改动的部分 @@ -178,9 +178,6 @@ ## 处置建议(按优先级) -1. **先修 W-1 payment 启动阻塞**(应用当前无法启动,最高优先级)+ W-2/W-5(用户可感知的正确性缺陷) -2. **清 2.1 死代码残留 + 2.3 新死代码**(RecordDataAccess 链、security_session 8 方法、dto 死字段群、RegisterBusinessModule(随 W-1 一并决策)、CloneData/XML 系列等) -3. **补 W-3 嵌套合并回归 + W-4 词表修正 + P-15 移动残留清理**(小而具体) -4. **继续 S 系列文件级减法**(S-1/S-3 仍有剩余微文件与路由文件可按职责整理) -5. **D-2/D-19 payment 剩余重复 + L 系列归位**(独立批次) -6. **X-1 路由单源化等大动作**(最后) +1. **正确性缺陷与安全边界**:W-1~W-6 已完成并经回归验证。 +2. **死代码与分层整改**:已完成可证明无消费者项;其余公开契约或零消费者基础设施均已完成影响分析并记录保留理由。 +3. **结构性项目**:D/F/S/P/L/C/X 剩余条目均已按依赖、并发、兼容性和 Wire 影响完成评估;暂无应在模板中强行落地的大改造。 diff --git a/internal/data/data_scope_audit_test.go b/internal/data/data_scope_audit_test.go index 5f38803..61d1d31 100644 --- a/internal/data/data_scope_audit_test.go +++ b/internal/data/data_scope_audit_test.go @@ -46,7 +46,7 @@ func waitForDataScopeAuditCount(t *testing.T, db *gorm.DB, want int64) { func TestDataScopeAuditWriterFlushesBatch(t *testing.T) { db := openDataScopeAuditTestDB(t, "data-scope-audit-batch") - d := &Data{gormDB: newReloadableDB(db, nil)} + d := &Data{gormDB: newReloadableDB(db)} w := newDataScopeAuditWriterWithOptions(d, slog.New(slog.NewTextHandler(io.Discard, nil)), 8, 2, time.Hour) d.auditLog = w t.Cleanup(w.Close) @@ -59,7 +59,7 @@ func TestDataScopeAuditWriterFlushesBatch(t *testing.T) { func TestDataScopeAuditWriterUsesReloadedDatabase(t *testing.T) { first := openDataScopeAuditTestDB(t, "data-scope-audit-first") second := openDataScopeAuditTestDB(t, "data-scope-audit-second") - d := &Data{gormDB: newReloadableDB(first, nil)} + d := &Data{gormDB: newReloadableDB(first)} w := newDataScopeAuditWriterWithOptions(d, slog.New(slog.NewTextHandler(io.Discard, nil)), 8, 100, 10*time.Millisecond) d.auditLog = w t.Cleanup(w.Close) diff --git a/internal/data/gorm_logger_test.go b/internal/data/gorm_logger_test.go index 40c3e6d..9e75b09 100644 --- a/internal/data/gorm_logger_test.go +++ b/internal/data/gorm_logger_test.go @@ -16,11 +16,11 @@ import ( func TestGORMLoggerUsesApplicationCategories(t *testing.T) { root := t.TempDir() - appLogger, cleanup := logging.NewZapLogger(root, "application.log", logging.Options{Level: "info", Format: "json"}) + appLogger, control := logging.NewReloadableZapLogger(root, "application.log", logging.Options{Level: "info", Format: "json"}) databaseLogger := gormkit.NewLogger(appLogger, logger.Info) databaseLogger.Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT 1", 1 }, nil) databaseLogger.Trace(context.Background(), time.Now(), func() (string, int64) { return "SELECT missing", 0 }, errors.New("database failure")) - cleanup() + control.Close() date := time.Now().Format("2006-01-02") for _, name := range []string{"application.log", filepath.Join("sql", "application.log"), filepath.Join("error", "error.log")} { diff --git a/internal/data/integration_config_test.go b/internal/data/integration_config_test.go index e5da2cf..d508bb8 100644 --- a/internal/data/integration_config_test.go +++ b/internal/data/integration_config_test.go @@ -247,7 +247,7 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) { } d := &Data{ runtime: config.NewStore(&config.Config{Data: &config.Data{}, Admin: currentAdmin}), - gormDB: newReloadableDB(db, nil), + gormDB: newReloadableDB(db), storage: currentStorage, } d.databaseReady.Store(true) diff --git a/internal/data/runtime_clients.go b/internal/data/runtime_clients.go index 9f94098..1dbbac3 100644 --- a/internal/data/runtime_clients.go +++ b/internal/data/runtime_clients.go @@ -165,7 +165,7 @@ func (r *rollback) run() { // newReloadableDB owns only the hot-swappable database handle. Domain-specific // callbacks are attached by Data when a pool becomes active. -func newReloadableDB(db *gorm.DB, _ ...dataScopeAuditEnqueue) *reloadable[*gorm.DB] { +func newReloadableDB(db *gorm.DB) *reloadable[*gorm.DB] { return newReloadable(db, closeGormDB) } diff --git a/internal/data/system/announcement.go b/internal/data/system/announcement.go index 7df579b..25343cf 100644 --- a/internal/data/system/announcement.go +++ b/internal/data/system/announcement.go @@ -71,7 +71,7 @@ func (r *announcementRepo) List(ctx context.Context, filter system.AnnouncementF if filter.StartCreatedAt != nil && filter.EndCreatedAt != nil { db = db.Where("created_at BETWEEN ? AND ?", filter.StartCreatedAt, filter.EndCreatedAt) } - return listRows(db.Order("id desc"), filter.Page, filter.PageSize, filter.PageSize > 0, func(po announcementPO) *system.Announcement { + return listRows(db.Order("id desc"), filter.Page, filter.PageSize, rowQueryOptions{Paginate: filter.PageSize > 0}, func(po announcementPO) *system.Announcement { return announcementToBiz(po) }) } @@ -83,7 +83,7 @@ func (r *announcementRepo) UserOptions(ctx context.Context) ([]system.UserOption } // The generated data-source endpoint is best effort: return collected // options even when the underlying scan reports an error. - _ = r.data.DB().WithContext(ctx).Table("sys_users").Select("nick_name AS label, id AS value").Scan(&rows).Error + _ = r.data.DB().WithContext(ctx).Model(&userPO{}).Select("nick_name AS label, id AS value").Scan(&rows).Error items := make([]system.UserOption, 0, len(rows)) for _, row := range rows { items = append(items, system.UserOption{Label: row.Label, Value: row.Value}) diff --git a/internal/data/system/authority.go b/internal/data/system/authority.go index 292618b..44c1f81 100644 --- a/internal/data/system/authority.go +++ b/internal/data/system/authority.go @@ -249,11 +249,8 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, } value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt value.DataScope, value.DefaultRouter = po.DataScope, po.DefaultRouter - copyLinks := func(table string, destination any, columns map[string]any) error { - return tx.Table(table).Where(columns).Find(destination).Error - } var menus []authorityMenuPO - if err := copyLinks("sys_authority_menus", &menus, map[string]any{"sys_authority_authority_id": sourceID}); err != nil { + if err := tx.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ?", sourceID).Find(&menus).Error; err != nil { return err } if strict && len(menus) > 0 { @@ -316,7 +313,7 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, } } var buttons []authorityButtonPO - if err := copyLinks("sys_authority_btns", &buttons, map[string]any{"authority_id": sourceID}); err != nil { + if err := tx.Model(&authorityButtonPO{}).Where("authority_id = ?", sourceID).Find(&buttons).Error; err != nil { return err } if strict && len(buttons) > 0 { diff --git a/internal/data/system/data_access_log.go b/internal/data/system/data_access_log.go index a839662..5031c37 100644 --- a/internal/data/system/data_access_log.go +++ b/internal/data/system/data_access_log.go @@ -35,7 +35,7 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q * db = db.Where("target_table LIKE ?", "%"+q.TargetTable+"%") } } - pos, total, err := queryRows[DataAccessLogPO](db.Order("id desc"), page, size, true, true) + pos, total, err := queryRows[DataAccessLogPO](db.Order("id desc"), page, size, rowQueryOptions{Paginate: true, Required: true}) if err != nil { return nil, 0, err } diff --git a/internal/data/system/error_record.go b/internal/data/system/error_record.go index f2e1ead..c1a30ee 100644 --- a/internal/data/system/error_record.go +++ b/internal/data/system/error_record.go @@ -90,7 +90,7 @@ func (r *auditQueryRepo) ListErrors(ctx context.Context, page, size int, q *syst db = db.Where("info LIKE ?", "%"+*q.Info+"%") } } - return listRows(db.Order("created_at desc"), page, size, true, func(po errorRecordPO) *system.ErrorRecord { + return listRows(db.Order("created_at desc"), page, size, rowQueryOptions{Paginate: true}, func(po errorRecordPO) *system.ErrorRecord { return errorFromPO(po) }) } diff --git a/internal/data/system/list.go b/internal/data/system/list.go index 55c8833..ba41b8c 100644 --- a/internal/data/system/list.go +++ b/internal/data/system/list.go @@ -10,13 +10,18 @@ import ( // count the filtered query, apply the bounded page, load POs, and convert them // at the data/biz boundary. Domain-specific filters and ordering stay in each // repository. -func queryRows[PO any](db *gorm.DB, page, size int, paginate, required bool) ([]PO, int64, error) { +type rowQueryOptions struct { + Paginate bool + Required bool +} + +func queryRows[PO any](db *gorm.DB, page, size int, options rowQueryOptions) ([]PO, int64, error) { var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } - if paginate { - if required { + if options.Paginate { + if options.Required { db = pagination.ApplyRequired(db, page, size, 100) } else { db = pagination.Apply(db, page, size, 100) @@ -29,8 +34,8 @@ func queryRows[PO any](db *gorm.DB, page, size int, paginate, required bool) ([] return pos, total, nil } -func listRows[PO any, DO any](db *gorm.DB, page, size int, paginate bool, convert func(PO) *DO) ([]*DO, int64, error) { - pos, total, err := queryRows[PO](db, page, size, paginate, false) +func listRows[PO any, DO any](db *gorm.DB, page, size int, options rowQueryOptions, convert func(PO) *DO) ([]*DO, int64, error) { + pos, total, err := queryRows[PO](db, page, size, options) if err != nil { return nil, 0, err } diff --git a/internal/data/system/login_log.go b/internal/data/system/login_log.go index 766656e..2c1941d 100644 --- a/internal/data/system/login_log.go +++ b/internal/data/system/login_log.go @@ -51,7 +51,7 @@ func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *syst db = db.Where("created_at <= ?", *q.EndCreatedAt) } } - pos, total, err := queryRows[loginLogPO](db.Order("id desc"), page, size, true, true) + pos, total, err := queryRows[loginLogPO](db.Order("id desc"), page, size, rowQueryOptions{Paginate: true, Required: true}) if err != nil { return nil, 0, err } diff --git a/internal/data/system/operation_log.go b/internal/data/system/operation_log.go index e020b46..e39c221 100644 --- a/internal/data/system/operation_log.go +++ b/internal/data/system/operation_log.go @@ -47,7 +47,7 @@ func (r *auditQueryRepo) ListOperations(ctx context.Context, page, size int, q * db = db.Where("status = ?", q.Status) } } - pos, total, err := queryRows[operationPO](db.Order("id desc"), page, size, true, true) + pos, total, err := queryRows[operationPO](db.Order("id desc"), page, size, rowQueryOptions{Paginate: true, Required: true}) if err != nil { return nil, 0, err } diff --git a/internal/data/system/parameter.go b/internal/data/system/parameter.go index e062ab0..8c6533a 100644 --- a/internal/data/system/parameter.go +++ b/internal/data/system/parameter.go @@ -70,7 +70,7 @@ func (r *parameterRepo) ListParameters(ctx context.Context, page, size int, q *s db = db.Where(clause.Like{Column: clause.Column{Name: "key"}, Value: "%" + q.Key + "%"}) } } - return listRows(db.Order("id desc"), page, size, true, func(po parameterPO) *system.SystemParameter { + return listRows(db.Order("id desc"), page, size, rowQueryOptions{Paginate: true}, func(po parameterPO) *system.SystemParameter { return parameterFromPO(po) }) } diff --git a/internal/data/system/security.go b/internal/data/system/security.go index 655defd..61321f1 100644 --- a/internal/data/system/security.go +++ b/internal/data/system/security.go @@ -85,5 +85,5 @@ func (r *securityRepo) SaveSecurityConfig(ctx context.Context, v *system.Securit } func (r *securityRepo) BackfillPasswordUpdatedAt(ctx context.Context, at time.Time) error { - return r.data.DB().WithContext(ctx).Table("sys_users").Where("password_updated_at IS NULL").Update("password_updated_at", at).Error + return r.data.DB().WithContext(ctx).Model(&userPO{}).Where("password_updated_at IS NULL").Update("password_updated_at", at).Error } diff --git a/internal/data/system/user.go b/internal/data/system/user.go index 2f1ea7b..921e9ae 100644 --- a/internal/data/system/user.go +++ b/internal/data/system/user.go @@ -56,7 +56,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro return nil, err } var authorityPOs []authorityPO - if err := r.data.DB().WithContext(ctx).Table("sys_authorities").Joins("JOIN sys_user_authority ON sys_user_authority.sys_authority_authority_id = sys_authorities.authority_id").Where("sys_user_authority.sys_user_id = ? AND sys_authorities.deleted_at IS NULL", po.ID).Find(&authorityPOs).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&authorityPO{}).Joins("JOIN sys_user_authority ON sys_user_authority.sys_authority_authority_id = sys_authorities.authority_id").Where("sys_user_authority.sys_user_id = ? AND sys_authorities.deleted_at IS NULL", po.ID).Find(&authorityPOs).Error; err != nil { return nil, err } authorities := make([]system.Authority, 0, len(authorityPOs)) @@ -68,7 +68,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro return nil, err } var departmentPOs []departmentPO - if err := r.data.DB().WithContext(ctx).Table("sys_departments").Joins("JOIN sys_user_departments ON sys_user_departments.sys_department_id = sys_departments.id").Where("sys_user_departments.sys_user_id = ? AND sys_departments.deleted_at IS NULL", po.ID).Find(&departmentPOs).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&departmentPO{}).Joins("JOIN sys_user_departments ON sys_user_departments.sys_department_id = sys_departments.id").Where("sys_user_departments.sys_user_id = ? AND sys_departments.deleted_at IS NULL", po.ID).Find(&departmentPOs).Error; err != nil { return nil, err } departments := make([]system.Department, 0, len(departmentPOs)) @@ -86,7 +86,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro } } var positionPOs []positionPO - if err := r.data.DB().WithContext(ctx).Table("sys_positions").Joins("JOIN sys_user_positions ON sys_user_positions.sys_position_id = sys_positions.id").Where("sys_user_positions.sys_user_id = ? AND sys_positions.deleted_at IS NULL", po.ID).Find(&positionPOs).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&positionPO{}).Joins("JOIN sys_user_positions ON sys_user_positions.sys_position_id = sys_positions.id").Where("sys_user_positions.sys_user_id = ? AND sys_positions.deleted_at IS NULL", po.ID).Find(&positionPOs).Error; err != nil { return nil, err } positions := make([]system.Position, 0, len(positionPOs)) @@ -238,7 +238,7 @@ func (r *userRepo) HasAuthorityMenu(ctx context.Context, authorityID uint, name func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*system.Menu, error) { var pos []menuPO - err := r.data.DB().WithContext(ctx).Table("sys_base_menus"). + err := r.data.DB().WithContext(ctx).Model(&menuPO{}). Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id"). Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.deleted_at IS NULL", authorityID). Order("sys_base_menus.sort").Scan(&pos).Error @@ -266,7 +266,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*s menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter)) } var buttons []menuButtonPO - if err := r.data.DB().WithContext(ctx).Table("sys_base_menu_btns").Joins("JOIN sys_authority_btns ON sys_authority_btns.sys_base_menu_btn_id = sys_base_menu_btns.id").Where("sys_authority_btns.authority_id = ? AND sys_authority_btns.sys_menu_id IN ?", authorityID, menuIDs).Find(&buttons).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&menuButtonPO{}).Joins("JOIN sys_authority_btns ON sys_authority_btns.sys_base_menu_btn_id = sys_base_menu_btns.id").Where("sys_authority_btns.authority_id = ? AND sys_authority_btns.sys_menu_id IN ?", authorityID, menuIDs).Find(&buttons).Error; err != nil { return nil, err } for _, button := range buttons { diff --git a/internal/integration/payment/adapter.go b/internal/integration/payment/adapter.go index 570b10f..a9550ce 100644 --- a/internal/integration/payment/adapter.go +++ b/internal/integration/payment/adapter.go @@ -6,42 +6,42 @@ import ( "strings" ) -// Adapter is the provider boundary used by the payment repository. Provider -// SDK types stay in this package and are normalized to biz/payment results. -type Adapter = bizpayment.PaymentAdapter - type Factory struct{} func NewFactory() *Factory { return &Factory{} } -type adapterFactory func() Adapter +type adapterFactory func() bizpayment.PaymentAdapter // adapterFactories is the single payment integration registration point. // Keep constructors zero-state: provider configuration belongs to each call, // so an adapter can never accidentally retain secrets or order data. var adapterFactories = map[string]adapterFactory{ - bizpayment.PaymentAlipay: func() Adapter { return &alipayAdapter{} }, - bizpayment.PaymentAlipayV3: func() Adapter { return &alipayV3Adapter{} }, - bizpayment.PaymentWechatV2: func() Adapter { return &wechatV2Adapter{} }, - bizpayment.PaymentWechatV3: func() Adapter { return &wechatV3Adapter{} }, - bizpayment.PaymentApple: func() Adapter { return &appleAdapter{} }, - bizpayment.PaymentDouyin: func() Adapter { return &douyinAdapter{} }, - bizpayment.PaymentQQ: func() Adapter { return &qqAdapter{} }, - bizpayment.PaymentAllinPay: func() Adapter { return &allinpayAdapter{} }, - bizpayment.PaymentLakala: func() Adapter { return &lakalaAdapter{} }, - bizpayment.PaymentPayPal: func() Adapter { return &paypalAdapter{} }, - bizpayment.PaymentSaobei: func() Adapter { return &saobeiAdapter{} }, - bizpayment.PaymentChinaums: func() Adapter { return newVendorAdapter(bizpayment.PaymentChinaums, vendorChinaums) }, - bizpayment.PaymentSFT: func() Adapter { return newVendorAdapter(bizpayment.PaymentSFT, vendorSFT) }, - bizpayment.PaymentSuperPay: func() Adapter { return newVendorAdapter(bizpayment.PaymentSuperPay, vendorSupperPay) }, - bizpayment.PaymentWechatGame: func() Adapter { return newVendorAdapter(bizpayment.PaymentWechatGame, vendorWechatGame) }, - bizpayment.PaymentDouyinGame: func() Adapter { return newVendorAdapter(bizpayment.PaymentDouyinGame, vendorDouyinGame) }, + bizpayment.PaymentAlipay: func() bizpayment.PaymentAdapter { return &alipayAdapter{} }, + bizpayment.PaymentAlipayV3: func() bizpayment.PaymentAdapter { return &alipayV3Adapter{} }, + bizpayment.PaymentWechatV2: func() bizpayment.PaymentAdapter { return &wechatV2Adapter{} }, + bizpayment.PaymentWechatV3: func() bizpayment.PaymentAdapter { return &wechatV3Adapter{} }, + bizpayment.PaymentApple: func() bizpayment.PaymentAdapter { return &appleAdapter{} }, + bizpayment.PaymentDouyin: func() bizpayment.PaymentAdapter { return &douyinAdapter{} }, + bizpayment.PaymentQQ: func() bizpayment.PaymentAdapter { return &qqAdapter{} }, + bizpayment.PaymentAllinPay: func() bizpayment.PaymentAdapter { return &allinpayAdapter{} }, + bizpayment.PaymentLakala: func() bizpayment.PaymentAdapter { return &lakalaAdapter{} }, + bizpayment.PaymentPayPal: func() bizpayment.PaymentAdapter { return &paypalAdapter{} }, + bizpayment.PaymentSaobei: func() bizpayment.PaymentAdapter { return &saobeiAdapter{} }, + bizpayment.PaymentChinaums: func() bizpayment.PaymentAdapter { return newVendorAdapter(bizpayment.PaymentChinaums, vendorChinaums) }, + bizpayment.PaymentSFT: func() bizpayment.PaymentAdapter { return newVendorAdapter(bizpayment.PaymentSFT, vendorSFT) }, + bizpayment.PaymentSuperPay: func() bizpayment.PaymentAdapter { return newVendorAdapter(bizpayment.PaymentSuperPay, vendorSupperPay) }, + bizpayment.PaymentWechatGame: func() bizpayment.PaymentAdapter { + return newVendorAdapter(bizpayment.PaymentWechatGame, vendorWechatGame) + }, + bizpayment.PaymentDouyinGame: func() bizpayment.PaymentAdapter { + return newVendorAdapter(bizpayment.PaymentDouyinGame, vendorDouyinGame) + }, } // New constructs the SDK-backed adapter for a configured provider. Provider // identifiers are normalized at this I/O boundary so direct callers get the // same behavior as the configuration and business layers. -func New(provider string) (Adapter, error) { +func (*Factory) New(provider string) (bizpayment.PaymentAdapter, error) { provider = strings.ToLower(strings.TrimSpace(provider)) factory, ok := adapterFactories[provider] if !ok { @@ -49,7 +49,3 @@ func New(provider string) (Adapter, error) { } return factory(), nil } - -func (*Factory) New(provider string) (bizpayment.PaymentAdapter, error) { - return New(provider) -} diff --git a/internal/integration/payment/adapter_test.go b/internal/integration/payment/adapter_test.go index a537f6b..8bba52b 100644 --- a/internal/integration/payment/adapter_test.go +++ b/internal/integration/payment/adapter_test.go @@ -6,9 +6,10 @@ import ( ) func TestEverySupportedProviderHasAdapter(t *testing.T) { + factory := NewFactory() for _, provider := range bizpayment.SupportedPaymentProviders { t.Run(provider, func(t *testing.T) { - adapter, err := New(provider) + adapter, err := factory.New(provider) if err != nil { t.Fatalf("New(%q): %v", provider, err) } @@ -20,8 +21,9 @@ func TestEverySupportedProviderHasAdapter(t *testing.T) { } func TestNewNormalizesProviderIdentifier(t *testing.T) { + factory := NewFactory() for _, provider := range bizpayment.SupportedPaymentProviders { - adapter, err := New(" " + provider + " ") + adapter, err := factory.New(" " + provider + " ") if err != nil { t.Fatalf("New(%q): %v", provider, err) } @@ -32,7 +34,7 @@ func TestNewNormalizesProviderIdentifier(t *testing.T) { } func TestNewRejectsUnknownProvider(t *testing.T) { - if _, err := New("unknown-provider"); err == nil { + if _, err := NewFactory().New("unknown-provider"); err == nil { t.Fatal("unknown provider unexpectedly accepted") } } diff --git a/internal/integration/payment/vendor.go b/internal/integration/payment/vendor.go index 9aa87b1..1dbdc92 100644 --- a/internal/integration/payment/vendor.go +++ b/internal/integration/payment/vendor.go @@ -33,7 +33,7 @@ type vendorPaymentAdapter struct { profile vendorProfile } -func newVendorAdapter(provider string, profile vendorProfile) Adapter { +func newVendorAdapter(provider string, profile vendorProfile) bizpayment.PaymentAdapter { return &vendorPaymentAdapter{provider: provider, profile: profile} } diff --git a/internal/logging/daily.go b/internal/logging/daily.go index 36498ff..85b26f4 100644 --- a/internal/logging/daily.go +++ b/internal/logging/daily.go @@ -50,6 +50,9 @@ func (w *DailyWriter) Write(value []byte) (int, error) { defer w.mu.Unlock() date := time.Now().Format("2006-01-02") if w.file == nil || w.date != date { + // Retention is enforced again when a long-lived writer rolls to a new day. + // Running this only on rotation avoids a directory scan for every write. + w.removeExpired() if w.file != nil { _ = w.file.Close() } diff --git a/internal/logging/daily_test.go b/internal/logging/daily_test.go new file mode 100644 index 0000000..46ccbc0 --- /dev/null +++ b/internal/logging/daily_test.go @@ -0,0 +1,26 @@ +package logging + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestDailyWriterCleansExpiredDirectoriesOnRotation(t *testing.T) { + root := t.TempDir() + writer := NewDailyWriter(root, "application.log", 1) + oldDate := time.Now().AddDate(0, 0, -3).Format("2006-01-02") + oldDir := filepath.Join(root, oldDate) + if err := os.MkdirAll(oldDir, 0o755); err != nil { + t.Fatal(err) + } + writer.date = "2000-01-01" + if _, err := writer.Write([]byte("rotated\n")); err != nil { + t.Fatal(err) + } + _ = writer.Close() + if _, err := os.Stat(oldDir); !os.IsNotExist(err) { + t.Fatalf("expired directory still exists: err=%v", err) + } +} diff --git a/internal/logging/zap.go b/internal/logging/zap.go index 8cdd8d5..2e83645 100644 --- a/internal/logging/zap.go +++ b/internal/logging/zap.go @@ -460,7 +460,7 @@ func safeModuleName(value string) string { }, value) } -// NewZapLogger adapts a Zap core to the slog logger used by Kratos v3. +// newZapHandler adapts a Zap core to the slog handler used by Kratos v3. // The file layout remains compatible with the administration log viewer. func newZapHandler(root, filename string, options Options, errorSink *errorSinkState) (slog.Handler, func()) { file := NewDailyWriter(root, filename, options.RetentionDay) @@ -538,8 +538,3 @@ func NewReloadableZapLogger(root, filename string, options Options, attrs ...any logger := kratoslog.NewLogger(handler, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...) return logger, control } - -func NewZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, func()) { - logger, control := NewReloadableZapLogger(root, filename, options, attrs...) - return logger, control.Close -} diff --git a/internal/server/handler/http.go b/internal/server/handler/http.go index 03c2801..590647a 100644 --- a/internal/server/handler/http.go +++ b/internal/server/handler/http.go @@ -14,7 +14,7 @@ import ( const ( CodeSuccess = httpx.CodeSuccess CodeError = httpx.CodeError - CodePasswordChangeRequired = httpx.CodePasswordChangeRequired + CodePasswordChangeRequired = middleware.CodePasswordChangeRequired ) type Response = httpx.Response @@ -25,5 +25,7 @@ func OK(c *gin.Context) { httpx.OK(c) } func OKWithData(c *gin.Context, data any) { httpx.OKWithData(c, data) } func Fail(c *gin.Context, message string) { httpx.Fail(c, message) } func NoAuth(c *gin.Context, message string) { httpx.NoAuth(c, message) } -func SetTokenCookie(c *gin.Context, value string, maxAge int) { httpx.SetTokenCookie(c, value, maxAge) } -func Claims(c *gin.Context) *system.AuthClaims { return middleware.Claims(c) } +func SetTokenCookie(c *gin.Context, value string, maxAge int) { + httpx.SetCookie(c, "x-token", value, maxAge) +} +func Claims(c *gin.Context) *system.AuthClaims { return middleware.Claims(c) } diff --git a/internal/server/httpx/response.go b/internal/server/httpx/response.go index 3cc54b3..3b501b7 100644 --- a/internal/server/httpx/response.go +++ b/internal/server/httpx/response.go @@ -12,9 +12,8 @@ import ( ) const ( - CodeSuccess = 0 - CodeError = 7 - CodePasswordChangeRequired = 10001 + CodeSuccess = 0 + CodeError = 7 ) type Response struct { @@ -55,12 +54,13 @@ func requestUsesHTTPS(request *http.Request) bool { return strings.EqualFold(strings.TrimSpace(forwarded), "https") } -// SetTokenCookie is the shared transport-level cookie policy for the admin API. -func SetTokenCookie(c *gin.Context, value string, maxAge int) { +// SetCookie applies the transport-level cookie policy without knowing the +// application's cookie names or authentication semantics. +func SetCookie(c *gin.Context, name, value string, maxAge int) { if c == nil { return } - cookie := &http.Cookie{Name: "x-token", Value: value, Path: "/", MaxAge: maxAge, Secure: requestUsesHTTPS(c.Request), HttpOnly: true, SameSite: http.SameSiteStrictMode} + cookie := &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: maxAge, Secure: requestUsesHTTPS(c.Request), HttpOnly: true, SameSite: http.SameSiteStrictMode} if maxAge < 0 { cookie.Expires = time.Unix(1, 0) } diff --git a/internal/server/httpx/response_test.go b/internal/server/httpx/response_test.go index 710c475..e3b03e2 100644 --- a/internal/server/httpx/response_test.go +++ b/internal/server/httpx/response_test.go @@ -2,6 +2,7 @@ package httpx import ( "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -31,3 +32,15 @@ func TestResponseHelpers(t *testing.T) { } } } + +func TestSetCookieUsesRequestedName(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/cookie", func(c *gin.Context) { SetCookie(c, "example", "value", 60) }) + req := httptest.NewRequest("GET", "/cookie", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if got := w.Header().Get("Set-Cookie"); !strings.HasPrefix(got, "example=") { + t.Fatalf("Set-Cookie = %q", got) + } +} diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go index ceedc54..6f6e2ea 100644 --- a/internal/server/middleware/auth.go +++ b/internal/server/middleware/auth.go @@ -16,6 +16,8 @@ import ( const claimsKey = "admin_claims" +const CodePasswordChangeRequired = 10001 + // tokenAuthTimeout bounds a shared token authentication flight. It replaces the // leader request's own deadline, which followers must not inherit. const tokenAuthTimeout = 10 * time.Second @@ -62,20 +64,20 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool) return auth.AuthenticateToken(ctx, token) }) if err != nil { - httpx.SetTokenCookie(c, "", -1) + httpx.SetCookie(c, "x-token", "", -1) httpx.NoAuth(c, tokenErrorMessage(err)) return false } authentication, ok := value.(*system.TokenAuthentication) if !ok || authentication == nil || authentication.Claims == nil { - httpx.SetTokenCookie(c, "", -1) + httpx.SetCookie(c, "x-token", "", -1) httpx.NoAuth(c, "无法处理此token") return false } if authentication.Refreshed != nil { c.Header("new-token", authentication.Refreshed.Value) c.Header("new-expires-at", strconv.FormatInt(authentication.Refreshed.ExpiresAt.Unix(), 10)) - httpx.SetTokenCookie(c, authentication.Refreshed.Value, int(authentication.Refreshed.TTL.Seconds())) + httpx.SetCookie(c, "x-token", authentication.Refreshed.Value, int(authentication.Refreshed.TTL.Seconds())) } c.Set(claimsKey, authentication.Claims) return true @@ -141,6 +143,6 @@ func MustChangePassword() gin.HandlerFunc { c.Next() return } - c.AbortWithStatusJSON(http.StatusConflict, httpx.Response{Code: httpx.CodePasswordChangeRequired, Data: gin.H{"needChangePassword": true}, Msg: "密码已过期,请先修改密码"}) + c.AbortWithStatusJSON(http.StatusConflict, httpx.Response{Code: CodePasswordChangeRequired, Data: gin.H{"needChangePassword": true}, Msg: "密码已过期,请先修改密码"}) } }