优化结构

This commit is contained in:
Yvan 2026-08-27 16:20:37 +08:00
parent 001e39da50
commit 653414a1da
54 changed files with 1257 additions and 3450 deletions

4
.gitignore vendored
View File

@ -42,3 +42,7 @@ bin/
web/dist/
web/node_modules/
.pnpm-store/
# Local reference sources and desktop-only tooling state
/gva/
/.claude/settings.local.json

View File

@ -6,12 +6,6 @@ VERSION=$(shell git describe --tags --always)
# init env
init:
go install github.com/google/wire/cmd/wire@latest
go install github.com/bufbuild/buf/cmd/buf@latest
.PHONY: api
# generate api proto
api:
buf generate --template buf.gen.yaml
.PHONY: build
# build
@ -27,7 +21,6 @@ generate:
.PHONY: all
# generate all
all:
make api
make generate
# show help

View File

@ -1,21 +0,0 @@
version: v2
inputs:
- directory: api
plugins:
- local: ["go", "run", "google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11"]
out: api
opt: paths=source_relative
- local: ["go", "run", "google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2"]
out: api
opt: paths=source_relative
- local: ["go", "run", "github.com/go-kratos/kratos/cmd/protoc-gen-go-http/v3@v3.0.0-20260526000039-30da04b769dc"]
out: api
opt: paths=source_relative
- local: ["go", "run", "go.einride.tech/protoc-gen-typescript-http@v0.10.0"]
out: web/src/services
- local: ["go", "run", "github.com/google/gnostic/cmd/protoc-gen-openapi@v0.7.1"]
out: .
strategy: all
opt:
- fq_schema_naming=true
- default_response=false

View File

@ -1,6 +0,0 @@
# Generated by buf. DO NOT EDIT.
version: v2
deps:
- name: buf.build/googleapis/googleapis
commit: c17df5b2beca46928cc87d5656bd5343
digest: b5:648a01e0170d4512dea7d564016165decd1ed6e34bef79fe54753e51ad7e27545709ad9157d7551270147d551155c595a2fb0bf5bb33b1c83040ddbce915c604

View File

@ -1,5 +0,0 @@
version: v2
modules:
- path: api
deps:
- buf.build/googleapis/googleapis

View File

