diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index c4fd917..b179d6f 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -25,6 +25,7 @@ import ( "kra/internal/integration/mq" "kra/internal/integration/payment" "kra/internal/integration/storage" + "kra/internal/integration/systeminfo" "kra/internal/integration/websocket" "kra/internal/logging" "kra/internal/modules" @@ -107,8 +108,8 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg handlerEmail := handler.NewEmail(emailService) paymentConfigReader := integration.NewPaymentConfigReader(dataData) factory := payment.NewFactory() - paymentRepo := payment2.NewPaymentRepo(dataData, paymentConfigReader, factory) paymentOrderRepo := payment2.NewPaymentOrderRepo(dataData) + paymentRepo := payment2.NewPaymentRepo(dataData, paymentConfigReader, factory, paymentOrderRepo) paymentHooks := payment3.NewPaymentHooks() paymentOrderSourceRegistry := payment3.NewPaymentOrderSourceRegistry() paymentFulfillmentRegistry := payment3.NewPaymentFulfillmentRegistry() @@ -158,7 +159,8 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg apiToken := handler.NewAPIToken(tokenService) initializationRepo := initialize.NewRepo(dataData, catalog) taskReloader := worker.NewTaskReloader(taskScheduler) - systemConfigUsecase := system2.NewSystemConfigUsecase(initializationRepo, taskReloader) + provider := systeminfo.NewProvider() + systemConfigUsecase := system2.NewSystemConfigUsecase(initializationRepo, taskReloader, provider) systemConfigService := system3.NewSystemConfigService(systemConfigUsecase, runtimeSettings) systemConfig := handler.NewSystemConfig(systemConfigService, securityService) public := handler.NewPublic(authService, systemConfigService, securityService) diff --git a/docs/code-review-issues.md b/docs/code-review-issues.md index 267294c..be8c7f6 100644 --- a/docs/code-review-issues.md +++ b/docs/code-review-issues.md @@ -1,297 +1,187 @@ # 代码审查问题清单(internal + pkg) -- 审查日期:2026-08-27,共三轮全量审查 -- 审查方式:codegraph 符号分析 + 多路并行逐文件深读 + 调用链 Grep 反查验证;第三轮附带 `go build ./...` 编译验证通过 -- 文档结构:问题按**类型**归类(不按轮次);每条标注发现轮次【一轮/二轮/三轮】;已修复并经复查确认的统一列在文末 +- 审查日期:2026-08-27 ~ 2026-08-28,共四轮全量审查。第四轮(2026-08-28):核查第三至六轮处置声称是否真实落地 + 全量回归审查(重点:重构引入的新问题),`go build ./...` 编译验证通过 +- 文档结构:只保留待修复问题,按**类型**归类(不按轮次);每条标注发现轮次;已修复并经复查确认的直接删除 - gva/ 目录是遗留参考库(独立 module 不参与 kra 编译),不在审查范围 -- 依赖方向合规确认:pkg 无 import internal;integration 无 import data/service;service→biz→data 无反向;无循环依赖——架构骨架健康,债务集中在粒度与零消费抽象 +- 依赖方向合规确认:pkg 无 import internal;integration 不 import data/service;data 不再 import integration/payment(第四轮重构后复验单向);无循环依赖 --- -## 一、修复回归与残留缺陷(S0 修复后遗留,9 条) +## 一、正确性缺陷(第四轮回归审查新发现,最高优先级) -| # | 问题 | 位置 | 轮次 | +| # | 问题 | 位置 | 说明 | |---|------|------|------| -| R-1 | **vendor 退款静默受理残留(资金安全,高危)**:S0-1 修复覆盖"FAIL 状态字段"场景,但 create/refund 响应 HTTP 200 且 body 非 JSON、或无可识别状态字段时仍默认 `created`;biz `validatePaymentRefundResult` 接受 created → 真实被拒但响应无状态字段的退款会被永久记为已受理。建议 refund 端点无状态字段时报错或至少 pending | internal/integration/payment/vendor.go:135-163;biz/payment/payment.go:1020-1021,837-855 | 三轮 | -| R-2 | Excel `[]byte` 全转文本副作用:MySQL 数值列也变文本单元格,大面积"数字以文本存储"警告(原值保真达成,展示体验回退);可结合模板列类型区分处理 | internal/service/system/export_excel.go:85-86 | 三轮 | -| R-3 | ErrAuthoritiesRequired 错误透传缺失:biz 返回具体错误后 handler 只回"修改失败";data 层 setUserAuthorities 还残留一处重复中文防御错误(双轨) | internal/server/handler/user.go:208-212;internal/data/system/user.go:563-565 | 三轮 | -| R-4 | 版本导入非原子残留:落库成功但留痕 CreateVersion 失败时返回错误,用户误判"导入失败"(幂等查重使重试可控) | internal/service/system/version.go:256-270 | 三轮 | -| R-5 | 任务元数据双源漂移已发生:种子描述("**定时**清理…")与注册方法描述("清理…")不一致;建议 worker 注册时复用 catalog TimedTask 元数据 | internal/modules/task/definition.go:14-15 vs internal/worker/task_registry.go:31,45 | 三轮 | -| R-6 | export SQL/ImportSQL 摆设字段:dto 保留但 ValidateExportTemplate 拒绝非空,只能提交空值且响应回显空值 | internal/service/dto/export.go:26-27;internal/biz/system/export.go:72-74 | 三轮 | -| R-7 | payment 回调读体失败分支未入 Gin 错误链(S0-12 修复只覆盖 service 调用错误) | internal/server/handler/payment.go:149-153 | 三轮 | -| R-8 | LoginLogFilter 收敛为仅 Username/Status 两字段:若前端需按 IP/时间筛选登录日志则能力缺失(设计取舍需确认) | internal/biz/system/audit.go:42-45 | 三轮 | -| R-9 | PaymentUsecase 注册面零调用时,生产装配下支付主链路 Create/Refund/Fulfill 必然在 preparePaymentRequest 报"支付业务订单来源未注册"——模板未完成态,建议 Wire/cmd 层提供默认注册或 fail-fast 提示 | internal/biz/payment/payment.go:296-326,407-410 | 三轮 | +| W-1 | **payment 空注册表导致应用必然启动失败(高危,阻塞)**:wire 装配链创建空注册表(`NewPaymentOrderSourceRegistry()`/`NewPaymentFulfillmentRegistry()` 返回空 map)后仅流向 `NewConfiguredPaymentUsecase` 的 fail-fast 检查;`RegisterBusinessModule` 全库零调用、`PaymentBusinessModule` 无任何生产实现 → sources/fulfillments 恒为空 → `NewConfiguredPaymentUsecase` 必报"支付业务订单来源未注册"→ wireApp 失败,应用无法启动。biz.go 无条件包含 payment.ProviderSet,无跳过路径。task 域有 `taskRegistry()` 自定义 provider 做装配前注册,payment 域缺等价机制 | cmd/wire_gen.go:114-120;internal/biz/payment/payment.go:390-398,400-412;biz/payment/payment_order.go:144-152(接口无实现) | R-9 的 fail-fast 机制本身正确落地,但与"仓库内零业务模块"叠加产生阻塞。修法:装配前注册机制(仿 taskRegistry)或默认业务模块或允许显式禁用 | +| W-2 | **CompleteUpload 吞掉 ListChunks 底层错误并误置会话失败**:`err != nil || len(chunks) != ChunkTotal` 合并判定,DB 瞬断被误报为"分片不全",且 fail() 将会话置 failed → 用户必须重建会话全量重传。直接违背 D-16 修复意图(仓储层已正确透传,仅此处用例层吞掉) | internal/biz/system/media_upload.go:208-211 | 修法:拆开 err 与数量不足两个分支分别返回 | +| W-3 | MergeRuntimeConfig 嵌套子节合并缺口:仅当 `next.Data == nil` 才补 current.Data;next.Data 非 nil 但子节(Database 等)为 nil 时静默丢失,watchLoop 无 reloadConfig 那样的后置校验;Admin 子节同理 | internal/config/clone.go:29-40;config/runtime.go:271 | 建议补"next.Data 非 nil 但子节 nil"回归测试并做嵌套合并 | +| W-4 | 退款状态词表两处语义边角:① `NormalizeStatus` 将裸词 "REFUND" 归 failed——微信 v2 已退款订单在 vendor 查询回退路径会被归为失败,与 `PaymentStatusRefunded` 语义冲突;② `NormalizeRefundStatus` 成功词表缺 "TRADE_SUCCESS"(支付宝风格),会触发保守失败关闭 | internal/paymentkit/status.go:12,16,25 | 低危但需知晓/修正词表 | +| W-5 | 公告/参数/版本列表无 ORDER BY:统一接入 listRows 后这三个列表无排序,LIMIT/OFFSET 翻页顺序不稳定(MySQL/PG 均不保证) | data/system/announcement.go:74、parameter.go:73、version.go:67 | 收敛时遗留;补 `id desc` 或 `created_at desc` | +| W-6 | 外部退款分支存在不可达死分支(复制未裁剪):`accepted := providerErr == nil` 后 `else if !accepted` 永不触发 | internal/biz/payment/payment.go:923-928 | 删除死分支 | ## 二、死代码与零消费者机制 -### 2.1 biz/data/service 三层死方法(约 15 组,均经全仓 Grep 反查确认零调用)【二轮发现,三轮复核仍在】 +### 2.1 三层死方法残留(第三至五轮处置后仍未删,均经全仓 Grep 反查确认零调用)【二轮发现,四轮复核仍在】 | 层 | 死代码 | 位置 | |----|--------|------| -| biz 接口 | PermissionRepo.Buttons/SetAuthorityButtons/AuthorityButtonIDs(3/4 方法死) | biz/system/permission.go:6-8 | -| biz 接口 | APITokenRepo.DisableAPIToken;UserRepo.CreateUser/UpdateUserWithAuthorities | biz/system/api_token.go:26;user.go:45,49 | -| biz 接口 | MediaMetadataRepo.FindMediaByHash;MenuRepo.AuthorityMenuIDs | biz/system/media_metadata.go:44;menu.go:61 | -| biz 接口 | EmailUsecase.Alert;SecurityUsecase.ActiveTokenMatches | biz/system/email.go:31-48;security.go:243-249 | -| biz 注入面 | PaymentUsecase 注册面:SetHooks/SetOrderSourceRegistry/SetFulfillmentRegistry/RegisterBusinessModule(含两阶段注册+回滚补偿);PayInternal/RefundInternal/AuthorizeRefund 无生产实现,service 却作为正式 API 暴露(Fulfill) | biz/payment/payment.go:296-326 | -| data 实现 | 与上述接口配套:parseTemplateColumns、userRepo.CreateUser/UpdateUserWithAuthorities、DisableAPIToken、FindMediaByHash、AuthorityButtonIDs、AuthorityMenuIDs | data/system/export.go:430-440、user.go:317-319,474-491、api_token.go:108-114、media.go:67-73、permission.go:113-117、menu.go:283-287 | -| service 包装 | MenuService.List(连带 biz MenuUsecase.List;handler 实际用 Tree);RecordLogin/RecordDataAccess(后者整链含 biz 接口+data 实现全死);IsTokenDisabled;security_session.go 14 方法中 8 个死;AuditRecorder.CreateErrorRequest+recordedErrorDomain | service/system/menu.go:14-20、audit.go:56-58,91-93、api_token.go:60-62、security_session.go:17-67、audit_error.go:17-31 | -| dto 死类型 | LoginLogRequest;DataAccessRecordRequest;GetAuthorityButtonsRequest.Selected;MenuResponse.Authorities 恒 null(biz 无此字段);DynamicMenuResponse.MenuButtons 恒 nil;SysBaseMenuID 输入被静默丢弃;version 导出结构体大量零值噪声字段(ID:0/CreatedAt 零时间/authoritys:null) | dto/system.go:68-75、audit.go:21-30、menu.go:21,28,132、version.go:23-94 | +| biz 接口+data 实现 | `RecordDataAccess` 整链(接口+实现,查询侧 ListDataAccess/DeleteDataAccess 是活的,仅写入侧死) | biz/system/audit.go:103;data/system/data_access_log.go:24 | +| service 包装 | security_session.go 14 个方法中 8 个死:ActiveToken/LoginLocked/IncrementLoginFailure/LockLogin/ClearLoginState/IncrementLoginIP/UseMultipoint/RotateActiveToken(biz 内部直调 usecase,这些包装无人调用;存活 6 个被 public/rate_limit 消费) | service/system/security_session.go:17-67 | +| biz 注入面 | `RegisterBusinessModule`(W-1 的成因之一,两阶段注册+回滚补偿零调用);`PaymentBusinessModule`/`PayInternal`/`RefundInternal`/`AuthorizeRefund` 接口面仍无生产实现(biz 调用链真实存在,仅实现者缺——与 W-1 一并处理) | biz/payment/payment.go:400-412;payment_order.go:139-152 | +| dto 死字段 | GetAuthorityButtonsRequest.Selected(输入被丢弃);MenuResponse.Authorities 恒 null;DynamicMenuResponse.MenuButtons/Authorities 恒 nil;SysBaseMenuID 输入侧两处被丢弃;version 导出结构体零值噪声字段群(ID:0/CreatedAt 零时间/authoritys:null) | dto/permission.go:6、menu.go:19,24,109,126,128;service/system/version.go:22-96 | +| 死分支 | export_excel.go 的 `case []byte` 在 data 层按列类型转换(R-2 修复)后成为死分支 | service/system/export_excel.go:85-86 | -误报更正:service 层 PersistConfig 实为活代码(initialize.go:20 消费)【三轮更正】。 +### 2.2 mq / websocket 零消费者基础设施【既定排除范围,历轮明确不处理,现状保持】 -### 2.2 mq / websocket 零消费者基础设施【二轮发现,三轮复核反而扩大】 +- mq 全链(声明式订阅+legacy API+簿记/dispatcher/reconcile)业务消费者为零;`_ mq.Client` 幻影参数(cmd/main.go:65);integration/provider.go:33-38 三重死绑定 + Hub 死绑定 +- websocket:Hub 接口零消费者;integration 层 On* 四注册方法零调用,双层 handler 登记机制两层都为空 +- namedClient/Client() 整型死代码(integration/mq/emqx.go:632-650) -- mq 全链(声明式订阅+legacy API+簿记/dispatcher/reconcile)业务消费者为零:emqx.go 约 710 行 + mq.go 106 行 + subscription.go 55 行空转;legacy API(Subscribe/Unsubscribe/Publish,emqx.go:539-589)内部又转译为声明式 Register,双面并存且加码;`internal/modules`、`biz`、`service` 无一处注册订阅或调用 Publish -- websocket:Hub 接口零消费者单实现(pkg/websocket/melody.go:28-40,wire 死绑定 provider.go:31);integration 层 On* 四注册方法零调用(server.go:326-373),双层 handler 登记机制两层都永远为空(server.go:28-31,169-180 vs melody.go:65-118) -- 连带死装配:cmd/main.go:65 `_ mq.Client` 幻影参数(为保活 wire 图);integration/provider.go:28,29 两条死 wire 绑定 -- 建议:要么接入首个真实业务消费者,要么裁掉保留最小面(TestConfig 探测 + Enabled/Path/HandleRequest) +### 2.3 零散死代码(第四轮新扫描)【四轮】 -### 2.3 零散死代码【二/三轮】 - -| 死代码 | 位置 | -|--------|------| -| s3 多 provider endpoint 五个不可达分支(local.go:39-48 只让 minio 进 newS3Storage) | storage/s3_storage.go:29-41 | -| namedClient/Client() 整型死代码 | integration/mq/emqx.go:47-50,632-650 | -| isBootstrapPath 零调用 | middleware/capture.go:72-74 | -| NewGinEngine 生产死代码(wire 用 NewGinEngineWithRuntime,测试专用) | server/gin.go:23-25 | -| vendorSupperPay 死枚举(定义注册但 switch 无 case) | integration/payment/vendor.go:26,38 | -| stringOr;normalizePayPalOrderState;paypalOrderTradeNo;nestedString/amountFromDecimalField(shim 连调用者都没有) | wechat_v2.go:584、paypal.go:651,388-391、result.go:98,148-155 | -| RegisterAll 仅测试调用;TaskScheduler.Trigger(task) 可未导出(仅 TriggerID 内部用) | pkg/task/registry.go:39;worker/task_scheduler.go:425 | -| 仅测试调用:decryptWechatV3、alipaySignContent、validatePaymentConfig、wechatV2Sign(可移测试文件) | wechat_v3.go:600-605、alipay.go:572-585、data/payment/payment.go:551-553、wechat_v2.go:47 | -| P1-3 修复残留 micro-shim:result.go:58-64 与 payment_helpers.go:5-11 仍保留同名本地包装转发 paymentkit | integration/payment/result.go;data/payment/payment_helpers.go | +| 死代码 | 位置 | 证据 | +|--------|------|------| +| `config.CloneData` | internal/config/clone.go:8 | 全仓零调用(其余 Clone* 均有生产调用) | +| `paymentkit.XMLValues`/`XMLEncode` | internal/paymentkit/xml.go:16,41 | 仅测试调用,生产 XML 走 gopay 库 | +| `paymentkit.NestedString` | internal/paymentkit/json.go:18 | 生产+测试均零调用(旧 shim 删除后的孤儿) | +| `logging.NewZapLogger` | internal/logging/zap.go:542 | 仅测试调用,生产用 NewReloadableZapLogger | +| `data/payment.contains` | data/payment/payment.go:520-527 | 与 paymentkit.ContainsFold 功能重复 | +| `paymentkit status.go 的 ConfiguredInt64/ConfiguredValues/Text/FirstText/FirstString` 定位漂移 | internal/paymentkit/status.go:36-90 | 属"供应商配置解析"超出 README 声称范围(文档漂移,非死代码) | ## 三、重复实现 / 双份维护 -### 3.1 大块可消除(估算合计 1900+ 行)【二轮】 +### 3.1 大块可消除 -| # | 问题 | 位置 | -|---|------|------| -| D-1 | data/system List 样板 13 处同构(构建 db→逐字段翻译 filter→Count→分页→Find→循环 toBiz),可抽泛型 helper 消 200+ 行 | data/system/user.go:281-315、api.go:119-181、data_access_log.go:32-55、operation_log.go:39-68、error_record.go:83-109 等 13 处 | -| D-2 | payment 渠道适配器家族重复约 500 行:下单方式归一化骨架 9 份(Replacer 归一化行逐字出现 9 次);退款身份校验 4 份同构;状态归一化 switch 10 份(可收敛为 normalizeState+词表);金额拆分守恒 5 份+biz 层第 6 份;SDK 客户端构造 10 份同构;firstNonEmpty 三胞胎;mustMarshalAlipayPayload≡mustJSON | integration/payment/alipay.go:434-456、douyin.go:108-126、qq.go:124-144、lakala.go:69-75、saobei.go:87-101、wechat_v2.go:184-204、allinpay.go:205-221、gopay_helpers.go:145-225、alipay.go:475/qq.go:146/paypal.go:707、alipay.go:470-473 等 | -| D-3 | storage provider 家族重复约 200 行:key/unkey/file 三件套 5 份逐行相同;DeletePrefix 分页循环 5 份(可提 deletePrefixViaList);Compose 一行委托 7 份;limit 守卫 7 份;构造尾部样板 5 份 | storage/aliyun_storage.go:35-48、aws_storage.go:63-76、huawei_storage.go:31-44、s3_storage.go:61-74、tencent_storage.go:45-58 等 | -| D-4 | handler 四段式样板约 70 处(ShouldBindJSON→Fail→service→Write),抽 bindJSON+respond 两个 helper 可消一半 | server/handler/*(announcement.go:17-28、api.go:43-240、audit.go:27-316 等) | -| D-5 | middleware:请求体读取三处(access_log.go:56-74 / audit.go:49-67 fallback / handler/media.go:17-21,同一请求体三层 MaxBytesReader);脱敏/截断三处且分散(capture.go:41-68 / audit.go:118-136 / redact 词表在 redact.go 函数散在 access_log.go);audit.go:47-48,93 对已 mask 已截断的正文再处理一遍 | server/middleware/* | -| D-6 | mq/websocket 两包各写一套 map 解码 helper 且逐字符相同(configText≡text、configBool≡boolValue);TestConfig 探测骨架三处同构 | emqx.go:226-260 vs websocket/server.go:189-240;emqx.go:92-133/server.go:65-126/connectivity.go | +| # | 问题 | 位置 | 轮次 | +|---|------|------|------| +| D-2 | payment 渠道适配器剩余重复(第一非空字符串/JSON 编码/双状态归一化已收敛):下单方式归一化骨架仍 9 份(Replacer 归一化行逐字出现 9 次);退款身份校验 4 份同构;状态归一化 SDK 专属词表 7 个 normalize*State 与 paymentkit 通用归一化双轨维护(同一状态词需两族词表同步) | integration/payment/alipay.go:434-456、douyin.go:108-126、qq.go:124-144 等;gopay_helpers.go:145-225 | 二轮(四轮部分收敛) | +| D-4 | handler 四段式样板约 70 处(ShouldBindJSON→Fail→service→Write) | server/handler/* | 二轮 | +| D-6 | mq/websocket 两包各写一套 map 解码 helper 且逐字符相同;TestConfig 探测骨架三处同构【属 2.2 排除范围交叉项】 | emqx.go:226-260 vs websocket/server.go:189-240 | 二轮 | -### 3.2 配置/数据不变式双份维护【一轮,三轮复核仍在】 +### 3.2 配置/数据不变式双份维护 -| # | 问题 | 位置 | -|---|------|------| -| D-7 | DSN 一致性三处维护"结构化字段→Source"不变式,refreshDatabaseSource 用"先清空再恢复"绕开 databaseDSN 短路 | data/config_store.go:78-94;initialization_backend.go:42-59,135-140 | -| D-8 | "storage/email 缺省则沿用现值"策略散布 4 处(原 5 处收敛为 4,无统一 MergeRuntimeConfig) | config/runtime.go:275-286;config_store.go:173-183;initialization_backend.go:77-85,171-186 | -| D-9 | config.Store 与 runtimeconfig.Store 各写一套同构 listener/通知/克隆机制(语义有差异:文件全量 vs DB 集成窄通道,可辩护为有意分离,需明确决策;桥接靠 data 层三处 Replace) | config/runtime.go:29,150-196;runtimeconfig/store.go:62-167 | -| D-10 | "storage/email 不落盘"不变式三重执行:persistConfigValues 置 nil+Delete、persistDatabaseConfig 再 Delete、removeIntegrationConfigFromFile 启动时又删一遍(一次性迁移 shim 常驻持久化路径) | config_store.go:46-52,103-104,108-125;data.go:279-283 | +| # | 问题 | 位置 | 轮次 | +|---|------|------|------| +| D-9 | config.Store 与 runtimeconfig.Store 各写一套同构 listener/通知/克隆机制【既定不采用原建议,保持分离——待写决策注释固化】 | config/runtime.go:29,150-196;runtimeconfig/store.go:62-167 | 一轮 | +| D-10 | "storage/email 不落盘"不变式三重执行(persistConfigValues 置 nil+Delete、persistDatabaseConfig 再 Delete、removeIntegrationConfigFromFile 启动时又删) | config_store.go:45-47,102,106-122;data.go:279-283 | 一轮 | -### 3.3 中小重复【一/二轮】 +### 3.3 中小重复 -| # | 问题 | 位置 | -|---|------|------| -| D-11 | authority 树构建算法两份(byID map+Children 重置+父子装配,仅节点类型不同) | biz authority.go:48-78 vs menu.go:80-99 | -| D-12 | CreateAuthority/CopyAuthority 前 8 行校验逐字重复 | data/system/authority.go:133-151,211-245 | -| D-13 | gorm.DeletedAt→*time.Time 转换 4 处逐字重复 | data/system/user.go:99-104、menu.go:17-21,241-245,250-255 | -| D-14 | version 阶段→消息映射两份;engine.Routes()→dto 转换两份 | handler version.go:107-121 vs 158-167;api.go:161-167 vs public.go:135-139 | -| D-15 | defaults 合并逻辑三层三份 | service/integration:86-98 / biz/integration:215-224 / data/integration/migrations.go:69-75 | -| D-16 | media 分片根目录逻辑两份;media_upload 三处复制会话校验样板(顺带吞掉 ErrUploadNotFound 可判定性) | biz media.go:126-132 vs media_upload.go:61-69;media_upload.go:120-129,176-184,244-250 | -| D-17 | data/payment:callbackFields/first 整函数复制且已分叉(integration 版 Content-Type 大小写不敏感遍历 vs data 版直接下标,行为漂移隐患);values() 与 testRow() 近重复;渠道"免 notify_url"同义词表在 data 层重抄一份与各适配器 createMethod 表双份维护 | data/payment/payment.go:507-540,31-50,273-292,394-425 vs integration/payment/result.go:14-56 | -| D-18 | service:authorityResponse 与 convertAuthority 逐字重复;"单条 DTO helper + for-append"样板 9 处;"Request 包装+Filter 包装+裸方法"三重入口家族(audit/parameter/export/dictionary/media);user 空对象兜底三连 | service/system/authority.go:30-36 vs user_conversion.go:8-14 等 | -| D-19 | payment 金额守恒校验四处重复(biz validatePaymentBreakdown + vendor + wechat_v3 + douyin 各一份) | biz/payment/payment.go:1112-1135;vendor.go:235-253;wechat_v3.go:555;douyin.go | -| D-20 | 状态词汇归一三处维护(paymentkit.NormalizeStatus + 各 adapter + data/payment normalizeOrderPaymentStatus) | paymentkit/status.go:8-19;data/payment/payment_order.go:478 | +| # | 问题 | 位置 | 轮次 | +|---|------|------|------| +| D-11 | authority 树构建算法两份【既定不采用,保留】 | biz authority.go:48-78 vs menu.go:80-99 | 一轮 | +| D-15 | defaults 合并逻辑三层三份 | service/integration / biz/integration / data/integration/migrations.go:24-29 | 二轮 | +| D-17 剩余 | CallbackFields 已复用 paymentkit(见文末);剩余 values() 与 testRow() 近重复 | data/payment/payment.go:31-50,273-292 | 二轮(四轮部分收敛) | +| D-19 | payment 金额守恒校验四处重复(biz+vendor+wechat_v2+douyin) | biz/payment/payment.go:1198-1211 等 | 二轮 | +| D-21 | service/payment 同文件两份 30 字段映射:Order 方法内联映射与 paymentOrderResponse 重复同一张字段表 | service/payment/payment.go:14-34,65-82 | 四轮 | +| D-22 | data/payment 本地 contains 与 paymentkit.ContainsFold 重复(同 2.3) | data/payment/payment.go:520-527 | 四轮 | ## 四、过度分层:转发门面 / 透传壳 / 回调穿透 | # | 问题 | 位置 | 轮次 | |---|------|------|------| -| F-1 | payment 回调 ack 穿透四层(定义已单源化到 paymentkit、biz 用别名——算部分收敛;但 biz/service/handler 三层转发仍在,dto/payment.go:58-62 重复声明同构 3 字段结构体,加字段需同步三处) | paymentkit/callback.go:11-83 → biz/payment/payment.go:206-215 → service/payment/payment.go:131-148 → handler/payment.go:173-184 | 一轮(三轮复核部分收敛) | -| F-2 | integration/payment/result.go 自称 "Compatibility shims":11 个纯转发函数全部仍在 | integration/payment/result.go:68-110 | 一轮 | -| F-3 | service 根包 25 个 `type X = systemservice.X` 别名门面 + 函数转发(迁移脚手架;使 handler 无法感知真实包结构) | service/service.go:18-51 | 一轮 | -| F-4 | handler/http.go 纯转发别名层:3 常量+2 类型+6 函数一对一转发,仅为省一个 import(每个 handler 文件仍要同时认识两个包) | server/handler/http.go:14-29 | 一轮 | -| F-5 | service/task 双面 API:DO 签名版与 DTO 版并存;DO 版仅被同文件 DTO 版内部调用,handler 只用 `*Request` 版 | service/task/task.go:35-46 | 一轮 | -| F-6 | task 双 usecase 并存且加重:TaskUsecase 嵌入 TaskRepo 透传 9 方法给 worker;TaskApplicationUsecase 再包一层、其 6 方法纯转发(worker 持前者、service 持后者,wire_gen.go:115-119) | biz/task/task.go:76-79,159-237 | 一/三轮 | -| F-7 | Backend 三层缝合:biz InitializationRepo → initialize.Repo(内嵌 6 方法 Backend + seed 回调单传入点)→ data.Data;initialize 包只为"转手 6 个同名方法+注入 catalog"存在 | initialize/initialize.go:17-46;data/initialization_backend.go:17-217;cmd/wire.go:45 | 一轮 | -| F-8 | data-scope 审计回调 dataScopeAuditEnqueue 穿透 6 层签名,生产实现唯一 | data/data.go:238,245,392 → runtime_clients.go:168 → data_scope.go:19-136 | 一轮 | -| F-9 | **纯透传壳 usecase 12 个**(整个 struct 无自有逻辑/纯改名转发;service 可直依赖 biz repo 接口——repo 接口仍在 biz,分层契约不破;wire 链物证 wire_gen.go:88-91 permission 全程零逻辑):Parameter/Permission/Version/Announcement(7 方法全透传)/Maintenance/AccessControl/Department(2 改名)/Authority(仅 Tree 有逻辑)/Task/Menu(10 处透传)/User(13 处透传)/SystemConfig(7 处透传)。对照组(有真实逻辑应保留):Authentication/Security/Media/Email/IntegrationConfig/Payment/TaskApplication | biz/system/*、biz/task/task.go | 三轮(二轮发现同型) | -| F-10 | security_session.go:SecurityService 14 方法全部一行透传(零 DTO 工作);service 层另有近纯透传小文件 email.go(16)/permission.go(24)/access_control.go(28,仅 5 行逻辑) | service/system/security_session.go:17-68 等 | 三轮 | -| F-11 | biz 接口嵌入透传 12 处 usecase(API/Token/LogViewer/Audit/AuditRecorder/Authority/Dictionary/Export/Media/Parameter/Permission/Position/Version),与同包 9 个私有字段风格并存 | biz/system/* | 二轮 | -| F-12 | `openWithDriver` 单调用点便捷转发(已拆出 openWithDriverConfig;wrapper 仍有 1 个生产调用 + 约 20 处测试调用) | data/database.go:134-136(生产调用 :248) | 一轮(三轮复核部分修复) | +| 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 | 四轮 | ## 五、过分拆分 / 文件组织 -**根因模式三条**【三轮】:①零逻辑 usecase 壳(wire 强制每域一个构造器放大);②"每资源 N 文件"机械切分(dto+biz+service+handler+router 各一个);③为 import 美观引入的中间缝合包/门面。 +**根因模式三条**【三轮】:①零逻辑 usecase 壳(wire 强制每域一个构造器放大);②"每资源 N 文件"机械切分;③为 import 美观引入的中间缝合包/门面。 | # | 问题 | 位置 | 轮次 | |---|------|------|------| -| S-1 | biz/system 38 文件 3430 行,其中 17 个非测试文件 <60 行(合计约 575 行,保守可归并 8-10 个文件):errors.go(14)、cache.go(15)、maintenance.go(19)、actor.go(19)、access_control.go(20)、storage.go(26)、data_scope.go(27)、upload_session.go(38)、parameter.go(33)、settings.go(43)、department.go(46)、position.go(48)、token.go(48)、media_metadata.go(53)、email.go(48)/api_token.go(55)/version.go(57)。合并建议:actor/data_scope→authority;cache→security;storage/media_metadata/upload_session→media 域;department+position→organization;token→authentication;errors 集中 | biz/system/* | 三轮 | -| S-2 | SystemConfigService 一型拆四文件:system.go(19)+system_config.go(26)+system_init.go(38)+system_info.go(42)=125 行;audit.go 与 audit_error.go 同属 AuditService/AuditRecorder 可合并(audit_log_file.go 是独立 LogViewerService,保留) | service/system/* | 三轮 | -| S-3 | router 22 文件 464 行,平均 21 行/文件(最小 email.go 12 行);routes.go:18-43 手工 21 连调——纯注册碎片无内聚(与 X-1 表驱动合并一并解决) | server/router/* | 三轮 | -| S-4 | 单符号包/微文件:modules/surface 整包只有 1 个 10 行函数;data/provider 整包只有 1 个 7 行 2 方法接口(中性缝可辩护);provider.go+providers.go 双小文件模式 ×4 子包(8 文件可并 4);bootstrap.go(27 行仅 seed 用);worker/worker.go(7 行)、initialize/provider.go(5 行)、pkg/task/provider.go(3 行)、biz/payment/provider.go(8 行) 纯 wire-set 微文件;data_scope_record.go 整文件 1 行别名 | 各处 | 三轮 | -| S-5 | 巨微两极:biz/payment/payment.go 单文件 1150 行(usecase+常量再导出+5 validator+指纹工具)vs 同域 payment_log.go(32)/provider.go(8);dto authentication.go(8 行)/email.go(7 行) vs settings.go(170 行横跨四域) | biz/payment、service/dto | 三轮 | -| S-6 | dto 包文件组织混乱:system.go 混装 Login/User/ServerInfo 三域;settings.go 横跨 Dictionary/SystemParameter/APIToken/SecurityConfig 四域(名不副实) | service/dto/system.go:5-133;settings.go:5-170 | 一轮 | -| S-7 | data 层组织纪律:转换函数命名四种风格违反 new/toBiz 契约(FromPO×16/ToPO×4/ToBiz×2/new×2);PO 分布无规则(models.go 集中 6 个+散落 30+,models.go:98-100 还混 repo 声明);audit.go 名不副实(只有构造器,实现在 5 个文件);runtime.go 拼盘(settings+tokenIssuer 无关联);转换函数跨文件错位(dictionary.go 的 parameterFromPO 服务 parameter.go) | data/system/* | 二轮 | -| S-8 | media 域同域碎片:biz/system 的 media.go(158)/media_upload.go(268)/media_metadata.go(53)/upload_session.go(38) 拆四文件 | biz/system/media* | 三轮 | -| S-9 | 单方法 handler 各占结构体+构造器+Set 字段:Session/Navigation 可并入相邻资源 handler(Set 已膨胀到 23 字段、provider 23 个构造器) | server/handler/session.go、navigation.go、set.go:3-26 | 一轮 | +| S-1 | biz/system 33 个非测试文件,17 个 <60 行微文件群仍在(errors 14/cache 15/maintenance 19/actor 19/access_control 20/storage 26/data_scope 27 等,合计约 575 行,可归并 8-10 个文件) | biz/system/* | 三轮(四轮复核未动) | +| S-2 | SystemConfigService 一型仍四文件:system.go(19)+system_config.go(26)+system_init.go(38)+system_info.go(23) | service/system/* | 三轮(四轮复核未动) | +| S-3 | router 22 文件 464 行平均 21 行/文件;routes.go 手工 21 连调(与 X-1 一并解决) | server/router/* | 三轮 | +| S-4 | provider.go+providers.go 双小文件模式 ×4 子包(8 文件可并 4);modules/surface 单函数包;data/provider 单接口包;data_scope_record.go 单行别名文件 | 各处 | 三轮 | +| S-5 | 巨微两极:biz/payment/payment.go 1150 行 vs 同域微文件;dto 超小文件 vs settings.go 170 行跨四域 | biz/payment、service/dto | 三轮 | +| S-6 | dto 包组织混乱:system.go 混装三域;settings.go 横跨四域 | service/dto/system.go、settings.go | 一轮 | +| S-7 | data 层组织纪律:转换函数命名四种风格;PO 分布无规则;audit.go 名不副实;runtime.go 拼盘 | data/system/* | 二轮 | +| S-8 | media 域同域四文件 | biz/system/media* | 三轮 | +| S-9 | 单方法 handler 各占结构体+Set 23 字段 | server/handler/session.go、navigation.go、set.go | 一轮 | -## 六、包归属问题(应移 pkg / internal / utils) +## 六、包归属问题 -### 6.1 移动建议清单【三轮汇总】 - -| # | 从 | 到 | 理由 | 影响 | -|---|---|---|---|---| -| P-1 | pkg/httpx | internal/server/httpx | 消费者 100% 在 internal/server 7 文件;中文文案+x-token cookie 是本项目契约;logging/source.go:59 已预留该路径 marker | 7 文件 | -| P-2 | pkg/logging | internal/logging | source.go:47-69 硬编码本仓库 internal 路径;zap.go:366-382 中文文案;模块路由硬编码服务日志查看器语义;AGENTS.md 声称的 internal/logging 不存在 | 约 5 文件 | -| P-3 | pkg/module | internal/modules | Menu/API/Surface/TimedTask 是本项目模块系统契约;import gin;15 消费者全在本仓库 | 15 文件 | -| P-4 | pkg/paymentkit 渠道常量(provider.go) | internal/biz/payment 本体 | 常量归位并删 biz/payment/payment.go:20-43 的 17+2 个别名转发层;目录更名 paymentkit 对齐包名(当前目录名≠包名);通用工具(signing/json/amount/xml)可留 pkg 或并入 internal/utils | 约 8 文件 | -| P-5 | pkg/mq + pkg/websocket(Hub) | 收缩或合并进 internal/integration/mq | 全链零业务消费者(见 2.2);保留 TestConfig 探测+基础驱动,删 Registry/Client()/namedClient/legacy API/Hub/ApplySubscriptions | 约 7 文件 | -| P-6 | integration/mq 与 integration/websocket 的重复 JSON map helper | internal/utils/jsonvalue | 上收去重(逐字符相同) | 2-3 文件 | -| P-7 | storage:aws-sdk-v2 栈与 minio-go 栈双 S3 实现并存 | 统一 S3 兼容单栈(可选) | qiniu/aliyun/huawei/tencent 原生 SDK 均有 S3 兼容端点,可收敛删 4 实现+s3 死分支 | 5-6 文件 | -| P-8 | handler/query.go:13-61(Gin 工具)、middleware/request.go:67-102(纯函数)、handler/announcement.go parseTime(被 export.go 跨域借用) | pkg 或 internal/utils 候选 | 通用无状态逻辑上收 | 3 文件 | - -### 6.2 其他归属/职责越界【一/二轮】 - -| # | 问题 | 位置 | -|---|------|------| -| P-9 | internal/initialize/configuration.go:management* 家族(:221-318)手工构造 camelCase JSON,属 service 层 DTO 塑形职责;根因是 biz InitializationRepo 以 json.RawMessage 为出入参,表现形状泄漏进 repo 层;464 行混杂 DTO 塑形/JSON 规范化/秘密掩码三种职责 | initialize/configuration.go | -| P-10 | pkg/database/pagination、gormkit:调用方 100% 在 internal/data(无业务语义,轻度) | pkg/database/* | -| P-11 | pkg/mq:rabbitmq.go:127 硬编码项目名前缀 `kra-`;subscription.go 的 Contributor 面向本项目 module 概念 | pkg/mq | -| P-12 | newReloadableDB 在通用热切换构造器里调用领域函数 registerDataScopeCallbacks,越界(应移到 data.go 与 replacePrimaryDB 同址) | data/runtime_clients.go:168-171 | -| P-13 | pkg/module、pkg/task、pkg/database/migration:互为依赖构成同层契约组,单独搬会破坏依赖方向一致性——维持现状 | pkg/* | +| # | 问题 | 位置 | 轮次 | +|---|------|------|------| +| 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-15 | 包移动注释漂移:http.go:2、response.go:59 仍写 "pkg/httpx";zap.go:29 写 "pkg/logging";source.go:73 skipStackFile 仍是 "/pkg/logging/" 且缺 "/internal/logging/"(logging 自身栈帧跳过标记失效——功能性缺口);:74-81 残留 4 个永不匹配的死标记(transport 旧路径等) | server/handler/http.go:2、server/httpx/response.go:59、internal/logging/zap.go:29、source.go:73-81 | 四轮(P-2 移动残留) | +| P-16 | CLAUDE.md 结构描述整体过时(描述 api/、internal/global/ 等不存在目录),与 AGENTS.md 不同步 | CLAUDE.md:9-17 | 四轮 | ## 七、分层 / 职责违规 | # | 问题 | 位置 | 轮次 | |---|------|------|------| -| L-1 | data 直依赖 integration:data/payment/payment.go:17 import kra/internal/integration/payment;paymentRepo 实为"配置读取+适配器编排+ack 组装"的编排层(Create/Query/Refund/HandleCallback 四方法无一行 DO↔PO 转换,真正仓储职责全在 paymentOrderRepo);TestProvider 是 145 行业务编排(本地建单、500ms×3 重试轮询、退款闭环)长在 data 层;还自建第二 paymentOrderRepo 实例(:91)绕过 wire 单一构造点 | data/payment/payment.go | 二轮 | -| L-2 | service 层混入业务/存储细节:system_init.go:13-26 DSN/驱动连接串与回退规则;system_info.go:14-41 直连 gopsutil 采集(每请求阻塞 200ms);export_excel.go:63-71 SQL 别名/前缀归一;api_token.go:28-42 发币编排与到期规则;user.go:36-37,85-96 密码策略编排;api.go:60-82 Groups 分组推导;version.go:184-221 导入导出全编排 | service/system/* | 二轮 | -| L-3 | biz DO 带 json 标签:PaymentResult/PaymentTestResult(死标签,service 逐字段转 dto);PaymentRequest(把指纹编码格式锚死在 DO);biz/integration 的 Definition/Field/Option 家族直接充当前端契约(dto/integration_config.go:21 内嵌 biz 类型,biz 事实上兼任 DTO 提供方) | biz/payment/payment.go:59-204;biz/integration | 二轮 | -| L-4 | DO 兼过滤器:API.OrderKey/Desc/StrictAll、SystemParameter/ExportTemplate.StartCreatedAt/EndCreatedAt 混入实体 | biz/system/api.go:16-18、parameter.go:16-17、export.go:30-31 | 二轮 | -| L-5 | middleware 硬编码业务语义:error_audit.go:70-77 靠中文消息黑名单判断是否审计(改文案即改审计行为);error_audit.go:25,55-67 硬编码业务路径;access_log.go:82-203 支付回调专用逻辑内嵌通用中间件;rate_limit.go:33-51 限流策略参数内联且挂在全局链却只匹配两个 public 路由 | server/middleware/* | 二轮 | -| L-6 | biz 契约泄漏存储/表现原语:QueryExport 返回 []map[string]any;LogViewer 的文件读取器细节;export DO 字面携带 SQL/Join/Table 片段(Export 域整体是查询引擎不是领域逻辑,应下沉 data);UserOptions {Label,Value} UI 形状进 biz;AuthenticationResult 携带含密码哈希的完整 User | biz/system/* | 二轮 | -| L-7 | data 层纪律:Table("字符串") 绕过已有 PO 7 处;saveRelations 回写入参 DO 约 10 处;OriginSetting 裸 string↔map 转换与 gormkit.JSON 封装并存 | data/system/user.go:58,70,88,231,259 等 | 二轮 | -| L-8 | 编排类文件过重:seedSystem 单函数 126 行 10 类职责;authority.go 834 行四类职责(CRUD/严格权限引擎被 4 文件 15+ 处借用/DataScope 域解析/用户-角色关联,权限引擎应独立 accessGuard);BuildVersionBundle 105 行五职责;migrations.go 两个通信 surface 迁移互为重复子集 | data/system/seed.go:30-156、authority.go、version.go:76-181、migrations.go:36-173 | 二轮 | -| L-9 | dto 契约问题:ID 类型三处分叉(int/uint/string 混用迫使 handler 转换);AuthorityResponse.DeletedAt 泄漏且破坏全库 json:"-" 约定;ErrorRecordMutationRequest 一半指针一半值不自洽;DTO 反向依赖 biz 类型 | service/dto/* | 二轮 | -| L-10 | 错误体系:双体系(errors.go 仅 3 个 kratos 类型错误,其余 stdlib errors.New 散落 13+ 文件,无 reason 码;token.go:35-42 另有 6 个分散);错误包装 Error+Unwrap 与 Error+Is 两机制混用;PasswordPolicyError 类型定义在 service | biz/system/errors.go 等 | 二轮 | -| L-11 | List 契约三种风格并存(过滤结构体内含分页/位置参数+指针 DO/裸标量 5-6 参);data 分页三风格(pagination.ApplyRequired/Apply/手写 Limit-Offset),手写版 page=0 产生负 offset(position.go:89、export.go:208-210 无防护) | biz+data 多处 | 二轮 | -| L-12 | 校验双轨制:handler 手工 if 校验与 dto binding 标签并存(user.go:26-64 手工 6 字段 vs dto.UserRequest 无标签);同类资源一半 binding、一半手工、一半裸奔 | server/handler/* + service/dto/* | 一/三轮 | +| L-3 | biz DO 带 json 标签:PaymentResult/PaymentTestResult(死标签);PaymentRequest(指纹编码格式锚死 DO);biz/integration Definition 家族充当前端契约 | biz/payment/payment.go:59-204;biz/integration | 二轮 | +| L-4 | DO 兼过滤器:API.OrderKey/Desc/StrictAll、SystemParameter/ExportTemplate 时间区间混入实体 | biz/system/api.go、parameter.go、export.go | 二轮 | +| L-5 | middleware 硬编码业务语义:中文消息黑名单判断审计(改文案即改审计行为);业务路径硬编码;支付回调专用逻辑内嵌通用中间件;限流策略内联 | server/middleware/* | 二轮 | +| L-6 | biz 契约泄漏存储/表现原语:QueryExport 返回 []map[string]any;export DO 携带 SQL 片段;UserOptions UI 形状;AuthenticationResult 携带密码哈希 | biz/system/* | 二轮 | +| 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-9 | dto 契约问题:ID 类型三处分叉;AuthorityResponse.DeletedAt 泄漏;ErrorRecordMutationRequest 指针/值不自洽;DTO 反向依赖 biz 类型 | service/dto/* | 二轮 | +| 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/* | 一/三轮 | ## 八、简单实现复杂化 | # | 问题 | 位置 | 轮次 | |---|------|------|------| -| C-1 | payment Create 过度防御:先拷 9+1 字段逐一回比+Extra 双次 JSON 序列化,同一不可变保证指纹层(RequestFingerprint+落库后二次比对)已做两道 | biz/payment/payment.go:385-402,898-907,588-590 | 二轮 | -| C-2 | loadUser/loadUsers 双实现(单实体 6 次串行查询 vs 批量实现,可复用省约 45 行) | data/system/user.go:53-97,115-218 | 二轮 | -| C-3 | JWT 签名双重检查(validateSigningOptions 后 signToken 再查一遍) | data/system/token.go:34-51 | 二轮 | -| C-4 | `*Data` 方法约 10 处模板式 `if d == nil` 防御(构造归 Wire 管理);NewIntegrationRuntime 的 nil→空 Store 回退掩盖错误状态;中间件 nil 防御四处(gin.go:28-30 已兜底,access/access_log/audit/cors 仍各自检查) | data/data.go:41,72,82,88,97,119,126,373-375;middleware/* | 一/二轮 | -| C-5 | public.go captchaConfig 恒真分支与无效首调用 | server/handler/public.go:29-43 | 二轮 | -| C-6 | 媒体上传三重大小防御(limitMultipartBody+rejectMediaTooLarge+header.Size 检查,三重中两重冗余) | server/handler/media.go:38-50,226-240 | 二轮 | -| C-7 | emqx.go 恒真 ctx 判断(:561-569,592-600);websocket 双层 handler 登记(两层各持四份列表互相重放,且都为空) | integration/mq/emqx.go;integration/websocket | 二轮 | -| C-8 | 单实现接口:PaymentLogger(仅为包装 *slog.Logger);DictionaryRepo/MediaRepo 组合式子接口无独立消费方 | biz/payment/payment_log.go:10-32;biz/system | 二轮 | -| C-9 | dictionary.go Tree 解析结果被丢弃(byType 分支不用 id/parseErr) | server/handler/dictionary.go:211-213 | 二轮 | -| C-10 | mq 体系接口面积翻倍且恶化(见 2.2);mq.Registry 双 API 并存 | pkg/mq/mq.go:66-83;emqx.go:539-589 | 一轮(三轮复核恶化) | +| 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 | 四轮 | ## 九、结构性设计(大动作需决策) | # | 问题 | 位置 | 轮次 | |---|------|------|------| -| X-1 | router 与 routecatalog 双声明:21 个 router 文件纯声明式注册 method+path;routecatalog 又用一张 195 条 map 声明同一批路由的元数据,靠契约测试强制对齐——改一条路径要同时改两处。表驱动合并为单一声明源可同时消掉对齐测试 | server/router/*;routecatalog/catalog.go:41-236 | 一轮 | -| X-2 | 新增一个资源实际要触碰 7 处:dto、service、handler、router 资源文件、routes.go、routecatalog、Set/provider | — | 一轮 | -| X-3 | 同一份集成配置三种形状两条通道:config.Storage 强类型 ↔ sys_integration_configs JSON 行 ↔ 运行时客户端;storage/email 走 config.Store(email 即时读快照、storage 手动 Replace),mq/websocket 走 runtimeconfig 订阅——同一"集成"概念两套配置源两种重载模式 | data/integration_config.go:43-104,157-179;data/integration/runtime.go:13-29 | 一轮(三轮细化) | -| X-4 | swagger 运行时文档:约 160 行 map 手拼 Swagger 2.0 JSON+正则加工,全局单例仍在 server 根包 | server/swagger.go:21-159 | 一轮 | -| X-5 | 错误日志热路径做磁盘 IO+go/parser AST 解析(每条 Error 日志触发 os.ReadFile+parser.ParseFile,无缓存) | pkg/logging/source.go:71-103;zap.go:373-379 | 一轮(三轮复核仍在) | -| X-6 | local 存储两套入口:staticfiles 直读 config 本地盘语义 vs integration/storage 的 Reloadable 体系 | server/staticfiles/staticfiles.go:20-47;integration/storage/local.go | 一轮 | -| X-7 | gopay.go/gopay_helpers.go 名实相反:gopay.go 是窄基座(4 个函数,:12-13 注释过时);gopay_helpers.go 是杂物间(7 类职责混装,渠道专属谓词/状态机应下沉各渠道文件,公共函数 mergeMap 反而散在 alipay.go) | integration/payment/* | 二轮 | -| X-8 | vendor.go:金额拆分 14 键配置 DSL 疑似投机通用性(无内置默认使用);通用渠道定义 18 字段全 Required=true(不用退款的商户也被迫配置退款地址) | integration/payment/vendor.go:170-238;biz/integration/integration_config_definition.go:58-77 | 二轮 | -| X-9 | 集成配置双轨制(同 X-3);config 热重载双通道:fsnotify watchLoop 只 Replace 快照不重建 DB/Redis/Mongo 客户端,全量重载只能手动 POST 触发——行为不透明 | config/runtime.go:263-287;data/config_store.go:127 | 三轮 | -| X-10 | payment 域新增一个渠道需改 4 处(paymentkit 常量+biz 再导出+adapter 工厂+配置定义)散弹式修改 | paymentkit/provider.go:4-30 等 | 三轮 | -| X-11 | TaskScheduler 的 6 把锁与 dispatch async 参数:每处均有并发正确性注释论证,是真实并发需求的代价——不建议改,仅记录 | worker/task_scheduler.go:24-36,260-283 | 二轮 | +| X-1 | router 与 routecatalog 双声明(表驱动合并为单一声明源) | server/router/*;routecatalog/catalog.go | 一轮 | +| X-2 | 新增资源触碰 7 处 | — | 一轮 | +| X-3 | 集成配置三形状两通道(storage/email 走 config.Store,mq/websocket 走 runtimeconfig) | data/integration_config.go 等 | 一轮 | +| 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-9 | config 热重载双通道:watchLoop 只换快照不重建客户端,全量重载需手动触发 | config/runtime.go:263-287 | 三轮 | +| X-10 | payment 新增渠道需改 4 处散弹式修改 | biz/payment 常量+adapter 工厂+配置定义 | 三轮 | +| X-11 | TaskScheduler 多锁【真实并发需求,仅记录不改】 | worker/task_scheduler.go | 二轮 | ## 审查后认为合理、不建议改动的部分 -- **modules 与 routecatalog 分离**:启动期模块装配 vs 请求期热路径策略查询,消费方零重叠,仅合理单向依赖 -- **Provider 接口缝模式**(子包不反向 import 根 data 避免成环+测试假 Data):模式正当;provider.Database 中性接口(P1-1 修复)设计合理,唯 system 包内 Provider(5 方法)与 DatabaseProvider(别名)两个近义缝命名易混淆,建议注释互指 -- **config.Store(YAML 文件)与 runtimeconfig.Store(DB 集成表)职责分离**:正确,仅 pub-sub 同构待决策(D-9) -- **utils/routepath 与 routecatalog**:互补非重复;utils 整体纪律良好(routepath/uploadpolicy 均无状态多域共用) -- **worker→biz 正向依赖+biz 经 TaskRuntime/TaskReloader 接口反向倒置**:任务链路最规范的一段 -- **data 根多文件同包共享 Data 状态与重载锁**:符合 data/README 既定约定 -- **apple_jws.go 的 CheckSignatureFrom+OID 检查**:经 gopay 源码核对是补真实漏洞(gopay 不验证叶证书由中间证书签发),必要;仅单一根指纹需运维轮换预案注释 -- **capture/auth 等中间件质量**:capture 作为唯一请求/响应捕获点、auth singleflight 防取消传染,有据可依 +- **modules 与 routecatalog 分离**:启动期装配 vs 请求期热路径,消费方零重叠 +- **Provider 接口缝模式** + provider.Database 中性接口:正当(system 包内 Provider 与 DatabaseProvider 两个近义缝命名易混淆,建议注释互指) +- **config.Store 与 runtimeconfig.Store 分离**:正确(D-9 词表同构为已知保留项) +- **utils/routepath、uploadpolicy**:纪律良好 +- **worker→biz 正向+接口倒置**:任务链路最规范的一段 +- **data 根多文件同包**:符合 data/README 约定 +- **apple_jws.go 证书链校验**:必要安全设计(经 gopay 源码核对补真实漏洞);单一根指纹需运维轮换预案注释 +- **capture/auth 中间件质量**:有据可依 +- **新增缝质量(第四轮验证)**:PaymentAdapterFactory(biz 接口+integration 实现+wire 绑定,单向无环)、MergeRuntimeConfig(单点三调用)、deletePrefixViaList(函数式注入,失败关闭正确)、AST 缓存(size+mtime 失效)、systeminfo(integration 定位正确)——均内聚、依赖最小、无越界 import ## 处置建议(按优先级) -1. **先修一节修复回归缺陷**(R-1 资金安全高危优先) -2. **删二节死代码**(纯减法零风险:三层死方法约 15 组+mq/websocket 零消费者机制+零散死码,估算 800+ 行) -3. **做五节过分拆分合并**(透传壳 usecase 12 个、biz/system 小文件归并、SystemConfigService 四合一、provider/providers 双文件 ×4——零行为变更的文件级减法) -4. **执行六节归属移动**(按 P-1~P-8 逐项决策) -5. **消除三节大块重复**(D-1/2/3 三大块估算 900+ 行) -6. **收敛四/七/八/九节结构与分层**(service 根门面、Backend 三层缝合、L 系列归位、X 系列大动作) +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-2/S-4 未动,零行为变更) +5. **D-2/D-19 payment 剩余重复 + L 系列归位**(独立批次) +6. **X-1 路由单源化等大动作**(最后) ---- - -## 第四轮处置结果(2026-08-27) - -本轮按 `2.1`、`2.3`、第三至第九节逐项反查。保持现有 HTTP 路径和 JSON 契约;`2.2` 及其交叉项 D-6、P-5、P-6、P-11、C-7、C-10、`namedClient/Client()` 明确排除。 - -### 已修复 - -- **2.1/2.3 死代码**:删除 Permission 三个旧按钮入口、APITokenRepo 旧禁用入口、UserRepo 两个冗余组合入口、FindMediaByHash、AuthorityMenuIDs、Email.Alert、Security.ActiveTokenMatches、MenuService.List、无消费者 service/DTO 包装及零散支付 helper;测试专用签名/解密 helper 移入 `_test.go`;`TaskScheduler.Trigger` 改为私有。 -- **支付与分层**:data 不再直接 import integration/payment,改由 biz 声明 `PaymentAdapterFactory`、integration 实现;hooks/订单来源/发货注册表改为构造注入;渠道常量与回调 ACK 归 `biz/payment`,通用金额/JSON/签名/XML 工具归 `internal/paymentkit`。 -- **正确性残留**:vendor 退款非 JSON或无业务状态时失败关闭;支付回调读体错误加入 Gin error chain;ErrAuthoritiesRequired 原样透传;版本导入与导入留痕改为单事务;Excel `[]byte` 区分数值和前导零文本;登录日志补 IP/时间筛选;任务描述复用模块元数据。 -- **包归属与门面**:`pkg/httpx`→`internal/server/httpx`、`pkg/logging`→`internal/logging`、`pkg/paymentkit`→`internal/paymentkit`;handler/server 直接依赖具体 service 子包,根 service 仅保留 Wire 聚合;DataScope 回调注册移回 Data 生命周期。 -- **其他减法**:删除 `RegisterAll`、`parseTemplateColumns`、PayPal/WeChat/vendor 等确认无调用函数;`NewGinEngine` 移为测试 helper。S3 构造器分支和 vendor SuperPay 等存在交叉或误报的条目未强行删除。 - -### 核实后保留 - -- APIToken 作废链、Permission SelectedButtons 链、AuditRecorder.CreateErrorRequest、vendor SuperPay、支付内部支付/退款扩展接口均有真实消费者或明确扩展职责,不按死代码删除。 -- 标准 biz usecase、service transport 边界、initialize 编排、config.Store/runtimeconfig.Store 分离、`pkg/module`、`pkg/database`、`internal/data/provider` 属于既定分层契约,保留。 -- F-1 保留 DTO↔DO 转换,只移除无意义转发;D-9、D-11、P-3、P-7、P-10、P-13、F-7、F-9、F-10、C-8、X-6、X-8、X-11 经核实不采用原建议。 - -### 部分完成/后续批次 - -- D-1~D-5、D-7~D-8、D-10、D-12~D-20、L-2~L-12、S-1~S-9、X-1~X-5、X-7、X-9~X-10 中的大范围统一 ListOptions、路由单一声明源、配置通道统一、middleware 全量去重和文件重组未在本轮强行完成;这些变更耦合面大,应拆分为独立批次并逐批验证。 -- L-1 已消除 data→integration/payment 直接依赖,但支付连通性测试编排仍位于 data/payment,后续可迁至 biz 应用流程。 -- F-4 handler/http 便捷门面和部分 payment micro-shim 暂留;外部契约不变,后续可在独立机械重构中清理。 - -## 已修复并经复查确认(第三轮逐条验证,2026-08-27) - -> 以下条目经第三轮 5 路代理逐条核查属实(含编译验证通过),从上方问题清单移除,此处留痕。复查中发现的部分修复残留已回升为一节 R 系列条目。 - -### 第一轮问题(P 系列) - -- P0-1:pkg/protoutil 整包删除 -- P0-2:paymentkit WechatV2Sign 死实现删除 -- P0-3:Data 的 MongoClient/NamedDatabases/NamedRedisClients 三死方法删除 -- P0-4:config Bootstrap 别名删除 -- P0-5:静态任务方法注册链删除(重建为活的 TimedTasks 单源链路,含防回归测试) -- P0-6:TaskScheduler.Schedule(task) 删除(生产与测试均改用 ScheduleID) -- P1-1:中性 internal/data/provider.Database 接口抽取(task/security 两处仅剩一行别名) -- P1-2:任务种子单源化(migrations fallback 删除,只来自 module catalog) -- P1-3:text/firstAny 收敛到 paymentkit.Text/FirstText(残留 micro-shim 见 2.3) -- P1-4:menu handler 校验提取 validateMenuRequest -- P1-5:surface.APIsForPrefix 提取(含契约测试) -- P1-6:persistConfig Locked 变体删除,锁逻辑单点化 -- P1-7:NewData/reloadConfig 均改用 rollback 收集器(commit/run 语义核对无误) -- P1-10:websocket snapshotHandlers 泛型收敛 -- P4-4:AGENTS.md 更新为真实栈(Gin+手写 DTO+Wire) - -### 第二轮 S0 正确性/安全缺陷(17 条全部修复,含回归验证) - -- S0-1:vendor 通用渠道退款业务状态解析+失败回归测试(残留缺口见 R-1) -- S0-2:登录日志独立 LoginLogFilter+*bool 三态筛选(status=false 正确过滤) -- S0-3:公告/媒体导入 UserID 从 claims 注入,DTO 移除可伪造字段 -- S0-4:客户端时间戳改为服务端生成(ErrorRecord/Menu/Parameter/Export 均覆盖) -- S0-5:审计中间件 x-user-id 头回退删除+CORS 白名单收紧(含防回归测试) -- S0-6:export 统一安全查询构建器(标识符/Join/条件/排序校验+禁任意 SQL,biz/data 双保险,BETWEEN 值参数化) -- S0-7:ListAuthorities 委托统一权限入口 -- S0-8:版本导入 JSON/Save 阶段错误返回 -- S0-9:用户服务失败关闭(安全配置读取错误即返回) -- S0-10:SecurityConfig 未就绪只返回 nil+error -- S0-11:限流文案加入预期客户端失败白名单(闭环匹配) -- S0-12:支付回调 service 错误加入 Gin error chain(读体分支残留见 R-7) -- S0-13:Excel 文本保真("007" 保持 string,含测试;副作用见 R-2) -- S0-14:Apple JWS 固定 Apple Root CA G3 真实指纹(经官方根证书清单核实)+完整链校验(叶←中←根+CA 约束+OID+ES256)+签名时刻验证 -- S0-15:SetUserAuthorities 空列表改 ErrAuthoritiesRequired(BadRequest) -- S0-16:OriginSetting JSON 解析错误传播(单条/批量/关联加载) -- S0-17:cache.Incr Redis 原子 Lua 补 TTL(仅无 TTL 的 key 补,语义正确;内存实现同步修复) - -### 第二轮 S4 两条 - -- export QueryExport 双重 JSON 解析 → 单次严格列解析 -- saveRelations 三布尔参数 → 单 replace 参数 diff --git a/internal/biz/system/media_test.go b/internal/biz/system/media_test.go index b8daef3..c34db69 100644 --- a/internal/biz/system/media_test.go +++ b/internal/biz/system/media_test.go @@ -135,14 +135,26 @@ type mediaUploadRepo struct { chunks []*UploadChunk claimed bool failed bool + err error } func (r *mediaUploadRepo) FindUploadSession(context.Context, uint) (*UploadSession, error) { + if r.err != nil { + return nil, r.err + } if r.session == nil { return nil, ErrUploadSessionNotFound } return r.session, nil } + +func TestSaveChunkPreservesUploadRepositoryErrors(t *testing.T) { + want := errors.New("database unavailable") + uc := NewMediaUsecase(&mediaUploadRepo{err: want}, &mediaLimitStorage{}, mediaLimitSettings{max: 10}) + if err := uc.SaveChunk(context.Background(), 1, 7, 0, md5Text("abc"), strings.NewReader("abc")); !errors.Is(err, want) { + t.Fatalf("SaveChunk() error = %v, want %v", err, want) + } +} func (r *mediaUploadRepo) ClaimUploadSession(context.Context, uint) (bool, error) { return r.claimed, nil } diff --git a/internal/biz/system/media_upload.go b/internal/biz/system/media_upload.go index d0caff3..ea7507d 100644 --- a/internal/biz/system/media_upload.go +++ b/internal/biz/system/media_upload.go @@ -58,6 +58,17 @@ func expectedChunkSize(session *UploadSession, index int) (int64, error) { return last, nil } +func (uc *MediaUsecase) uploadSession(ctx context.Context, id uint) (*UploadSession, error) { + session, err := uc.FindUploadSession(ctx, id) + if errors.Is(err, ErrUploadSessionNotFound) { + return nil, fmt.Errorf("上传会话不存在: %w", ErrUploadSessionNotFound) + } + if err != nil { + return nil, err + } + return session, nil +} + func (uc *MediaUsecase) chunkPrefix(uploadID uint) string { directory := "uploads/chunks" if uc.settings != nil { @@ -117,9 +128,9 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash return session, nil, indices, nil } func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, index int, expected string, reader io.Reader) error { - session, err := uc.FindUploadSession(ctx, uploadID) + session, err := uc.uploadSession(ctx, uploadID) if err != nil { - return errors.New("上传会话不存在") + return err } if session.UserID != userID { return errors.New("无权操作该上传") @@ -173,9 +184,9 @@ func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, in return nil } func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uint, mime string) (*MediaFile, error) { - session, err := uc.FindUploadSession(ctx, uploadID) + session, err := uc.uploadSession(ctx, uploadID) if err != nil { - return nil, errors.New("上传会话不存在") + return nil, err } if session.UserID != userID { return nil, errors.New("无权操作该上传") @@ -241,9 +252,9 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin return media, nil } func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint) error { - session, err := uc.FindUploadSession(ctx, uploadID) + session, err := uc.uploadSession(ctx, uploadID) if err != nil { - return errors.New("上传会话不存在") + return err } if session.UserID != userID { return errors.New("无权操作该上传") diff --git a/internal/biz/system/system_info.go b/internal/biz/system/system_info.go new file mode 100644 index 0000000..28a94a2 --- /dev/null +++ b/internal/biz/system/system_info.go @@ -0,0 +1,30 @@ +package system + +import "context" + +type ServerDiskInfo struct { + MountPoint string + UsedMB uint64 + UsedGB uint64 + TotalMB uint64 + TotalGB uint64 + UsedPercent int +} + +type ServerInfo struct { + GOOS string + NumCPU int + Compiler string + GoVersion string + NumGoroutine int + CPUs []float64 + Cores int + UsedMB uint64 + TotalMB uint64 + UsedPercent int + Disk []ServerDiskInfo +} + +type SystemInfoProvider interface { + Collect(context.Context, []string) (*ServerInfo, error) +} diff --git a/internal/biz/system/system_init.go b/internal/biz/system/system_init.go index 845866b..8cb6ade 100644 --- a/internal/biz/system/system_init.go +++ b/internal/biz/system/system_init.go @@ -50,10 +50,11 @@ type TaskReloader interface { type SystemConfigUsecase struct { repo InitializationRepo tasks TaskReloader + info SystemInfoProvider } -func NewSystemConfigUsecase(repo InitializationRepo, tasks TaskReloader) *SystemConfigUsecase { - return &SystemConfigUsecase{repo: repo, tasks: tasks} +func NewSystemConfigUsecase(repo InitializationRepo, tasks TaskReloader, info SystemInfoProvider) *SystemConfigUsecase { + return &SystemConfigUsecase{repo: repo, tasks: tasks, info: info} } func (uc *SystemConfigUsecase) IsInitialized(ctx context.Context) (bool, error) { @@ -128,3 +129,10 @@ func (uc *SystemConfigUsecase) DiskMountPoints() []string { } return uc.repo.DiskMountPoints() } + +func (uc *SystemConfigUsecase) ServerInfo(ctx context.Context) (*ServerInfo, error) { + if uc == nil || uc.repo == nil || uc.info == nil { + return nil, errors.New("系统信息服务未就绪") + } + return uc.info.Collect(ctx, uc.repo.DiskMountPoints()) +} diff --git a/internal/biz/system/system_init_test.go b/internal/biz/system/system_init_test.go index 41e652a..4d04c24 100644 --- a/internal/biz/system/system_init_test.go +++ b/internal/biz/system/system_init_test.go @@ -37,7 +37,7 @@ func (r taskReloaderStub) Reload(context.Context) error { return r.err } func TestInitializeClassifiesPostCommitTaskReloadFailure(t *testing.T) { reloadErr := errors.New("scheduler unavailable") - uc := NewSystemConfigUsecase(&initializationRepoStub{}, taskReloaderStub{err: reloadErr}) + uc := NewSystemConfigUsecase(&initializationRepoStub{}, taskReloaderStub{err: reloadErr}, nil) err := uc.Initialize(context.Background(), &DatabaseConfig{}) if !errors.Is(err, ErrTaskRuntimeReload) || !errors.Is(err, reloadErr) { t.Fatalf("Initialize() error = %v", err) @@ -45,7 +45,7 @@ func TestInitializeClassifiesPostCommitTaskReloadFailure(t *testing.T) { } func TestReloadConfigClassifiesMissingTaskRuntime(t *testing.T) { - uc := NewSystemConfigUsecase(&initializationRepoStub{}, nil) + uc := NewSystemConfigUsecase(&initializationRepoStub{}, nil, nil) err := uc.ReloadConfig(context.Background()) if !errors.Is(err, ErrTaskRuntimeReload) { t.Fatalf("ReloadConfig() error = %v", err) @@ -53,7 +53,7 @@ func TestReloadConfigClassifiesMissingTaskRuntime(t *testing.T) { } func TestSystemConfigUsecaseRejectsMissingRepository(t *testing.T) { - uc := NewSystemConfigUsecase(nil, nil) + uc := NewSystemConfigUsecase(nil, nil, nil) if _, err := uc.IsInitialized(context.Background()); err == nil { t.Fatal("IsInitialized accepted a missing repository") } diff --git a/internal/config/clone.go b/internal/config/clone.go index 05b77bf..59f59b9 100644 --- a/internal/config/clone.go +++ b/internal/config/clone.go @@ -10,3 +10,44 @@ func CloneAdmin(value *Admin) *Admin { return cloneAdmin(value) } func CloneDatabase(value *Database) *Database { return clonePtr(value) } func CloneStorage(value *Storage) *Storage { return cloneStorage(value) } func CloneEmail(value *Email) *Email { return clonePtr(value) } + +// MergeRuntimeConfig applies the runtime replacement contract shared by file +// watching and management updates. Omitted top-level sections keep their +// current values, while database-backed storage/email settings are never +// erased merely because they are absent from config.yaml. +func MergeRuntimeConfig(current, next *Config) *Config { + merged := cloneConfig(next) + if merged == nil { + merged = &Config{} + } + if current == nil { + if merged.Admin == nil { + merged.Admin = &Admin{} + } + return merged + } + if merged.Server == nil { + merged.Server = clonePtr(current.Server) + } + if merged.Data == nil { + merged.Data = cloneData(current.Data) + } + if merged.Admin == nil { + merged.Admin = cloneAdmin(current.Admin) + } + if merged.Admin == nil { + merged.Admin = &Admin{} + } + if current.Admin != nil { + if merged.Admin.Storage == nil { + merged.Admin.Storage = cloneStorage(current.Admin.Storage) + } + if merged.Admin.Email == nil { + merged.Admin.Email = clonePtr(current.Admin.Email) + } + if merged.Admin.ConfigPath == "" { + merged.Admin.ConfigPath = current.Admin.ConfigPath + } + } + return merged +} diff --git a/internal/config/document.go b/internal/config/document.go index c1d764d..889123b 100644 --- a/internal/config/document.go +++ b/internal/config/document.go @@ -45,6 +45,13 @@ func (d *Document) Set(key string, value any) error { // Delete removes the nested key path if present. func (d *Document) Delete(keys ...string) { deleteYAMLMapping(&d.node, keys...) } +// DeleteIntegrationConfig removes settings owned by the database-backed +// integration store rather than by config.yaml. +func (d *Document) DeleteIntegrationConfig() { + d.Delete("admin", "storage") + d.Delete("admin", "email") +} + // Has reports whether the nested key path exists. func (d *Document) Has(keys ...string) bool { return yamlMappingValue(&d.node, keys...) != nil } diff --git a/internal/config/runtime.go b/internal/config/runtime.go index ef3ecdf..503253f 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -268,23 +268,7 @@ func (r *Store) watchLoop(watcher *fsnotify.Watcher, stop <-chan struct{}, done // take down a running process or publish invalid state. continue } - current := r.Snapshot() - if next.Admin == nil { - next.Admin = &Admin{} - } - // Storage and Email move to database-backed integration settings after - // first initialization. External config file edits must not erase the - // active values when those sections are absent from config.yaml. - if current != nil && current.Admin != nil { - if next.Admin.Storage == nil { - next.Admin.Storage = cloneStorage(current.Admin.Storage) - } - if next.Admin.Email == nil && current.Admin.Email != nil { - email := *current.Admin.Email - next.Admin.Email = &email - } - } - r.Replace(next) + r.Replace(MergeRuntimeConfig(r.Snapshot(), next)) case _, ok := <-errors: // fsnotify errors are intentionally non-fatal; the watcher remains // useful for subsequent events and Close always terminates it. diff --git a/internal/config/runtime_test.go b/internal/config/runtime_test.go index a5aebcf..44ec1db 100644 --- a/internal/config/runtime_test.go +++ b/internal/config/runtime_test.go @@ -145,3 +145,26 @@ func TestStoreWatchPreservesDatabaseBackedIntegrationSettings(t *testing.T) { } t.Fatal("watcher did not publish the changed file") } + +func TestMergeRuntimeConfigPreservesOmittedSections(t *testing.T) { + current := &Config{ + Server: &Server{HTTP: &ServerHTTP{Addr: ":8080"}}, + Data: &Data{Database: &Database{Source: "current-dsn"}}, + Admin: &Admin{ConfigPath: "config.yaml", Storage: &Storage{Type: "local"}, Email: &Email{Host: "smtp.example.com"}}, + } + next := &Config{Admin: &Admin{App: &App{AppID: "next"}}} + merged := MergeRuntimeConfig(current, next) + if merged.Server == nil || merged.Server.HTTP == nil || merged.Server.HTTP.Addr != ":8080" { + t.Fatalf("server was not preserved: %#v", merged.Server) + } + if merged.Data == nil || merged.Data.Database == nil || merged.Data.Database.Source != "current-dsn" { + t.Fatalf("data was not preserved: %#v", merged.Data) + } + if merged.Admin.Storage == nil || merged.Admin.Storage.Type != "local" || merged.Admin.Email == nil || merged.Admin.Email.Host != "smtp.example.com" { + t.Fatalf("integration settings were not preserved: %#v", merged.Admin) + } + merged.Admin.Storage.Type = "changed" + if current.Admin.Storage.Type != "local" { + t.Fatal("MergeRuntimeConfig leaked mutable storage state into current config") + } +} diff --git a/internal/data/config_store.go b/internal/data/config_store.go index bd590dc..45d86cf 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -48,8 +48,7 @@ func (d *Data) persistConfigValues(dataConfig *config.Data, adminConfig *config. if err = document.Set("admin", fileAdmin); err != nil { return err } - document.Delete("admin", "storage") - document.Delete("admin", "email") + document.DeleteIntegrationConfig() if adminConfig.System != nil { if err = document.SetServerHTTPPort(adminConfig.System.Addr); err != nil { return err @@ -100,8 +99,7 @@ func (d *Data) persistDatabaseConfig(database *config.Database, signingKey strin return err } } - document.Delete("admin", "storage") - document.Delete("admin", "email") + document.DeleteIntegrationConfig() return document.Save() } @@ -119,8 +117,7 @@ func (d *Data) removeIntegrationConfigFromFile() error { if !document.Has("admin", "storage") && !document.Has("admin", "email") { return nil } - document.Delete("admin", "storage") - document.Delete("admin", "email") + document.DeleteIntegrationConfig() return document.Save() } @@ -135,6 +132,7 @@ func (d *Data) reloadConfig(ctx context.Context) error { if err != nil { return err } + next = config.MergeRuntimeConfig(d.Config(), next) if next.Data == nil || next.Data.Database == nil || next.Admin == nil { return fmt.Errorf("data.database and admin configuration are required") } @@ -170,23 +168,12 @@ func (d *Data) reloadConfig(ctx context.Context) error { return fmt.Errorf("reload database migrations: %w", err) } } - legacyStorage := next.Admin.Storage - legacyEmail := next.Admin.Email - currentAdmin := d.runtime.Admin() - if legacyStorage == nil { - if currentAdmin != nil { - legacyStorage = currentAdmin.Storage - } - } - if legacyEmail == nil && currentAdmin != nil { - legacyEmail = currentAdmin.Email - } - storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), legacyStorage) + storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), next.Admin.Storage) if err != nil { return fmt.Errorf("reload storage configuration: %w", err) } next.Admin.Storage = storageConfig - emailConfig, err := dataintegration.ResolveEmailConfig(candidateDB.WithContext(ctx), legacyEmail) + emailConfig, err := dataintegration.ResolveEmailConfig(candidateDB.WithContext(ctx), next.Admin.Email) if err != nil { return fmt.Errorf("reload email configuration: %w", err) } diff --git a/internal/data/database.go b/internal/data/database.go index 55aebac..a2889fb 100644 --- a/internal/data/database.go +++ b/internal/data/database.go @@ -57,7 +57,7 @@ func normalizedDriver(driver string) string { } func databaseDSN(c *config.Database, name string) (string, error) { - if c.Source != "" && c.Host == "" && c.Path == "" { + if c.Source != "" && !databaseHasStructuredFields(c) { return c.Source, nil } driver := normalizedDriver(c.Driver) @@ -118,6 +118,10 @@ func databaseDSN(c *config.Database, name string) (string, error) { return "", fmt.Errorf("unsupported database driver %q", c.Driver) } +func databaseHasStructuredFields(c *config.Database) bool { + return c != nil && (c.Host != "" || c.Port != "" || c.User != "" || c.Password != "" || c.Name != "" || c.Config != "" || c.Path != "") +} + func gormConfig(config *config.Database, appLogger *slog.Logger) *gorm.Config { level := logger.Info switch strings.ToLower(config.LogMode) { diff --git a/internal/data/initialization_backend.go b/internal/data/initialization_backend.go index ce24c9e..352be8c 100644 --- a/internal/data/initialization_backend.go +++ b/internal/data/initialization_backend.go @@ -43,15 +43,11 @@ func refreshDatabaseSource(database *configpkg.Database) error { if database == nil { return nil } - hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" || database.Password != "" || database.Name != "" || database.Config != "" || database.Path != "" - if !hasStructuredConfig { + if !databaseHasStructuredFields(database) { return nil } - previousSource := database.Source - database.Source = "" source, err := databaseDSN(database, "") if err != nil { - database.Source = previousSource return err } database.Source = source @@ -64,25 +60,7 @@ func (d *Data) PersistRuntimeConfig(ctx context.Context, value *configpkg.Config if current == nil { current = &configpkg.Config{} } - next := configpkg.Clone(value) - if next == nil { - next = configpkg.Clone(current) - } - if next.Data == nil { - next.Data = configpkg.CloneData(current.Data) - } - if next.Admin == nil { - next.Admin = configpkg.CloneAdmin(current.Admin) - } - if next.Admin != nil && current.Admin != nil { - if next.Admin.Storage == nil { - next.Admin.Storage = configpkg.CloneStorage(current.Admin.Storage) - } - if next.Admin.Email == nil { - next.Admin.Email = configpkg.CloneEmail(current.Admin.Email) - } - next.Admin.ConfigPath = current.Admin.ConfigPath - } + next := configpkg.MergeRuntimeConfig(current, value) if next.Data == nil { next.Data = &configpkg.Data{} } diff --git a/internal/data/payment/payment.go b/internal/data/payment/payment.go index 9428bf4..da82979 100644 --- a/internal/data/payment/payment.go +++ b/internal/data/payment/payment.go @@ -9,7 +9,7 @@ import ( "fmt" integrationbiz "kra/internal/biz/integration" bizpayment "kra/internal/biz/payment" - "net/url" + "kra/internal/paymentkit" "strconv" "strings" "time" @@ -21,10 +21,11 @@ type paymentRepo struct { data Provider config integrationbiz.PaymentConfigReader factory bizpayment.PaymentAdapterFactory + orders bizpayment.PaymentOrderRepo } -func NewPaymentRepo(data Provider, config integrationbiz.PaymentConfigReader, factory bizpayment.PaymentAdapterFactory) bizpayment.PaymentRepo { - return &paymentRepo{data: data, config: config, factory: factory} +func NewPaymentRepo(data Provider, config integrationbiz.PaymentConfigReader, factory bizpayment.PaymentAdapterFactory, orders bizpayment.PaymentOrderRepo) bizpayment.PaymentRepo { + return &paymentRepo{data: data, config: config, factory: factory, orders: orders} } func (r *paymentRepo) values(ctx context.Context, provider string) (map[string]any, error) { @@ -71,7 +72,7 @@ func (r *paymentRepo) TestProvider(ctx context.Context, provider string) (*bizpa add("config", "failed", err.Error(), "", started) return test, err } - test.Mode = strings.ToLower(strings.TrimSpace(text(values, "environment"))) + test.Mode = strings.ToLower(strings.TrimSpace(paymentkit.Text(values, "environment"))) configStart := time.Now() if err = integrationbiz.ValidateIntegrationConfig(integrationbiz.IntegrationKindPayment, provider, values); err != nil { add("config", "failed", err.Error(), "", configStart) @@ -94,12 +95,16 @@ func (r *paymentRepo) TestProvider(ctx context.Context, provider string) (*bizpa add("adapter", "failed", err.Error(), "", time.Now()) return test, err } + if r.orders == nil { + err = errors.New("支付订单仓储未接入") + add("local_order", "failed", err.Error(), "", time.Now()) + return test, err + } req := paymentTestRequest(provider, values) test.TradeNo = req.TradeNo - orders := &paymentOrderRepo{data: r.data} extra, _ := json.Marshal(req.Extra) localStart := time.Now() - order, _, err := orders.CreatePaymentOrder(ctx, &bizpayment.PaymentOrder{ + order, _, err := r.orders.CreatePaymentOrder(ctx, &bizpayment.PaymentOrder{ TradeNo: req.TradeNo, Provider: provider, BusinessType: req.BusinessType, BusinessID: req.BusinessID, Subject: req.Subject, PaymentMode: bizpayment.PaymentModeExternal, OriginalAmount: req.Amount, Amount: req.Amount, Currency: req.Currency, PaymentStatus: bizpayment.PaymentStatusInitialized, FulfillmentStatus: bizpayment.FulfillmentStatusPending, @@ -127,7 +132,7 @@ func (r *paymentRepo) TestProvider(ctx context.Context, provider string) (*bizpa add("create", "failed", err.Error(), req.TradeNo, createStart) return test, err } - if order, err = orders.RecordPaymentCreate(ctx, provider, req.TradeNo, paymentTestProviderUpdate(created)); err != nil { + if order, err = r.orders.RecordPaymentCreate(ctx, provider, req.TradeNo, paymentTestProviderUpdate(created)); err != nil { add("local_order", "failed", "记录第三方下单结果失败: "+err.Error(), req.TradeNo, createStart) return test, err } @@ -142,7 +147,7 @@ func (r *paymentRepo) TestProvider(ctx context.Context, provider string) (*bizpa queryID = req.TradeNo } if provider == bizpayment.PaymentApple { - queryID = strings.TrimSpace(text(values, "test_transaction_id")) + queryID = strings.TrimSpace(paymentkit.Text(values, "test_transaction_id")) if queryID == "" { err = errors.New("Apple 连通性测试需要配置 test_transaction_id(沙箱交易 ID)") add("query", "failed", err.Error(), req.TradeNo, time.Now()) @@ -162,7 +167,7 @@ func (r *paymentRepo) TestProvider(ctx context.Context, provider string) (*bizpa return test, err } if provider != bizpayment.PaymentApple { - if order, err = orders.ApplyPaymentResult(ctx, provider, req.TradeNo, paymentTestProviderUpdate(queried)); err != nil { + if order, err = r.orders.ApplyPaymentResult(ctx, provider, req.TradeNo, paymentTestProviderUpdate(queried)); err != nil { add("local_order", "failed", "回写测试查单结果失败: "+err.Error(), req.TradeNo, queryStart) return test, err } @@ -185,7 +190,7 @@ func (r *paymentRepo) TestProvider(ctx context.Context, provider string) (*bizpa return test, nil } refundStart := time.Now() - order, refundToken, beginErr := orders.BeginPaymentRefund(ctx, provider, req.TradeNo, req.Amount, time.Minute) + order, refundToken, beginErr := r.orders.BeginPaymentRefund(ctx, provider, req.TradeNo, req.Amount, time.Minute) if beginErr != nil { add("refund", "failed", beginErr.Error(), req.TradeNo, refundStart) return test, beginErr @@ -201,7 +206,7 @@ func (r *paymentRepo) TestProvider(ctx context.Context, provider string) (*bizpa add("refund", "failed", err.Error(), req.TradeNo, refundStart) return test, err } - if _, err = orders.CompletePaymentRefundRequest(ctx, provider, req.TradeNo, refundToken, true, ""); err != nil { + if _, err = r.orders.CompletePaymentRefundRequest(ctx, provider, req.TradeNo, refundToken, true, ""); err != nil { add("local_order", "failed", "回写测试退款结果失败: "+err.Error(), req.TradeNo, refundStart) return test, err } @@ -301,19 +306,19 @@ func (r *paymentRepo) testRow(ctx context.Context, provider string) (map[string] func paymentTestRequest(provider string, values map[string]any) *bizpayment.PaymentRequest { tradeNo := "kra-test-" + time.Now().UTC().Format("20060102150405.000000000") - amount := configuredInt64(values, "test_amount", 1) + amount := paymentkit.ConfiguredInt64(values, "test_amount", 1) if amount <= 0 { amount = 1 } - req := &bizpayment.PaymentRequest{Provider: provider, TradeNo: strings.ReplaceAll(tradeNo, ".", ""), Subject: "Kra 支付渠道连通性测试", Amount: amount, Currency: strings.ToUpper(firstAny(values, "test_currency", "currency", "fee_type")), NotifyURL: text(values, "notify_url"), ReturnURL: text(values, "return_url"), BusinessType: "system_payment_test", BusinessID: uuid.NewString(), Extra: map[string]any{}} + req := &bizpayment.PaymentRequest{Provider: provider, TradeNo: strings.ReplaceAll(tradeNo, ".", ""), Subject: "Kra 支付渠道连通性测试", Amount: amount, Currency: strings.ToUpper(paymentkit.FirstText(values, "test_currency", "currency", "fee_type")), NotifyURL: paymentkit.Text(values, "notify_url"), ReturnURL: paymentkit.Text(values, "return_url"), BusinessType: "system_payment_test", BusinessID: uuid.NewString(), Extra: map[string]any{}} if req.Currency == "" { req.Currency = "CNY" } if provider == bizpayment.PaymentApple { req.TradeNo = uuid.NewString() - req.Extra["product_id"] = firstAny(values, "product_id", "test_product_id") + req.Extra["product_id"] = paymentkit.FirstText(values, "product_id", "test_product_id") } - if raw := strings.TrimSpace(text(values, "test_extra")); raw != "" { + if raw := strings.TrimSpace(paymentkit.Text(values, "test_extra")); raw != "" { var extra map[string]any if json.Unmarshal([]byte(raw), &extra) == nil { for key, value := range extra { @@ -322,7 +327,7 @@ func paymentTestRequest(provider string, values map[string]any) *bizpayment.Paym } } for _, key := range []string{"trade_type", "method", "pay_type", "channel", "openid", "open_id", "auth_code", "authcode", "barcode"} { - if value := text(values, key); value != "" { + if value := paymentkit.Text(values, key); value != "" { req.Extra[key] = value } } @@ -333,16 +338,16 @@ func validatePaymentTestSettings(provider string, values map[string]any) error { if !testModeEnabled(values) { return errors.New("请先打开 test_mode(允许执行渠道测试)") } - if raw := strings.TrimSpace(text(values, "test_extra")); raw != "" { + if raw := strings.TrimSpace(paymentkit.Text(values, "test_extra")); raw != "" { var extra map[string]any if err := json.Unmarshal([]byte(raw), &extra); err != nil { return fmt.Errorf("test_extra 必须是 JSON 对象: %w", err) } } - if provider == bizpayment.PaymentApple && strings.TrimSpace(text(values, "test_transaction_id")) == "" { + if provider == bizpayment.PaymentApple && strings.TrimSpace(paymentkit.Text(values, "test_transaction_id")) == "" { return errors.New("Apple 测试需要 test_transaction_id(沙箱交易 ID)") } - if provider == bizpayment.PaymentApple && strings.TrimSpace(firstAny(values, "test_product_id", "product_id")) == "" { + if provider == bizpayment.PaymentApple && strings.TrimSpace(paymentkit.FirstText(values, "test_product_id", "product_id")) == "" { return errors.New("Apple 测试需要 test_product_id(沙箱商品 ID)") } return nil @@ -374,8 +379,8 @@ func (r *paymentRepo) Create(ctx context.Context, req *bizpayment.PaymentRequest return nil, err } effective := *req - effective.NotifyURL = strings.TrimSpace(text(c, "notify_url")) - effective.ReturnURL = strings.TrimSpace(text(c, "return_url")) + effective.NotifyURL = strings.TrimSpace(paymentkit.Text(c, "notify_url")) + effective.ReturnURL = strings.TrimSpace(paymentkit.Text(c, "return_url")) if paymentCreateRequiresNotifyURL(req.Provider, req.Extra, c) && effective.NotifyURL == "" { return nil, fmt.Errorf("支付渠道 %s 未配置服务端 notify_url", req.Provider) } @@ -412,9 +417,9 @@ func paymentCreateRequiresNotifyURL(provider string, extra, config map[string]an case bizpayment.PaymentLakala: keys = []string{"method", "pay_method", "trade_type"} } - value := firstAny(extra, keys...) + value := paymentkit.FirstText(extra, keys...) if value == "" { - value = firstAny(config, keys...) + value = paymentkit.FirstText(config, keys...) } normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) switch provider { @@ -484,8 +489,8 @@ func paymentCallbackEventID(callback *bizpayment.PaymentCallback, result *bizpay if callback == nil { return "" } - fields := callbackFields(callback) - if eventID := strings.TrimSpace(first(fields, "event_id", "notify_id", "notificationUUID", "id")); eventID != "" { + fields := paymentkit.CallbackFields(callback.Query, callback.Headers, callback.Body) + if eventID := strings.TrimSpace(paymentkit.FirstString(fields, "event_id", "notify_id", "notificationUUID", "id")); eventID != "" { return eventID } hash := sha256.Sum256(append([]byte(callback.Provider+"\x00"), callback.Body...)) @@ -498,12 +503,12 @@ func paymentCallbackAck(provider string, values map[string]any, success bool) bi if !success { prefix = "callback_failure_" } - if configured := strings.TrimSpace(text(values, prefix+"status")); configured != "" { + if configured := strings.TrimSpace(paymentkit.Text(values, prefix+"status")); configured != "" { if status, err := strconv.Atoi(configured); err == nil && status >= 200 && status <= 599 { ack.StatusCode = status } } - if contentType := strings.TrimSpace(text(values, prefix+"content_type")); contentType != "" { + if contentType := strings.TrimSpace(paymentkit.Text(values, prefix+"content_type")); contentType != "" { ack.ContentType = contentType } if body, exists := values[prefix+"body"]; exists { @@ -512,41 +517,6 @@ func paymentCallbackAck(provider string, values map[string]any, success bool) bi return ack } -func callbackFields(callback *bizpayment.PaymentCallback) map[string]string { - fields := map[string]string{} - for key, value := range callback.Query { - fields[key] = value - } - contentType := strings.ToLower(callback.Headers["Content-Type"]) - if strings.Contains(contentType, "application/x-www-form-urlencoded") { - if values, err := url.ParseQuery(string(callback.Body)); err == nil { - for key, value := range values { - if len(value) > 0 { - fields[key] = value[0] - } - } - } - } - var object map[string]any - if json.Unmarshal(callback.Body, &object) == nil { - for key, value := range object { - if text, ok := value.(string); ok { - fields[key] = text - } - } - } - return fields -} - -func first(values map[string]string, keys ...string) string { - for _, key := range keys { - if values[key] != "" { - return values[key] - } - } - return "" -} - func contains(values []string, value string) bool { for _, item := range values { if item == value { diff --git a/internal/data/payment/payment_helpers.go b/internal/data/payment/payment_helpers.go deleted file mode 100644 index e327e9c..0000000 --- a/internal/data/payment/payment_helpers.go +++ /dev/null @@ -1,17 +0,0 @@ -package payment - -import "kra/internal/paymentkit" - -func text(values map[string]any, key string) string { - return paymentkit.Text(values, key) -} - -func firstAny(values map[string]any, keys ...string) string { - return paymentkit.FirstText(values, keys...) -} - -// configuredInt64 keeps repository-side validation independent from the -// provider adapter package while sharing the canonical payment parser. -func configuredInt64(values map[string]any, key string, fallback int64) int64 { - return paymentkit.ConfiguredInt64(values, key, fallback) -} diff --git a/internal/data/payment/payment_order.go b/internal/data/payment/payment_order.go index 7513c96..d61e90d 100644 --- a/internal/data/payment/payment_order.go +++ b/internal/data/payment/payment_order.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" bizpayment "kra/internal/biz/payment" + "kra/internal/paymentkit" "strings" "time" @@ -476,12 +477,12 @@ func applyProviderIdentity(tx *gorm.DB, po *paymentOrderPO, update *bizpayment.P } func normalizeOrderPaymentStatus(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "success", "paid", "fulfilled": + switch paymentkit.NormalizeStatus(value, "") { + case "success": return bizpayment.PaymentStatusPaid - case "pending", "created", "client_pending", "processing": + case "pending": return bizpayment.PaymentStatusPending - case "failed", "closed", "cancelled", "canceled": + case "failed": return bizpayment.PaymentStatusFailed default: return "" diff --git a/internal/data/system/announcement.go b/internal/data/system/announcement.go index 6e330ac..1714dd6 100644 --- a/internal/data/system/announcement.go +++ b/internal/data/system/announcement.go @@ -7,7 +7,6 @@ import ( "time" "kra/pkg/database/gormkit" - "kra/pkg/database/pagination" "gorm.io/gorm" ) @@ -72,22 +71,9 @@ 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) } - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, 0, err - } - if filter.PageSize > 0 { - db = pagination.Apply(db, filter.Page, filter.PageSize, 100) - } - var pos []announcementPO - if err := db.Find(&pos).Error; err != nil { - return nil, 0, err - } - items := make([]*system.Announcement, 0, len(pos)) - for _, po := range pos { - items = append(items, announcementToBiz(po)) - } - return items, total, nil + return listRows(db, filter.Page, filter.PageSize, filter.PageSize > 0, func(po announcementPO) *system.Announcement { + return announcementToBiz(po) + }) } func (r *announcementRepo) UserOptions(ctx context.Context) ([]system.UserOption, error) { diff --git a/internal/data/system/authority.go b/internal/data/system/authority.go index 9cbb55c..292618b 100644 --- a/internal/data/system/authority.go +++ b/internal/data/system/authority.go @@ -131,15 +131,7 @@ func (r *authorityAccessRepo) ensureAuthorityParentAcyclic(ctx context.Context, } func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *system.Authority) error { - if value.DataScope == 0 { - value.DataScope = 1 - } else if value.DataScope < 1 || value.DataScope > 5 { - return errInvalidDataScope - } - if err := r.checkDataScopeGrant(ctx, value.DataScope, nil); err != nil { - return err - } - actor, allowed, strict, err := r.strictAuthorityAccess(ctx) + actor, allowed, strict, err := r.prepareAuthorityCreate(ctx, value) if err != nil { return err } @@ -208,16 +200,24 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *system return nil }) } -func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, value *system.Authority) error { + +func (r *authorityAccessRepo) prepareAuthorityCreate(ctx context.Context, value *system.Authority) (system.Actor, map[uint]bool, bool, error) { if value.DataScope == 0 { value.DataScope = 1 } else if value.DataScope < 1 || value.DataScope > 5 { - return errInvalidDataScope + return system.Actor{}, nil, false, errInvalidDataScope } if err := r.checkDataScopeGrant(ctx, value.DataScope, nil); err != nil { - return err + return system.Actor{}, nil, false, err } actor, allowed, strict, err := r.strictAuthorityAccess(ctx) + if err != nil { + return system.Actor{}, nil, false, err + } + return actor, allowed, strict, nil +} +func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, value *system.Authority) error { + actor, allowed, strict, err := r.prepareAuthorityCreate(ctx, value) if err != nil { return err } diff --git a/internal/data/system/data_access_log.go b/internal/data/system/data_access_log.go index 994db67..1c86316 100644 --- a/internal/data/system/data_access_log.go +++ b/internal/data/system/data_access_log.go @@ -5,8 +5,6 @@ import ( "kra/internal/biz/system" "time" - "kra/pkg/database/pagination" - "gorm.io/gorm" ) @@ -39,12 +37,8 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q * db = db.Where("target_table LIKE ?", "%"+q.TargetTable+"%") } } - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, 0, err - } - var pos []DataAccessLogPO - if err := pagination.ApplyRequired(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + pos, total, err := queryRows[DataAccessLogPO](db.Order("id desc"), page, size, true, true) + if err != nil { return nil, 0, err } out := make([]*system.DataAccessLog, 0, len(pos)) diff --git a/internal/data/system/error_record.go b/internal/data/system/error_record.go index 942108e..f2e1ead 100644 --- a/internal/data/system/error_record.go +++ b/internal/data/system/error_record.go @@ -5,8 +5,6 @@ import ( "kra/internal/biz/system" "time" - "kra/pkg/database/pagination" - "gorm.io/gorm" ) @@ -92,17 +90,7 @@ func (r *auditQueryRepo) ListErrors(ctx context.Context, page, size int, q *syst db = db.Where("info LIKE ?", "%"+*q.Info+"%") } } - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, 0, err - } - var pos []errorRecordPO - if err := pagination.Apply(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil { - return nil, 0, err - } - out := make([]*system.ErrorRecord, 0, len(pos)) - for _, po := range pos { - out = append(out, errorFromPO(po)) - } - return out, total, nil + return listRows(db.Order("created_at desc"), page, size, true, func(po errorRecordPO) *system.ErrorRecord { + return errorFromPO(po) + }) } diff --git a/internal/data/system/export.go b/internal/data/system/export.go index 9d76da6..3c00e2c 100644 --- a/internal/data/system/export.go +++ b/internal/data/system/export.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "kra/pkg/database/pagination" + "gorm.io/gorm" ) @@ -171,11 +173,7 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q return nil, 0, err } var pos []exportTemplatePO - // This module applies pageSize and its raw offset formula verbatim instead - // of using the shared PageInfo normalization. - if size != 0 { - db = db.Limit(size).Offset(size * (page - 1)) - } + db = pagination.Apply(db, page, size, 100) if err := db.Find(&pos).Error; err != nil { return nil, 0, err } diff --git a/internal/data/system/list.go b/internal/data/system/list.go new file mode 100644 index 0000000..55c8833 --- /dev/null +++ b/internal/data/system/list.go @@ -0,0 +1,42 @@ +package system + +import ( + "kra/pkg/database/pagination" + + "gorm.io/gorm" +) + +// listRows centralizes the storage-only part shared by simple repositories: +// 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) { + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if paginate { + if required { + db = pagination.ApplyRequired(db, page, size, 100) + } else { + db = pagination.Apply(db, page, size, 100) + } + } + var pos []PO + if err := db.Find(&pos).Error; err != nil { + return nil, 0, err + } + 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) + if err != nil { + return nil, 0, err + } + out := make([]*DO, 0, len(pos)) + for _, po := range pos { + out = append(out, convert(po)) + } + return out, total, nil +} diff --git a/internal/data/system/login_log.go b/internal/data/system/login_log.go index 6f3010d..766656e 100644 --- a/internal/data/system/login_log.go +++ b/internal/data/system/login_log.go @@ -5,8 +5,6 @@ import ( "kra/internal/biz/system" "time" - "kra/pkg/database/pagination" - "gorm.io/gorm" ) @@ -53,12 +51,8 @@ func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *syst db = db.Where("created_at <= ?", *q.EndCreatedAt) } } - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, 0, err - } - var pos []loginLogPO - if err := pagination.ApplyRequired(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + pos, total, err := queryRows[loginLogPO](db.Order("id desc"), page, size, true, true) + if err != nil { return nil, 0, err } ids := make([]uint, 0, len(pos)) diff --git a/internal/data/system/media_upload.go b/internal/data/system/media_upload.go index 37b2270..41198d1 100644 --- a/internal/data/system/media_upload.go +++ b/internal/data/system/media_upload.go @@ -75,6 +75,9 @@ func (r *mediaRepo) CreateUploadSession(ctx context.Context, v *system.UploadSes func (r *mediaRepo) FindUploadSession(ctx context.Context, id uint) (*system.UploadSession, error) { var po uploadSessionPO if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, system.ErrUploadSessionNotFound + } return nil, err } return uploadFromPO(po), nil diff --git a/internal/data/system/menu.go b/internal/data/system/menu.go index b3ec687..0ebfaf5 100644 --- a/internal/data/system/menu.go +++ b/internal/data/system/menu.go @@ -4,7 +4,6 @@ import ( "context" "errors" "kra/internal/biz/system" - "time" "gorm.io/gorm" ) @@ -14,12 +13,7 @@ type menuRepo struct{ data Provider } func NewMenuRepo(data Provider) system.MenuRepo { return &menuRepo{data: data} } func menuFromPO(po menuPO) *system.Menu { - var deletedAt *time.Time - if po.DeletedAt.Valid { - value := po.DeletedAt.Time - deletedAt = &value - } - return &system.Menu{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, ID: po.ID, ParentID: po.ParentID, Path: po.Path, Name: po.Name, Hidden: po.Hidden, Component: po.Component, Sort: po.Sort, ActiveName: po.ActiveName, KeepAlive: po.KeepAlive, DefaultMenu: po.DefaultMenu, Title: po.Title, Icon: po.Icon, CloseTab: po.CloseTab, TransitionType: po.TransitionType} + return &system.Menu{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAtPointer(po.DeletedAt), ID: po.ID, ParentID: po.ParentID, Path: po.Path, Name: po.Name, Hidden: po.Hidden, Component: po.Component, Sort: po.Sort, ActiveName: po.ActiveName, KeepAlive: po.KeepAlive, DefaultMenu: po.DefaultMenu, Title: po.Title, Icon: po.Icon, CloseTab: po.CloseTab, TransitionType: po.TransitionType} } func menuToPO(v *system.Menu) menuPO { @@ -239,21 +233,11 @@ func (r *menuRepo) List(ctx context.Context) ([]*system.Menu, error) { } func menuParameterFromPO(po menuParameterPO) *system.MenuParameter { - var deletedAt *time.Time - if po.DeletedAt.Valid { - value := po.DeletedAt.Time - deletedAt = &value - } - return &system.MenuParameter{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, ID: po.ID, MenuID: po.MenuID, Type: po.Type, Key: po.Key, Value: po.Value} + return &system.MenuParameter{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAtPointer(po.DeletedAt), ID: po.ID, MenuID: po.MenuID, Type: po.Type, Key: po.Key, Value: po.Value} } func menuButtonFromPO(po menuButtonPO) *system.MenuButton { - var deletedAt *time.Time - if po.DeletedAt.Valid { - value := po.DeletedAt.Time - deletedAt = &value - } - return &system.MenuButton{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, ID: po.ID, Name: po.Name, Description: po.Description, MenuID: po.MenuID} + return &system.MenuButton{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAtPointer(po.DeletedAt), ID: po.ID, Name: po.Name, Description: po.Description, MenuID: po.MenuID} } func (r *menuRepo) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) error { diff --git a/internal/data/system/operation_log.go b/internal/data/system/operation_log.go index b6a729c..e020b46 100644 --- a/internal/data/system/operation_log.go +++ b/internal/data/system/operation_log.go @@ -5,8 +5,6 @@ import ( "kra/internal/biz/system" "time" - "kra/pkg/database/pagination" - "gorm.io/gorm" ) @@ -49,12 +47,8 @@ func (r *auditQueryRepo) ListOperations(ctx context.Context, page, size int, q * db = db.Where("status = ?", q.Status) } } - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, 0, err - } - var pos []operationPO - if err := pagination.ApplyRequired(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + pos, total, err := queryRows[operationPO](db.Order("id desc"), page, size, true, true) + if err != nil { return nil, 0, err } users := auditUsers(ctx, r.data.DB().WithContext(ctx), operationUserIDs(pos)) diff --git a/internal/data/system/parameter.go b/internal/data/system/parameter.go index ec6766e..2abaf82 100644 --- a/internal/data/system/parameter.go +++ b/internal/data/system/parameter.go @@ -5,8 +5,6 @@ import ( "kra/internal/biz/system" "time" - "kra/pkg/database/pagination" - "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -72,17 +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 + "%"}) } } - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, 0, err - } - var pos []parameterPO - if err := pagination.Apply(db, page, size, 100).Find(&pos).Error; err != nil { - return nil, 0, err - } - out := make([]*system.SystemParameter, 0, len(pos)) - for _, po := range pos { - out = append(out, parameterFromPO(po)) - } - return out, total, nil + return listRows(db, page, size, true, func(po parameterPO) *system.SystemParameter { + return parameterFromPO(po) + }) } diff --git a/internal/data/system/position.go b/internal/data/system/position.go index 4814564..a19cdb4 100644 --- a/internal/data/system/position.go +++ b/internal/data/system/position.go @@ -4,6 +4,7 @@ import ( "context" "errors" "kra/internal/biz/system" + "kra/pkg/database/pagination" "time" "gorm.io/gorm" @@ -83,10 +84,7 @@ func (r *positionRepo) ListPositions(ctx context.Context, page, size int, q *sys return nil, 0, err } var pos []positionPO - // The compatible position endpoint applies the supplied LIMIT/OFFSET - // directly (it does not normalize missing page values through the shared - // pagination helper). - if err := db.Order("sort").Limit(size).Offset(size * (page - 1)).Find(&pos).Error; err != nil { + if err := pagination.ApplyRequired(db.Order("sort"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*system.Position, 0, len(pos)) @@ -95,6 +93,7 @@ func (r *positionRepo) ListPositions(ctx context.Context, page, size int, q *sys } return out, total, nil } + func (r *positionRepo) PositionUserIDs(ctx context.Context, id uint) ([]uint, error) { var ids []uint err := r.data.DB().WithContext(ctx).Model(&userPositionPO{}).Where("sys_position_id = ?", id).Pluck("sys_user_id", &ids).Error diff --git a/internal/data/system/time.go b/internal/data/system/time.go new file mode 100644 index 0000000..3bf5618 --- /dev/null +++ b/internal/data/system/time.go @@ -0,0 +1,15 @@ +package system + +import ( + "time" + + "gorm.io/gorm" +) + +func deletedAtPointer(value gorm.DeletedAt) *time.Time { + if !value.Valid { + return nil + } + result := value.Time + return &result +} diff --git a/internal/data/system/token.go b/internal/data/system/token.go index 01c5948..ccceaf8 100644 --- a/internal/data/system/token.go +++ b/internal/data/system/token.go @@ -37,7 +37,7 @@ func generateToken(secret, issuer string, expires, buffer time.Duration, userID, } now := time.Now() claims := &tokenClaims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, BufferTime: int64(buffer / time.Second), UserType: "admin", MustChangePwd: mustChange, PasswordVersion: passwordVersion, RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{tokenAudience}, Issuer: issuer, IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-time.Second)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}} - token, err := signToken(secret, claims) + token, err := signTokenUnchecked(secret, claims) return token, claims, err } @@ -48,6 +48,10 @@ func signToken(secret string, claims *tokenClaims) (string, error) { if claims == nil { return "", errInvalidClaims } + return signTokenUnchecked(secret, claims) +} + +func signTokenUnchecked(secret string, claims *tokenClaims) (string, error) { return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) } diff --git a/internal/data/system/user.go b/internal/data/system/user.go index 353639a..2f1ea7b 100644 --- a/internal/data/system/user.go +++ b/internal/data/system/user.go @@ -97,12 +97,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro } func toBizAuthority(po authorityPO) system.Authority { - var deletedAt *time.Time - if po.DeletedAt.Valid { - value := po.DeletedAt.Time - deletedAt = &value - } - return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter} + return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAtPointer(po.DeletedAt), AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter} } func parseOriginSetting(raw string) (map[string]any, error) { diff --git a/internal/integration/payment/alipay.go b/internal/integration/payment/alipay.go index 500c7c2..e0dfc36 100644 --- a/internal/integration/payment/alipay.go +++ b/internal/integration/payment/alipay.go @@ -82,7 +82,7 @@ func (a *alipayAdapter) Create(ctx context.Context, req *bizpayment.PaymentReque case "10003": status = "pending" default: - return nil, fmt.Errorf("支付宝付款码支付失败: %s", firstNonEmptyAlipay(response.SubMsg, response.Msg, response.SubCode, response.Code)) + return nil, fmt.Errorf("支付宝付款码支付失败: %s", firstNonEmpty(response.SubMsg, response.Msg, response.SubCode, response.Code)) } if strings.TrimSpace(response.OutTradeNo) == "" { return nil, errors.New("支付宝付款码支付响应缺少 out_trade_no") @@ -125,7 +125,7 @@ func (a *alipayAdapter) Create(ctx context.Context, req *bizpayment.PaymentReque } return &bizpayment.PaymentResult{ Provider: bizpayment.PaymentAlipay, Status: "created", TradeNo: req.TradeNo, - Payload: mustMarshalAlipayPayload(map[string]string{"order_string": orderString}), + Payload: mustJSON(map[string]string{"order_string": orderString}), }, nil case "alipay.trade.page.pay": payURL, callErr := sdk.client.TradePagePay(ctx, bm) @@ -141,7 +141,7 @@ func (a *alipayAdapter) Create(ctx context.Context, req *bizpayment.PaymentReque } return &bizpayment.PaymentResult{ Provider: bizpayment.PaymentAlipay, Status: "created", TradeNo: req.TradeNo, - Payload: mustMarshalAlipayPayload(map[string]string{"pay_url": payURL}), + Payload: mustJSON(map[string]string{"pay_url": payURL}), }, nil case "alipay.trade.wap.pay": payURL, callErr := sdk.client.TradeWapPay(ctx, bm) @@ -157,7 +157,7 @@ func (a *alipayAdapter) Create(ctx context.Context, req *bizpayment.PaymentReque } return &bizpayment.PaymentResult{ Provider: bizpayment.PaymentAlipay, Status: "created", TradeNo: req.TradeNo, - Payload: mustMarshalAlipayPayload(map[string]string{"pay_url": payURL}), + Payload: mustJSON(map[string]string{"pay_url": payURL}), }, nil default: rsp, callErr := sdk.client.TradeCreate(ctx, bm) @@ -214,7 +214,7 @@ func (a *alipayAdapter) Query(ctx context.Context, tradeNo string, c map[string] if err != nil { return nil, fmt.Errorf("解析支付宝订单金额: %w", err) } - result.Currency = strings.ToUpper(firstNonEmptyAlipay(response.TransCurrency, response.PayCurrency)) + result.Currency = strings.ToUpper(firstNonEmpty(response.TransCurrency, response.PayCurrency)) if result.Currency == "" { result.Currency = "CNY" } @@ -383,7 +383,7 @@ func (c *alipayClient) payload(fallback any) []byte { if c.response.Len() > 0 { return append([]byte(nil), c.response.Bytes()...) } - return mustMarshalAlipayPayload(fallback) + return mustJSON(fallback) } func (c *alipayClient) rewriteGeneratedURL(value string) (string, error) { @@ -466,20 +466,6 @@ func alipayResponseValues(value any) (map[string]any, error) { return result, nil } -func mustMarshalAlipayPayload(value any) []byte { - raw, _ := json.Marshal(value) - return raw -} - -func firstNonEmptyAlipay(values ...string) string { - for _, value := range values { - if value = strings.TrimSpace(value); value != "" { - return value - } - } - return "" -} - func populateAlipayBreakdown(result *bizpayment.PaymentResult, response map[string]any) error { if result == nil || result.Status != "success" { return nil diff --git a/internal/integration/payment/paypal.go b/internal/integration/payment/paypal.go index 5069aad..1a4da58 100644 --- a/internal/integration/payment/paypal.go +++ b/internal/integration/payment/paypal.go @@ -688,15 +688,6 @@ func paypalNestedMapString(values map[string]any, keys ...string) string { return strings.TrimSpace(fmt.Sprint(current)) } -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value = strings.TrimSpace(value); value != "" && value != "" { - return value - } - } - return "" -} - func paypalResponseError(operation string, code int, detail string) error { detail = strings.TrimSpace(detail) if detail == "" { diff --git a/internal/integration/payment/qq.go b/internal/integration/payment/qq.go index 440ab8e..6afbf7e 100644 --- a/internal/integration/payment/qq.go +++ b/internal/integration/payment/qq.go @@ -103,7 +103,7 @@ func (a *qqAdapter) Create(ctx context.Context, req *bizpayment.PaymentRequest, if status == "pending" && strings.EqualFold(rsp.ResultCode, "SUCCESS") && strings.TrimSpace(rsp.TransactionId) != "" { status = "success" } - return &bizpayment.PaymentResult{Provider: bizpayment.PaymentQQ, Status: status, TradeNo: firstNonEmptyQQ(rsp.OutTradeNo, req.TradeNo), ProviderTradeNo: strings.TrimSpace(rsp.TransactionId), Payload: mustJSON(rsp)}, nil + return &bizpayment.PaymentResult{Provider: bizpayment.PaymentQQ, Status: status, TradeNo: firstNonEmpty(rsp.OutTradeNo, req.TradeNo), ProviderTradeNo: strings.TrimSpace(rsp.TransactionId), Payload: mustJSON(rsp)}, nil } bm.Set("trade_type", tradeType) mergeGoPayExtras(bm, req.Extra, "nonce_str", "body", "out_trade_no", "total_fee", "spbill_create_ip", "notify_url", "trade_type", "mch_id", "sign_type") @@ -143,15 +143,6 @@ func qqCreateMethod(extra, config map[string]any) (string, error) { } } -func firstNonEmptyQQ(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - func (a *qqAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*bizpayment.PaymentResult, error) { tradeNo = strings.TrimSpace(tradeNo) if tradeNo == "" { diff --git a/internal/integration/payment/result.go b/internal/integration/payment/result.go index a522676..f13ca64 100644 --- a/internal/integration/payment/result.go +++ b/internal/integration/payment/result.go @@ -1,55 +1,29 @@ package payment import ( - "encoding/json" "errors" "fmt" bizpayment "kra/internal/biz/payment" - "net/url" "strings" "kra/internal/paymentkit" ) func callbackFields(callback *bizpayment.PaymentCallback) map[string]string { - fields := map[string]string{} if callback == nil { - return fields + return map[string]string{} } - for key, value := range callback.Query { - fields[key] = value - } - contentType := "" - for key, value := range callback.Headers { - if strings.EqualFold(key, "Content-Type") { - contentType = strings.ToLower(value) - break - } - } - if strings.Contains(contentType, "application/x-www-form-urlencoded") { - if values, err := url.ParseQuery(string(callback.Body)); err == nil { - for key, value := range values { - if len(value) > 0 { - fields[key] = value[0] - } - } - } - } - var object map[string]any - if json.Unmarshal(callback.Body, &object) == nil { - for key, value := range object { - if stringValue, ok := value.(string); ok { - fields[key] = stringValue - } - } - } - return fields + return paymentkit.CallbackFields(callback.Query, callback.Headers, callback.Body) } func first(values map[string]string, keys ...string) string { - for _, key := range keys { - if values[key] != "" { - return values[key] + return paymentkit.FirstString(values, keys...) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" && value != "" { + return value } } return "" @@ -69,6 +43,10 @@ func normalizePaymentStatus(value, fallback string) string { return paymentkit.NormalizeStatus(value, fallback) } +func normalizeRefundStatus(value, fallback string) string { + return paymentkit.NormalizeRefundStatus(value, fallback) +} + func parseIntegerAmount(value string) (int64, error) { return paymentkit.ParseIntegerAmount(value) } diff --git a/internal/integration/payment/vendor.go b/internal/integration/payment/vendor.go index bb3c4e3..9aa87b1 100644 --- a/internal/integration/payment/vendor.go +++ b/internal/integration/payment/vendor.go @@ -164,7 +164,7 @@ func (a *vendorPaymentAdapter) call(ctx context.Context, endpointKey string, pay result.Status = "created" default: if operation == "refund" { - normalized := normalizePaymentStatus(state, "") + normalized := normalizeRefundStatus(state, "") switch normalized { case "success", "pending", "failed": result.Status = normalized diff --git a/internal/integration/provider.go b/internal/integration/provider.go index 65c1bfe..89e5b63 100644 --- a/internal/integration/provider.go +++ b/internal/integration/provider.go @@ -12,6 +12,7 @@ import ( mqintegration "kra/internal/integration/mq" paymentintegration "kra/internal/integration/payment" "kra/internal/integration/storage" + "kra/internal/integration/systeminfo" websocketintegration "kra/internal/integration/websocket" "kra/pkg/mq" platformws "kra/pkg/websocket" @@ -25,6 +26,8 @@ var ProviderSet = wire.NewSet( paymentintegration.NewFactory, wire.Bind(new(bizpayment.PaymentAdapterFactory), new(*paymentintegration.Factory)), storage.NewFileStorage, + systeminfo.NewProvider, + wire.Bind(new(system.SystemInfoProvider), new(*systeminfo.Provider)), NewConnectivityTester, wire.Bind(new(integrationbiz.IntegrationConnectionTester), new(*ConnectivityTester)), mqintegration.New, diff --git a/internal/integration/storage/aliyun_storage.go b/internal/integration/storage/aliyun_storage.go index ffcfa78..672b577 100644 --- a/internal/integration/storage/aliyun_storage.go +++ b/internal/integration/storage/aliyun_storage.go @@ -69,29 +69,7 @@ func (s *aliyunStorage) Compose(ctx context.Context, names []string, destination return composeFiles(ctx, s, names, destination) } func (s *aliyunStorage) DeletePrefix(ctx context.Context, prefix string) error { - prefix, err := normalizeDeletePrefix(prefix) - if err != nil { - return err - } - cursor := "" - for { - items, next, more, err := s.List(ctx, prefix+"/", cursor, 1000) - if err != nil { - return err - } - for _, item := range items { - if err = s.Delete(ctx, item.Path); err != nil { - return err - } - } - if !more { - return nil - } - cursor, err = advanceDeletePrefixCursor(cursor, next, more) - if err != nil { - return err - } - } + return deletePrefixViaList(ctx, prefix, s.List, s.Delete) } func (s *aliyunStorage) List(_ context.Context, prefix, cursor string, limit int) ([]*system.StoredFile, string, bool, error) { if limit <= 0 { diff --git a/internal/integration/storage/aws_storage.go b/internal/integration/storage/aws_storage.go index 2c14df0..3694fdf 100644 --- a/internal/integration/storage/aws_storage.go +++ b/internal/integration/storage/aws_storage.go @@ -106,29 +106,7 @@ func (s *awsStorage) Compose(ctx context.Context, names []string, destination st return composeFiles(ctx, s, names, destination) } func (s *awsStorage) DeletePrefix(ctx context.Context, prefix string) error { - prefix, err := normalizeDeletePrefix(prefix) - if err != nil { - return err - } - cursor := "" - for { - items, next, more, err := s.List(ctx, prefix+"/", cursor, 1000) - if err != nil { - return err - } - for _, item := range items { - if err = s.Delete(ctx, item.Path); err != nil { - return err - } - } - if !more { - return nil - } - cursor, err = advanceDeletePrefixCursor(cursor, next, more) - if err != nil { - return err - } - } + return deletePrefixViaList(ctx, prefix, s.List, s.Delete) } func (s *awsStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*system.StoredFile, string, bool, error) { if limit <= 0 { diff --git a/internal/integration/storage/compose_test.go b/internal/integration/storage/compose_test.go index 399d979..9623809 100644 --- a/internal/integration/storage/compose_test.go +++ b/internal/integration/storage/compose_test.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "io" + "kra/internal/biz/system" "strings" "testing" "time" @@ -117,3 +118,38 @@ func TestAdvanceDeletePrefixCursor(t *testing.T) { t.Fatalf("advance cursor = %q, %v", next, err) } } + +func TestDeletePrefixViaListDeletesAllPages(t *testing.T) { + pages := map[string][]*system.StoredFile{ + "": {{Path: "uploads/a"}, {Path: "uploads/b"}}, + "next": {{Path: "uploads/c"}}, + } + removed := []string{} + if err := deletePrefixViaList(context.Background(), "uploads", func(_ context.Context, prefix, cursor string, limit int) ([]*system.StoredFile, string, bool, error) { + if prefix != "uploads/" || limit != 1000 { + t.Fatalf("list arguments = %q, %q, %d", prefix, cursor, limit) + } + items := pages[cursor] + if cursor == "" { + return items, "next", true, nil + } + return items, "", false, nil + }, func(_ context.Context, path string) error { + removed = append(removed, path) + return nil + }); err != nil { + t.Fatal(err) + } + if len(removed) != 3 || removed[2] != "uploads/c" { + t.Fatalf("removed paths = %#v", removed) + } +} + +func TestDeletePrefixViaListRejectsStalledCursor(t *testing.T) { + err := deletePrefixViaList(context.Background(), "uploads", func(_ context.Context, _, _ string, _ int) ([]*system.StoredFile, string, bool, error) { + return nil, "", true, nil + }, func(context.Context, string) error { return nil }) + if err == nil || !strings.Contains(err.Error(), "no progress") { + t.Fatalf("stalled pagination error = %v", err) + } +} diff --git a/internal/integration/storage/huawei_storage.go b/internal/integration/storage/huawei_storage.go index 9c828ac..1684584 100644 --- a/internal/integration/storage/huawei_storage.go +++ b/internal/integration/storage/huawei_storage.go @@ -68,29 +68,7 @@ func (s *huaweiStorage) Compose(ctx context.Context, names []string, destination return composeFiles(ctx, s, names, destination) } func (s *huaweiStorage) DeletePrefix(ctx context.Context, prefix string) error { - prefix, err := normalizeDeletePrefix(prefix) - if err != nil { - return err - } - cursor := "" - for { - items, next, more, err := s.List(ctx, prefix+"/", cursor, 1000) - if err != nil { - return err - } - for _, item := range items { - if err = s.Delete(ctx, item.Path); err != nil { - return err - } - } - if !more { - return nil - } - cursor, err = advanceDeletePrefixCursor(cursor, next, more) - if err != nil { - return err - } - } + return deletePrefixViaList(ctx, prefix, s.List, s.Delete) } func (s *huaweiStorage) List(_ context.Context, prefix, cursor string, limit int) ([]*system.StoredFile, string, bool, error) { if limit <= 0 { diff --git a/internal/integration/storage/prefix.go b/internal/integration/storage/prefix.go index 1eded18..ea1e923 100644 --- a/internal/integration/storage/prefix.go +++ b/internal/integration/storage/prefix.go @@ -1,11 +1,39 @@ package storage import ( + "context" "errors" + "kra/internal/biz/system" "path" "strings" ) +func deletePrefixViaList(ctx context.Context, prefix string, list func(context.Context, string, string, int) ([]*system.StoredFile, string, bool, error), remove func(context.Context, string) error) error { + prefix, err := normalizeDeletePrefix(prefix) + if err != nil { + return err + } + cursor := "" + for { + items, next, more, err := list(ctx, prefix+"/", cursor, 1000) + if err != nil { + return err + } + for _, item := range items { + if err := remove(ctx, item.Path); err != nil { + return err + } + } + if !more { + return nil + } + cursor, err = advanceDeletePrefixCursor(cursor, next, more) + if err != nil { + return err + } + } +} + // normalizeDeletePrefix rejects requests that could accidentally target the // storage root and returns the canonical key form used by all backends. func normalizeDeletePrefix(prefix string) (string, error) { diff --git a/internal/integration/storage/qiniu_storage.go b/internal/integration/storage/qiniu_storage.go index be894fa..1ad0fe3 100644 --- a/internal/integration/storage/qiniu_storage.go +++ b/internal/integration/storage/qiniu_storage.go @@ -99,29 +99,7 @@ func (s *qiniuStorage) Compose(ctx context.Context, names []string, destination return composeFiles(ctx, s, names, destination) } func (s *qiniuStorage) DeletePrefix(ctx context.Context, prefix string) error { - prefix, err := normalizeDeletePrefix(prefix) - if err != nil { - return err - } - cursor := "" - for { - items, next, more, err := s.List(ctx, prefix+"/", cursor, 1000) - if err != nil { - return err - } - for _, item := range items { - if err = s.Delete(ctx, item.Path); err != nil { - return err - } - } - if !more { - return nil - } - cursor, err = advanceDeletePrefixCursor(cursor, next, more) - if err != nil { - return err - } - } + return deletePrefixViaList(ctx, prefix, s.List, s.Delete) } func (s *qiniuStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*system.StoredFile, string, bool, error) { if limit <= 0 { diff --git a/internal/integration/storage/tencent_storage.go b/internal/integration/storage/tencent_storage.go index 123e84f..15868cb 100644 --- a/internal/integration/storage/tencent_storage.go +++ b/internal/integration/storage/tencent_storage.go @@ -82,29 +82,7 @@ func (s *tencentStorage) Compose(ctx context.Context, names []string, destinatio return composeFiles(ctx, s, names, destination) } func (s *tencentStorage) DeletePrefix(ctx context.Context, prefix string) error { - prefix, err := normalizeDeletePrefix(prefix) - if err != nil { - return err - } - cursor := "" - for { - items, next, more, err := s.List(ctx, prefix+"/", cursor, 1000) - if err != nil { - return err - } - for _, item := range items { - if err = s.Delete(ctx, item.Path); err != nil { - return err - } - } - if !more { - return nil - } - cursor, err = advanceDeletePrefixCursor(cursor, next, more) - if err != nil { - return err - } - } + return deletePrefixViaList(ctx, prefix, s.List, s.Delete) } func (s *tencentStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*system.StoredFile, string, bool, error) { if limit <= 0 { diff --git a/internal/integration/systeminfo/provider.go b/internal/integration/systeminfo/provider.go new file mode 100644 index 0000000..0bb8cd6 --- /dev/null +++ b/internal/integration/systeminfo/provider.go @@ -0,0 +1,50 @@ +package systeminfo + +import ( + "context" + "runtime" + "time" + + "kra/internal/biz/system" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/mem" +) + +type Provider struct{} + +func NewProvider() *Provider { return &Provider{} } + +func (*Provider) Collect(ctx context.Context, mountPoints []string) (*system.ServerInfo, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return nil, err + } + physicalCores, err := cpu.Counts(false) + if err != nil { + return nil, err + } + cpuPercent, err := cpu.PercentWithContext(ctx, 200*time.Millisecond, true) + if err != nil { + return nil, err + } + memory, err := mem.VirtualMemoryWithContext(ctx) + if err != nil { + return nil, err + } + diskInfo := make([]system.ServerDiskInfo, 0, len(mountPoints)) + for _, mountPoint := range mountPoints { + if err = ctx.Err(); err != nil { + return nil, err + } + usage, usageErr := disk.UsageWithContext(ctx, mountPoint) + if usageErr != nil { + return nil, usageErr + } + diskInfo = append(diskInfo, system.ServerDiskInfo{MountPoint: mountPoint, UsedMB: usage.Used / 1024 / 1024, UsedGB: usage.Used / 1024 / 1024 / 1024, TotalMB: usage.Total / 1024 / 1024, TotalGB: usage.Total / 1024 / 1024 / 1024, UsedPercent: int(usage.UsedPercent)}) + } + return &system.ServerInfo{GOOS: runtime.GOOS, NumCPU: runtime.NumCPU(), Compiler: runtime.Compiler, GoVersion: runtime.Version(), NumGoroutine: runtime.NumGoroutine(), CPUs: cpuPercent, Cores: physicalCores, UsedMB: memory.Used / 1024 / 1024, TotalMB: memory.Total / 1024 / 1024, UsedPercent: int(memory.UsedPercent), Disk: diskInfo}, nil +} diff --git a/internal/logging/source.go b/internal/logging/source.go index 3cc8959..4c9dcd8 100644 --- a/internal/logging/source.go +++ b/internal/logging/source.go @@ -6,9 +6,12 @@ import ( "go/parser" "go/token" "os" + "path/filepath" "regexp" "strconv" "strings" + "sync" + "time" ) type stackFrame struct { @@ -19,6 +22,23 @@ type stackFrame struct { var stackFileLinePattern = regexp.MustCompile(`\s*(.+\.go):(\d+)\s*$`) +type sourceFunction struct { + name string + source string + startLine, endLine int +} + +type sourceFileCache struct { + size int64 + modTime time.Time + items []sourceFunction +} + +var sourceCache = struct { + sync.RWMutex + files map[string]sourceFileCache +}{files: make(map[string]sourceFileCache)} + func finalApplicationCaller(stack string) (stackFrame, bool) { if stack == "" { return stackFrame{}, false @@ -69,6 +89,17 @@ func skipStackFile(filename string) bool { } func functionSourceAt(filename string, line int) (name, source string, startLine, endLine int, err error) { + filename = filepath.Clean(filename) + info, err := os.Stat(filename) + if err != nil { + return "", "", 0, 0, fmt.Errorf("stat file failed: %w", err) + } + sourceCache.RLock() + cached, found := sourceCache.files[filename] + sourceCache.RUnlock() + if found && cached.size == info.Size() && cached.modTime.Equal(info.ModTime()) { + return sourceFunctionAtLine(cached.items, line, filename) + } content, err := os.ReadFile(filename) if err != nil { return "", "", 0, 0, fmt.Errorf("read file failed: %w", err) @@ -78,7 +109,15 @@ func functionSourceAt(filename string, line int) (name, source string, startLine if err != nil { return "", "", 0, 0, fmt.Errorf("parse file failed: %w", err) } - var target *ast.FuncDecl + sourceCache.Lock() + sourceCache.files[filename] = sourceFileCache{size: info.Size(), modTime: info.ModTime(), items: allFunctions(parsed, files, content)} + cached = sourceCache.files[filename] + sourceCache.Unlock() + return sourceFunctionAtLine(cached.items, line, filename) +} + +func allFunctions(parsed *ast.File, files *token.FileSet, content []byte) []sourceFunction { + items := make([]sourceFunction, 0) ast.Inspect(parsed, func(node ast.Node) bool { declaration, ok := node.(*ast.FuncDecl) if !ok { @@ -86,19 +125,21 @@ func functionSourceAt(filename string, line int) (name, source string, startLine } start := files.Position(declaration.Pos()).Line end := files.Position(declaration.End()).Line - if line >= start && line <= end { - target, startLine, endLine = declaration, start, end - return false + startOffset := files.Position(declaration.Pos()).Offset + endOffset := files.Position(declaration.End()).Offset + if startOffset >= 0 && endOffset <= len(content) && startOffset < endOffset { + items = append(items, sourceFunction{name: declaration.Name.Name, source: string(content[startOffset:endOffset]), startLine: start, endLine: end}) } return true }) - if target == nil { - return "", "", 0, 0, fmt.Errorf("no function encloses line %d in %s", line, filename) - } - start := files.Position(target.Pos()).Offset - end := files.Position(target.End()).Offset - if start < 0 || end > len(content) || start >= end { - return "", "", 0, 0, fmt.Errorf("invalid offsets for function: start=%d end=%d len=%d", start, end, len(content)) - } - return target.Name.Name, string(content[start:end]), startLine, endLine, nil + return items +} + +func sourceFunctionAtLine(items []sourceFunction, line int, filename string) (string, string, int, int, error) { + for _, item := range items { + if line >= item.startLine && line <= item.endLine { + return item.name, item.source, item.startLine, item.endLine, nil + } + } + return "", "", 0, 0, fmt.Errorf("no function encloses line %d in %s", line, filename) } diff --git a/internal/paymentkit/status.go b/internal/paymentkit/status.go index 1fe8102..eb7f5c8 100644 --- a/internal/paymentkit/status.go +++ b/internal/paymentkit/status.go @@ -1,13 +1,28 @@ package paymentkit import ( + "encoding/json" "fmt" + "net/url" "strings" ) func NormalizeStatus(value, fallback string) string { switch strings.ToUpper(strings.TrimSpace(value)) { - case "SUCCESS", "OK", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS", "REFUND_SUCCESS", "REFUNDED": + case "SUCCESS", "OK", "PAID", "FULFILLED", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS", "REFUND_SUCCESS", "REFUNDED": + return "success" + case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "CLIENT_PENDING", "ACCEPT", "ACCEPTED", "PAYING": + return "pending" + case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "FAILED", "FAIL", "REJECTED", "DENIED", "DECLINED", "ERROR", "PAYERROR": + return "failed" + default: + return fallback + } +} + +func NormalizeRefundStatus(value, fallback string) string { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "SUCCESS", "OK", "PAID", "REFUND_SUCCESS", "REFUNDED", "COMPLETED", "FINISHED": return "success" case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "ACCEPTED", "PAYING": return "pending" @@ -65,6 +80,49 @@ func FirstText(values map[string]any, keys ...string) string { return "" } +func FirstString(values map[string]string, keys ...string) string { + for _, key := range keys { + if values[key] != "" { + return values[key] + } + } + return "" +} + +// CallbackFields merges query parameters, form data, and top-level JSON +// strings using the same precedence for every payment boundary. +func CallbackFields(query, headers map[string]string, body []byte) map[string]string { + fields := make(map[string]string, len(query)) + for key, value := range query { + fields[key] = value + } + contentType := "" + for key, value := range headers { + if strings.EqualFold(key, "Content-Type") { + contentType = strings.ToLower(value) + break + } + } + if strings.Contains(contentType, "application/x-www-form-urlencoded") { + if values, err := url.ParseQuery(string(body)); err == nil { + for key, value := range values { + if len(value) > 0 { + fields[key] = value[0] + } + } + } + } + var object map[string]any + if json.Unmarshal(body, &object) == nil { + for key, value := range object { + if text, ok := value.(string); ok { + fields[key] = text + } + } + } + return fields +} + func toString(value any) string { if value == nil { return "" diff --git a/internal/paymentkit/status_test.go b/internal/paymentkit/status_test.go new file mode 100644 index 0000000..465b400 --- /dev/null +++ b/internal/paymentkit/status_test.go @@ -0,0 +1,26 @@ +package paymentkit + +import "testing" + +func TestNormalizeStatusCoversCanonicalPaymentStates(t *testing.T) { + for _, test := range []struct { + input, want string + }{ + {input: "fulfilled", want: "success"}, + {input: "client_pending", want: "pending"}, + {input: "trade_closed", want: "failed"}, + } { + if got := NormalizeStatus(test.input, "unknown"); got != test.want { + t.Fatalf("NormalizeStatus(%q) = %q, want %q", test.input, got, test.want) + } + } +} + +func TestNormalizeStatusSeparatesRefundStates(t *testing.T) { + if got := NormalizeStatus("REFUND_SUCCESS", "unknown"); got != "success" { + t.Fatalf("payment status for completed payment = %q, want success", got) + } + if got := NormalizeRefundStatus("REFUND_SUCCESS", "unknown"); got != "success" { + t.Fatalf("refund status = %q, want success", got) + } +} diff --git a/internal/server/handler/dictionary.go b/internal/server/handler/dictionary.go index a6bdbd1..28cdfce 100644 --- a/internal/server/handler/dictionary.go +++ b/internal/server/handler/dictionary.go @@ -210,7 +210,7 @@ func (h *Dictionary) Details(c *gin.Context) { func (h *Dictionary) Tree(byType bool) gin.HandlerFunc { return func(c *gin.Context) { rawID := c.Query("sysDictionaryID") - id, parseErr := strconv.ParseUint(rawID, 10, 32) + var id uint64 typ := "" if byType { typ = c.Query("type") @@ -221,9 +221,13 @@ func (h *Dictionary) Tree(byType bool) gin.HandlerFunc { } else if rawID == "" { Fail(c, "字典ID不能为空") return - } else if parseErr != nil { - Fail(c, "字典ID格式错误") - return + } else { + var parseErr error + id, parseErr = strconv.ParseUint(rawID, 10, 32) + if parseErr != nil { + Fail(c, "字典ID格式错误") + return + } } items, err := h.service.DictionaryTree(c.Request.Context(), uint(id), typ) if err != nil { diff --git a/internal/server/handler/public.go b/internal/server/handler/public.go index 9e89826..05d6937 100644 --- a/internal/server/handler/public.go +++ b/internal/server/handler/public.go @@ -27,29 +27,20 @@ func NewPublic(auth *systemservice.AuthService, system *systemservice.SystemConf return &Public{auth: auth, system: system, settings: settings, store: &captchaStore{service: settings}} } -func (h *Public) captchaConfig() (int, int, int) { - config := h.settings.CaptchaSettings() - return config.KeyLong, config.ImageWidth, config.ImageHeight -} - func (h *Public) Captcha(c *gin.Context) { - keyLong, width, height := h.captchaConfig() security, err := h.settings.CurrentSecurity(c.Request.Context()) if err != nil || security == nil { Fail(c, "安全服务暂不可用") return } - openCaptcha := true - if security != nil { - keyLong, width, height = security.KeyLong, security.ImgWidth, security.ImgHeight - ttl := time.Duration(security.CaptchaTimeout) * time.Second - failures, counterErr := h.settings.EnsureLoginIPCounter(c.Request.Context(), c.ClientIP(), ttl) - if counterErr != nil { - Fail(c, "安全服务暂不可用") - return - } - openCaptcha = security.CaptchaOpen == 0 || failures > security.CaptchaOpen + keyLong, width, height := security.KeyLong, security.ImgWidth, security.ImgHeight + ttl := time.Duration(security.CaptchaTimeout) * time.Second + failures, counterErr := h.settings.EnsureLoginIPCounter(c.Request.Context(), c.ClientIP(), ttl) + if counterErr != nil { + Fail(c, "安全服务暂不可用") + return } + openCaptcha := security.CaptchaOpen == 0 || failures > security.CaptchaOpen driver := base64Captcha.NewDriverDigit(height, width, keyLong, 0.7, 80) id, picture, _, err := base64Captcha.NewCaptcha(driver, h.store).Generate() if err != nil { diff --git a/internal/server/handler/system_config.go b/internal/server/handler/system_config.go index 3ec96c5..5802411 100644 --- a/internal/server/handler/system_config.go +++ b/internal/server/handler/system_config.go @@ -81,7 +81,7 @@ func (h *SystemConfig) Reload(c *gin.Context) { } func (h *SystemConfig) ServerInfo(c *gin.Context) { - server, err := h.system.ServerInfo() + server, err := h.system.ServerInfo(c.Request.Context()) if err != nil { Fail(c, "获取失败") return diff --git a/internal/server/middleware/audit.go b/internal/server/middleware/audit.go index fc75664..4b0284b 100644 --- a/internal/server/middleware/audit.go +++ b/internal/server/middleware/audit.go @@ -87,8 +87,22 @@ func OperationAudit(runtime *config.Store, recorder *systemservice.AuditRecorder responseBody = "[超出记录长度]" } errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String() - operationBody := operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes) - if routecatalog.BodyPolicyFor(c.Request.Method, path) == routecatalog.BodyPolicyPaymentConfig { + operationBody := "" + capturedBody := false + if c.Request.Method != http.MethodGet { + if value, ok := c.Get(ctxReqBodyKey); ok { + // AccessLog has already captured, redacted, summarized, and bounded the + // request body. Treat that value as final so audit does not process it a + // second time. + operationBody = stringValue(value) + capturedBody = true + } else { + operationBody = operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes) + } + } else { + operationBody = operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes) + } + if !capturedBody && routecatalog.BodyPolicyFor(c.Request.Method, path) == routecatalog.BodyPolicyPaymentConfig { operationBody = paymentConfigSummary(requestBody) } if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil { @@ -105,7 +119,11 @@ func operationQueryBody(raw string) []byte { values := make(map[string]string, len(parsed)) for key, items := range parsed { if len(items) > 0 { - values[key] = items[len(items)-1] + value := items[len(items)-1] + if isSensitivePayloadKey(key) { + value = redactedValue + } + values[key] = value } } body, _ := json.Marshal(&values) diff --git a/internal/server/middleware/error_audit_test.go b/internal/server/middleware/error_audit_test.go index 3c26d98..82cb2bf 100644 --- a/internal/server/middleware/error_audit_test.go +++ b/internal/server/middleware/error_audit_test.go @@ -23,6 +23,16 @@ func TestOperationQueryBodyPreservesEncodedSeparators(t *testing.T) { } } +func TestOperationQueryBodyRedactsSensitiveValues(t *testing.T) { + var values map[string]string + if err := json.Unmarshal(operationQueryBody("token=secret-token&name=alice"), &values); err != nil { + t.Fatal(err) + } + if values["token"] != redactedValue || values["name"] != "alice" { + t.Fatalf("unexpected redacted query body: %#v", values) + } +} + func runErrorAudit(t *testing.T, path, response string, logger *slog.Logger) { t.Helper() gin.SetMode(gin.TestMode) diff --git a/internal/service/system/authority.go b/internal/service/system/authority.go index 319e120..b4c820c 100644 --- a/internal/service/system/authority.go +++ b/internal/service/system/authority.go @@ -27,14 +27,6 @@ func authorityDomain(req *dto.AuthorityRequest) *system.Authority { return &system.Authority{AuthorityID: req.AuthorityID, AuthorityName: req.AuthorityName, ParentID: req.ParentID, DataScope: req.DataScope, DefaultRouter: req.DefaultRouter} } -func authorityResponse(value *system.Authority) *dto.AuthorityResponse { - var menus []*dto.MenuResponse - if value.Menus != nil { - menus = menuResponses(value.Menus) - } - return &dto.AuthorityResponse{CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: value.DeletedAt, AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, Children: nil, Menus: menus, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter} -} - func (s *AuthorityService) Authorities(ctx context.Context) ([]*dto.AuthorityResponse, error) { items, err := s.uc.AuthorityTree(ctx) if err != nil { @@ -52,7 +44,7 @@ func (s *AuthorityService) CreateAuthorityRequest(ctx context.Context, req *dto. if err := s.uc.CreateAuthority(ctx, value); err != nil { return nil, err } - return authorityResponse(value), nil + return convertAuthority(*value), nil } func (s *AuthorityService) CopyAuthorityRequest(ctx context.Context, req *dto.CopyAuthorityRequest) (*dto.AuthorityResponse, error) { @@ -60,7 +52,7 @@ func (s *AuthorityService) CopyAuthorityRequest(ctx context.Context, req *dto.Co if err := s.uc.CopyAuthority(ctx, req.OldAuthorityID, value); err != nil { return nil, err } - result := authorityResponse(value) + result := convertAuthority(*value) result.Children = []*dto.AuthorityResponse{} return result, nil } @@ -70,7 +62,7 @@ func (s *AuthorityService) UpdateAuthorityRequest(ctx context.Context, req *dto. if err := s.uc.UpdateAuthority(ctx, value); err != nil { return nil, err } - return authorityResponse(value), nil + return convertAuthority(*value), nil } func (s *AuthorityService) DeleteAuthority(ctx context.Context, id uint) error { diff --git a/internal/service/system/system_info.go b/internal/service/system/system_info.go index 27a1c8b..e112445 100644 --- a/internal/service/system/system_info.go +++ b/internal/service/system/system_info.go @@ -1,42 +1,23 @@ package system import ( - "runtime" - "time" - + "context" "kra/internal/service/dto" - - "github.com/shirou/gopsutil/v4/cpu" - "github.com/shirou/gopsutil/v4/disk" - "github.com/shirou/gopsutil/v4/mem" ) -func (s *SystemConfigService) ServerInfo() (*dto.ServerInfoResponse, error) { - physicalCores, err := cpu.Counts(false) +func (s *SystemConfigService) ServerInfo(ctx context.Context) (*dto.ServerInfoResponse, error) { + value, err := s.uc.ServerInfo(ctx) if err != nil { return nil, err } - cpuPercent, err := cpu.Percent(200*time.Millisecond, true) - if err != nil { - return nil, err + diskInfo := make([]dto.ServerDiskResponse, 0, len(value.Disk)) + for _, disk := range value.Disk { + diskInfo = append(diskInfo, dto.ServerDiskResponse{MountPoint: disk.MountPoint, UsedMB: disk.UsedMB, UsedGB: disk.UsedGB, TotalMB: disk.TotalMB, TotalGB: disk.TotalGB, UsedPercent: disk.UsedPercent}) } - memory, err := mem.VirtualMemory() - if err != nil { - return nil, err - } - diskInfo := make([]dto.ServerDiskResponse, 0) - for _, mountPoint := range s.DiskMountPoints() { - usage, usageErr := disk.Usage(mountPoint) - if usageErr != nil { - return nil, usageErr - } - diskInfo = append(diskInfo, dto.ServerDiskResponse{MountPoint: mountPoint, UsedMB: usage.Used / 1024 / 1024, UsedGB: usage.Used / 1024 / 1024 / 1024, TotalMB: usage.Total / 1024 / 1024, TotalGB: usage.Total / 1024 / 1024 / 1024, UsedPercent: int(usage.UsedPercent)}) - } - usedMB, totalMB := memory.Used/1024/1024, memory.Total/1024/1024 return &dto.ServerInfoResponse{ - OS: dto.ServerOSResponse{GOOS: runtime.GOOS, NumCPU: runtime.NumCPU(), Compiler: runtime.Compiler, GoVersion: runtime.Version(), NumGoroutine: runtime.NumGoroutine()}, - CPU: dto.ServerCPUResponse{CPUs: cpuPercent, Cores: physicalCores}, - RAM: dto.ServerRAMResponse{UsedMB: usedMB, TotalMB: totalMB, UsedPercent: int(memory.UsedPercent)}, + OS: dto.ServerOSResponse{GOOS: value.GOOS, NumCPU: value.NumCPU, Compiler: value.Compiler, GoVersion: value.GoVersion, NumGoroutine: value.NumGoroutine}, + CPU: dto.ServerCPUResponse{CPUs: value.CPUs, Cores: value.Cores}, + RAM: dto.ServerRAMResponse{UsedMB: value.UsedMB, TotalMB: value.TotalMB, UsedPercent: value.UsedPercent}, Disk: diskInfo, }, nil } diff --git a/internal/service/task/task.go b/internal/service/task/task.go index a589447..ea54830 100644 --- a/internal/service/task/task.go +++ b/internal/service/task/task.go @@ -21,34 +21,16 @@ func taskDomain(v *dto.TaskRequest) *taskbiz.TimedTask { } func (s *TaskService) CreateRequest(ctx context.Context, req *dto.TaskRequest) (uint, error) { value := taskDomain(req) - if err := s.Create(ctx, value); err != nil { + if err := s.uc.Create(ctx, value); err != nil { return 0, err } return value.ID, nil } func (s *TaskService) UpdateRequest(ctx context.Context, req *dto.TaskRequest) error { - return s.Update(ctx, taskDomain(req)) + return s.uc.Update(ctx, taskDomain(req)) } func (s *TaskService) ListRequest(ctx context.Context, page, size int, name, executorType string, enabled *bool) ([]*dto.TaskResponse, int64, error) { - return s.Tasks(ctx, page, size, &taskbiz.TimedTask{Name: name, ExecutorType: executorType, EnabledFilter: enabled}) -} -func (s *TaskService) Create(ctx context.Context, v *taskbiz.TimedTask) error { - return s.uc.Create(ctx, v) -} -func (s *TaskService) Update(ctx context.Context, v *taskbiz.TimedTask) error { - return s.uc.Update(ctx, v) -} -func (s *TaskService) Delete(ctx context.Context, id uint) error { - return s.uc.Delete(ctx, id) -} -func (s *TaskService) Toggle(ctx context.Context, id uint, enabled bool) error { - return s.uc.Toggle(ctx, id, enabled) -} -func taskDTO(v *taskbiz.TimedTask, next *time.Time) *dto.TaskResponse { - return &dto.TaskResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: json.RawMessage(v.Params), HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: json.RawMessage(v.HTTPHeader), HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled, NextRunAt: next} -} -func (s *TaskService) Tasks(ctx context.Context, page, size int, q *taskbiz.TimedTask) ([]*dto.TaskResponse, int64, error) { - items, total, next, err := s.uc.List(ctx, page, size, q) + items, total, next, err := s.uc.List(ctx, page, size, &taskbiz.TimedTask{Name: name, ExecutorType: executorType, EnabledFilter: enabled}) if err != nil { return nil, 0, err } @@ -63,7 +45,15 @@ func (s *TaskService) Tasks(ctx context.Context, page, size int, q *taskbiz.Time } return out, total, nil } - +func (s *TaskService) Delete(ctx context.Context, id uint) error { + return s.uc.Delete(ctx, id) +} +func (s *TaskService) Toggle(ctx context.Context, id uint, enabled bool) error { + return s.uc.Toggle(ctx, id, enabled) +} +func taskDTO(v *taskbiz.TimedTask, next *time.Time) *dto.TaskResponse { + return &dto.TaskResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: json.RawMessage(v.Params), HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: json.RawMessage(v.HTTPHeader), HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled, NextRunAt: next} +} func (s *TaskService) Logs(ctx context.Context, page, size int, taskID uint, status string) ([]*dto.TaskLogResponse, int64, error) { items, total, err := s.uc.Logs(ctx, page, size, taskID, status) if err != nil {