@ -8,7 +8,6 @@ import (
"os"
"strings"
"kra/internal/app"
"kra/internal/config"
"kra/internal/server/router"
"kra/internal/service"
@ -50,13 +49,17 @@ func init() {
flag.StringVar(&flagconf, "conf", "./configs", "config path, eg: -conf config.yaml")
}
// runtimeContributions is the binary-level list of modules with constructed
// route or task dependencies. Adding another runtime module is explicit here.
func runtimeContributions(systemRoutes *router.Routes, systemTasks *worker.TaskMethods) app.Composition {
return app.Composition{
Routes: []module.RouteRegistrar{systemRoutes},
Tasks: []platformtask.Contributor{systemTasks},
}
// taskRegistry builds the process-wide registry from the dependency-free task
// methods the static module catalog declares. Methods whose handlers need
// constructed usecases are added later by buildRuntime.
func taskRegistry() *platformtask.Registry { return platformtask.NewRegistry() }
// buildRuntime is the binary-level list of modules with constructed route or
// task dependencies: it activates their task methods and composes their routes.
// Adding another runtime module is explicit here.
func buildRuntime(registry *platformtask.Registry, systemRoutes *router.Routes, systemTasks *worker.TaskMethods) module.RouteRegistrar {
systemTasks.RegisterTasks(registry)
return systemRoutes
}
func newApp(logger *slog.Logger, hs *kratoshttp.Server, scheduler *worker.TaskScheduler, audit *service.AuditRecorder, loggerControl *logging.ReloadableLogger, _ mq.Client) *kratos.App {

View File

@ -8,7 +8,6 @@ package main
import (
"log/slog"
"kra/internal/app"
"kra/internal/biz"
taskbiz "kra/internal/biz/task"
"kra/internal/config"
@ -38,9 +37,8 @@ func wireApp(*config.Server, *config.Store, *slog.Logger, *logging.ReloadableLog
router.ProviderSet,
worker.ProviderSet,
modules.Catalog,
app.TaskRegistry,
runtimeContributions,
app.Build,
taskRegistry,
buildRuntime,
data.ProviderSet,
integration.ProviderSet,
initialize.ProviderSet,

12
cmd/wire_gen.go generated
View File

@ -8,7 +8,6 @@ package main
import (
"github.com/go-kratos/kratos/v3"
"kra/internal/app"
integration3 "kra/internal/biz/integration"
payment2 "kra/internal/biz/payment"
system2 "kra/internal/biz/system"
@ -75,6 +74,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
v2 := system3.NewSecurityService(securityUsecase)
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
v3 := system3.NewAuditRecorder(auditRecorderUsecase)
registry := taskRegistry()
authorityUsecase := system2.NewAuthorityUsecase(authorityAccessRepo)
v4 := system3.NewAuthorityService(authorityUsecase)
authority := handler.NewAuthority(v4)
@ -111,7 +111,6 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
v12 := payment3.NewPaymentService(paymentUsecase)
handlerPayment := handler.NewPayment(v12)
taskRepo := task.NewTaskRepo(dataData)
registry := app.TaskRegistry(catalog)
taskUsecase := task2.NewTaskUsecaseWithRegistry(taskRepo, registry)
taskExecutor := worker.NewTaskExecutorWithRegistry(taskUsecase, registry)
taskScheduler := worker.NewTaskScheduler(taskUsecase, authorityUsecase, taskExecutor, logger)
@ -192,14 +191,13 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
maintenanceRepo := system.NewMaintenanceRepo(dataData)
maintenanceUsecase := system2.NewMaintenanceUsecase(maintenanceRepo)
taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store)
composition := runtimeContributions(routes, taskMethods)
runtime := app.Build(composition, registry)
routeRegistrar := buildRuntime(registry, routes, taskMethods)
websocketServer, cleanup2, err := websocket.New(runtimeconfigStore)
if err != nil {
cleanup()
return nil, nil, err
}
engine := server.NewGinEngineWithRuntime(store, v, authService, v2, v3, logger, string2, runtime, websocketServer)
engine := server.NewGinEngineWithRuntime(store, v, authService, v2, v3, logger, string2, routeRegistrar, websocketServer)
httpServer := server.NewGinServer(configServer, engine)
mqReloadable, cleanup3, err := mq.New(runtimeconfigStore, logger)
if err != nil {
@ -207,8 +205,8 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
cleanup()
return nil, nil, err
}
kratosApp := newApp(logger, httpServer, taskScheduler, v3, reloadableLogger, mqReloadable)
return kratosApp, func() {
app := newApp(logger, httpServer, taskScheduler, v3, reloadableLogger, mqReloadable)
return app, func() {
cleanup3()
cleanup2()
cleanup()

View File

@ -1,52 +0,0 @@
# Kra 管理后台
## 范围
Kra 管理后台由 Kratos 应用承载生命周期Gin 提供兼容管理端的 HTTP 路由。后端按 `service -> biz -> data` 分层,前端位于 `web/`
保留的业务模块:
- 初始化、认证、用户和个人中心
- 角色、菜单、API、Casbin 和按钮权限
- 部门、岗位和数据范围
- 字典、字典项和系统参数
- 安全策略、系统配置和 API Token
- Excel 导入导出与版本管理
- 操作日志、登录日志、数据访问日志、错误日志和文件日志
- 定时任务、SSE、媒体上传和附件分类
- 公告管理与邮件发送
不包含代码生成、智能辅助、模块安装发布、客户示例和测试页面。
## 目录
| 目录 | 职责 |
|---|---|
| `internal/server` | Gin 路由、中间件和 Kratos transport 生命周期 |
| `internal/service` | HTTP DTO 与领域对象转换、用例编排 |
| `internal/biz` | 领域对象、业务规则和仓储接口 |
| `internal/data` | GORM、MySQL、Redis 和本地文件存储实现 |
| `web` | Vue 3 管理端 |
## 兼容约定
- HTTP 响应为 `{code, data, msg}`,成功码为 `0`
- JWT 接受 `x-token` Header 和同名 Cookie。
- 初始化创建后台数据表、管理员、菜单,并从 Gin 路由同步 API 权限数据。
- 超级管理员角色 `888` 保留全权限旁路,其他角色由 Casbin 控制。
- SMTP 配置位于 `admin.email`;未配置时错误邮件告警保持关闭。
- 媒体文件默认使用本地 `uploads/file` 存储。
## 验证
```bash
go generate ./cmd
go test ./...
go vet ./...
go build ./...
cd web
pnpm install
pnpm run lint
pnpm run build
```

File diff suppressed because it is too large Load Diff

View File

@ -1,410 +0,0 @@
# 支付接入设计
## 代码分层
支付渠道适配器统一放在 `internal/data/payment`,订单仓储和运行时入口保留在
`internal/data`;这与 `internal/data/storage` 集中管理 OSS provider 的方式一致,
避免各渠道协议散落在 data 根目录。它们仍属于 data 层,因为需要读取
`sys_integration_configs`、调用运行时客户端、转换 `biz` 对象并在数据库事务中推进
`pay_orders` 状态。无状态的协议基础能力已下沉到领域化公共包:
- `pkg/paymentkit`金额整数化、支付状态归一化、JSON 路径读取、微信 v2 XML、
微信 v2/聚合渠道签名等纯函数;不依赖 `biz`、`data` 或数据库。
- `internal/data/payment`GoPay 和配置驱动渠道的 adapter、SDK 配置映射、
回调验签/解密以及 DO 结果归一化GoPay/driver 类型不越过 data 边界。
- `pkg/osskit`:跨对象存储 provider 的流式合并和 MD5 计算;不依赖具体 OSS SDK。
- `internal/data/storage`:本地磁盘、七牛、阿里云 OSS、华为 OBS、腾讯 COS、
S3/MinIO/R2 的客户端创建、配置映射和 `biz.FileStorage` 适配仍保留在 data。
因此不能把整个 `internal/data` 或所有 OSS provider 直接移动到 `pkg`:那会让公共包
反向依赖内部领域模型和运行时配置,破坏分层。后续新增 provider 时,优先把纯签名、
金额和报文转换放入 `pkg/paymentkit`把配置、HTTP 客户端、数据库和业务结果转换留在
`internal/data`
支付模块创建一张 `pay_orders` 表,用于记录一次支付尝试的金额、渠道状态、发货状态、退款状态和并发租约。业务模块继续拥有自己的业务订单,通过 `business_type + business_id` 关联支付订单。渠道配置继续使用已有的 `sys_integration_configs``kind = payment`;不创建独立回调事件表或支付日志表。
## 订单表边界
`pay_orders` 一行代表一次支付尝试,而不是业务订单:
- `(provider, trade_no)` 唯一,同一个业务订单可以创建多个不同渠道或不同批次的支付尝试。
- `(provider, provider_trade_no)` 唯一,禁止一个平台交易绑定两个本地订单。
- `request_fingerprint` 防止相同商户订单号被不同金额、币种或业务对象重复使用。
- `payment_status`、`fulfillment_status`、`refund_status` 相互独立,退款不会抹掉原始支付和发货事实。
- `original_amount` 是业务原价;`amount` 是提交给第三方平台的订单总额,二者均使用币种最小单位整数。
- `payer_paid_amount` 是用户实际支付金额;`cash_paid_amount` / `point_paid_amount` 分别表示现金和积分/平台资产部分;`discount_amount` 是订单总额减用户实付。
- `provider_discount_amount` / `merchant_discount_amount` 只有渠道明确返回出资拆分时才填写;未知字段不能推测。`settlement_amount` 是渠道最终结算商户金额,不等于用户实付。
- `amount_breakdown_known` 只有在上述核心金额满足守恒校验时才为 `true`,否则仍保留总额校验,但不伪造优惠拆分。
- `confirmation_id` 根据 `provider + trade_no` 稳定生成,是业务发货最终幂等键。
- 只保存平台响应哈希和必要状态;不保存完整回调原文、签名密钥或请求头。
- `fulfillment_token` / `refund_token` 和租约时间用于跨实例互斥,进程退出后租约到期可安全重试。
## GoPay 渠道矩阵v1.5.122
本分支固定使用 `github.com/go-pay/gopay v1.5.122`。下面的九行是 GoPay
稳定版本中可复用的渠道族;微信在本地拆成 `wechat-v2``wechat-v3` 两个
provider因此渠道族仍然是九个而不是把微信重复计算成两个渠道。
| GoPay 渠道族 | 本地 provider | 当前适配的 GoPay 能力 | 回调/退款边界 |
| --- | --- | --- | --- |
| Alipay旧网关协议 | `alipay` | `TradeCreate`、`TradePay`、`TradePrecreate`、`TradeAppPay`、`TradePagePay`、`TradeWapPay`、查单、退款、通知验签 | 支持服务端验签后主动查单 |
| Alipay V3 | `alipay-v3` | GoPay V3 `TradeCreate`、`TradePay`、`TradePrecreate`、`TradeAppPay`、`TradePagePay`、`TradeWapPay`、查单、退款、证书响应验签、通知证书验签 | 必须配置应用公钥证书、支付宝根证书和支付宝公钥证书;确认仍以主动查单为准 |
| WeChat | `wechat-v2`、`wechat-v3` | v2 `UnifiedOrder`/`Micropay`/`QueryOrder`/`Refund`v3 JSAPI、App、Native、H5、CodePay 付款码下单、查单、退款、通知验签/解密 | v2 使用商户密钥v3 使用平台证书和 API v3 key |
| Apple | `apple-iap` | App Store Server API 交易查询、JWS/证书链处理 | 下单由客户端 StoreKit 驱动;服务端不提供主动退款 |
| PayPal | `paypal` | `CreateOrder`、`OrderDetail`、`OrderCapture`、`PaymentCaptureRefund`、Webhook 验签 | 买家批准后由查单链路捕获;退款需要已持久化的 capture ID |
| Douyin | `douyin` | App、JSAPI、H5、Native 下单,按商户订单号查单、退款、通知验签和解密 | 平台证书必须配置 |
| QQ | `qq` | `UnifiedOrder`、`OrderQuery`、`Refund`、通知解析和验签 | 退款需要商户证书、私钥或 PKCS#12 |
| AllinPay | `allinpay` | `Pay`、`ScanPay`、`NativePay`、`Query`、`Refund` | 当前不接收通知;使用主动查单/对账确认 |
| Lakala | `lakala` | JSAPI、H5、小程序、Native/二维码、Native JSAPI、SDK、Web Gateway、线下条码/二维码下单,`OrderStatus`、`Refund`、通知解析和验签 | 回调字段以 GoPay 返回模型为准 |
| Saobei | `saobei` | `MiniPay`、`BarcodePay`、`Query`、`Refund` | 当前不接收通知;使用主动查单/对账确认 |
`CMB`(招商银行)不在固定的 v1.5.122 模块中,不能作为本分支已接入渠道;
上游未发布版本中的目录或提交不构成稳定依赖,故这里明确排除。
AllinPay 和 Saobei 的 adapter 会拒绝回调入口,因为固定版本没有可复用且能在
本项目边界内完成验签的通知路径。对这两个渠道,生产流程必须依赖主动查单、
定时对账和幂等状态推进,不得把未验签的通知当作支付事实。
本矩阵只说明代码和 GoPay 方法的接入情况,不代表真实商户沙箱或生产联调已经完成。
当前仓库仅做单元测试和本地 mock/HTTP 响应验证;上线前仍须使用实际商户凭证、
证书、平台回调和退款报文逐项联调。
统一 adapter 面向普通商户的核心下单、查单、退款和回调流程。微信服务商/合单、
PayPal `AUTHORIZE` 意图后的授权捕获,以及账单、分账、转账等能力需要不同的业务
状态机和持久化字段,不能仅靠透传 `extra` 安全接入;这些扩展应按实际业务合同
单独建模,不属于“稳定渠道族已接入”的含义。
## 其他已保留渠道
| Provider | 实现方式 |
| --- | --- |
| `alipay`、`alipay-v3`、`wechat-v2`、`wechat-v3`、`apple-iap`、`paypal`、`douyin`、`qq`、`allinpay`、`lakala`、`saobei` | 统一走上面的 GoPay v1.5.122 adapter业务层只接收 `paymentbiz.PaymentResult` |
| `chinaums` | 配置驱动的银联商务 JSON 签名适配器 |
| `sft` | 配置驱动的商福通 JSON/MD5 适配器 |
| `supper-pay` | 配置驱动的 Supper Pay HMAC 适配器 |
| `wechat-game-pay` | 配置驱动的微信小游戏虚拟支付 2.0 适配器,支持 access token |
| `douyin-game-pay` | 配置驱动的抖音小游戏支付签名适配器 |
上表后五个 provider 不是 GoPay v1.5.122 的九个稳定渠道族,仍保留现有的
配置驱动协议适配器。它们的协议字段依商户合同和产品版本不同,因此没有强行
假设某一个固定请求格式,必须在 `sys_integration_configs.config` 中声明查单
字段和金额单位。
## 回调安全流程
创建支付按以下顺序处理:
1. 根据 `business_type` 调用业务模块注册的 `PaymentOrderSource`
2. 业务模块返回可信的金额、币种、标题、业务 ID 和商户订单号;客户端提交的金额不是权威数据。
3. 先插入 `pay_orders`,利用唯一索引和请求指纹实现本地下单幂等。
4. 再调用支付平台创建订单;平台创建响应只会把本地状态推进到 `pending`,不能直接认定已支付。
5. 平台调用成功后保存客户端后续支付所需的创建响应。数据库记录失败时,可使用相同商户订单号安全重试平台创建接口。
每次支付回调按以下顺序处理:
1. 根据渠道配置加载适配器。
2. 校验渠道签名、证书/JWS/通知解密,并校验应用号、商户号等身份字段。
3. 从已验签的通知中提取商户订单号。
4. **主动调用对应平台查单接口**,不以回调中的支付状态、金额或币种作为最终依据。
5. 查单状态只允许 `success`、`pending`、`failed`;未知状态直接失败。成功状态必须同时包含商户订单号、平台交易号、正整数金额和币种。
6. 查单状态不是成功时,不发货;后续回调或业务主动查询可以再次确认。
7. 从 `pay_orders` 读取本地权威金额、币种、业务类型和业务 ID。
8. 严格比较渠道、商户订单号、平台交易号、金额、币种;任一关键字段缺失或不一致都拒绝更新为已支付。
9. 在数据库事务中把支付状态推进到 `paid`,再竞争发货租约。
10. 根据 `business_type` 查找 `PaymentFulfillmentHandler`,使用稳定 `confirmation_id` 执行业务发货。
11. 发货成功后将 `fulfillment_status` 更新为 `succeeded`;失败记录为 `failed` 并允许重试。重复回调和主动查询不会重复执行已完成发货。
支付模块不会接受客户端传入的金额、标题或币种作为权威订单数据。持久化流程启用后,未注册 `PaymentOrderSource` 的业务类型会直接拒绝下单。
## 业务扩展接口
业务侧必须把同一个实现作为 `PaymentBusinessModule` 注册。该接口同时要求实现
`PaymentOrderSource`(返回权威金额、币种和标题)、`PaymentFulfillmentHandler`
(按 `confirmation_id` 幂等发货)和 `PaymentRefundAuthorizer`(校验业务订单是否
允许退款)。应用组合阶段调用 `PaymentUsecase.RegisterBusinessModule(...)`
只注册支付 adapter 而不注册业务模块是不完整的接入。
当前仓库没有注册任何生产业务模块,因此直接调用持久化支付创建流程会返回
`支付业务订单来源未注册`。这不是渠道配置错误,接入具体商品、订单或订阅业务时
必须在应用启动组装处完成注册。
每个业务模块先实现可信订单来源:
```go
import paymentbiz "kra/internal/biz/payment"
type GameItemPayment struct {
orders GameItemOrderRepo
}
func (GameItemPayment) Type() string { return "game_item" }
func (p GameItemPayment) PreparePayment(ctx context.Context, provider, tradeNo, businessID string) (*paymentbiz.PaymentIntent, error) {
order, err := p.orders.FindPayable(ctx, businessID)
if err != nil {
return nil, err
}
return &paymentbiz.PaymentIntent{
Provider: provider, TradeNo: tradeNo,
BusinessType: "game_item", BusinessID: businessID,
Subject: order.Title, Amount: order.PayableAmount, Currency: order.Currency,
}, nil
}
```
然后注册按业务类型分发的发货处理器:
```go
func (p GameItemPayment) Fulfill(ctx context.Context, c *paymentbiz.PaymentConfirmation) error {
return p.orders.Transaction(ctx, func(tx GameItemOrderTx) error {
// confirmation_id 必须有唯一约束。已处理时直接返回 nil。
if tx.HasPaymentConfirmation(c.ID) {
return nil
}
if err := tx.Deliver(c.BusinessID); err != nil {
return err
}
return tx.SavePaymentConfirmation(c.ID)
})
}
func (p GameItemPayment) AuthorizeRefund(ctx context.Context, order *paymentbiz.PaymentOrder, amount int64) error {
return p.orders.CheckRefundable(ctx, order.BusinessID, amount)
}
```
启动时将同一个实现分别注册到 `PaymentOrderSourceRegistry``PaymentFulfillmentRegistry`。`PaymentConfirmation.ID` 同一支付尝试永远不变。业务处理器必须在自己的事务中以该 ID 建唯一约束,才能覆盖“业务已发货但进程在更新 `pay_orders` 前退出”的极端窗口。
推荐直接注册完整业务模块:
```go
if err := paymentUsecase.RegisterBusinessModule(GameItemPayment{orders: gameOrders}); err != nil {
return err
}
```
## 一致性边界
- 本地下单:唯一索引和 `request_fingerprint` 保证同一支付号不可换金额或业务对象。
- 平台确认:只有验签后的主动查单结果可以把订单推进到 `paid`,状态不会从已支付回退到待支付或失败。
- 多实例发货:数据库行锁、处理令牌和租约保证同一时刻只有一个实例执行发货。
- 最终发货幂等:业务模块必须在自己的事务中对 `confirmation_id` 建唯一约束。
- 退款:一次只允许一个在途退款;每次退款都有持久化 `refund_no`网络超时会复用同一个退款号重试。渠道响应提供退款号回显时adapter 必须校验其与本地 `refund_no` 一致,并要求独立的平台退款号非空;平台接受退款后状态为 `pending`,只有携带匹配 `refund_no` 的退款通知或对账任务调用 `ConfirmRefund` 后才增加 `refunded_amount`
- 退款授权:业务模块必须实现 `AuthorizeRefund`,支付模块不会仅凭渠道、订单号和金额执行退款。
- 外部平台调用和本地数据库无法组成单个 ACID 事务,因此采用“本地先落单、平台接口幂等重试、主动查单、数据库状态机、业务最终幂等”的组合保证最终一致性。
## 配置示例
支付宝:
```json
{"app_id":"","private_key":"PEM","public_key":"PEM","environment":"production","sign_type":"RSA2","gateway_url":"https://openapi.alipay.com/gateway.do","method":"alipay.trade.create"}
```
`method` 可选 `alipay.trade.create`、`alipay.trade.pay`、`alipay.trade.precreate`、
`alipay.trade.app.pay`、`alipay.trade.page.pay` 或 `alipay.trade.wap.pay`
付款码支付可使用 `barcode` / `micropay` 别名,并在订单 `extra.auth_code` 中传入付款码。
支付宝 V3
```json
{"app_id":"","private_key":"PEM","app_cert":"PEM 或文件路径","root_cert":"PEM 或文件路径","public_cert":"PEM 或文件路径","environment":"production","api_base_url":"https://openapi.alipay.com","gateway_url":"https://openapi.alipay.com/gateway.do","method":"alipay.trade.create"}
```
`alipay-v3` 独立于旧 `alipay` provider。`app_cert`、`root_cert` 和 `public_cert`
分别对应 GoPay `ClientV3.SetCert` 的应用公钥证书、支付宝根证书和支付宝公钥证书;
也可使用 `*_content` / `*_path` 以及 `alipay_root_cert*`、`alipay_public_cert*`
兼容别名。V3 HTTP 接口使用 `api_base_url`(测试代理可指向本地 mock页面/APP
调起参数使用 GoPay 已封装的 `TradeAppPay`、`TradePagePay` 和 `TradeWapPay`
`method` 支持 `alipay.trade.create`、`alipay.trade.pay`、`alipay.trade.precreate`、
`alipay.trade.app.pay`、`alipay.trade.page.pay` 和 `alipay.trade.wap.pay`。V3 的
REST 响应只有在证书验签通过后才会进入业务层,通知回调同样使用 GoPay 的证书验签。
微信支付 v2
```json
{"app_id":"","merchant_id":"","mch_key":"","sign_type":"MD5","trade_type":"NATIVE","client_cert":"PEM","client_key":"PEM"}
```
`client_cert` / `client_key` 在退款等双向 TLS 请求中使用;也接受
`appid`、`mch_id`、`api_key` 等兼容别名。自定义测试端点可分别配置
`create_url`、`query_url` 和 `refund_url`。`trade_type` 支持 `JSAPI`、`APP`、
`NATIVE`、`MWEB`;付款码支付可使用 `micropay` / `barcode` 别名,并在订单
`extra.auth_code`(或渠道配置同名字段)传入付款码。未知下单方式会直接拒绝;
付款码下单返回的平台交易号和金额会校验,客户端调起数据只保存在 `Payload`
微信支付 v3
```json
{"app_id":"","merchant_id":"","serial_no":"","private_key":"PEM","api_v3_key":"32-byte key","platform_cert":"PEM","platform_serial_no":"","trade_type":"jsapi"}
```
`trade_type` 支持 `jsapi`、`app`、`native`、`h5` 和 `codepay` / `micropay`
JSAPI/小程序下单还需在订单 `extra.openid` 中传入用户标识,付款码支付则需在
订单 `extra.auth_code` 中传入用户付款码(兼容 `authcode` / `barcode` 字段名)。
Apple 内购:
```json
{"issuer_id":"","key_id":"","bundle_id":"","private_key":"PEM","price_divisor":"10","environment":"production"}
```
Apple Server API 的交易 `price` 使用平台返回的单位;`price_divisor` 必须按业务订单使用的最小货币单位配置。金额不能整除时,支付模块拒绝确认。
Apple 多币种可使用 `price_divisors` 对不同 `currency` 分别配置比例;服务端不会把 Apple 的价格字段默认当作人民币分。
Apple 没有传统服务端“预下单”。创建接口要求 `tradeNo` 是 UUID并把它作为 `appAccountToken` 返回给客户端;客户端发起 StoreKit 购买时必须原样传入。回调使用 `appAccountToken` 关联本地订单,使用 `transactionId` 调 Apple Server API 主动查单,两者不会混用。最终确认还会把签名载荷的 `productId` 与业务订单持久化的 `extra.product_id` 精确匹配;缺少 `appAccountToken`、Bundle ID、商品 ID 或环境不一致、JWS 算法不是 ES256、证书链校验失败时均拒绝确认。
adapter 在调用 GoPay 解码前还会绑定 `x5c[0]` 叶子到 `x5c[1]`/`x5c[2]`,并检查 Apple App Store 签名证书扩展,避免仅凭叶子公钥验签。
Apple IAP 的购买流程由客户端发起,退款/撤销由 App Store 管理。本项目的
`apple-iap` adapter 不提供商户服务端主动退款,业务侧应通过 Apple 的退款流程和
后续通知/查询更新状态。
PayPal
```json
{"client_id":"","client_secret":"","webhook_id":"","environment":"sandbox","return_url":"https://merchant.example/paypal/return","cancel_url":"https://merchant.example/paypal/cancel","amount_scales":{"USD":"100","JPY":"1"}}
```
启用 PayPal 配置必须提供 `webhook_id`,回调验签会将其传给 GoPay默认金额比例按
PayPal 币种处理,也可使用 `amount_scales`/`currency_scales` 覆盖。`CAPTURE` 意图的
订单在买家批准后会由查单流程调用 GoPay `OrderCapture` 完成捕获,只有捕获成功才会
进入支付成功和发货;可显式配置 `auto_capture=false` 关闭。退款需要订单状态中已
持久化的 capture ID。
抖音支付:
```json
{"app_id":"","merchant_id":"","serial_no":"","api_key":"32-byte key","private_key":"PEM","platform_cert":"PEM","platform_serial_no":"","trade_type":"jsapi","environment":"production"}
```
当前 adapter 支持 `app`、`jsapi`、`h5`、`native`;固定版本的抖音客户端按
生产接口工作,`environment` 只能使用 `production`/`prod`。平台证书序列号也接受
GoPay 模型使用的兼容字段 `platform_cert_serial`
QQ 支付:
```json
{"mch_id":"","api_key":"","sign_type":"MD5","trade_type":"NATIVE","cert_file":"/secure/qq/apiclient_cert.pem","key_file":"/secure/qq/apiclient_key.pem","environment":"production"}
```
`sign_type` 可用 `MD5``HMAC-SHA256`。退款必须配置
`cert_file` + `key_file`,或 `pkcs12_file`;也可使用对应的 `*_content` 字段。
通联支付AllinPay
```json
{"cus_id":"","app_id":"","private_key":"PEM","public_key":"PEM","org_id":"","pay_type":"W02","query_order_type":"reqsn","currency":"CNY","environment":"production"}
```
`query_order_type` 只允许 `reqsn``trxid`,默认使用商户订单号 `reqsn`
选择 `trxid` 时,下单响应必须返回交易号并将其持久化为后续查单、退款标识;
Native 下单不会返回该标识,因此不能与 `trxid` 模式组合。该 provider 只接入
下单、查单和退款,不接收通知;支付确认由主动查单/对账触发。
拉卡拉Lakala
```json
{"partner_code":"","credential_code":"","channel":"Wechat","method":"jsapi","currency":"JPY","environment":"production"}
```
`method` 支持 `jsapi`、`h5`、`mini`、`native`、`qrcode`、`native_jsapi`、
`sdk`、`web`、`retail` 和 `retail_qrcode`,并映射到 GoPay v1.5.122 对应的
创建方法;未知值会直接拒绝。下单响应中的 `order_id` 是持久化查单键,二维码、
跳转 URL 和 SDK 参数只放在创建结果 `Payload`。支持查单、退款和 GoPay 通知验签。
当前固定版本客户端只允许生产环境配置。
扫呗Saobei
```json
{"inst_no":"","key":"","merchant_no":"","terminal_id":"","access_token":"","pay_type":"010","currency":"CNY","environment":"production"}
```
该 provider 只接入条码/小程序下单、查单和退款,不接收通知;支付确认由主动
查单/对账触发。条码支付在订单 `extra.auth_no` 中传入付款码,也兼容
`auth_code`、`authcode`、`barcode` 和 `pay_code` 字段名。
非 GoPay 的配置驱动聚合/小游戏渠道最少需要:
```json
{
"protocol_version":"以商户协议为准",
"app_id":"",
"merchant_id":"",
"create_url":"",
"query_url":"",
"refund_url":"",
"app_key":"",
"query_status_field":"data.status",
"query_success_values":"SUCCESS,PAID",
"query_trade_no_field":"data.trade_no",
"query_provider_trade_no_field":"data.transaction_id",
"query_amount_field":"data.amount",
"query_currency_field":"data.currency",
"query_amount_scale":"1",
"callback_status_field":"data.status",
"callback_success_values":"SUCCESS,PAID",
"callback_trade_no_field":"data.trade_no",
"callback_provider_trade_no_field":"data.transaction_id"
}
```
`query_amount_scale = 1` 表示响应已经是最小货币单位整数;`100` 表示响应是元/主货币单位并转换为分;其他值必须是 10 的幂。字段路径使用点号访问 JSON 对象。
聚合/小游戏渠道如需保存优惠和内部资产拆分,可额外配置:
```json
{
"query_payer_paid_amount_field":"data.payer_total",
"query_cash_paid_amount_field":"data.cash_fee",
"query_point_paid_amount_field":"data.point_fee",
"query_discount_amount_field":"data.discount",
"query_provider_discount_amount_field":"data.provider_discount",
"query_merchant_discount_amount_field":"data.merchant_discount",
"query_settlement_amount_field":"data.settlement_amount",
"query_payer_currency_field":"data.payer_currency",
"query_payer_paid_amount_scale":"100",
"query_cash_paid_amount_scale":"100"
}
```
金额拆分字段缺失时,适配器把 `amount_breakdown_known` 设为 `false`,不会将未知优惠归给平台或商户。字段存在但不能按配置的整数比例精确换算时,查单失败并要求修正渠道配置。
内部支付使用 `provider = internal`、`payment_mode = internal`,不调用第三方平台。业务模块实现 `PayInternal`,以 `trade_no` 原子、幂等扣减积分、余额或其他内部资产,并返回授权号和完整金额拆分;仍然走同一套 `pay_orders`、查单/确认、发货和退款状态机。内部退款必须由业务模块以同一订单幂等执行 `RefundInternal`
Chinaums、SFT、Supper Pay、微信小游戏和抖音小游戏没有在代码中假设所有商户都使用同一合同版本。启用前必须拿实际商户文档和沙箱报文逐项确认请求字段、签名串、金额单位、回调字段和成功状态缺少 `protocol_version` 或明确回调字段映射时配置校验会拒绝启用。这里提供的是严格失败的适配框架,不应在未完成渠道联调测试时标记为生产可用。
## 回调 ACK
回调接口不会返回内部错误文本、平台原始查询结果或业务发货信息。支付宝、微信 v2、微信 v3 和 Apple 使用各自固定 ACK聚合渠道默认返回纯文本 `success` / `failure`,并可按实际协议配置:
```json
{
"callback_success_status":"200",
"callback_success_content_type":"application/json",
"callback_success_body":"{\"code\":\"SUCCESS\"}",
"callback_failure_status":"500",
"callback_failure_content_type":"application/json",
"callback_failure_body":"{\"code\":\"FAIL\"}"
}
```
状态码只允许 `200-599`Content-Type 禁止换行,响应体最大 64KiB。验签、主动查单、订单解析、金额校验或发货失败时返回失败 ACK让支持重试的平台再次通知查单为 `pending` / `failed` 时不发货,但通知已被安全处理,因此返回成功 ACK。
## HTTP 接口
- `GET /integration/configs/payment`
- `GET /integration/configs/payment/:provider`
- `PUT /integration/configs/payment/:provider`
- `DELETE /integration/configs/payment/:provider`
- `POST /payment/order`
- `POST /payment/create`
- `POST /payment/query`
- `POST /payment/refund`
- `POST /payment/callback/:provider`
`amount` 使用整数,单位是业务约定的最小货币单位。`extra` 传递渠道特有字段,例如 `openid`、`trade_type`、`product_id`。
## 日志
支付日志在 `internal/biz/payment/payment_log.go` 通过 `PaymentLogger` 独立抽象,包含下单、查单失败、回调查单、金额校验、重复回调和发货结果等结构化事件。日志只记录渠道、商户订单号、业务类型、业务 ID、确认 ID 等审计字段,不记录私钥、密钥、证书内容或完整敏感回调原文。

View File

@ -1,87 +0,0 @@
# `` 到 `pkg` 复用性审查
审查原则:公共包只提供跨模块稳定的机制、协议或无状态纯函数;不能依赖
`app/*/internal`,也不承载 system 的业务表、用例、provider 客户端或运行时
配置。
## 已抽取到 `pkg`
| 能力 | 公共位置 | 说明 |
| --- | --- | --- |
| HTTP JSON 响应契约 | `pkg/httpx` | `Response`、`PageResult`、状态码和 Gin 响应助手;`server/httpx/response.go` 仅保留 system 适配。 |
| protobuf JSON 局部合并 | `pkg/protoutil` | 与业务无关的字段归一化和局部反序列化;初始化直接使用公共包。 |
| 支付 provider/mode 标识 | `pkg/paymentkit` | provider 常量、支持列表、金额/签名/JSON 等跨模块协议;`biz/payment` 与 `biz/integration` 直接复用。 |
| 支付回调 ACK | `pkg/paymentkit` | 回调应答、失败包装和默认 provider 应答;具体渠道 SDK 留在 `internal/integration/payment`。 |
| WebSocket 通用收发 | `pkg/websocket` | Melody 的连接、事件、点对点发送、广播和会话查询封装;`internal/integration` 管理配置与生命周期。 |
| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口EMQX/Paho 与 RabbitMQ/AMQP 客户端由 `internal/integration` 管理。 |
| 模块、任务和迁移协议 | `pkg/module`、`pkg/task`、`pkg/database/migration` | 供不同业务模块注册贡献,不带 system 业务语义。 |
## system 内部保留边界
- `app`:运行时组合根,汇总依赖注入后的路由和任务贡献。
- `modules`:静态模块 catalog汇总各模块迁移、菜单、API 和默认任务。
- `modules/system`system 模块的 Definition声明系统表迁移。
- `modules/integration`integration 配置迁移和管理面贡献。
- `modules/task`:定时任务迁移和默认任务贡献。
- `modules/payment`payment 模块的 Definition声明支付迁移和支付管理面。
- `biz/system`:用户、权限、菜单、审计、媒体和系统配置等系统领域模型与用例。
- `biz/payment`:支付订单、支付流程、支付接口和支付日志。
- `biz/integration`:支付/消息队列/WebSocket 集成配置定义与校验。
- `biz/task`:定时任务模型、任务用例和任务注册协议。
- `conf`system 配置 proto、运行时快照和生成代码。
- `data`:共享数据库生命周期与配置 watcherPO/仓储按 `system`、`integration`、`task`、`payment` 子包隔离;后台 JWT claims 与签发/解析位于 `data/system/token.go`
- `initialize`:首次安装、配置迁移、种子编排和运行时重载。
- `integration`Redis、邮件、存储、支付、WebSocket、EMQX 和 RabbitMQ 的 provider 生命周期。
- `routecatalog`HTTP 公开性、操作审计、请求体策略和 API 分组/说明的统一目录。
- `service`HTTP DTO`service/dto`、DTO 与 DO 转换和应用服务。
- `server`Gin 生命周期handler、middleware、router、HTTP 适配按子包维护。
- `worker`任务调度、执行器、SSE 订阅及其并发状态。
这些代码都带有 system 的 API、配置、数据表或生命周期语义不应为了减少
文件数量搬到 `pkg`
## 目录分类约定
```text
internal/
app/ # 应用组合根
modules/ # 静态 catalog、业务模块定义及其模块级贡献
biz/
system/ # 系统领域
payment/ # 支付领域
integration/# 集成配置领域
task/ # 定时任务领域
config/ # Viper 配置、快照和热更新
global/ # 进程级共享资源入口
data/
system/ # 系统表与系统仓储,含后台 JWT token.go
integration/# 集成配置表与仓储
task/ # 定时任务表与仓储
payment/ # 支付表与仓储
initialize/ # 首次安装和配置编排
integration/ # 外部 I/O provider
server/ # Gin 生命周期,内部按 handler/middleware/router/httpx 分类
service/ # 应用服务DTO 集中在 dto 子包
worker/ # 任务运行时
```
技术角色使用子目录表达,子目录内部使用资源名,例如 `handler/payment.go`
`router/payment.go`、`dto/payment.go`。只有少量代码且没有独立边界时不建立
新包;同一角色文件较多时也不应全部堆在父目录。
## 暂不抽取的候选
1. token cookie 名称、SameSite 和反向代理策略:目前是 system 认证策略。
2. operation-audit 脱敏和请求采集:包含 system context key 与审计字段。
3. biz 查询选项和错误:当前绑定 system 的领域接口及 API reason。
只有当其他模块出现相同、稳定且不带 system 语义的契约时,才新增公共包;不要
直接把单个 system 类型搬到 `pkg`
## 验证
```text
go test ./...
```
当前全仓测试通过,且未发现对已删除旧路径的 Go import。

View File

@ -1,63 +0,0 @@
# `internal` 目录结构优化结论
参考 Go Kratos 的分层方式,顶层保留 `app`、`modules`、`biz`、`config`、`global`、
`data`、`initialize`、`integration`、`security`、`server`、`service`、`worker` 等
稳定职责。目录不是越少越好:同一技术角色文件较多时,应在所属层下分组,避免
一个目录堆积几十个文件。
## 当前结构
```text
internal/
app/ # 应用组合根
modules/ # 静态 catalog 和模块定义
modules/payment/ # payment 模块定义
biz/ # DO、usecase、repo interface
config/ # Viper 配置、快照和热更新
global/ # 进程级共享资源
data/ # PO、repo、数据库和迁移
initialize/ # 首次安装和配置编排
integration/ # 外部 I/O provider
security/ # JWT 和安全实现
server/
handler/ # Gin handler按资源命名
middleware/ # 认证、审计、限流、恢复等中间件
router/ # 各资源路由及 Routes 聚合
httpx/ # system HTTP 响应和 cookie 适配
service/
dto/ # HTTP 请求、响应和查询 DTO按模块命名
worker/ # 任务运行时
```
## 本次调整
- DTO 从 `service` 根目录归档到 `service/dto`
- handler、middleware、router、HTTP helper 分别归档到 `server` 子包。
- 子目录内文件直接使用资源名,例如 `handler/payment.go`
`router/payment.go`、`dto/payment.go`,不保留重复角色前缀。
- Wire 直接装配 `handler.ProviderSet``router.ProviderSet`server 根目录只
负责 Gin/Swagger 生命周期。
- `security/adminauth/token.go` 合并为 `security/token.go`;单文件子包没有
独立边界时不继续拆分。
- 删除只转发 `pkg/protoutil``utils/configutil`
## `internal/app` 为什么只保留应用组合
`modules/catalog.go` 是静态模块注册点,负责按依赖顺序汇总各模块
`Definition()``app/runtime.go` 只负责任务注册和依赖注入后的路由组合。
这样模块定义不再和应用组合逻辑混在一起,也不能误并入 `biz`、`service` 或
`data`
Catalog 只能自动汇总静态模块贡献;新增模块若提供运行时路由或依赖型任务,仍需
在 cmd/Wire 中显式注册。
## 其他目录审查
- `data``integration` 已按仓储或 provider 分类,边界和生命周期明确。
- `biz`、`service` 根目录采用一资源一文件;进一步拆成资源子包会改变 Go 包
边界并容易引入循环依赖,本次不做纯视觉拆分。
- `security` 当前只有一个文件,但安全实现是明确的依赖边界,后续认证机制也会
在此扩展,因此保留顶层包。
新增目录应至少满足独立依赖方向、状态生命周期或稳定技术角色之一。不要回到
一文件一目录,也不要为了减少目录数量把大量不同角色重新铺平。

View File

@ -1,31 +0,0 @@
// Package app is the composition root for the running administration service.
// It wires runtime objects that need constructed dependencies. Static module
// declarations live in the sibling internal/modules package.
package app
import (
"kra/pkg/module"
platformtask "kra/pkg/task"
)
// TaskRegistry builds the process-wide registry from dependency-free module
// contributions. Dependency-bearing methods are added by their module runtime
// constructors after the usecases have been created.
func TaskRegistry(catalog module.Catalog) *platformtask.Registry {
registry := platformtask.NewRegistry()
registry.RegisterAll(catalog.TaskMethods())
return registry
}
// Composition groups runtime objects that need constructed dependencies. The
// binary composition root supplies the concrete modules.
type Composition struct {
Routes []module.RouteRegistrar
Tasks []platformtask.Contributor
}
// Build activates dependency-bearing tasks and composes module routes.
func Build(contributions Composition, registry *platformtask.Registry) *module.Runtime {
platformtask.Apply(registry, contributions.Tasks...)
return module.NewRuntime(contributions.Routes...)
}

View File

@ -1,55 +0,0 @@
package app
import (
"context"
"encoding/json"
"testing"
"github.com/gin-gonic/gin"
"kra/pkg/module"
platformtask "kra/pkg/task"
)
func TestTaskRegistryRegistersStaticModuleMethods(t *testing.T) {
method := platformtask.Method{
Name: "test.static",
Run: func(context.Context, json.RawMessage) error { return nil },
}
catalog := module.Catalog{Definitions: []module.Definition{{Tasks: []platformtask.Method{method}}}}
registry := TaskRegistry(catalog)
if _, ok := registry.Lookup(method.Name); !ok {
t.Fatalf("method %q was not registered", method.Name)
}
}
type testRouteRegistrar struct{ called bool }
func (registrar *testRouteRegistrar) RegisterRoutes(*gin.RouterGroup, *gin.RouterGroup, *gin.Engine) {
registrar.called = true
}
type testTaskContributor struct{ name string }
func (contributor testTaskContributor) RegisterTasks(registry *platformtask.Registry) {
registry.Register(platformtask.Method{
Name: contributor.name,
Run: func(context.Context, json.RawMessage) error { return nil },
})
}
func TestRuntimeAppliesAllContributions(t *testing.T) {
registry := platformtask.NewRegistry()
route := &testRouteRegistrar{}
runtime := Build(Composition{
Routes: []module.RouteRegistrar{route},
Tasks: []platformtask.Contributor{testTaskContributor{name: "test.runtime"}},
}, registry)
if _, ok := registry.Lookup("test.runtime"); !ok {
t.Fatal("runtime task contributor was not applied")
}
runtime.RegisterRoutes(nil, nil, nil)
if !route.called {
t.Fatal("runtime route contributor was not called")
}
}

View File

@ -0,0 +1,15 @@
package system
import (
"context"
"time"
)
// Cache is the shared cache seam. The data implementation uses Redis when it
// is reachable and falls back to process memory for local development.
type Cache interface {
Get(context.Context, string) (string, bool, error)
Set(context.Context, string, string, time.Duration) error
Delete(context.Context, string) error
Increment(context.Context, string, time.Duration) (int64, error)
}

View File

@ -1,114 +0,0 @@
package system
import (
"context"
"errors"
"io"
"time"
)
// Cache is the shared cache seam. The data implementation uses Redis when it
// is reachable and falls back to process memory for local development.
type Cache interface {
Get(context.Context, string) (string, bool, error)
Set(context.Context, string, string, time.Duration) error
Delete(context.Context, string) error
Increment(context.Context, string, time.Duration) (int64, error)
}
type StoredFile struct {
Name string
Path string
URL string
Size int64
LastModified time.Time
ContentType string
}
// FileStorage owns the persistence boundary for uploaded files.
type FileStorage interface {
Put(context.Context, string, io.Reader) (*StoredFile, error)
Open(context.Context, string) (io.ReadCloser, error)
Delete(context.Context, string) error
Compose(context.Context, []string, string) (*StoredFile, string, error)
DeletePrefix(context.Context, string) error
List(context.Context, string, string, int) ([]*StoredFile, string, bool, error)
}
type JWTSettings struct {
SigningKey string
Issuer string
Expires time.Duration
Buffer time.Duration
}
type CaptchaSettings struct {
KeyLong int
ImageWidth int
ImageHeight int
StoreExpiration time.Duration
}
type MediaSettings struct {
SessionTTL int
MaxFileSize int64
ChunkDir string
}
const DefaultMaxMediaFileSize int64 = 100 << 20
func (s MediaSettings) EffectiveMaxFileSize() int64 {
if s.MaxFileSize > 0 {
return s.MaxFileSize
}
return DefaultMaxMediaFileSize
}
// RuntimeSettings exposes only the active values needed by the application.
// The data implementation resolves every call from config.Store so hot reloads
// take effect without rebuilding services.
type RuntimeSettings interface {
RouterPrefix() string
JWTSettings() JWTSettings
CaptchaSettings() CaptchaSettings
MediaSettings() MediaSettings
UseMultipoint() bool
}
type IssuedToken struct {
Value string
ExpiresAt time.Time
TTL time.Duration
}
type AuthClaims struct {
UUID string
ID uint
Username string
NickName string
AuthorityID uint
UserType string
BufferTime time.Duration
MustChangePwd bool
PasswordVersion int64
Issuer string
Audience []string
IssuedAt time.Time
NotBefore time.Time
ExpiresAt time.Time
}
var (
ErrTokenExpired = errors.New("token expired")
ErrTokenMalformed = errors.New("token malformed")
ErrTokenSignatureInvalid = errors.New("token signature invalid")
ErrTokenNotValidYet = errors.New("token not valid yet")
ErrTokenInvalid = errors.New("token invalid")
ErrTokenDisabled = errors.New("token disabled")
)
type TokenIssuer interface {
IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error)
ReissueToken(*AuthClaims, uint) (*IssuedToken, error)
ParseToken(string) (*AuthClaims, error)
}

View File

@ -0,0 +1,43 @@
package system
import "time"
type JWTSettings struct {
SigningKey string
Issuer string
Expires time.Duration
Buffer time.Duration
}
type CaptchaSettings struct {
KeyLong int
ImageWidth int
ImageHeight int
StoreExpiration time.Duration
}
type MediaSettings struct {
SessionTTL int
MaxFileSize int64
ChunkDir string
}
const DefaultMaxMediaFileSize int64 = 100 << 20
func (s MediaSettings) EffectiveMaxFileSize() int64 {
if s.MaxFileSize > 0 {
return s.MaxFileSize
}
return DefaultMaxMediaFileSize
}
// RuntimeSettings exposes only the active values needed by the application.
// The data implementation resolves every call from config.Store so hot reloads
// take effect without rebuilding services.
type RuntimeSettings interface {
RouterPrefix() string
JWTSettings() JWTSettings
CaptchaSettings() CaptchaSettings
MediaSettings() MediaSettings
UseMultipoint() bool
}

View File

@ -0,0 +1,26 @@
package system
import (
"context"
"io"
"time"
)
type StoredFile struct {
Name string
Path string
URL string
Size int64
LastModified time.Time
ContentType string
}
// FileStorage owns the persistence boundary for uploaded files.
type FileStorage interface {
Put(context.Context, string, io.Reader) (*StoredFile, error)
Open(context.Context, string) (io.ReadCloser, error)
Delete(context.Context, string) error
Compose(context.Context, []string, string) (*StoredFile, string, error)
DeletePrefix(context.Context, string) error
List(context.Context, string, string, int) ([]*StoredFile, string, bool, error)
}

View File

@ -0,0 +1,48 @@
package system
import (
"errors"
"time"
)
// The token contract lives apart from the other infrastructure seams because
// the transport middleware and every service that authenticates a request reach
// for these types and errors without caring about cache or storage.
type IssuedToken struct {
Value string
ExpiresAt time.Time
TTL time.Duration
}
type AuthClaims struct {
UUID string
ID uint
Username string
NickName string
AuthorityID uint
UserType string
BufferTime time.Duration
MustChangePwd bool
PasswordVersion int64
Issuer string
Audience []string
IssuedAt time.Time
NotBefore time.Time
ExpiresAt time.Time
}
var (
ErrTokenExpired = errors.New("token expired")
ErrTokenMalformed = errors.New("token malformed")
ErrTokenSignatureInvalid = errors.New("token signature invalid")
ErrTokenNotValidYet = errors.New("token not valid yet")
ErrTokenInvalid = errors.New("token invalid")
ErrTokenDisabled = errors.New("token disabled")
)
type TokenIssuer interface {
IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error)
ReissueToken(*AuthClaims, uint) (*IssuedToken, error)
ParseToken(string) (*AuthClaims, error)
}

View File

@ -1,41 +1,12 @@
package config
// Clone returns a deep copy of a configuration snapshot. The copy helpers are
// intentionally kept in the config package so data/integration code does not
// need protobuf cloning or storage-specific serializers.
func Clone(value *Config) *Config { return cloneConfig(value) }
func CloneData(value *Data) *Data {
if value == nil {
return nil
}
return cloneConfig(&Config{Data: value}).Data
}
func CloneAdmin(value *Admin) *Admin {
if value == nil {
return nil
}
return cloneConfig(&Config{Admin: value}).Admin
}
func CloneDatabase(value *Database) *Database {
if value == nil {
return nil
}
return cloneConfig(&Config{Data: &Data{Database: value}}).Data.Database
}
func CloneStorage(value *Storage) *Storage {
if value == nil {
return nil
}
return cloneConfig(&Config{Admin: &Admin{Storage: value}}).Admin.Storage
}
func CloneEmail(value *Email) *Email {
if value == nil {
return nil
}
return cloneConfig(&Config{Admin: &Admin{Email: value}}).Admin.Email
}
// The exported clone surface. Deep copying lives in the config package so
// data/integration code needs neither protobuf cloning nor a storage-specific
// serializer to hand out a snapshot a caller may safely mutate. Each function
// returns nil for a nil input.
func Clone(value *Config) *Config { return cloneConfig(value) }
func CloneData(value *Data) *Data { return cloneData(value) }
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) }

209
internal/config/document.go Normal file
View File

@ -0,0 +1,209 @@
package config
import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"gopkg.in/yaml.v3"
)
// Document is config.yaml as an editable YAML tree. Persisting through the tree
// rather than re-marshalling a Config keeps two things the typed model cannot
// represent: keys this program does not know about, and the user's comments.
// Loading lives next to it in this package so one place owns the file format.
type Document struct {
path string
node yaml.Node
}
func OpenDocument(path string) (*Document, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
document := &Document{path: path}
if err = yaml.Unmarshal(raw, &document.node); err != nil {
return nil, err
}
return document, nil
}
// Set replaces a top-level section, merging into whatever is already there.
// The value is normalized through a generic map so a typed struct and a
// hand-built map produce the same tree.
func (d *Document) Set(key string, value any) error {
normalized, err := normalizeYAML(value)
if err != nil {
return err
}
return setYAMLMapping(&d.node, key, normalized)
}
// Delete removes the nested key path if present.
func (d *Document) Delete(keys ...string) { deleteYAMLMapping(&d.node, keys...) }
// Has reports whether the nested key path exists.
func (d *Document) Has(keys ...string) bool { return yamlMappingValue(&d.node, keys...) != nil }
// SetServerHTTPPort rewrites only the port of server.http.addr, keeping the
// host the user configured. A non-positive port is ignored.
func (d *Document) SetServerHTTPPort(port int) error {
if port <= 0 {
return nil
}
host := "0.0.0.0"
if addr := yamlMappingValue(&d.node, "server", "http", "addr"); addr != nil {
if currentHost, _, err := net.SplitHostPort(addr.Value); err == nil && currentHost != "" {
host = currentHost
}
}
return d.Set("server", map[string]any{"http": map[string]any{"addr": net.JoinHostPort(host, strconv.Itoa(port))}})
}
// Save writes the document through a temporary file so a crash mid-write can
// never leave a truncated config.yaml behind.
func (d *Document) Save() error {
output, err := yaml.Marshal(&d.node)
if err != nil {
return err
}
if err = os.MkdirAll(filepath.Dir(d.path), 0o755); err != nil {
return err
}
temporary, err := os.CreateTemp(filepath.Dir(d.path), ".kra-config-*.yaml")
if err != nil {
return err
}
tempName := temporary.Name()
defer os.Remove(tempName)
if _, err = temporary.Write(output); err != nil {
_ = temporary.Close()
return err
}
if err = temporary.Chmod(0o600); err != nil {
_ = temporary.Close()
return err
}
if err = temporary.Close(); err != nil {
return err
}
return os.Rename(tempName, d.path)
}
func normalizeYAML(input any) (map[string]any, error) {
raw, err := yaml.Marshal(input)
if err != nil {
return nil, err
}
var output map[string]any
if err = yaml.Unmarshal(raw, &output); err != nil {
return nil, err
}
return output, nil
}
func setYAMLMapping(node *yaml.Node, key string, value any) error {
if node.Kind == yaml.DocumentNode {
node = node.Content[0]
}
if node.Kind != yaml.MappingNode {
return fmt.Errorf("configuration root is not a mapping")
}
raw, err := yaml.Marshal(value)
if err != nil {
return err
}
var replacement yaml.Node
if err = yaml.Unmarshal(raw, &replacement); err != nil {
return err
}
for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value == key {
mergeYAMLNode(node.Content[i+1], replacement.Content[0])
return nil
}
}
node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, replacement.Content[0])
return nil
}
// mergeYAMLNode updates values produced from the runtime configuration while
// retaining keys that are not represented by the typed configuration and
// comments that were already present in the user's config file.
func mergeYAMLNode(dst, src *yaml.Node) {
if dst.Kind == yaml.MappingNode && src.Kind == yaml.MappingNode {
for i := 0; i+1 < len(src.Content); i += 2 {
key := src.Content[i].Value
found := false
for j := 0; j+1 < len(dst.Content); j += 2 {
if dst.Content[j].Value == key {
mergeYAMLNode(dst.Content[j+1], src.Content[i+1])
found = true
break
}
}
if !found {
dst.Content = append(dst.Content, src.Content[i], src.Content[i+1])
}
}
return
}
// Keep comments attached to a scalar/sequence node when its value changes.
head, line, foot := dst.HeadComment, dst.LineComment, dst.FootComment
*dst = *src
dst.HeadComment, dst.LineComment, dst.FootComment = head, line, foot
}
func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node {
if node == nil {
return nil
}
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
node = node.Content[0]
}
for _, key := range keys {
if node.Kind != yaml.MappingNode {
return nil
}
var next *yaml.Node
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value == key {
next = node.Content[i+1]
break
}
}
if next == nil {
return nil
}
node = next
}
return node
}
func deleteYAMLMapping(node *yaml.Node, keys ...string) {
if len(keys) == 0 || node == nil {
return
}
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
node = node.Content[0]
}
if node.Kind != yaml.MappingNode {
return
}
key := keys[0]
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value != key {
continue
}
if len(keys) == 1 {
node.Content = append(node.Content[:i], node.Content[i+2:]...)
return
}
deleteYAMLMapping(node.Content[i+1], keys[1:]...)
return
}
}

View File

@ -0,0 +1,39 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDocumentPreservesUnknownKeysAndUpdatesHTTPPort(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
input := "# keep this comment\nserver:\n http:\n addr: 127.0.0.1:8000\ncustom:\n enabled: true\n"
if err := os.WriteFile(path, []byte(input), 0o600); err != nil {
t.Fatal(err)
}
document, err := OpenDocument(path)
if err != nil {
t.Fatal(err)
}
if err = document.SetServerHTTPPort(9000); err != nil {
t.Fatal(err)
}
if err = document.Set("admin", map[string]any{"router_prefix": "/api"}); err != nil {
t.Fatal(err)
}
if err = document.Save(); err != nil {
t.Fatal(err)
}
loaded, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(loaded)
for _, want := range []string{"127.0.0.1:9000", "custom:", "enabled: true", "admin:", "router_prefix: /api"} {
if !strings.Contains(text, want) {
t.Fatalf("saved document missing %q:\n%s", want, text)
}
}
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
@ -128,7 +129,7 @@ func (r *Store) UpdateDatabase(database *Database) {
if config.Data == nil {
config.Data = &Data{}
}
config.Data.Database = cloneDatabase(database)
config.Data.Database = clonePtr(database)
r.replace(config, false)
}
@ -358,162 +359,115 @@ func decode(v *viper.Viper) (*Config, error) {
}
func setConfigPath(config *Config, path string) {
if config != nil && config.Admin != nil {
config.Admin.ConfigPath = path
if config == nil {
return
}
if config.Admin == nil {
config.Admin = &Admin{}
}
config.Admin.ConfigPath = path
}
// Every config type is plain data: scalars, pointers to structs, and slices of
// those. clonePtr and cloneSlice cover both shapes, so a clone function below is
// one line per pointer or slice field and nothing else. A field holding only
// scalars needs no line at all — the struct assignment already copied it.
func clonePtr[T any](value *T) *T {
if value == nil {
return nil
}
copied := *value
return &copied
}
func cloneSlice[T any](values []*T, clone func(*T) *T) []*T {
if values == nil {
return nil
}
copied := make([]*T, len(values))
for i, item := range values {
copied[i] = clone(item)
}
return copied
}
func cloneConfig(value *Config) *Config {
if value == nil {
return nil
}
// Viper's mapstructure output contains only value fields and pointers;
// round-tripping through a temporary map is unnecessarily lossy for
// durations. Explicit copy via YAML gives us a robust deep copy while
// preserving time.Duration values through the text hook.
copy := *value
if value.Server != nil {
server := *value.Server
copy.Server = &server
if value.Server.HTTP != nil {
http := *value.Server.HTTP
copy.Server.HTTP = &http
}
copied := *value
copied.Server = clonePtr(value.Server)
if copied.Server != nil {
copied.Server.HTTP = clonePtr(value.Server.HTTP)
}
if value.Data != nil {
data := *value.Data
copy.Data = &data
data.Database = cloneDatabase(value.Data.Database)
data.Redis = cloneRedis(value.Data.Redis)
data.Mongo = cloneMongo(value.Data.Mongo)
data.DatabaseList = make([]*Database, len(value.Data.DatabaseList))
for i, item := range value.Data.DatabaseList {
data.DatabaseList[i] = cloneDatabase(item)
}
data.RedisList = make([]*Redis, len(value.Data.RedisList))
for i, item := range value.Data.RedisList {
data.RedisList[i] = cloneRedis(item)
}
}
if value.Admin != nil {
copy.Admin = cloneAdmin(value.Admin)
}
return &copy
copied.Data = cloneData(value.Data)
copied.Admin = cloneAdmin(value.Admin)
return &copied
}
func cloneDatabase(value *Database) *Database {
func cloneData(value *Data) *Data {
if value == nil {
return nil
}
copy := *value
return &copy
copied := *value
copied.Database = clonePtr(value.Database)
copied.Redis = cloneRedis(value.Redis)
copied.Mongo = cloneMongo(value.Mongo)
copied.DatabaseList = cloneSlice(value.DatabaseList, clonePtr)
copied.RedisList = cloneSlice(value.RedisList, cloneRedis)
return &copied
}
func cloneRedis(value *Redis) *Redis {
if value == nil {
return nil
copied := clonePtr(value)
if copied != nil {
copied.ClusterAddrs = slices.Clone(value.ClusterAddrs)
}
copy := *value
copy.ClusterAddrs = append([]string(nil), value.ClusterAddrs...)
return &copy
return copied
}
func cloneMongo(value *Mongo) *Mongo {
if value == nil {
return nil
copied := clonePtr(value)
if copied != nil {
copied.Hosts = cloneSlice(value.Hosts, clonePtr)
}
copy := *value
copy.Hosts = make([]*MongoHost, len(value.Hosts))
for i, item := range value.Hosts {
if item != nil {
host := *item
copy.Hosts[i] = &host
}
}
return &copy
return copied
}
func cloneAdmin(value *Admin) *Admin {
if value == nil {
return nil
}
copy := *value
if value.JWT != nil {
item := *value.JWT
copy.JWT = &item
copied := *value
copied.JWT = clonePtr(value.JWT)
copied.Captcha = clonePtr(value.Captcha)
copied.Local = clonePtr(value.Local)
copied.Email = clonePtr(value.Email)
copied.Media = clonePtr(value.Media)
copied.System = clonePtr(value.System)
copied.App = clonePtr(value.App)
copied.DiskList = cloneSlice(value.DiskList, clonePtr)
copied.Storage = cloneStorage(value.Storage)
if copied.Zap = clonePtr(value.Zap); copied.Zap != nil {
copied.Zap.FileOnlyModules = slices.Clone(value.Zap.FileOnlyModules)
}
if value.Captcha != nil {
item := *value.Captcha
copy.Captcha = &item
if copied.CORS = clonePtr(value.CORS); copied.CORS != nil {
copied.CORS.Whitelist = cloneSlice(value.CORS.Whitelist, clonePtr)
}
if value.Local != nil {
item := *value.Local
copy.Local = &item
}
if value.Email != nil {
item := *value.Email
copy.Email = &item
}
if value.Media != nil {
item := *value.Media
copy.Media = &item
}
if value.System != nil {
item := *value.System
copy.System = &item
}
if value.Zap != nil {
item := *value.Zap
item.FileOnlyModules = append([]string(nil), value.Zap.FileOnlyModules...)
copy.Zap = &item
}
if value.App != nil {
item := *value.App
copy.App = &item
}
if value.CORS != nil {
item := *value.CORS
item.Whitelist = make([]*CORSRule, len(value.CORS.Whitelist))
for i, rule := range value.CORS.Whitelist {
if rule != nil {
next := *rule
item.Whitelist[i] = &next
}
}
copy.CORS = &item
}
if value.Storage != nil {
copy.Storage = cloneStorage(value.Storage)
}
copy.DiskList = make([]*Disk, len(value.DiskList))
for i, item := range value.DiskList {
if item != nil {
next := *item
copy.DiskList[i] = &next
}
}
return &copy
return &copied
}
func cloneStorage(value *Storage) *Storage {
if value == nil {
return nil
}
copy := *value
if value.Qiniu != nil {
item := *value.Qiniu
copy.Qiniu = &item
}
copy.AliyunOSS = cloneObjectStore(value.AliyunOSS)
copy.HuaweiOBS = cloneObjectStore(value.HuaweiOBS)
copy.TencentCOS = cloneObjectStore(value.TencentCOS)
copy.AWSS3 = cloneObjectStore(value.AWSS3)
copy.CloudflareR2 = cloneObjectStore(value.CloudflareR2)
copy.Minio = cloneObjectStore(value.Minio)
return &copy
}
func cloneObjectStore(value *ObjectStore) *ObjectStore {
if value == nil {
return nil
}
copy := *value
return &copy
copied := *value
copied.Qiniu = clonePtr(value.Qiniu)
copied.AliyunOSS = clonePtr(value.AliyunOSS)
copied.HuaweiOBS = clonePtr(value.HuaweiOBS)
copied.TencentCOS = clonePtr(value.TencentCOS)
copied.AWSS3 = clonePtr(value.AWSS3)
copied.CloudflareR2 = clonePtr(value.CloudflareR2)
copied.Minio = clonePtr(value.Minio)
return &copied
}

View File

@ -1,12 +0,0 @@
package data
import (
"kra/internal/config"
)
func cloneAdminConfig(value *config.Admin) *config.Admin {
if value == nil {
return &config.Admin{}
}
return config.CloneAdmin(value)
}

View File

@ -3,144 +3,24 @@ package data
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"kra/internal/config"
dataintegration "kra/internal/data/integration"
"kra/internal/integration/storage"
"gopkg.in/yaml.v3"
"gorm.io/gorm"
)
func configMap(input any) (map[string]any, error) {
raw, err := yaml.Marshal(input)
if err != nil {
return nil, err
}
var output map[string]any
if err = yaml.Unmarshal(raw, &output); err != nil {
return nil, err
}
return output, nil
}
// Writing config.yaml goes through config.Document, which owns the file format
// for both directions. This file only decides which sections to write.
func setYAMLMapping(node *yaml.Node, key string, value any) error {
if node.Kind == yaml.DocumentNode {
node = node.Content[0]
// cloneAdminConfig copies an admin section, normalizing nil to an empty value so
// callers can edit the result without a nil check.
func cloneAdminConfig(value *config.Admin) *config.Admin {
if value == nil {
return &config.Admin{}
}
if node.Kind != yaml.MappingNode {
return fmt.Errorf("configuration root is not a mapping")
}
raw, err := yaml.Marshal(value)
if err != nil {
return err
}
var replacement yaml.Node
if err = yaml.Unmarshal(raw, &replacement); err != nil {
return err
}
for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value == key {
mergeYAMLNode(node.Content[i+1], replacement.Content[0])
return nil
}
}
node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, replacement.Content[0])
return nil
}
// mergeYAMLNode updates values produced from the runtime configuration while
// retaining keys that are not represented by the typed configuration and
// comments that were already present in the user's config file.
func mergeYAMLNode(dst, src *yaml.Node) {
if dst.Kind == yaml.MappingNode && src.Kind == yaml.MappingNode {
for i := 0; i+1 < len(src.Content); i += 2 {
key := src.Content[i].Value
found := false
for j := 0; j+1 < len(dst.Content); j += 2 {
if dst.Content[j].Value == key {
mergeYAMLNode(dst.Content[j+1], src.Content[i+1])
found = true
break
}
}
if !found {
dst.Content = append(dst.Content, src.Content[i], src.Content[i+1])
}
}
return
}
// Keep comments attached to a scalar/sequence node when its value changes.
head, line, foot := dst.HeadComment, dst.LineComment, dst.FootComment
*dst = *src
dst.HeadComment, dst.LineComment, dst.FootComment = head, line, foot
}
func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node {
if node == nil {
return nil
}
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
node = node.Content[0]
}
for _, key := range keys {
if node.Kind != yaml.MappingNode {
return nil
}
var next *yaml.Node
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value == key {
next = node.Content[i+1]
break
}
}
if next == nil {
return nil
}
node = next
}
return node
}
func deleteYAMLMapping(node *yaml.Node, keys ...string) {
if len(keys) == 0 || node == nil {
return
}
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
node = node.Content[0]
}
if node.Kind != yaml.MappingNode {
return
}
key := keys[0]
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value != key {
continue
}
if len(keys) == 1 {
node.Content = append(node.Content[:i], node.Content[i+2:]...)
return
}
deleteYAMLMapping(node.Content[i+1], keys[1:]...)
return
}
}
func setServerHTTPPort(document *yaml.Node, port int) error {
if port <= 0 {
return nil
}
host := "0.0.0.0"
if addr := yamlMappingValue(document, "server", "http", "addr"); addr != nil {
if currentHost, _, err := net.SplitHostPort(addr.Value); err == nil && currentHost != "" {
host = currentHost
}
}
return setYAMLMapping(document, "server", map[string]any{"http": map[string]any{"addr": net.JoinHostPort(host, strconv.Itoa(int(port)))}})
return config.CloneAdmin(value)
}
func (d *Data) persistConfig() error {
@ -158,40 +38,28 @@ func (d *Data) persistConfigValuesLocked(dataConfig *config.Data, adminConfig *c
if adminConfig == nil || adminConfig.ConfigPath == "" {
return nil
}
configPath := adminConfig.ConfigPath
raw, err := os.ReadFile(configPath)
document, err := config.OpenDocument(adminConfig.ConfigPath)
if err != nil {
return err
}
var document yaml.Node
if err = yaml.Unmarshal(raw, &document); err != nil {
return err
}
dataValue, err := configMap(dataConfig)
if err != nil {
if err = document.Set("data", dataConfig); err != nil {
return err
}
// Storage and email live in the database; the file must not shadow them.
fileAdmin := cloneAdminConfig(adminConfig)
fileAdmin.Storage = nil
fileAdmin.Email = nil
adminValue, err := configMap(fileAdmin)
if err != nil {
if err = document.Set("admin", fileAdmin); err != nil {
return err
}
if err = setYAMLMapping(&document, "data", dataValue); err != nil {
return err
}
if err = setYAMLMapping(&document, "admin", adminValue); err != nil {
return err
}
deleteYAMLMapping(&document, "admin", "storage")
deleteYAMLMapping(&document, "admin", "email")
document.Delete("admin", "storage")
document.Delete("admin", "email")
if adminConfig.System != nil {
if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil {
if err = document.SetServerHTTPPort(adminConfig.System.Addr); err != nil {
return err
}
}
return writeConfigDocument(configPath, &document)
return document.Save()
}
// persistDatabaseConfig writes only the database selected on the init page and
@ -207,14 +75,10 @@ func (d *Data) persistDatabaseConfig(database *config.Database, signingKey strin
if configPath == "" {
return nil
}
raw, err := os.ReadFile(configPath)
document, err := config.OpenDocument(configPath)
if err != nil {
return err
}
var document yaml.Node
if err = yaml.Unmarshal(raw, &document); err != nil {
return err
}
source, err := databaseDSN(database, "")
if err != nil {
return err
@ -232,17 +96,17 @@ func (d *Data) persistDatabaseConfig(database *config.Database, signingKey strin
"config": database.Config,
"path": database.Path,
}
if err = setYAMLMapping(&document, "data", map[string]any{"database": databaseValue}); err != nil {
if err = document.Set("data", map[string]any{"database": databaseValue}); err != nil {
return err
}
if signingKey != "" {
if err = setYAMLMapping(&document, "admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil {
if err = document.Set("admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil {
return err
}
}
deleteYAMLMapping(&document, "admin", "storage")
deleteYAMLMapping(&document, "admin", "email")
return writeConfigDocument(configPath, &document)
document.Delete("admin", "storage")
document.Delete("admin", "email")
return document.Save()
}
func (d *Data) removeIntegrationConfigFromFile() error {
@ -252,48 +116,16 @@ func (d *Data) removeIntegrationConfigFromFile() error {
if configPath == "" {
return nil
}
raw, err := os.ReadFile(configPath)
document, err := config.OpenDocument(configPath)
if err != nil {
return err
}
var document yaml.Node
if err = yaml.Unmarshal(raw, &document); err != nil {
return err
}
if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil {
if !document.Has("admin", "storage") && !document.Has("admin", "email") {
return nil
}
deleteYAMLMapping(&document, "admin", "storage")
deleteYAMLMapping(&document, "admin", "email")
return writeConfigDocument(configPath, &document)
}
func writeConfigDocument(configPath string, document *yaml.Node) error {
output, err := yaml.Marshal(document)
if err != nil {
return err
}
if err = os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
temporary, err := os.CreateTemp(filepath.Dir(configPath), ".kra-config-*.yaml")
if err != nil {
return err
}
tempName := temporary.Name()
defer os.Remove(tempName)
if _, err = temporary.Write(output); err != nil {
_ = temporary.Close()
return err
}
if err = temporary.Chmod(0o600); err != nil {
_ = temporary.Close()
return err
}
if err = temporary.Close(); err != nil {
return err
}
return os.Rename(tempName, configPath)
document.Delete("admin", "storage")
document.Delete("admin", "email")
return document.Save()
}
func (d *Data) reloadConfig(ctx context.Context) error {
@ -311,6 +143,11 @@ func (d *Data) reloadConfig(ctx context.Context) error {
return fmt.Errorf("data.database and admin configuration are required")
}
next.Admin.ConfigPath = configPath
// Every client below is opened before anything is published, so a failure at
// any step leaves the running process untouched. undo closes whatever this
// call opened; commit at the end hands all of it over instead.
var undo rollback
defer undo.run()
databaseReady := databaseConnectionConfigured(next.Data.Database)
var candidateDB *gorm.DB
if databaseReady {
@ -324,14 +161,7 @@ func (d *Data) reloadConfig(ctx context.Context) error {
return fmt.Errorf("reload bootstrap database: %w", err)
}
}
closeCandidate := true
defer func() {
if closeCandidate {
if sqlDB, closeErr := candidateDB.DB(); closeErr == nil {
_ = sqlDB.Close()
}
}
}()
undo.add(func() { closeGormDB(candidateDB) })
if databaseReady {
if sqlDB, dbErr := candidateDB.DB(); dbErr != nil {
return dbErr
@ -355,12 +185,12 @@ func (d *Data) reloadConfig(ctx context.Context) error {
if legacyEmail == nil && currentAdmin != nil {
legacyEmail = currentAdmin.Email
}
storageConfig, err := resolveStorageIntegrationConfig(candidateDB.WithContext(ctx), legacyStorage)
storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), legacyStorage)
if err != nil {
return fmt.Errorf("reload storage configuration: %w", err)
}
next.Admin.Storage = storageConfig
emailConfig, err := resolveEmailIntegrationConfig(candidateDB.WithContext(ctx), legacyEmail)
emailConfig, err := dataintegration.ResolveEmailConfig(candidateDB.WithContext(ctx), legacyEmail)
if err != nil {
return fmt.Errorf("reload email configuration: %w", err)
}
@ -371,47 +201,37 @@ func (d *Data) reloadConfig(ctx context.Context) error {
}
useRedis := next.Admin.System != nil && next.Admin.System.UseRedis
candidateRedis := openRedis(next.Data.Redis, useRedis, d.logger())
candidateRedisAccepted := false
defer func() {
if !candidateRedisAccepted && candidateRedis != nil {
_ = candidateRedis.Close()
undo.add(func() {
if candidateRedis != nil {
closeRedisClient(candidateRedis)
}
}()
})
useRedisList := useRedis && next.Admin.System.UseMultipoint
candidateRedisList := openRedisList(next.Data.RedisList, useRedisList, d.logger())
candidateRedisListAccepted := false
defer func() {
if !candidateRedisListAccepted {
closeRedisList(candidateRedisList)
}
}()
undo.add(func() { closeRedisList(candidateRedisList) })
useMongo := next.Admin.System != nil && next.Admin.System.UseMongo
// openMongo always reports a nil client alongside its error, so an unreachable
// Mongo only warns: it must not fail an otherwise valid reload.
candidateMongo, mongoErr := openMongo(next.Data.Mongo, useMongo)
if mongoErr != nil {
d.logger().Error("mongo unavailable during configuration reload", "mod", "mongo", "error", mongoErr)
}
mongoAccepted := false
defer func() {
if !mongoAccepted && candidateMongo != nil {
_ = candidateMongo.Disconnect(context.Background())
undo.add(func() {
if candidateMongo != nil {
closeMongoClient(candidateMongo)
}
}()
})
candidateDBList, err := openDatabaseList(next.Data.DatabaseList, d.logger())
if err != nil {
return err
}
candidateDBListAccepted := false
defer func() {
if !candidateDBListAccepted {
closeDatabaseList(candidateDBList)
}
}()
integrationConfigs, err := readIntegrationRuntime(candidateDB.WithContext(ctx))
undo.add(func() { closeDatabaseList(candidateDBList) })
integrationConfigs, err := dataintegration.ReadRuntime(candidateDB.WithContext(ctx))
if err != nil {
return fmt.Errorf("reload integration runtime: %w", err)
}
d.gormDB.replace(candidateDB, d.enqueueDataScopeAudit)
d.replacePrimaryDB(candidateDB)
d.databaseReady.Store(databaseReady)
for _, item := range candidateDBList {
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
@ -431,14 +251,12 @@ func (d *Data) reloadConfig(ctx context.Context) error {
} else {
d.logger().Warn("keeping the previous redis list because the reloaded configuration failed to connect", "mod", "redis")
}
// A nil client here means Mongo is switched off in the reloaded configuration,
// which must still retire the running client; only an outright failure keeps it.
if mongoErr == nil {
d.mongo.replace(candidateMongo)
mongoAccepted = true
}
closeCandidate = false
candidateDBListAccepted = true
candidateRedisAccepted = true
candidateRedisListAccepted = true
undo.commit()
d.runtime.Replace(next)
if d.integrations != nil {
d.integrations.Replace(integrationConfigs)

View File

@ -19,6 +19,7 @@ import (
datatask "kra/internal/data/task"
"kra/internal/integration/runtimeconfig"
"kra/internal/integration/storage"
"kra/pkg/database/migration"
"kra/pkg/module"
)
@ -47,11 +48,11 @@ type Data struct {
initMu sync.Mutex
configMu sync.Mutex
databaseReady atomic.Bool
gormDB *reloadableDB
redis *reloadableRedis
gormDB *reloadable[*gorm.DB]
redis *reloadable[redis.UniversalClient]
redisListMu sync.RWMutex
redisList map[string]redis.UniversalClient
mongo *reloadableMongo
mongo *reloadable[*mongo.Client]
runtime *config.Store
integrations *runtimeconfig.Store
storage *storage.Reloadable
@ -71,7 +72,7 @@ func (d *Data) DB() *gorm.DB {
if d == nil || d.gormDB == nil {
return nil
}
return d.gormDB.DB()
return d.gormDB.load()
}
// DatabaseReady reports whether the configured primary database has been
@ -103,7 +104,7 @@ func (d *Data) IntegrationRuntime() *runtimeconfig.Store {
// the system export module.
func (d *Data) Database(name string) (*gorm.DB, error) {
if name == "" {
return d.gormDB.DB(), nil
return d.gormDB.load(), nil
}
d.dbListMu.RLock()
db := d.dbList[name]
@ -306,11 +307,11 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
}
}
if !usingFallback {
storageConfig, storageErr := resolveStorageIntegrationConfig(db, admin.Storage)
storageConfig, storageErr := dataintegration.ResolveStorageConfig(db, admin.Storage)
if storageErr != nil {
return nil, nil, fmt.Errorf("load storage integration configuration: %w", storageErr)
}
emailConfig, emailErr := resolveEmailIntegrationConfig(db, admin.Email)
emailConfig, emailErr := dataintegration.ResolveEmailConfig(db, admin.Email)
if emailErr != nil {
return nil, nil, fmt.Errorf("load email integration configuration: %w", emailErr)
}
@ -337,7 +338,7 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
return nil, nil, fmt.Errorf("storage manager is nil")
}
useRedis := admin != nil && admin.System != nil && admin.System.UseRedis
d.redis = newReloadableRedis(openRedis(c.Redis, useRedis, appLogger))
d.redis = newReloadable(openRedis(c.Redis, useRedis, appLogger), closeRedisClient)
useRedisList := useRedis && admin.System.UseMultipoint
d.redisList = openRedisList(c.RedisList, useRedisList, appLogger)
useMongo := admin != nil && admin.System != nil && admin.System.UseMongo
@ -346,7 +347,7 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
appLogger.Error("mongo unavailable", "mod", "mongo", "error", err)
mongoClient = nil
}
d.mongo = newReloadableMongo(mongoClient)
d.mongo = newReloadable(mongoClient, closeMongoClient)
initialized = true
return d, cleanup, nil
}
@ -437,8 +438,35 @@ func (d *Data) replaceRedisList(clients map[string]redis.UniversalClient) {
}
}
// replacePrimaryDB swaps in a hot-reloaded pool, attaching the row-level data
// scope callbacks before the handle becomes readable.
func (d *Data) replacePrimaryDB(db *gorm.DB) {
registerDataScopeCallbacks(db, d.enqueueDataScopeAudit)
d.gormDB.replace(db)
}
func (d *Data) activateDatabase(db *gorm.DB, config *config.Database) {
d.gormDB.replace(db, d.enqueueDataScopeAudit)
d.replacePrimaryDB(db)
d.runtime.UpdateDatabase(config)
d.databaseReady.Store(true)
}
// migrateAll is the single data-layer migration entry point. Every module
// must register its migrations through the application catalog; there is no
// hidden system/payment fallback that could silently omit a new module.
func migrateAll(db *gorm.DB, catalog module.Catalog) error {
return migration.Run(db, catalog.MigrationSteps())
}
// loadIntegrationRuntime republishes the database-backed integration
// configuration into the live store the long-lived adapters read.
func (d *Data) loadIntegrationRuntime(db *gorm.DB) error {
configs, err := dataintegration.ReadRuntime(db)
if err != nil {
return err
}
if d.integrations != nil {
d.integrations.Replace(configs)
}
return nil
}

View File

@ -81,7 +81,7 @@ func (w *dataScopeAuditWriter) run() {
if len(batch) == 0 || w.data == nil || w.data.gormDB == nil {
return
}
db := w.data.gormDB.DB()
db := w.data.gormDB.load()
if db == nil {
return
}

View File

@ -65,7 +65,7 @@ func TestDataScopeAuditWriterUsesReloadedDatabase(t *testing.T) {
t.Cleanup(w.Close)
d.enqueueDataScopeAudit(dataAccessLogPO{EventType: "no_identity", TargetTable: "example"})
d.gormDB.replace(second, nil)
d.gormDB.replace(second)
waitForDataScopeAuditCount(t, second, 1)
var firstCount int64

View File

@ -7,6 +7,7 @@ import (
"kra/internal/biz/system"
configpkg "kra/internal/config"
dataintegration "kra/internal/data/integration"
"kra/internal/integration/storage"
"github.com/google/uuid"
@ -172,7 +173,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
if currentAdmin != nil {
legacyStorage = currentAdmin.Storage
}
storageConfig, err := resolveStorageIntegrationConfig(candidate.WithContext(ctx), legacyStorage)
storageConfig, err := dataintegration.ResolveStorageConfig(candidate.WithContext(ctx), legacyStorage)
if err != nil {
return fmt.Errorf("initialize storage integration configuration: %w", err)
}
@ -180,7 +181,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
if currentAdmin != nil {
legacyEmail = currentAdmin.Email
}
emailConfig, err := resolveEmailIntegrationConfig(candidate.WithContext(ctx), legacyEmail)
emailConfig, err := dataintegration.ResolveEmailConfig(candidate.WithContext(ctx), legacyEmail)
if err != nil {
return fmt.Errorf("initialize email integration configuration: %w", err)
}
@ -188,7 +189,7 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
if err := d.persistDatabaseConfig(database, signingKey); err != nil {
return fmt.Errorf("persist database configuration: %w", err)
}
integrationConfigs, err := readIntegrationRuntime(candidate)
integrationConfigs, err := dataintegration.ReadRuntime(candidate)
if err != nil {
return fmt.Errorf("initialize integration runtime: %w", err)
}

View File

@ -26,6 +26,73 @@ type ConfigPO struct {
func (ConfigPO) TableName() string { return "sys_integration_configs" }
// ConfigRow is one integration configuration row without its PO: the parent
// data package owns the storage and email upgrade paths, but the table shape
// stays private here.
type ConfigRow struct {
Provider string
Enabled bool
Config string
}
// HasConfigTable reports whether the integration configuration table exists,
// which callers use to tell "not migrated yet" from "no rows".
func HasConfigTable(db *gorm.DB) bool {
return db != nil && cleanSession(db).Migrator().HasTable(&ConfigPO{})
}
// ListConfigs returns every row of a kind in insertion order.
func ListConfigs(db *gorm.DB, kind string) ([]ConfigRow, error) {
var rows []ConfigPO
if err := cleanSession(db).Where("kind = ?", kind).Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
result := make([]ConfigRow, 0, len(rows))
for _, row := range rows {
result = append(result, ConfigRow{Provider: row.Provider, Enabled: row.Enabled, Config: row.Config})
}
return result, nil
}
// FindConfig returns one row and whether it exists. A missing row is not an
// error because every caller treats it as "fall back to the legacy value".
func FindConfig(db *gorm.DB, kind, provider string) (ConfigRow, bool, error) {
var row ConfigPO
err := cleanSession(db).Where("kind = ? AND provider = ?", kind, provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return ConfigRow{}, false, nil
}
if err != nil {
return ConfigRow{}, false, err
}
return ConfigRow{Provider: row.Provider, Enabled: row.Enabled, Config: row.Config}, true, nil
}
// UpsertConfigs writes every given row of a kind in one transaction, inserting
// the ones that do not exist yet and updating the rest in place.
func UpsertConfigs(db *gorm.DB, kind string, rows []ConfigRow) error {
return cleanSession(db).Transaction(func(tx *gorm.DB) error {
for _, item := range rows {
var current ConfigPO
err := tx.Where("kind = ? AND provider = ?", kind, item.Provider).First(&current).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
err = tx.Create(&ConfigPO{Kind: kind, Provider: item.Provider, Enabled: item.Enabled, Config: item.Config}).Error
case err == nil:
err = tx.Model(&current).Updates(map[string]any{"enabled": item.Enabled, "config": item.Config}).Error
}
if err != nil {
return err
}
}
return nil
})
}
// cleanSession drops conditions inherited from the caller's handle so a scoped
// query cannot leak a WHERE clause into these statements.
func cleanSession(db *gorm.DB) *gorm.DB { return db.Session(&gorm.Session{NewDB: true}) }
type integrationConfigRepo struct{ data Provider }
type paymentConfigReader struct{ data Provider }

View File

@ -29,7 +29,7 @@ func ensureCommunicationIntegrationConfigs(db *gorm.DB) error {
for _, item := range defaults {
var row ConfigPO
err := db.Where("kind = ? AND provider = ?", item.kind, item.provider).First(&row).Error
if gorm.ErrRecordNotFound == err {
if errors.Is(err, gorm.ErrRecordNotFound) {
values, marshalErr := json.Marshal(integrationbiz.DefaultIntegrationConfig(item.kind, item.provider))
if marshalErr != nil {
return marshalErr

View File

@ -0,0 +1,219 @@
package integration
import (
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/config"
"gorm.io/gorm"
)
const (
kindStorage = "storage"
kindEmail = "email"
providerSMTP = "smtp"
)
var storageProviders = []string{
"local", "qiniu", "aliyun-oss", "huawei-obs", "tencent-cos", "aws-s3", "cloudflare-r2", "minio",
}
func normalizeStorageType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return "local"
}
return value
}
func storageProviderValue(storage *config.Storage, provider string) any {
if storage == nil {
storage = &config.Storage{}
}
switch provider {
case "qiniu":
if storage.Qiniu == nil {
storage.Qiniu = &config.Qiniu{}
}
return storage.Qiniu
case "aliyun-oss":
return ensureObjectStore(&storage.AliyunOSS)
case "huawei-obs":
return ensureObjectStore(&storage.HuaweiOBS)
case "tencent-cos":
return ensureObjectStore(&storage.TencentCOS)
case "aws-s3":
return ensureObjectStore(&storage.AWSS3)
case "cloudflare-r2":
return ensureObjectStore(&storage.CloudflareR2)
case "minio":
return ensureObjectStore(&storage.Minio)
default:
return nil
}
}
func ensureObjectStore(value **config.ObjectStore) *config.ObjectStore {
if *value == nil {
*value = &config.ObjectStore{}
}
return *value
}
func marshalStorageProvider(storage *config.Storage, provider string) (string, error) {
value := storageProviderValue(storage, provider)
if value == nil {
return "{}", nil
}
raw, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(raw), nil
}
func unmarshalStorageProvider(storage *config.Storage, provider, value string) error {
if provider == "local" || strings.TrimSpace(value) == "" {
return nil
}
target := storageProviderValue(storage, provider)
if target == nil {
return nil
}
if !json.Valid([]byte(value)) {
return fmt.Errorf("invalid %s integration configuration", provider)
}
if err := json.Unmarshal([]byte(value), target); err != nil {
return fmt.Errorf("decode %s integration configuration: %w", provider, err)
}
return nil
}
func SaveStorageConfig(db *gorm.DB, storage *config.Storage) error {
if storage == nil {
storage = &config.Storage{}
}
active := normalizeStorageType(storage.Type)
known := false
for _, provider := range storageProviders {
if provider == active {
known = true
break
}
}
if !known {
return fmt.Errorf("unsupported storage type %q", active)
}
rows := make([]ConfigRow, 0, len(storageProviders))
for _, provider := range storageProviders {
value, err := marshalStorageProvider(storage, provider)
if err != nil {
return fmt.Errorf("encode %s integration configuration: %w", provider, err)
}
rows = append(rows, ConfigRow{Provider: provider, Enabled: provider == active, Config: value})
}
return UpsertConfigs(db, kindStorage, rows)
}
func LoadStorageConfig(db *gorm.DB) (*config.Storage, bool, error) {
rows, err := ListConfigs(db, kindStorage)
if err != nil {
return nil, false, err
}
if len(rows) == 0 {
return nil, false, nil
}
storage := &config.Storage{Type: "local"}
for _, row := range rows {
if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil {
return nil, false, err
}
if row.Enabled {
storage.Type = row.Provider
}
}
return storage, true, nil
}
// ResolveStorageConfig imports a legacy YAML value only when the database has
// no storage configuration yet. The database is authoritative afterwards.
func ResolveStorageConfig(db *gorm.DB, legacy *config.Storage) (*config.Storage, error) {
if !HasConfigTable(db) {
if legacy == nil {
return &config.Storage{Type: "local"}, nil
}
return config.CloneStorage(legacy), nil
}
storage, found, err := LoadStorageConfig(db)
if err != nil {
return nil, err
}
if found {
return storage, nil
}
if legacy == nil {
legacy = &config.Storage{Type: "local"}
}
if err = SaveStorageConfig(db, legacy); err != nil {
return nil, err
}
storage, _, err = LoadStorageConfig(db)
return storage, err
}
func defaultEmailConfig() *config.Email { return &config.Email{Port: 465, IsSSL: true} }
func SaveEmailConfig(db *gorm.DB, email *config.Email) error {
if email == nil {
email = defaultEmailConfig()
}
raw, err := json.Marshal(email)
if err != nil {
return fmt.Errorf("encode smtp integration configuration: %w", err)
}
enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0
return UpsertConfigs(db, kindEmail, []ConfigRow{{Provider: providerSMTP, Enabled: enabled, Config: string(raw)}})
}
func LoadEmailConfig(db *gorm.DB) (*config.Email, bool, error) {
row, found, err := FindConfig(db, kindEmail, providerSMTP)
if err != nil || !found {
return nil, false, err
}
if !json.Valid([]byte(row.Config)) {
return nil, false, errors.New("invalid smtp integration configuration")
}
email := defaultEmailConfig()
if err = json.Unmarshal([]byte(row.Config), email); err != nil {
return nil, false, fmt.Errorf("decode smtp integration configuration: %w", err)
}
return email, true, nil
}
func ResolveEmailConfig(db *gorm.DB, legacy *config.Email) (*config.Email, error) {
if !HasConfigTable(db) {
if legacy == nil {
return defaultEmailConfig(), nil
}
return config.CloneEmail(legacy), nil
}
email, found, err := LoadEmailConfig(db)
if err != nil {
return nil, err
}
if found {
return email, nil
}
if legacy == nil {
legacy = defaultEmailConfig()
}
if err = SaveEmailConfig(db, legacy); err != nil {
return nil, err
}
email, _, err = LoadEmailConfig(db)
return email, err
}

View File

@ -2,292 +2,30 @@ package data
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/config"
dataintegration "kra/internal/data/integration"
"gorm.io/gorm"
)
const (
integrationKindStorage = "storage"
integrationKindEmail = "email"
)
var storageProviderNames = []string{
"local",
"qiniu",
"aliyun-oss",
"huawei-obs",
"tencent-cos",
"aws-s3",
"cloudflare-r2",
"minio",
}
func normalizeStorageType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return "local"
}
return value
}
func storageProviderValue(storage *config.Storage, provider string) any {
if storage == nil {
storage = &config.Storage{}
}
switch provider {
case "qiniu":
if storage.Qiniu == nil {
storage.Qiniu = &config.Qiniu{}
}
return storage.Qiniu
case "aliyun-oss":
return ensureObjectStore(&storage.AliyunOSS)
case "huawei-obs":
return ensureObjectStore(&storage.HuaweiOBS)
case "tencent-cos":
return ensureObjectStore(&storage.TencentCOS)
case "aws-s3":
return ensureObjectStore(&storage.AWSS3)
case "cloudflare-r2":
return ensureObjectStore(&storage.CloudflareR2)
case "minio":
return ensureObjectStore(&storage.Minio)
default:
return nil
}
}
func ensureObjectStore(value **config.ObjectStore) *config.ObjectStore {
if *value == nil {
*value = &config.ObjectStore{}
}
return *value
}
func marshalStorageProvider(storage *config.Storage, provider string) (string, error) {
value := storageProviderValue(storage, provider)
if value == nil {
return "{}", nil
}
raw, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(raw), nil
}
func unmarshalStorageProvider(storage *config.Storage, provider, value string) error {
if provider == "local" || strings.TrimSpace(value) == "" {
return nil
}
target := storageProviderValue(storage, provider)
if target == nil {
return nil
}
if !json.Valid([]byte(value)) {
return fmt.Errorf("invalid %s integration configuration", provider)
}
if err := json.Unmarshal([]byte(value), target); err != nil {
return fmt.Errorf("decode %s integration configuration: %w", provider, err)
}
return nil
}
func saveStorageIntegrationConfig(db *gorm.DB, storage *config.Storage) error {
if storage == nil {
storage = &config.Storage{}
}
active := normalizeStorageType(storage.Type)
known := false
for _, provider := range storageProviderNames {
if provider == active {
known = true
break
}
}
if !known {
return fmt.Errorf("unsupported storage type %q", active)
}
return db.Session(&gorm.Session{NewDB: true}).Transaction(func(tx *gorm.DB) error {
for _, provider := range storageProviderNames {
value, err := marshalStorageProvider(storage, provider)
if err != nil {
return fmt.Errorf("encode %s integration configuration: %w", provider, err)
}
var current dataintegration.ConfigPO
err = tx.Where("kind = ? AND provider = ?", integrationKindStorage, provider).First(&current).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
current = dataintegration.ConfigPO{Kind: integrationKindStorage, Provider: provider}
current.Enabled, current.Config = provider == active, value
if err = tx.Create(&current).Error; err != nil {
return err
}
case err != nil:
return err
default:
if err = tx.Model(&current).Updates(map[string]any{"enabled": provider == active, "config": value}).Error; err != nil {
return err
}
}
}
return nil
})
}
func loadStorageIntegrationConfig(db *gorm.DB) (*config.Storage, bool, error) {
var rows []dataintegration.ConfigPO
err := db.Session(&gorm.Session{NewDB: true}).
Where("kind = ?", integrationKindStorage).
Order("id ASC").
Find(&rows).Error
if err != nil {
return nil, false, err
}
if len(rows) == 0 {
return nil, false, nil
}
storage := &config.Storage{Type: "local"}
for _, row := range rows {
if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil {
return nil, false, err
}
if row.Enabled {
storage.Type = row.Provider
}
}
return storage, true, nil
}
// resolveStorageIntegrationConfig upgrades a legacy YAML configuration only
// when the database has no storage rows yet. From then on the database is the
// sole source of truth.
func resolveStorageIntegrationConfig(db *gorm.DB, legacy *config.Storage) (*config.Storage, error) {
clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) {
if legacy == nil {
return &config.Storage{Type: "local"}, nil
}
return config.CloneStorage(legacy), nil
}
storage, found, err := loadStorageIntegrationConfig(clean)
if err != nil {
return nil, err
}
if found {
return storage, nil
}
if legacy == nil {
legacy = &config.Storage{Type: "local"}
}
if err = saveStorageIntegrationConfig(clean, legacy); err != nil {
return nil, err
}
storage, _, err = loadStorageIntegrationConfig(clean)
return storage, err
}
func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *config.Storage) error {
if !d.databaseReady.Load() {
return errors.New("database is not initialized")
}
db := d.gormDB.WithContext(ctx)
if !db.Migrator().HasTable(&dataintegration.ConfigPO{}) {
db := d.gormDB.load().WithContext(ctx)
if !dataintegration.HasConfigTable(db) {
return errors.New("integration configuration table does not exist")
}
return saveStorageIntegrationConfig(db, storage)
}
func defaultEmailIntegrationConfig() *config.Email {
return &config.Email{Port: 465, IsSSL: true}
}
func saveEmailIntegrationConfig(db *gorm.DB, email *config.Email) error {
if email == nil {
email = defaultEmailIntegrationConfig()
}
raw, err := json.Marshal(email)
if err != nil {
return fmt.Errorf("encode smtp integration configuration: %w", err)
}
enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0
clean := db.Session(&gorm.Session{NewDB: true})
var current dataintegration.ConfigPO
err = clean.Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").First(&current).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return clean.Create(&dataintegration.ConfigPO{
Kind: integrationKindEmail, Provider: "smtp", Enabled: enabled, Config: string(raw),
}).Error
case err != nil:
return err
default:
return clean.Model(&current).Updates(map[string]any{"enabled": enabled, "config": string(raw)}).Error
}
}
func loadEmailIntegrationConfig(db *gorm.DB) (*config.Email, bool, error) {
var row dataintegration.ConfigPO
err := db.Session(&gorm.Session{NewDB: true}).
Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
if !json.Valid([]byte(row.Config)) {
return nil, false, errors.New("invalid smtp integration configuration")
}
email := defaultEmailIntegrationConfig()
if err = json.Unmarshal([]byte(row.Config), email); err != nil {
return nil, false, fmt.Errorf("decode smtp integration configuration: %w", err)
}
return email, true, nil
}
func resolveEmailIntegrationConfig(db *gorm.DB, legacy *config.Email) (*config.Email, error) {
clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(&dataintegration.ConfigPO{}) {
if legacy == nil {
return defaultEmailIntegrationConfig(), nil
}
return config.CloneEmail(legacy), nil
}
email, found, err := loadEmailIntegrationConfig(clean)
if err != nil {
return nil, err
}
if found {
return email, nil
}
if legacy == nil {
legacy = defaultEmailIntegrationConfig()
}
if err = saveEmailIntegrationConfig(clean, legacy); err != nil {
return nil, err
}
email, _, err = loadEmailIntegrationConfig(clean)
return email, err
return dataintegration.SaveStorageConfig(db, storage)
}
func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *config.Email) error {
if !d.databaseReady.Load() {
return errors.New("database is not initialized")
}
db := d.gormDB.WithContext(ctx)
if !db.Migrator().HasTable(&dataintegration.ConfigPO{}) {
db := d.gormDB.load().WithContext(ctx)
if !dataintegration.HasConfigTable(db) {
return errors.New("integration configuration table does not exist")
}
return saveEmailIntegrationConfig(db, email)
return dataintegration.SaveEmailConfig(db, email)
}

View File

@ -64,21 +64,21 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
},
Minio: &config.ObjectStore{Endpoint: "127.0.0.1:9000", Bucket: "local", ForcePathStyle: true},
}
if err := saveStorageIntegrationConfig(db, storage); err != nil {
if err := dataintegration.SaveStorageConfig(db, storage); err != nil {
t.Fatal(err)
}
email := &config.Email{
To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com",
Secret: "smtp-secret", Nickname: "Kra", Port: 465, IsSSL: true,
}
if err := saveEmailIntegrationConfig(db, email); err != nil {
if err := dataintegration.SaveEmailConfig(db, email); err != nil {
t.Fatal(err)
}
if err := db.Create(&dataintegration.ConfigPO{Kind: integrationbiz.IntegrationKindPayment, Provider: "wechat-pay", Config: `{"merchant_id":"123"}`}).Error; err != nil {
t.Fatal(err)
}
loaded, found, err := loadStorageIntegrationConfig(db)
loaded, found, err := dataintegration.LoadStorageConfig(db)
if err != nil {
t.Fatal(err)
}
@ -95,7 +95,7 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
t.Fatalf("qiniu configuration = %#v", loaded.Qiniu)
}
loadedEmail, found, err := loadEmailIntegrationConfig(db)
loadedEmail, found, err := dataintegration.LoadEmailConfig(db)
if err != nil || !found {
t.Fatalf("email configuration found=%v, err=%v", found, err)
}
@ -104,17 +104,17 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
}
var storageCount, emailCount, paymentCount int64
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationKindStorage).Count(&storageCount).Error; err != nil {
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", "storage").Count(&storageCount).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationbiz.IntegrationKindPayment).Count(&paymentCount).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", integrationKindEmail).Count(&emailCount).Error; err != nil {
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", "email").Count(&emailCount).Error; err != nil {
t.Fatal(err)
}
if storageCount != int64(len(storageProviderNames)) {
t.Fatalf("storage row count = %d, want %d", storageCount, len(storageProviderNames))
if storageCount != 8 {
t.Fatalf("storage row count = %d, want 8", storageCount)
}
if paymentCount != 1 {
t.Fatalf("payment row count = %d, want 1", paymentCount)
@ -130,7 +130,7 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
Type: "qiniu",
Qiniu: &config.Qiniu{Bucket: "legacy", SecretKey: "legacy-secret"},
}
loaded, err := resolveStorageIntegrationConfig(db, legacy)
loaded, err := dataintegration.ResolveStorageConfig(db, legacy)
if err != nil {
t.Fatal(err)
}
@ -142,7 +142,7 @@ func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
Type: "minio",
Minio: &config.ObjectStore{Bucket: "must-not-replace-database"},
}
loaded, err = resolveStorageIntegrationConfig(db, other)
loaded, err = dataintegration.ResolveStorageConfig(db, other)
if err != nil {
t.Fatal(err)
}
@ -169,14 +169,14 @@ func TestMaskStorageSecretsLeavesUnconfiguredProvidersEmpty(t *testing.T) {
func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
db := openIntegrationConfigTestDB(t)
legacy := &config.Email{To: "ops@example.com", From: "old@example.com", Host: "smtp.old.example.com", Secret: "old-secret", Port: 465, IsSSL: true}
loaded, err := resolveEmailIntegrationConfig(db, legacy)
loaded, err := dataintegration.ResolveEmailConfig(db, legacy)
if err != nil {
t.Fatal(err)
}
if loaded.Host != legacy.Host || loaded.Secret != legacy.Secret {
t.Fatalf("migrated email = %#v", loaded)
}
loaded, err = resolveEmailIntegrationConfig(db, &config.Email{Host: "must-not-replace.example.com"})
loaded, err = dataintegration.ResolveEmailConfig(db, &config.Email{Host: "must-not-replace.example.com"})
if err != nil {
t.Fatal(err)
}
@ -245,11 +245,9 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
if err != nil {
t.Fatal(err)
}
reloadableDB := &reloadableDB{}
reloadableDB.current.Store(db)
d := &Data{
runtime: config.NewStore(&config.Config{Data: &config.Data{}, Admin: currentAdmin}),
gormDB: reloadableDB,
gormDB: newReloadableDB(db, nil),
storage: currentStorage,
}
d.databaseReady.Store(true)
@ -274,11 +272,11 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) {
if _, err = os.Stat(filepath.Join(newRoot, "active.txt")); err != nil {
t.Fatalf("active storage did not write to the new root: %v", err)
}
loaded, found, err := loadStorageIntegrationConfig(db)
loaded, found, err := dataintegration.LoadStorageConfig(db)
if err != nil || !found || loaded.Type != "local" {
t.Fatalf("database storage config = %#v, found=%v, err=%v", loaded, found, err)
}
loadedEmail, found, err := loadEmailIntegrationConfig(db)
loadedEmail, found, err := dataintegration.LoadEmailConfig(db)
if err != nil || !found || loadedEmail.Secret != "runtime-secret" {
t.Fatalf("database email config = %#v, found=%v, err=%v", loadedEmail, found, err)
}

View File

@ -1,22 +0,0 @@
package data
import (
"gorm.io/gorm"
dataintegration "kra/internal/data/integration"
"kra/internal/integration/runtimeconfig"
)
func readIntegrationRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) {
return dataintegration.ReadRuntime(db)
}
func (d *Data) loadIntegrationRuntime(db *gorm.DB) error {
configs, err := readIntegrationRuntime(db)
if err != nil {
return err
}
if d.integrations != nil {
d.integrations.Replace(configs)
}
return nil
}

View File

@ -1,15 +0,0 @@
package data
import (
"kra/pkg/database/migration"
"kra/pkg/module"
"gorm.io/gorm"
)
// migrateAll is the single data-layer migration entry point. Every module
// must register its migrations through the application catalog; there is no
// hidden system/payment fallback that could silently omit a new module.
func migrateAll(db *gorm.DB, catalog module.Catalog) error {
return migration.Run(db, catalog.MigrationSteps())
}

View File

@ -3,7 +3,6 @@ package data
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
@ -48,6 +47,21 @@ func (s *retiredSet[T]) take(item T) bool {
return false
}
// forget removes every pending retirement for item without closing it. A
// reload can legitimately publish a previously retired handle again (for
// example A -> B -> A); its old timers must no longer own that live handle.
func (s *retiredSet[T]) forget(item T) {
s.mu.Lock()
defer s.mu.Unlock()
for index := 0; index < len(s.items); {
if s.items[index] != item {
index++
continue
}
s.items = append(s.items[:index], s.items[index+1:]...)
}
}
func (s *retiredSet[T]) drain() []T {
s.mu.Lock()
defer s.mu.Unlock()
@ -56,138 +70,116 @@ func (s *retiredSet[T]) drain() []T {
return items
}
// reloadableDB makes the pointer swap atomic. Replaced pools stay open for
// retireGrace so in-flight GORM operations remain valid.
type reloadableDB struct {
current atomic.Pointer[gorm.DB]
retired retiredSet[*gorm.DB]
// reloadable owns one hot-swappable storage client. The database, Redis and
// Mongo handles all need the same three things — a racy-free read on every
// request, an atomic swap on reload, and a close that also drains whatever the
// grace period has not reaped yet — so they share this one implementation.
type reloadable[T comparable] struct {
mu sync.RWMutex
current T
retired retiredSet[T]
closeOne func(T)
}
func newReloadableDB(db *gorm.DB, enqueue dataScopeAuditEnqueue) *reloadableDB {
r := &reloadableDB{}
registerDataScopeCallbacks(db, enqueue)
r.current.Store(db)
return r
func newReloadable[T comparable](current T, closeOne func(T)) *reloadable[T] {
return &reloadable[T]{current: current, closeOne: closeOne}
}
func (r *reloadableDB) WithContext(ctx context.Context) *gorm.DB {
return r.current.Load().WithContext(ctx)
// load returns the active client, or the zero value when the client was never
// configured, so optional backends need no separate presence flag.
func (r *reloadable[T]) load() T {
var zero T
if r == nil {
return zero
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.current
}
func (r *reloadableDB) DB() *gorm.DB { return r.current.Load() }
func (r *reloadableDB) replace(db *gorm.DB, enqueue dataScopeAuditEnqueue) {
registerDataScopeCallbacks(db, enqueue)
old := r.current.Swap(db)
if old != nil && old != db {
r.retired.retire(old, closeGormDB)
// replace publishes next and retires the handle it displaced.
func (r *reloadable[T]) replace(next T) {
var zero T
if next != zero {
r.retired.forget(next)
}
r.mu.Lock()
old := r.current
r.current = next
r.mu.Unlock()
if old != zero && old != next {
r.retired.retire(old, func(item T) {
// Serialize the final liveness check with replace: a handle that was
// re-published while its timer fired must never be closed underneath
// the new active configuration.
r.mu.Lock()
defer r.mu.Unlock()
if r.current != item {
r.closeOne(item)
}
})
}
}
func (r *reloadableDB) close() {
all := append([]*gorm.DB{r.current.Load()}, r.retired.drain()...)
seen := map[*gorm.DB]struct{}{}
for _, db := range all {
if db == nil {
func (r *reloadable[T]) close() {
var zero T
r.mu.Lock()
current := r.current
r.current = zero
r.mu.Unlock()
seen := make(map[T]struct{})
for _, item := range append([]T{current}, r.retired.drain()...) {
if item == zero {
continue
}
if _, ok := seen[db]; ok {
if _, ok := seen[item]; ok {
continue
}
seen[db] = struct{}{}
closeGormDB(db)
seen[item] = struct{}{}
r.closeOne(item)
}
}
// rollback collects the cleanup for every client a multi-step reload opened
// before it knows whether the reload will succeed. Any early return closes
// exactly what was opened, in reverse order; commit hands every handle over to
// the process and cancels all of it. One flag replaces one accepted-boolean plus
// one deferred closure per client.
type rollback struct {
cleanups []func()
committed bool
}
func (r *rollback) add(cleanup func()) { r.cleanups = append(r.cleanups, cleanup) }
func (r *rollback) commit() { r.committed = true }
func (r *rollback) run() {
if r.committed {
return
}
for index := len(r.cleanups) - 1; index >= 0; index-- {
r.cleanups[index]()
}
}
// newReloadableDB attaches the row-level data scope callbacks the pool needs
// before it becomes readable. Data.replacePrimaryDB is the reload counterpart.
func newReloadableDB(db *gorm.DB, enqueue dataScopeAuditEnqueue) *reloadable[*gorm.DB] {
registerDataScopeCallbacks(db, enqueue)
return newReloadable(db, closeGormDB)
}
func closeGormDB(db *gorm.DB) {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
}
type reloadableMongo struct {
mu sync.RWMutex
current *mongo.Client
retired retiredSet[*mongo.Client]
}
func newReloadableMongo(client *mongo.Client) *reloadableMongo {
return &reloadableMongo{current: client}
}
func (r *reloadableMongo) replace(client *mongo.Client) {
r.mu.Lock()
old := r.current
r.current = client
r.mu.Unlock()
if old != nil && old != client {
r.retired.retire(old, closeMongoClient)
}
}
func (r *reloadableMongo) load() *mongo.Client {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.current
}
func (r *reloadableMongo) close() {
r.mu.Lock()
current := r.current
r.current = nil
r.mu.Unlock()
for _, client := range append([]*mongo.Client{current}, r.retired.drain()...) {
if client != nil {
closeMongoClient(client)
}
}
}
func closeMongoClient(client *mongo.Client) {
_ = client.Disconnect(context.Background())
}
type reloadableRedis struct {
mu sync.RWMutex
current redis.UniversalClient
retired retiredSet[redis.UniversalClient]
}
func newReloadableRedis(client redis.UniversalClient) *reloadableRedis {
return &reloadableRedis{current: client}
}
func (r *reloadableRedis) load() redis.UniversalClient {
r.mu.RLock()
defer r.mu.RUnlock()
return r.current
}
func (r *reloadableRedis) replace(client redis.UniversalClient) {
r.mu.Lock()
old := r.current
r.current = client
r.mu.Unlock()
if old != nil && old != client {
r.retired.retire(old, closeRedisClient)
}
}
func (r *reloadableRedis) close() {
r.mu.Lock()
current := r.current
r.current = nil
r.mu.Unlock()
for _, client := range append([]redis.UniversalClient{current}, r.retired.drain()...) {
if client != nil {
closeRedisClient(client)
}
}
}
func closeRedisClient(client redis.UniversalClient) {
_ = client.Close()
}

View File

@ -0,0 +1,38 @@
package data
import "testing"
type reloadableTestClient struct{ id int }
func TestReloadableReclaimsRepublishedClient(t *testing.T) {
first := &reloadableTestClient{id: 1}
second := &reloadableTestClient{id: 2}
closed := map[*reloadableTestClient]int{}
clients := newReloadable(first, func(client *reloadableTestClient) { closed[client]++ })
clients.replace(second)
clients.replace(first)
if clients.retired.take(first) {
t.Fatal("republished client remained scheduled for retirement")
}
clients.close()
if closed[first] != 1 {
t.Fatalf("active client closed %d times, want 1", closed[first])
}
if closed[second] != 1 {
t.Fatalf("retired client closed %d times, want 1", closed[second])
}
}
func TestReloadableCloseDeduplicatesHandles(t *testing.T) {
client := &reloadableTestClient{id: 1}
closed := 0
clients := newReloadable(client, func(*reloadableTestClient) { closed++ })
clients.retired.items = append(clients.retired.items, client, client)
clients.close()
if closed != 1 {
t.Fatalf("client closed %d times, want 1", closed)
}
}

View File

@ -1,20 +0,0 @@
package initialize
import (
"context"
"kra/internal/biz/system"
"kra/internal/config"
"gorm.io/gorm"
)
// Backend is the infrastructure boundary required by application
// initialization and runtime configuration management.
type Backend interface {
IsInitialized(context.Context) (bool, error)
InitializeDatabase(context.Context, *system.DatabaseConfig, func(context.Context, *gorm.DB) error) error
PersistConfig(context.Context) error
PersistRuntimeConfig(context.Context, *config.Config) error
ReloadConfig(context.Context) error
Config() *config.Config
}

View File

@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"unicode"
"kra/internal/config"
)
@ -16,7 +18,7 @@ type configurationEnvelope struct {
}
func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
current := r.backend.Config()
current := r.Config()
if current == nil {
current = &config.Config{}
}
@ -26,7 +28,7 @@ func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
}
func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error {
current := r.backend.Config()
current := r.Config()
if current == nil {
current = &config.Config{}
}
@ -56,14 +58,13 @@ func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) e
if next.Admin != nil && current.Admin != nil {
next.Admin.ConfigPath = current.Admin.ConfigPath
}
if err := refreshDatabaseSources(next.Data); err != nil {
return err
}
// The data backend rebuilds every DSN from the structured fields while
// preserving a standalone Source, so no pre-clearing is needed here.
return r.PersistRuntimeConfig(ctx, next)
}
func (r *Repo) DiskMountPoints() []string {
current := r.backend.Config()
current := r.Config()
if current == nil || current.Admin == nil {
return nil
}
@ -85,74 +86,45 @@ func mergeJSON(raw json.RawMessage, target any) error {
func mergeDataJSON(raw json.RawMessage, target **config.Data) error {
return mergeJSONMap(raw, target, func(values map[string]any) error {
if redis := jsonObject(values["redis"]); redis != nil {
if err := normalizeDuration(redis, "read_timeout", "readTimeout"); err != nil {
return err
}
if err := normalizeDuration(redis, "write_timeout", "writeTimeout"); err != nil {
return err
}
if err := normalizeRedisDurations(jsonObject(values["redis"])); err != nil {
return err
}
if items, ok := values["redis_list"].([]any); ok {
for _, item := range items {
redis := jsonObject(item)
if err := normalizeDuration(redis, "read_timeout", "readTimeout"); err != nil {
return err
}
if err := normalizeDuration(redis, "write_timeout", "writeTimeout"); err != nil {
return err
}
items, _ := values["redis_list"].([]any)
for _, item := range items {
if err := normalizeRedisDurations(jsonObject(item)); err != nil {
return err
}
}
return nil
})
}
func normalizeRedisDurations(values map[string]any) error {
if err := normalizeDuration(values, "read_timeout"); err != nil {
return err
}
return normalizeDuration(values, "write_timeout")
}
// The admin page posts the camelCase shape managementConfig produced, so only
// the duration strings still need converting once mergeJSONMap has restored the
// snake_case key names the configuration structs declare.
func mergeAdminJSON(raw json.RawMessage, target **config.Admin) error {
return mergeJSONMap(raw, target, func(values map[string]any) error {
moveJSONKey(values, "routerPrefix", "router_prefix")
if jwt := jsonObject(values["jwt"]); jwt != nil {
moveJSONKey(jwt, "signingKey", "signing_key")
moveJSONKey(jwt, "expiresTime", "expires_time")
moveJSONKey(jwt, "bufferTime", "buffer_time")
if err := normalizeDuration(jwt, "expires_time", "expiresTime"); err != nil {
return err
}
if err := normalizeDuration(jwt, "buffer_time", "bufferTime"); err != nil {
return err
}
jwt := jsonObject(values["jwt"])
if err := normalizeDuration(jwt, "expires_time"); err != nil {
return err
}
if captcha := jsonObject(values["captcha"]); captcha != nil {
moveJSONKey(captcha, "keyLong", "key_long")
moveJSONKey(captcha, "imgWidth", "img_width")
moveJSONKey(captcha, "imgHeight", "img_height")
moveJSONKey(captcha, "storeExpiration", "store_expiration")
return normalizeDuration(captcha, "store_expiration", "storeExpiration")
if err := normalizeDuration(jwt, "buffer_time"); err != nil {
return err
}
if local := jsonObject(values["local"]); local != nil {
moveJSONKey(local, "storePath", "store_path")
moveJSONKey(local, "pathPrefix", "path_prefix")
}
if media := jsonObject(values["media"]); media != nil {
moveJSONKey(media, "sessionTtl", "session_ttl")
moveJSONKey(media, "maxFileSize", "max_file_size")
moveJSONKey(media, "chunkDir", "chunk_dir")
}
if system := jsonObject(values["system"]); system != nil {
moveJSONKey(system, "useRedis", "use_redis")
moveJSONKey(system, "useMultipoint", "use_multipoint")
moveJSONKey(system, "useStrictAuth", "use_strict_auth")
moveJSONKey(system, "disableAutoMigrate", "disable_auto_migrate")
moveJSONKey(system, "useMongo", "use_mongo")
moveJSONKey(system, "iplimitCount", "iplimit_count")
moveJSONKey(system, "iplimitTime", "iplimit_time")
}
return nil
return normalizeDuration(jsonObject(values["captcha"]), "store_expiration")
})
}
func mergeEmailJSON(raw json.RawMessage, target **config.Email) error {
return mergeJSONMap(raw, target, func(values map[string]any) error {
// Two legacy dashed keys the generic snake_case pass cannot derive.
moveJSONKey(values, "is-ssl", "is_ssl")
moveJSONKey(values, "is-loginauth", "is_login_auth")
return nil
@ -167,6 +139,7 @@ func mergeJSONMap(raw json.RawMessage, target any, transform func(map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return err
}
snakeCaseKeys(values)
if transform != nil {
if err := transform(values); err != nil {
return err
@ -179,31 +152,62 @@ func mergeJSONMap(raw json.RawMessage, target any, transform func(map[string]any
return mergeJSON(normalized, target)
}
// snakeCaseKeys rewrites every camelCase key in the decoded payload to the
// snake_case name the configuration structs declare. One recursive pass replaces
// a hand-written rename list that had to grow with every new setting — and that
// silently dropped whole sections when one of them was missed.
func snakeCaseKeys(value any) {
switch typed := value.(type) {
case map[string]any:
renamed := map[string]any{}
for key, item := range typed {
snakeCaseKeys(item)
if snake := snakeCase(key); snake != key {
renamed[snake] = item
delete(typed, key)
}
}
for key, item := range renamed {
typed[key] = item
}
case []any:
for _, item := range typed {
snakeCaseKeys(item)
}
}
}
func snakeCase(key string) string {
var builder strings.Builder
for index, symbol := range key {
if !unicode.IsUpper(symbol) {
builder.WriteRune(symbol)
continue
}
if index > 0 {
builder.WriteByte('_')
}
builder.WriteRune(unicode.ToLower(symbol))
}
return builder.String()
}
func jsonObject(value any) map[string]any {
result, _ := value.(map[string]any)
return result
}
func normalizeDuration(values map[string]any, keys ...string) error {
if values == nil {
// A nil map reads as absent, so callers can pass jsonObject(...) straight in.
func normalizeDuration(values map[string]any, key string) error {
text, ok := values[key].(string)
if !ok {
return nil
}
for _, key := range keys {
raw, ok := values[key]
if !ok {
continue
}
text, ok := raw.(string)
if !ok {
return nil
}
value, err := time.ParseDuration(text)
if err != nil {
return fmt.Errorf("invalid duration %q: %w", text, err)
}
values[key] = int64(value)
return nil
value, err := time.ParseDuration(text)
if err != nil {
return fmt.Errorf("invalid duration %q: %w", text, err)
}
values[key] = int64(value)
return nil
}
@ -458,34 +462,3 @@ func preserveStorageSecrets(next, current *config.Storage) {
}
}
}
func refreshDatabaseSources(value *config.Data) error {
if value == nil {
return nil
}
if err := refreshDatabaseSource(value.Database); err != nil {
return err
}
for _, database := range value.DatabaseList {
if database == nil || database.Disable {
continue
}
if err := refreshDatabaseSource(database); err != nil {
return err
}
}
return nil
}
func refreshDatabaseSource(database *config.Database) error {
if database == nil {
return nil
}
hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" || database.Password != "" || database.Name != "" || database.Config != "" || database.Path != ""
if hasStructuredConfig {
// Driver-specific DSN construction remains in data. Clearing Source
// makes the data backend rebuild it from structured values, while a
// standalone DSN is preserved when no structured fields are supplied.
database.Source = ""
}
return nil
}

View File

@ -35,7 +35,7 @@ func TestConfigurationJSONMasksSecretsWithoutMutatingConfig(t *testing.T) {
Data: &config.Data{Database: &config.Database{Password: "database-secret", Source: "dsn"}, Redis: &config.Redis{Password: "redis-secret"}},
Admin: &config.Admin{JWT: &config.JWT{SigningKey: "jwt-secret", ExpiresTime: 24 * time.Hour}, Email: &config.Email{Secret: "email-secret"}, Storage: &config.Storage{Qiniu: &config.Qiniu{SecretKey: "storage-secret"}}},
}}
raw, err := (&Repo{backend: backend}).ConfigurationJSON()
raw, err := (&Repo{Backend: backend}).ConfigurationJSON()
if err != nil {
t.Fatal(err)
}
@ -58,7 +58,7 @@ func TestSaveConfigurationJSONMergesPartialValuesAndPreservesSecrets(t *testing.
Admin: &config.Admin{ConfigPath: "config.yaml", JWT: &config.JWT{SigningKey: "jwt-secret", Issuer: "old", ExpiresTime: time.Hour}, Captcha: &config.Captcha{StoreExpiration: time.Minute}, Email: &config.Email{Host: "old.smtp", Secret: "email-secret", IsSSL: true}},
}}
raw := json.RawMessage(`{"data":{"database":{"host":"new","password":"******"},"redis":{"read_timeout":"250ms"}},"admin":{"jwt":{"issuer":"new","signingKey":"******","expiresTime":"48h"},"captcha":{"storeExpiration":"5m"}},"email":{"host":"new.smtp","secret":"******","is-ssl":false}}`)
if err := (&Repo{backend: backend}).SaveConfigurationJSON(context.Background(), raw); err != nil {
if err := (&Repo{Backend: backend}).SaveConfigurationJSON(context.Background(), raw); err != nil {
t.Fatal(err)
}
got := backend.persisted
@ -81,7 +81,7 @@ func TestSaveConfigurationJSONPreservesStandaloneDSN(t *testing.T) {
Data: &config.Data{Database: &config.Database{Driver: "mysql", Source: "user:secret@tcp(database.example:3306)/kra"}},
Admin: &config.Admin{},
}}
if err := (&Repo{backend: backend}).SaveConfigurationJSON(context.Background(), json.RawMessage(`{"admin":{"routerPrefix":"/api"}}`)); err != nil {
if err := (&Repo{Backend: backend}).SaveConfigurationJSON(context.Background(), json.RawMessage(`{"admin":{"routerPrefix":"/api"}}`)); err != nil {
t.Fatal(err)
}
if got := backend.persisted.Data.Database.Source; got != "user:secret@tcp(database.example:3306)/kra" {

View File

@ -12,36 +12,36 @@ import (
"gorm.io/gorm"
)
// Backend is the infrastructure boundary required by application
// initialization and runtime configuration management.
type Backend interface {
IsInitialized(context.Context) (bool, error)
InitializeDatabase(context.Context, *system.DatabaseConfig, func(context.Context, *gorm.DB) error) error
PersistConfig(context.Context) error
PersistRuntimeConfig(context.Context, *config.Config) error
ReloadConfig(context.Context) error
Config() *config.Config
}
// Repo adapts Backend to system.InitializationRepo. Backend is embedded so the
// four identically shaped methods are promoted instead of hand-forwarded; this
// file adds only what the two boundaries genuinely disagree on.
type Repo struct {
backend Backend
Backend
catalog platformmodule.Catalog
}
func NewRepo(backend Backend, catalog platformmodule.Catalog) system.InitializationRepo {
return &Repo{backend: backend, catalog: catalog}
}
func (r *Repo) IsInitialized(ctx context.Context) (bool, error) {
return r.backend.IsInitialized(ctx)
return &Repo{Backend: backend, catalog: catalog}
}
// Initialize layers system and task seeding on top of the backend's database
// lifecycle, which is the one place the two interfaces differ in shape.
func (r *Repo) Initialize(ctx context.Context, input *system.DatabaseConfig) error {
return r.backend.InitializeDatabase(ctx, input, func(ctx context.Context, db *gorm.DB) error {
return r.InitializeDatabase(ctx, input, func(ctx context.Context, db *gorm.DB) error {
if err := datasystem.SeedSystemWithCatalog(ctx, db, input, r.catalog); err != nil {
return err
}
return datatask.SeedDefaults(ctx, db, r.catalog.DefaultTimedTasks())
})
}
func (r *Repo) PersistConfig(ctx context.Context) error {
return r.backend.PersistConfig(ctx)
}
func (r *Repo) PersistRuntimeConfig(ctx context.Context, value *config.Config) error {
return r.backend.PersistRuntimeConfig(ctx, value)
}
func (r *Repo) ReloadConfig(ctx context.Context) error {
return r.backend.ReloadConfig(ctx)
}

View File

@ -28,8 +28,13 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
if got := catalog.MigrationSteps(); len(got) != 8 {
t.Fatalf("module migrations = %d, want 8", len(got))
}
if surface := catalog.Surface(); len(surface.Menus) != 3 || len(surface.APIs) != 15 {
t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", len(surface.Menus), len(surface.APIs))
var menus, apis int
for _, definition := range catalog.Definitions {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
if menus != 3 || apis != 15 {
t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))

View File

@ -235,6 +235,62 @@ var routes = map[string]routeValue{
"PUT /user/setUserInfo": {group: "系统用户", description: "设置用户信息", audit: true},
}
// compiled is one catalog entry with its path pre-split into segments so that
// per-request matching neither parses the map key nor allocates. wildcard marks
// a Gin catch-all tail ("/*any"); that segment is dropped from parts because it
// absorbs every remaining segment instead of matching one.
type compiled struct {
parts []string
wildcard bool
descriptor Descriptor
}
// exact answers a canonical "METHOD /path" in a single map hit. byMethod holds
// the same entries pre-split and bucketed by method, so the fallback needed for
// path parameters and router prefixes scans one method's routes rather than the
// whole catalog, and never re-splits a pattern.
var (
exact = make(map[string]Descriptor, len(routes))
byMethod = make(map[string][]compiled)
)
func init() {
for key, value := range routes {
descriptor := descriptorFrom(key, value)
exact[descriptorKey(descriptor.Method, descriptor.Path)] = descriptor
entry := compiled{parts: splitPath(descriptor.Path), descriptor: descriptor}
if last := len(entry.parts) - 1; last >= 0 && strings.HasPrefix(entry.parts[last], "*") {
entry.parts, entry.wildcard = entry.parts[:last], true
}
byMethod[descriptor.Method] = append(byMethod[descriptor.Method], entry)
}
// Suffix matching lets two patterns answer the same URL. Ordering the
// buckets most-specific-first makes the winner deterministic instead of
// dependent on map iteration order.
for method := range byMethod {
entries := byMethod[method]
sort.SliceStable(entries, func(i, j int) bool {
if len(entries[i].parts) != len(entries[j].parts) {
return len(entries[i].parts) > len(entries[j].parts)
}
if entries[i].wildcard != entries[j].wildcard {
return entries[j].wildcard
}
return literalCount(entries[i].parts) > literalCount(entries[j].parts)
})
}
}
func literalCount(parts []string) int {
count := 0
for _, part := range parts {
if !strings.HasPrefix(part, ":") {
count++
}
}
return count
}
func splitKey(key string) (string, string) {
parts := strings.SplitN(key, " ", 2)
if len(parts) != 2 {
@ -267,20 +323,52 @@ func descriptorFrom(key string, value routeValue) Descriptor {
}
}
func pathMatches(pattern, path string) bool {
patternParts := strings.Split(strings.Trim(normalizePath(pattern), "/"), "/")
pathParts := strings.Split(strings.Trim(normalizePath(path), "/"), "/")
if len(patternParts) == 1 && patternParts[0] == "" {
return len(pathParts) == 1 && pathParts[0] == ""
func splitPath(path string) []string {
return strings.Split(strings.Trim(normalizePath(path), "/"), "/")
}
// Lookup accepts either a canonical Gin route template or an actual URL. A
// configured router prefix is tolerated by matching canonical paths by suffix.
// Every middleware resolves the descriptor on each request, so the pattern set
// is pre-split at init: a miss on the exact key costs one path split and a scan
// of the entries declared for that method, not a full catalog walk.
func Lookup(method, path string) (Descriptor, bool) {
if descriptor, ok := exact[descriptorKey(method, path)]; ok {
return descriptor, true
}
if len(pathParts) < len(patternParts) {
parts := splitPath(path)
for _, entry := range byMethod[strings.ToUpper(strings.TrimSpace(method))] {
if entry.matches(parts) {
return entry.descriptor, true
}
}
return Descriptor{}, false
}
// matches compares the pre-split pattern against a pre-split request path.
// Canonical patterns match by suffix so a router prefix is tolerated; a
// wildcard tail absorbs any number of trailing segments, so its fixed head is
// searched at every offset that leaves the wildcard something to consume.
func (c compiled) matches(pathParts []string) bool {
if len(c.parts) == 0 {
return !c.wildcard && len(pathParts) == 1 && pathParts[0] == ""
}
if len(pathParts) < len(c.parts) {
return false
}
offset := len(pathParts) - len(patternParts)
for index, patternPart := range patternParts {
if strings.HasPrefix(patternPart, "*") {
return true
if c.wildcard {
for offset := 0; offset+len(c.parts) < len(pathParts); offset++ {
if matchAt(c.parts, pathParts, offset) {
return true
}
}
return false
}
return matchAt(c.parts, pathParts, len(pathParts)-len(c.parts))
}
func matchAt(patternParts, pathParts []string, offset int) bool {
for index, patternPart := range patternParts {
actual := pathParts[offset+index]
if strings.HasPrefix(patternPart, ":") {
if actual == "" {
@ -295,22 +383,6 @@ func pathMatches(pattern, path string) bool {
return true
}
// Lookup accepts either a canonical Gin route template or an actual URL. A
// configured router prefix is tolerated by matching canonical paths by suffix.
func Lookup(method, path string) (Descriptor, bool) {
key := descriptorKey(method, path)
if value, ok := routes[key]; ok {
return descriptorFrom(key, value), true
}
for key, value := range routes {
descriptor := descriptorFrom(key, value)
if strings.EqualFold(descriptor.Method, method) && pathMatches(descriptor.Path, path) {
return descriptor, true
}
}
return Descriptor{}, false
}
func Describe(method, path string) Descriptor {
if descriptor, ok := Lookup(method, path); ok {
return descriptor

View File

@ -28,6 +28,15 @@ func TestRoutePoliciesShareOneDescriptor(t *testing.T) {
}
}
func TestLookupMatchesWildcardTail(t *testing.T) {
if descriptor, ok := Lookup("GET", "/api/swagger/index.html"); !ok || !descriptor.Public || descriptor.Path != "/swagger/*any" {
t.Fatalf("wildcard descriptor = %#v, ok=%v", descriptor, ok)
}
if _, ok := Lookup("GET", "/api/swagger"); ok {
t.Fatal("wildcard route matched without a tail")
}
}
func TestEveryRouteHasAPIMetadata(t *testing.T) {
for _, descriptor := range Descriptors() {
if descriptor.Group == "" || descriptor.Description == "" {

View File

@ -21,10 +21,10 @@ import (
)
func NewGinEngine(runtime *config.Store, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, platformmodule.NewRuntime(router.NewRoutes(handlers)), nil)
return NewGinEngineWithRuntime(runtime, access, auth, security, audit, logger, version, router.NewRoutes(handlers), nil)
}
func NewGinEngineWithRuntime(runtime *config.Store, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes *platformmodule.Runtime, ws *websocket.Server) *gin.Engine {
func NewGinEngineWithRuntime(runtime *config.Store, access *service.AccessControlService, auth middleware.TokenAuthenticator, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string, routes platformmodule.RouteRegistrar, ws *websocket.Server) *gin.Engine {
if runtime == nil {
runtime = config.NewStore(nil)
}

View File

@ -1,9 +1,12 @@
// This file is the handler package's single vocabulary for HTTP replies: the
// envelope helpers come from pkg/httpx and Claims from the auth middleware, so
// handlers name one package instead of two.
package handler
import (
"kra/internal/biz/system"
"kra/internal/server/middleware"
httpx "kra/pkg/httpx"
"kra/pkg/httpx"
"github.com/gin-gonic/gin"
)

View File

@ -11,8 +11,7 @@ import (
platformtask "kra/pkg/task"
)
// TaskMethods is the system module's dependency-bearing task contribution.
// Other modules provide their own contributor instead of editing this file.
// TaskMethods owns the dependency-bearing built-in task handlers.
type TaskMethods struct {
tasks *taskbiz.TaskUsecase
maintenance *system.MaintenanceUsecase
@ -24,8 +23,6 @@ func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceU
return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime}
}
var _ platformtask.Contributor = (*TaskMethods)(nil)
func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) {
if methods == nil || registry == nil {
return

View File

@ -6,7 +6,6 @@ package module
import (
"github.com/gin-gonic/gin"
"kra/pkg/database/migration"
"kra/pkg/task"
)
type Menu struct {
@ -33,7 +32,6 @@ type Definition struct {
Migrations []migration.Step
Surface Surface
TimedTasks []TimedTask
Tasks []task.Method
}
type Catalog struct {
@ -48,15 +46,6 @@ func (c Catalog) MigrationSteps() []migration.Step {
return steps
}
func (c Catalog) Surface() Surface {
var surface Surface
for _, item := range c.Definitions {
surface.Menus = append(surface.Menus, item.Surface.Menus...)
surface.APIs = append(surface.APIs, item.Surface.APIs...)
}
return surface
}
func (c Catalog) DefaultTimedTasks() []TimedTask {
var tasks []TimedTask
for _, item := range c.Definitions {
@ -65,41 +54,6 @@ func (c Catalog) DefaultTimedTasks() []TimedTask {
return tasks
}
func (c Catalog) TaskMethods() []task.Method {
var methods []task.Method
for _, item := range c.Definitions {
methods = append(methods, item.Tasks...)
}
return methods
}
type RouteRegistrar interface {
RegisterRoutes(public, private *gin.RouterGroup, engine *gin.Engine)
}
type Runtime struct {
routes []RouteRegistrar
}
func NewRuntime(routes ...RouteRegistrar) *Runtime {
return &Runtime{routes: append([]RouteRegistrar(nil), routes...)}
}
func (r *Runtime) Add(routes ...RouteRegistrar) {
if r == nil {
return
}
r.routes = append(r.routes, routes...)
}
func (r *Runtime) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.Engine) {
if r == nil {
return
}
for _, registrar := range r.routes {
if registrar == nil {
continue
}
registrar.RegisterRoutes(public, private, engine)
}
}

View File

@ -1,59 +1,34 @@
package module
import (
"context"
"encoding/json"
"testing"
"github.com/gin-gonic/gin"
"kra/pkg/database/migration"
"kra/pkg/task"
)
func TestCatalogCollectsContributions(t *testing.T) {
run := func(context.Context, json.RawMessage) error { return nil }
catalog := Catalog{Definitions: []Definition{
{
Name: "system",
Migrations: []migration.Step{{ID: "system_schema"}},
Surface: Surface{Menus: []Menu{{Name: "users"}}, APIs: []API{{Path: "/users"}}},
TimedTasks: []TimedTask{{Name: "system.cleanup"}},
Tasks: []task.Method{{Name: "system.cleanup", Run: run}},
},
{
Name: "orders",
Migrations: []migration.Step{{ID: "orders_schema"}},
Surface: Surface{Menus: []Menu{{Name: "orders"}}, APIs: []API{{Path: "/orders"}}},
TimedTasks: []TimedTask{{Name: "orders.expire"}},
Tasks: []task.Method{{Name: "orders.expire", Run: run}},
},
}}
if got := catalog.MigrationSteps(); len(got) != 2 || got[0].ID != "system_schema" || got[1].ID != "orders_schema" {
t.Fatalf("unexpected migrations: %#v", got)
}
if got := catalog.Surface(); len(got.Menus) != 2 || len(got.APIs) != 2 || got.Menus[1].Name != "orders" {
if got := catalog.Definitions[1].Surface; len(got.Menus) != 1 || len(got.APIs) != 1 || got.Menus[0].Name != "orders" {
t.Fatalf("unexpected surface: %#v", got)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 || got[1].Name != "orders.expire" {
t.Fatalf("unexpected default tasks: %#v", got)
}
if got := catalog.TaskMethods(); len(got) != 2 || got[1].Name != "orders.expire" {
t.Fatalf("unexpected task methods: %#v", got)
}
}
type routeRegistrarStub struct{ called bool }
func (stub *routeRegistrarStub) RegisterRoutes(*gin.RouterGroup, *gin.RouterGroup, *gin.Engine) {
stub.called = true
}
func TestRuntimeSkipsNilRouteRegistrars(t *testing.T) {
stub := &routeRegistrarStub{}
runtime := NewRuntime(nil, stub)
runtime.RegisterRoutes(nil, nil, nil)
if !stub.called {
t.Fatal("non-nil route registrar was not called")
}
}

View File

@ -1,80 +0,0 @@
// Package protoutil contains protobuf helpers that are independent of any
// application module.
package protoutil
import (
"encoding/json"
"strings"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
// MergeProtoJSON applies a JSON object as a partial protobuf update while
// retaining fields omitted by the caller.
func MergeProtoJSON(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error {
currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target)
if err != nil {
return err
}
var current map[string]any
if err = json.Unmarshal(currentRaw, &current); err != nil {
return err
}
var incoming map[string]any
if err = json.Unmarshal(patch, &incoming); err != nil {
return err
}
incoming = normalizeJSONKeys(incoming).(map[string]any)
mergeJSONObjects(current, incoming)
merged, err := json.Marshal(current)
if err != nil {
return err
}
return options.Unmarshal(merged, target)
}
func normalizeJSONKeys(value any) any {
switch item := value.(type) {
case map[string]any:
result := make(map[string]any, len(item))
for key, nested := range item {
result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested)
}
return result
case []any:
result := make([]any, len(item))
for index, nested := range item {
result[index] = normalizeJSONKeys(nested)
}
return result
default:
return value
}
}
func snakeToLowerCamel(value string) string {
if !strings.Contains(value, "_") {
return value
}
parts := strings.Split(value, "_")
result := parts[0]
for _, part := range parts[1:] {
if part != "" {
result += strings.ToUpper(part[:1]) + part[1:]
}
}
return result
}
func mergeJSONObjects(target, patch map[string]any) {
for key, value := range patch {
if incoming, ok := value.(map[string]any); ok {
if existing, exists := target[key].(map[string]any); exists {
mergeJSONObjects(existing, incoming)
continue
}
}
target[key] = value
}
}

View File

@ -17,13 +17,6 @@ type Method struct {
Run MethodFunc
}
// Contributor registers task methods whose handlers need constructed runtime
// dependencies. Modules expose a contributor and the application composition
// root activates it; the task runtime never imports business modules.
type Contributor interface {
RegisterTasks(*Registry)
}
type Registry struct {
mu sync.RWMutex
methods map[string]Method
@ -31,17 +24,6 @@ type Registry struct {
func NewRegistry() *Registry { return &Registry{methods: make(map[string]Method)} }
func Apply(registry *Registry, contributors ...Contributor) {
if registry == nil {
return
}
for _, contributor := range contributors {
if contributor != nil {
contributor.RegisterTasks(registry)
}
}
}
func (r *Registry) Register(method Method) {
if r == nil || method.Name == "" || method.Run == nil {
return

View File

@ -15,18 +15,3 @@ func TestRegistryIsComposableAndIdempotent(t *testing.T) {
t.Fatalf("unexpected methods: %#v", methods)
}
}
type testContributor struct{ name string }
func (contributor testContributor) RegisterTasks(registry *Registry) {
registry.Register(Method{Name: contributor.name, Run: func(context.Context, json.RawMessage) error { return nil }})
}
func TestApplyCollectsModuleContributors(t *testing.T) {
registry := NewRegistry()
Apply(registry, testContributor{name: "system.cleanup"}, testContributor{name: "orders.expire"})
methods := registry.List()
if len(methods) != 2 || methods[0].Name != "orders.expire" || methods[1].Name != "system.cleanup" {
t.Fatalf("unexpected contributed methods: %#v", methods)
}
}