优化结构

This commit is contained in:
Yvan 2026-08-27 22:33:43 +08:00
parent 5052835fcb
commit e407d3ac10
86 changed files with 3151 additions and 726 deletions

View File

@ -6,19 +6,22 @@ agents must follow when changing the template.
## Project structure
```
api/<domain>/<version>/ Proto sources and generated stubs. Public contract.
cmd/<app>/ Entrypoint, Wire injector, main.go.
cmd/ Entrypoint, Wire injector, main.go.
configs/ Runtime config (config.yaml). No secrets.
internal/config/ Viper config models, loading, snapshots, and reloads.
internal/global/ Process-wide shared resource registry.
internal/server/ HTTP/gRPC server wiring.
internal/service/ Transport adapters; one file per resource.
internal/server/ Gin handlers, middleware, routers, static files, HTTP wiring.
internal/service/dto/ Hand-written request and response DTOs.
internal/service/ DTO/DO transport adapters; organized by domain.
internal/biz/ Domain models, usecases, repo interfaces, errors.
internal/data/ Repo implementations, database clients, migrations.
internal/initialize/ First-install and configuration orchestration.
internal/integration/ External I/O adapters: cache, email, payment, storage.
internal/logging/ Application logging infrastructure.
internal/modules/ Built-in module schema, seed, menu, API, and task contributions.
internal/routecatalog/ HTTP route metadata and runtime policies.
internal/worker/ Timed-task runtime and scheduler.
internal/utils/ Stateless, internal-only helper packages.
pkg/ Reusable infrastructure packages.
web/ Vue administration frontend.
```
## Layering & dependency rules
@ -32,7 +35,7 @@ owns the PO; `service` is a pass-through that converts at its boundary.
│ declares │ implements
└─── repo IF ────┘
DTO Data Transfer Object — proto request / response.
DTO Data Transfer Object — hand-written HTTP request / response.
DO Domain Object — pure biz model, no proto, no storage tags.
PO Persistent Object — storage shape, owned by `data`.
```
@ -43,9 +46,9 @@ owns the PO; `service` is a pass-through that converts at its boundary.
| biz | DO | DO | DTO, PO, storage client |
| data | PO | DO ↔ PO | DTO |
- `service` imports `api/...` (DTO) and `biz` (DO). Never `data`.
- `biz` imports `api/...` only for error reason enums. Never `service`,
never `data`. The repo interface declared here is the inversion seam.
- `service` imports `internal/service/dto` (DTO) and `biz` (DO). Never `data`.
- `biz` never imports `service` or `data`. The repo interface declared here
is the inversion seam.
- `data` imports `biz` to implement the repo interface. Never `service`,
never DTOs.
- `integration` implements external I/O boundaries and may import `biz` and
@ -61,15 +64,10 @@ design rather than add the import.
**service (DTO ↔ DO)**
- `convert<Resource>` parses an incoming proto into a DO. The reverse
direction is built inline at the return site; the reply type is
whatever the proto declares — usually the resource itself
(`return &v1.<Resource>{...}, nil`), sometimes a list wrapper
(`*v1.<Resources>Set`), or `&emptypb.Empty{}` for deletes. Inlining
keeps each handler self-contained.
- Embed `Unimplemented<Resource>ServiceServer`.
- Parse AIP list requests via `filtering` / `ordering` / `pagination`;
apply `fieldmask.Update` for partial updates.
- Convert hand-written HTTP DTOs into DOs at the service boundary and build
response DTOs from returned DOs.
- Keep handlers focused on binding, authentication context, response envelopes,
and transport-specific limits.
- Validate request inputs at the service boundary before delegating to the
usecase.
- Return `biz` errors. No business rules, no storage access, no PO.
@ -78,8 +76,8 @@ design rather than add the import.
- Owns the DO (`type <Resource> struct` — no proto, no storage tags),
the usecase, and the repo interface (`type <Resource>Repo interface`).
- Owns typed errors built with `errors.NotFound` / `errors.BadRequest`
plus the API error reason enum.
- Owns typed errors built with `errors.NotFound` / `errors.BadRequest` and
stable reason strings.
- Owns `ListOption` helpers — `ListFilter`, `ListOrderBy`, `ListOffset`,
`ListLimit` — so callers compose queries without leaking storage
primitives.
@ -103,23 +101,23 @@ design rather than add the import.
**server**
- Construct HTTP/gRPC servers, apply middleware, register services. No
translation, no business logic.
- Construct the Gin engine and Kratos HTTP server, apply middleware, register
handlers and routes. No persistence access or business rules.
### Add-a-resource checklist
1. **DTO**: define `Create<Resource>` / `Get<Resource>` /
`List<Resources>` / `Update<Resource>` / `Delete<Resource>` in
`api/<domain>/<version>/`, then `make api`.
1. **DTO**: define request and response types in `internal/service/dto/`.
2. **DO + repo interface**: declare both in `biz`; build the usecase on
top of the interface.
3. **Repo impl**: implement in `data` returning `biz.<Resource>Repo`;
add a PO and the matching conversion helpers when storage shape
diverges from DO.
4. **Wiring**: register the repo constructor in `data.ProviderSet`, the
usecase in `biz.ProviderSet`, the service in `service.ProviderSet`;
register HTTP/gRPC services in `internal/server`.
5. **Regenerate**: `make all` to refresh Wire and `go.mod`.
4. **Transport**: add the service adapter, handler, router registration, and
routecatalog metadata.
5. **Wiring**: register the repo constructor in `data.ProviderSet`, the
usecase in `biz.ProviderSet`, and the service/handler providers in their sets.
6. **Regenerate**: run `make generate` or `make all` to refresh Wire and
`go.mod`.
### Testing seam
@ -129,8 +127,7 @@ tests exercise repo implementations at the storage boundary.
## Generation & generated files
Regenerate via `make api` or `make all`; never hand-edit
`*.pb.go`, `*_grpc.pb.go`, `*_http.pb.go`, or `wire_gen.go`.
Regenerate via `make generate` or `make all`; never hand-edit `wire_gen.go`.
## Naming & error reasons
@ -140,8 +137,8 @@ Regenerate via `make api` or `make all`; never hand-edit
`<Resource>Service`. PO types live inside `internal/data/`; pick a
name that fits the storage driver and convert with
`new<Resource>(do)` / `toBiz(po)` free functions.
- Error reasons: declared in `api/<domain>/<version>/error_reason.proto`,
surfaced as `Err<Resource><Cause>` in `biz`.
- Error reasons use stable strings and are surfaced as
`Err<Resource><Cause>` in `biz`.
## Commits & security

View File

@ -138,7 +138,7 @@ admin:
# Example:
# whitelist:
# - allow_origin: https://admin.example.com
# allow_headers: Content-Type,Authorization,X-Token,X-User-Id
# allow_headers: Content-Type,Authorization,X-Token
# allow_methods: POST,GET,PUT,DELETE,OPTIONS
# expose_headers: Content-Length,Content-Type
# allow_credentials: true

52
docs/ADMIN_BACKEND.md Normal file
View File

@ -0,0 +1,52 @@
# 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
```

1355
docs/OA_MIGRATION_PLAN.md Normal file

File diff suppressed because it is too large Load Diff

410
docs/PAYMENT.md Normal file
View File

@ -0,0 +1,410 @@
# 支付接入设计
## 代码分层
支付渠道适配器统一放在 `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 等审计字段,不记录私钥、密钥、证书内容或完整敏感回调原文。

261
docs/code-review-issues.md Normal file
View File

@ -0,0 +1,261 @@
# 代码审查问题清单internal + pkg
- 审查日期2026-08-27三轮。第三轮2026-08-27核查前两轮修复落地情况 + 以"高内聚低耦合 / 简单实现复杂化 / 过分拆分 / 包归属(可抽 pkg/utils"为重点重审全库
- 审查方式:第一轮 codegraph + 5 路并行深读;第二轮 6 路逐文件深读;第三轮 5 路并行(修复核查 + 重审)。所有死代码结论均经全仓库 Grep 反查调用方验证;第三轮附带 `go build ./...` 编译验证通过
- 第三轮修复核查结论:**S0×17、P0×6、P1-1/2/3/4/5/6/7/10、P4-4、S4 两条全部真实落地**(已从本文档删除,留痕见文末"已修复确认清单"**未修复**S1 死代码大部分、P2/P3/P4 结构问题大部分、1-8/1-8b/1-9/2-10/3-8/3-9
- 当前待办集中在T0 修复回归缺陷 → S1/T3 死代码 → T1 过分拆分合并 → T2 归属移动 → S2 重复消除 → P2/P4 结构
优先级定义P0 = 零风险可直接删P1 = 低风险去重P2 = 中风险删转发层P3 = 涉及 import 路径批量修改的移位P4 = 大动作结构合并/文档修正。
---
## P1 重复实现 / 双份维护(遗留)
| # | 问题 | 位置 | 建议 |
|---|------|------|------|
| 1-8 | DSN 一致性双写:`persistDatabaseConfig`、`refreshDatabaseSource`、`InitializeDatabase` 三处维护"结构化字段→Source"不变式refresh 用"先清空再恢复"绕开 databaseDSN 短路 | internal/data/config_store.go:78-94internal/data/initialization_backend.go:42-59,135-140 | 固化进 databaseDSN 唯一入口 |
| 1-8b | "storage/email 缺省则沿用现值"策略散布 4 处(文件/DB 配置合并规则;原 5 处已收敛为 4 处,但无统一 MergeRuntimeConfig | internal/config/runtime.go:275-286internal/data/config_store.go:173-183internal/data/initialization_backend.go:77-85,171-186 | config 包提供唯一 `MergeRuntimeConfig` |
| 1-9 | config.Store 与 runtimeconfig.Store 各写一套同构的 listener 注册/通知/克隆机制(语义确有差异:文件全量 vs DB 集成窄通道,可辩护为有意分离,需明确决策) | internal/config/runtime.go:29,150-196internal/integration/runtimeconfig/store.go:62-167桥接靠 data 层三处 Replacedata.go:417、config_store.go:258、initialization_backend.go:214 | 抽泛型 notifier或写决策注释固化现状 |
| 1-11 | mq.Registry 接口面积翻倍且恶化legacy APISubscribe/Unsubscribe/Publish内部转译为声明式 Register双面并存且整个 mq 体系业务消费者为零 | pkg/mq/mq.gointernal/integration/mq/emqx.go:539-589 | 见 T3整体裁撤或收缩 |
## P2 转发 shim / 门面层(过度分层)
| # | 问题 | 位置 |
|---|------|------|
| 2-1 | payment 回调 ack 穿透仍四层(第三轮部分收敛:定义已单源化到 paymentkit、biz 用别名零拷贝;但 biz/service/handler 三层转发仍在,且 dto/payment.go:58-62 重复声明同构 3 字段结构体,加字段需同步三处) | pkg/paymentkit/callback.go:11-83 → biz/payment/payment.go:206-215 → service/payment/payment.go:131-148 → handler/payment.go:173-184 |
| 2-2 | integration/payment/result.go 自称 "Compatibility shims"11 个纯转发函数全部仍在normalizePaymentStatus/parseIntegerAmount/jsonObject/nestedString 等) | internal/integration/payment/result.go:68-110 |
| 2-3 | service 根包 25 个 `type X = systemservice.X` 别名门面 + 函数转发(迁移脚手架,注释自辩 "one import instead of four";使 handler 无法感知真实包结构) | internal/service/service.go:18-51 |
| 2-4 | handler/http.go 纯转发别名层3 常量+2 类型+6 函数一对一转发到 pkg/httpx 与 middleware.Claims仅为省一个 import每个 handler 文件仍要同时认识两个包) | internal/server/handler/http.go:14-29 |
| 2-5 | service/task 双面 APIDO 签名版与 DTO 版并存DO 版 Create/Update/Tasks 仅被同文件 DTO 版内部调用handler 只用 `*Request` 版 | internal/service/task/task.go:35-46 |
| 2-6 | TaskUsecase 嵌入透传且加重:仍嵌入 TaskRepo 透传 9 方法给 worker新增 TaskApplicationUsecase 再包一层,其 6 方法纯转发——双 usecase 并存worker 持前者、service 持后者wire_gen.go:115-119 | internal/biz/task/task.go:76-79,159-237 |
| 2-7 | `openWithDriver` 单调用点便捷转发(第三轮部分修复:已拆出 openWithDriverConfigwrapper 仍有 1 个生产调用 + 约 20 处测试调用) | internal/data/database.go:134-136生产调用 :248 |
| 2-8 | Backend 接口三层缝合biz `InitializationRepo` → initialize.Repo内嵌 6 方法 Backend + seed 回调单传入点)→ data.Datainitialize 包只为"转手 6 个同名方法 + 注入 catalog"存在 | internal/initialize/initialize.go:17-46internal/data/initialization_backend.go:17-217cmd/wire.go:45 |
| 2-9 | data-scope 审计回调 `dataScopeAuditEnqueue` 穿透 6 层签名,生产实现唯一 | internal/data/data.go:238,245,392 → runtime_clients.go:168 → data_scope.go:19-136 |
| 2-10 | `*Data` 方法约 10 处模板式 `if d == nil` 防御(构造归 Wire 管理,不可能为 nil`NewIntegrationRuntime` 的 nil→空 Store 回退掩盖错误状态 | internal/data/data.go:41,72,82,88,97,119,126,373-375initialization_backend.go:18 |
## P3 包归属问题pkg 应为可对外复用、无业务语义)
| # | 包 | 问题 | 建议 |
|---|----|------|------|
| 3-1 | pkg/paymentkit | 目录名≠包名(实际 `package paymentutil`17 个支付渠道常量是本项目商户目录(与 biz/integration 中文渠道定义一一对应);回调 ack 硬编码微信/支付宝协议、中文业务文案;调用方 100% 在 internal | 整体并入 internal/integration/payment顺带消灭 17 常量双份导出biz/payment/payment.go:20-43 逐个重命名再导出一遍)、三层金额/回调转发、text/firstAny 双份 |
| 3-2 | pkg/logging | source.go:54-61 硬编码 internal 目录布局效果等同反向依赖zap.go:346-383 中文上报文案、:342 特判遗留 gva 项目文件名AGENTS.md 声称的 `internal/logging/` 目录实际不存在 | 内移为 internal/logging同步修 AGENTS.md |
| 3-3 | pkg/httpx | response.go:17 业务状态码 10001PasswordChangeRequired:60-69 硬编码 `x-token` cookie 契约;调用方全部在 internal/server | 移入 internal/server |
| 3-4 | internal/initialize/configuration.go | `management*` 家族(:221-318手工构造 camelCase JSON属 service 层 DTO 塑形职责;根因是 biz `InitializationRepo``json.RawMessage` 为出入参,表现形状泄漏进 repo 层 | JSON 形状定义移至 internal/service/dtosnakeCase/normalizeDuration 等纯函数与掩码逻辑分文件(当前 464 行混杂三种职责) |
| 3-5 | pkg/database/pagination、gormkit | 调用方 100% 在 internal/data分页纯数学无业务语义轻度 | 可下沉 internal/data低优先级 |
| 3-6 | pkg/mq | rabbitmq.go:127 硬编码项目名前缀 `kra-`subscription.go 的 Contributor 面向本项目 module 概念 | 去项目化(低优先级) |
| 3-7 | pkg/module、pkg/task、pkg/database/migration | 只被本仓库消费但互为依赖构成同层契约组module 依赖 migration/task单独搬会破坏依赖方向一致性 | 维持现状 |
| 3-8 | internal/data/runtime_clients.go | `newReloadableDB` 在通用热切换构造器里调用领域函数 `registerDataScopeCallbacks`,越界 | 该调用移到 data.go 与 replacePrimaryDB 同址 |
| 3-9 | internal/data/data_scope_record.go | 整文件只有一行类型别名 `dataAccessLogPO = datasystem.DataAccessLogPO` | 删除文件并入 data_scope.go |
## P4 结构性设计(双份数据 / 文档漂移,大动作需决策)
| # | 问题 | 位置 | 建议 |
|---|------|------|------|
| 4-1 | router 与 routecatalog 双声明21 个 router 文件纯声明式注册 method+pathroutecatalog 又用一张 map 声明同一批路由的元数据public/audit/group靠契约测试强制对齐——改一条路径要同时改两处 | internal/server/router/*routes.go:22-43 手工 21 连调internal/routecatalog/catalog.go:41- | 表驱动合并为单一声明源method+path+handlerFunc+元数据),可同时消掉对齐测试 |
| 4-2 | 新增一个资源实际要触碰 7 处dto、service/system、handler、router/资源文件、router/routes.go、routecatalog、Set/provider | — | 同 4-1 |
| 4-3 | 同一份集成配置三种形状两条通道:`config.Storage` 强类型 ↔ `sys_integration_configs` JSON 行 ↔ 运行时客户端storage/email 走 config.Storeemail 即时读快照、storage 手动 Replacemq/websocket 走 runtimeconfig 订阅——同一"集成"概念两套配置源两种重载模式 | internal/data/integration_config.go:43-104,157-179internal/data/integration/runtime.go:13-29storage/reloadable.go:18-32email/email.go:25-34 | 评估统一为一条解析-分发通道 |
| 4-5 | swagger 运行时文档:约 160 行 map 手拼 Swagger 2.0 JSON + 正则加工,全局单例仍在 server 根包 | internal/server/swagger.go:21-159 | 可辩护README 有意为之),但按 server/README 自己的规则更宜独立子包 |
| 4-6 | 错误日志热路径做磁盘 IO + go/parser AST 解析(为错误上报附上出错方法源码) | pkg/logging/source.go:71-104pkg/logging/zap.go:373-379 | 展示性需求不该在日志关键路径,缓存或降级 |
| 4-7 | dto 包文件组织混乱dto/system.go 混装 Login/User/ServerInfo 三域dto/settings.go 横跨 Dictionary/SystemParameter/APIToken/SecurityConfig 四域(名不副实) | internal/service/dto/system.go:5-105settings.go:5-172 | 按域拆分重命名 |
| 4-8 | 校验双轨制handler 手工 if 校验与 dto binding 标签并存menu dto 无 binding 标签全靠 handler 手补 | internal/server/handler/user.go:27-64internal/service/dto/settings.go:46-48 | 统一为 binding 标签 |
| 4-9 | 单方法 handler 各占结构体+构造器+Set 字段Session/Navigation 可并入相邻资源 handlerSet 已膨胀到 23 个字段、provider 23 个构造器) | internal/server/handler/session.go:10-12navigation.go:9-11set.go:3-26 | 合并 |
| 4-10 | local 存储两套入口逻辑staticfiles 直读 config 本地盘语义 vs integration/storage 的 Reloadable 体系 | internal/server/staticfiles/staticfiles.go:49-92internal/integration/storage/local.go | 边界收敛(低优先级) |
## 审查后认为合理、不建议改动的部分
- **modules 与 routecatalog 分离**前者是启动期模块装配wire/种子/迁移),后者是请求期热路径策略查询(有 byMethod 桶等性能优化),消费方零重叠,仅存在 modules→routecatalog 的合理单向依赖。
- **Provider 接口缝模式**(子包不反向 import 根 data 避免成环 + 各子包测试假 Data模式本身正当仅 1-1 所述两处重复需合并。
- **config.StoreYAML 文件)与 runtimeconfig.StoreDB 集成表)职责分离**:不重叠,不建议合并包;仅需评估统一 pub-sub 实现1-9
- **utils/routepath 与 routecatalog**:互补非重复(前者剥离配置前缀供落库/Casbin 用,后者纯策略匹配)。
- **TaskScheduler 多锁**:每处均有并发正确性注释论证,是真实并发需求的代价。
- **worker→biz 正向依赖 + biz 经 TaskRuntime/TaskReloader 接口反向倒置**:整个任务链路最规范的一段。
- **三个"registry"实为三种角色**pkg/task=实现、biz/task=接口缝、worker=贡献者):不是重复机制,但命名误导(建议重命名 worker/task_registry.go
- **data 根 11 个文件同包共享 Data 状态与重载锁**:符合 data/README 既定约定,不建议拆包。
---
## 第二轮深审发现S 系列编号)
### S1 死代码补充(第一轮 P0 之外;第三轮核查:约 15 组仍存在,仅 PersistConfig 属误报已更正)
**biz 层死接口方法/死注入面**
- PermissionRepo 3/4 方法死Buttons/SetAuthorityButtons/AuthorityButtonIDs— internal/biz/system/permission.go:6-8
- APITokenRepo.DisableAPIToken — api_token.go:26UserRepo.CreateUser/UpdateUserWithAuthorities — user.go:45,49
- MediaMetadataRepo.FindMediaByHash — media_metadata.go:44MenuRepo.AuthorityMenuIDs — menu.go:61
- SecurityUsecase.ActiveTokenMatches — security.go:243-249EmailUsecase.Alert — email.go:31-48
- PaymentUsecase 注册面全死SetHooks/SetOrderSourceRegistry/SetFulfillmentRegistry/RegisterBusinessModule含两阶段注册+回滚补偿)— biz/payment/payment.go:296-326PayInternal/RefundInternal/AuthorizeRefund 无生产实现service 却作为正式 API 暴露Fulfill
**data 层死实现**与上述接口配套data/system/export.go:430-440、user.go:317-319,474-491、api_token.go:108-114、media.go:67-73、permission.go:113-117、menu.go:283-287
**service 层死方法**MenuService.Listmenu.go:14-20handler 实际用 Tree连带 biz MenuUsecase.List 也死、RecordLogin/RecordDataAccessaudit.go:56-58,91-93后者整链含 biz 接口+data 实现全死、IsTokenDisabledapi_token.go:60-62、security_session.go 14 方法中 8 个死(:17-67、AuditRecorder.CreateErrorRequest+recordedErrorDomainaudit_error.go:17-31
**integration 层零消费者基础设施**
- mq legacy 发布/订阅 API 全链零生产调用Publish/Subscribe/Unsubscribe 及 To 变体、Register/Unregister、订阅簿记/dispatcher/reconcile 约 350 行空转)— integration/mq/emqx.go:358-630连带 cmd/main.go:69 `_ mq.Client` 幻影参数与 integration/provider.go:28,29 两条死 wire 绑定(外加 :31 的 platformws.Hub 死绑定)
- websocketHub 接口零消费者pkg/websocket/melody.go:28-40integration 层 On* 四注册方法零调用server.go:326-373双层 handler 登记机制两层都永远为空server.go:28-31,169-180 vs melody.go:65-118
- s3 多 provider endpoint 五个死分支(唯一调用点只传 "minio")— storage/s3_storage.go:29-41
- namedClient/Client() 整型死代码 — emqx.go:632-650
- 零散wechat_v2.go:584 `stringOr`、paypal.go:651 `normalizePayPalOrderState`、paypal.go:388-391 `paypalOrderTradeNo`、result.go:98,148-155 `nestedString`/`amountFromDecimalField`shim 连调用者都没有、middleware/capture.go:72-74 `isBootstrapPath`
**仅测试调用(生产死代码)**decryptWechatV3wechat_v3.go:600-605、alipaySignContentalipay.go:572-585、validatePaymentConfigdata/payment/payment.go:551-553
**dto 死类型/死字段**LoginLogRequestdto/system.go:68-75、DataAccessRecordRequestdto/audit.go:21-30、GetAuthorityButtonsRequest.Selected、MenuResponse.Authorities 恒 nullbiz 无此字段、DynamicMenuResponse.MenuButtons 恒 nil、SysBaseMenuID 输入被静默丢弃dto/menu.go:21,28、version 导出结构体大量零值噪声字段ID:0/CreatedAt 零时间/authoritys:nullversion.go:23-94
### S2 重复模式补充(大块可消除,估算合计 1000+ 行)
- **data/system List 样板 13 处同构**(构建 db→逐字段翻译 filter→Count→分页→Find→循环 toBiz可抽泛型 helper 消 200+ 行 — user.go:281-315、api.go:119-181、data_access_log.go:32-55、operation_log.go:39-68、error_record.go:83-109 等 13 处
- **payment 渠道适配器家族重复**(合计约 500 行):下单方式归一化骨架 9 份Replacer 归一化行逐字出现 9 次);退款身份校验 4 份同构;状态归一化 switch 10 份(可收敛为 `normalizeState(state, successWords, failedWords)` + 词表);金额拆分守恒 5 份 + biz 层再校验第 6 份SDK 客户端构造 10 份同构firstNonEmpty 三胞胎alipay.go:475/qq.go:146/paypal.go:707mustMarshalAlipayPayload ≡ mustJSON
- **storage provider 家族重复**(约 200 行key/unkey/file 三件套 5 份逐行相同aliyun:35-48/aws:63-76/huawei:31-44/s3:61-74/tencent:45-58DeletePrefix 分页循环 5 份(可提 `deletePrefixViaList`Compose 一行委托 7 份limit 守卫 7 份;构造尾部样板 5 份
- **handler 四段式样板约 70 处**ShouldBindJSON→Fail→service→Write`bindJSON`+`respond` 两个 helper 可消一半
- **biz 接口嵌入透传 12 处 usecase**API/APIRepo/Token/LogViewer/Audit/AuditRecorder/Authority/Dictionary/Export/Media/Parameter/Permission/Position/Version与同包 9 个私有字段风格并存
- **middleware**请求体读取三处access_log.go:56-74 / audit.go:49-67 fallback / handler/media.go:17-21同一请求体三层 MaxBytesReader脱敏/截断三处且分散capture.go:41-68 / audit.go:118-136 / redact 词表在 redact.go 但函数散在 access_log.go双重脱敏双重截断audit.go:47-48,93 对已 mask 已截断的正文再处理一遍)
- mq/websocket 两包各写一套 map 解码 helper 且逐字符相同emqx.go:226-260 vs server.go:189-240TestConfig 探测骨架三处同构emqx.go:92-133/server.go:65-126/connectivity.go
- authority 树构建算法两份biz authority.go:48-78 vs menu.go:80-99CreateAuthority/CopyAuthority 前 8 行校验逐字重复data authority.go:133-151,211-245
- serviceauthorityResponse 与 convertAuthority 逐字重复authority.go:30-36 vs user_conversion.go:8-14"单条 DTO helper + for-append"样板 9 处;"Request 包装 + Filter 包装 + 裸方法"三重入口家族audit/parameter/export/dictionary/mediauser 空对象兜底三连
- data/paymentcallbackFields/first 整函数复制payment.go:507-540 vs result.go:14-56values() 与 testRow() 近重复;渠道"免 notify_url 方法"同义词表在 data 层重抄一份payment.go:390-426与各适配器 createMethod 表双份维护)
- gorm.DeletedAt→*time.Time 转换 4 处逐字重复user.go:99-104、menu.go:17-21,241-245,250-255
- version 阶段→消息映射两份handler version.go:107-121 vs 158-167engine.Routes()→dto 转换两份api.go:161-167 vs public.go:135-139
- defaults 合并逻辑三层三份service/integration:86-98 / biz/integration:215-224 / data/integration/migrations.go:69-75
- media 分片根目录逻辑两份biz media.go:126-132 vs media_upload.go:61-69
- media_upload 三处复制会话校验样板biz media_upload.go:120-129,176-184,244-250顺带吞掉 ErrUploadSessionNotFound 可判定性)
### S3 分层/归属违规补充
- **data 直依赖 integration**data/payment/payment.go:17 import `kra/internal/integration/payment`TestProvider 是 145 行业务编排本地建单、500ms×3 重试轮询、退款闭环)长在 data 层;还自建第二 paymentOrderRepo 实例绕过 wire 单一构造点
- **service 层混入业务/存储细节**system_init.go:13-26 DSN/驱动连接串与回退规则system_info.go:14-41 直连 gopsutil 采集(每请求阻塞 200msexport_excel.go:63-71 SQL 别名/前缀归一api_token.go:28-42 发币编排与到期规则user.go:36-37,85-96 密码策略编排api.go:60-82 Groups 分组推导version.go:184-221 导入导出全编排
- **biz DO 带 json 标签**PaymentResult/PaymentTestResult死标签service 逐字段转 dtoPaymentRequest把指纹编码格式锚死在 DObiz/integration 的 Definition/Field/Option 家族直接充当前端契约dto/integration_config.go:21 内嵌 biz 类型biz 事实上兼任 DTO 提供方)
- **DO 兼过滤器**API.OrderKey/Desc/StrictAll、SystemParameter/ExportTemplate.StartCreatedAt/EndCreatedAt 混入实体LoginLog 已改为独立 Filter
- **middleware 硬编码业务语义**error_audit.go:70-77 靠中文消息黑名单判断是否审计改文案即改审计行为error_audit.go:25,55-67 硬编码业务路径access_log.go:82-203 支付回调专用逻辑内嵌通用中间件rate_limit.go:33-51 限流策略参数内联A8 限流挂在全局链却只匹配两个 public 路由
- **biz 契约泄漏存储/表现原语**QueryExport 返回 `[]map[string]any`LogViewer 的文件读取器细节NextCursor/LimitedByBytesexport DO 字面携带 SQL/Join/Table 片段Export 域整体是查询引擎不是领域逻辑,应下沉 dataUserOptions `{Label,Value}` UI 形状进 bizAuthenticationResult 携带含密码哈希的完整 User
- **data 层纪律**:转换函数命名四种风格违反 new<X>/toBiz 契约FromPO×16/ToPO×4/ToBiz×2/new×2PO 分布无规则models.go 集中 6 个 + 散落 30+models.go:98-100 还混 repo 声明Table("字符串") 绕过已有 PO 7 处user.go:58,70,88,231,259、announcement.go:100、security.go:92saveRelations 回写入参 DO 约 10 处audit.go 名不副实(只有构造器,实现在 5 个文件runtime.go 拼盘settings + tokenIssuer 无关联)
- **编排类文件过重**seedSystem 单函数 126 行 10 类职责seed.go:30-156authority.go 834 行四类职责CRUD/严格权限引擎被 4 个文件 15+ 处借用/DataScope 域解析/用户-角色关联,权限引擎应独立 accessGuardBuildVersionBundle 105 行五职责version.go:76-181migrations.go 两个通信 surface 迁移互为重复子集(:36-130 vs :132-173
- **dto 契约问题**ID 类型三处分叉int/uint/string 混用,迫使 handler 做转换AuthorityResponse.DeletedAt 泄漏且破坏全库 `json:"-"` 约定dto/authority.go:34ErrorRecordMutationRequest 一半指针一半值不自洽DTO 反向依赖 biz 类型dto/integration_config.go:21
- **错误体系**错误定义双体系errors.go 仅 3 个 kratos 类型错误,其余 stdlib errors.New 散落 13+ 文件,无 reason 码);错误包装 Error+Unwrap 与 Error+Is 两机制混用PasswordPolicyError 类型定义在 servicesecurity.go:11-19
- **List 契约三种风格并存**(过滤结构体内含分页 / 位置参数+指针 DO / 裸标量 5-6 参)
- **data 分页三风格**pagination.ApplyRequired/Apply/手写 Limit-Offset手写版 page=0 产生负 offsetposition.go:89、export.go:208-210 无防护media.go:112-115 有)
### S4 简单实现复杂化补充(遗留)
- payment Create 过度防御:先拷 9+1 字段再逐一回比 + Extra 双次 JSON 序列化同一不可变保证指纹层RequestFingerprint + 落库后二次指纹比对)已做两道 — biz/payment/payment.go:385-402,898-907,588-590
- loadUser/loadUsers 双实现(单实体 6 次串行查询 vs 批量实现,可复用)— data/system/user.go:53-97,115-218
- JWT 签名双重检查validateSigningOptions 后 signToken 再查一遍)— data/system/token.go:34-51
- 中间件 nil 防御四处gin.go:28-30 已兜底access.go:33/access_log.go:36/audit.go:27/cors.go:22-25 仍各自检查)
- public.go captchaConfig 恒真分支与无效首调用public.go:29-43
- 媒体上传三重大小防御limitMultipartBody + rejectMediaTooLarge + header.Size 检查)— handler/media.go:38-50,226-240
- emqx.go 恒真 ctx 判断(:561-569,592-600
- PaymentLogger 单实现接口(仅为包装 *slog.Logger— biz/payment/payment_log.go:10-32
- dictionary.go Tree 解析结果被丢弃handler dictionary.go:211-213byType 分支不用 id/parseErr
- DictionaryRepo/MediaRepo 组合式子接口无独立消费方biz dictionary.go:56-59、media.go:19-22
### 第二轮结构性发现(遗留)
- **mq 与 websocket 是"零消费者基础设施"**:大量生命周期/重放/簿记机制空转,要么接入首个真实业务消费者,要么裁掉 legacy 半区保留最小面TestConfig 探测 + Enabled/Path/HandleRequest
- **payment 域 data/integration 边界倒置**paymentRepo 实为"配置读取+适配器编排+ack 组装"的编排层,仅 4 个纯转发方法符合 repo 形态;渠道知识(同义词表)在 data 与 integration 双份维护必然漂移
- **gopay.go/gopay_helpers.go 名实相反**gopay.go 是窄基座4 个函数gopay_helpers.go 是杂物间7 类职责混装,渠道专属谓词/状态机应下沉各渠道文件,公共函数 mergeMap 反而散在 alipay.go
- **vendor.go**vendorSupperPay 是死枚举26,38 定义注册switch 无 case金额拆分 14 键配置 DSL 疑似投机通用性(无内置默认使用);通用渠道定义 18 字段全 Required=true
- **gva/ 目录是遗留参考库**(独立 module 不参与 kra 编译pkg/logging 为其保留文件名特判zap.go:342
---
## 第三轮审查发现T 系列编号2026-08-27
### T0 修复回归缺陷与残留S0 修复核查时发现,优先处理)
| # | 问题 | 位置 |
|---|------|------|
| T0-1 | **vendor 退款静默受理残留(资金安全,高危)**S0-1 修复覆盖了"FAIL 状态字段"场景,但 create/refund 响应 HTTP 200 且 body 非 JSON、或 JSON 无可识别状态字段时仍默认 `created`biz `validatePaymentRefundResult` 接受 created → 真实被渠道拒绝但响应无状态字段的退款会被永久记为已受理。建议refund 端点响应无状态字段时报错或至少 pending | internal/integration/payment/vendor.go:135-163biz/payment/payment.go:1020-1021,837-855 |
| T0-2 | Excel `[]byte` 全转文本副作用MySQL 数值列也会变文本单元格,大面积"数字以文本存储"警告(原值保真达成,展示体验回退);可结合模板列类型区分处理 | internal/service/system/export_excel.go:85-86 |
| T0-3 | ErrAuthoritiesRequired 错误透传缺失biz 返回具体错误后 handler 只回"修改失败",用户看不到"至少一个角色"data 层 setUserAuthorities 还残留一处重复中文防御错误(双轨) | internal/server/handler/user.go:208-212internal/data/system/user.go:563-565 |
| T0-4 | 版本导入非原子残留:落库成功但留痕 CreateVersion 失败时返回错误,用户误判"导入失败"(幂等查重使重试可控) | internal/service/system/version.go:256-270 |
| T0-5 | 任务元数据双源漂移已发生:种子描述("**定时**清理数据库过期日志…")与注册方法描述("清理数据库过期日志…")不一致——两处维护必然继续漂移;建议 worker 注册时复用 catalog TimedTask 元数据 | internal/modules/task/definition.go:14-15 vs internal/worker/task_registry.go:31,45 |
| T0-6 | export SQL/ImportSQL 摆设字段dto 保留但 ValidateExportTemplate 拒绝非空,只能提交空值且响应回显空值 | internal/service/dto/export.go:26-27internal/biz/system/export.go:72-74 |
| T0-7 | payment 回调读体失败分支未入 Gin 错误链S0-12 修复只覆盖 service 调用错误) | internal/server/handler/payment.go:149-153 |
| T0-8 | LoginLogFilter 收敛为仅 Username/Status 两字段:若前端需按 IP/时间筛选登录日志则能力缺失(设计取舍需确认) | internal/biz/system/audit.go:42-45 |
| T0-9 | 任务种子与注册方法的"必经链路"提示PaymentUsecase 注册面SetHooks 等 4 个方法)零调用时,生产装配下支付主链路 Create/Refund/Fulfill 必然在 preparePaymentRequest 报"支付业务订单来源未注册"——模板未完成态,建议 Wire/cmd 层提供默认注册或 fail-fast 提示 | internal/biz/payment/payment.go:296-326,407-410 |
### T1 过分拆分清单(用户重点维度,量化)
**根因模式三条**:①零逻辑 usecase 壳wire 强制每域一个构造器放大);②"每资源 N 文件"机械切分dto+biz+service+handler+router 各一个);③为 import 美观引入的中间缝合包/门面。
**A. biz/system38 文件 3430 行,其中 17 个非测试文件 <60 合计约 575 保守可归并 8-10 个文件**
| 文件 | 行数 | 内容 | 合并目标 |
|---|---|---|---|
| errors.go | 14 | 4 个错误变量token.go:35-42 另有 6 个,同类分散) | 并入 user.go 或统一 errors |
| cache.go | 15 | Cache 接口 4 方法 | 并入 security.go主消费者 |
| maintenance.go | 19 | Repo 接口+纯透传壳 | 并入 user.go 或保留worker 消费) |
| actor.go | 19 | ctx 携带 helper | 并入 authority.go唯一消费者 |
| access_control.go | 20 | 透传壳 2 方法 | 并入 authority.go/api.go |
| storage.go | 26 | FileStorage 接口 | 并入 media.go主消费者 |
| data_scope.go | 27 | DataScope 类型+ctx helper | 并入 authority.go |
| upload_session.go | 38 | DO+Repo 13 方法无 usecase | 并入 media_upload.go同域 |
| parameter.go | 33 | DO+Repo+零方法壳 | 壳删后 28 行 |
| settings.go | 43 | 3 设置类型+接口 | 可保留wire 5 处消费) |
| department.go | 46 | DO+Repo+改名壳 | 壳删后 32 行 |
| position.go | 48 | DO+Repo | 与 department.go 合并为 organization.go |
| token.go | 48 | AuthClaims+TokenIssuer | 并入 authentication.go唯一 biz 消费) |
| media_metadata.go | 53 | DO+Repo | 并入 media.gomedia 域 4 文件最典型同域碎片) |
| email.go / api_token.go / version.go | 48/55/57 | — | 保留(有逻辑)或去壳 |
**B. 纯透传壳 usecase 12 个**(整个 struct 无自有逻辑/纯改名转发service 可直依赖 biz repo 接口——repo 接口仍在 biz分层契约不破wire 链物证 wire_gen.go:88-91 permission 全程零逻辑):
ParameterUsecase(parameter.go:29)、PermissionUsecase(permission.go:14)、VersionUsecase(version.go:55)、AnnouncementUsecase(announcement.go:397 方法全一行透传)、MaintenanceUsecase(maintenance.go:11)、AccessControlUsecase(access_control.go:5)、DepartmentUsecase(department.go:342 改名)、AuthorityUsecase(authority.go:42仅 Tree 有逻辑)、TaskUsecase(task.go:76)、MenuUsecase(menu.go:7210 处透传)、UserUsecase(user.go:5913 处透传)、SystemConfigUsecase(system_init.go:507 处透传)。
对照组有真实逻辑应保留Authentication/Security/Media/Email/IntegrationConfig/Payment/TaskApplication。
**C. service/system 碎片**
- SystemConfigService 一型拆四文件system.go(19)+system_config.go(26)+system_init.go(38)+system_info.go(42)=125 行 4 文件
- security_session.go:17-68SecurityService 14 方法全部一行透传(零 DTO 工作)
- audit.go 与 audit_error.go 同属 AuditService/AuditRecorder 可合并audit_log_file.go 是独立 LogViewerService保留
- email.go(16)/permission.go(24)/access_control.go(28仅 5 行逻辑) 近纯透传小文件
**D. router 碎片**22 文件 464 行,平均 21 行/文件(最小 email.go 12 行routes.go:18-43 手工 21 连调——纯注册碎片无内聚(与 4-1 表驱动合并一并解决)。
**E. 单符号包/微文件**
- internal/modules/surface整包只有一个 10 行函数surface.go:10-192 个调用方
- internal/data/provider整包只有一个 7 行 2 方法接口provider.go:5-8——中性缝可辩护建议与 provider.go 别名使用方注释互指
- provider.go+providers.go 双小文件模式 ×4 子包integration 12+5 行、payment 8+5、task 7+5、system 17+29→ 8 文件并 4
- data/system/bootstrap.go 27 行仅 seed.go 使用可并入worker/worker.go 7 行、initialize/provider.go 5 行、pkg/task/provider.go 3 行、biz/payment/provider.go 8 行均为纯 wire-set 微文件
- data_scope_record.go 整文件 1 行别名(同 3-9
**F. 巨微并存两极**
- biz/payment/payment.go 单文件 1150 行usecase+常量再导出+5 validator+指纹工具vs 同域 payment_log.go 32 行/provider.go 8 行微文件
- dtoauthentication.go 8 行/email.go 7 行超小文件 vs settings.go 170 行横跨四域(同 4-7
### T2 包归属移动建议(从 → 到)
| # | 从 | 到 | 动作 | 影响 |
|---|---|---|---|---|
| 1 | pkg/httpx | internal/server/httpx | 整包内移(消费者 100% 在 internal/server 7 文件;中文文案+x-token 是本项目契约logging/source.go:59 已预留该路径 marker | 7 文件 |
| 2 | pkg/logging | internal/logging | 整包内移source.go:47-69 硬编码本仓库 internal 路径zap.go:366-382 中文文案;模块路由硬编码服务日志查看器语义) | 约 5 文件 |
| 3 | pkg/module | internal/modules | 契约内移Menu/API/Surface/TimedTask 是本项目模块系统契约import gin15 消费者全在本仓库) | 15 文件 |
| 4 | pkg/paymentkit 渠道常量provider.go | internal/biz/payment 本体 | 常量归位并删 biz/payment/payment.go:20-43 的 17+2 个别名转发层;目录更名 paymentutil 对齐包名通用工具signing/json/amount/xml可留 pkg 或并入 internal/utils | 约 8 文件 |
| 5 | pkg/mq + pkg/websocket(Hub) | 收缩或合并进 internal/integration/mq | 全链零业务消费者(详见 T3保留 TestConfig 探测+基础驱动,删 Registry/Client()/namedClient/legacy API/Hub/ApplySubscriptions | 约 7 文件 |
| 6 | integration/mq emqx.go:226-260 与 integration/websocket server.go:189-240 的重复 JSON map helper | internal/utils/jsonvalue | 上收去重configText≡text、configBool≡boolValue 逐字符相同) | 2-3 文件 |
| 7 | storageaws-sdk-v2 栈与 minio-go 栈双 S3 实现并存 | 统一 S3 兼容单栈(可选) | qiniu/aliyun/huawei/tencent 原生 SDK 均有 S3 兼容端点,可收敛删 4 实现+s3 死分支 | 5-6 文件 |
| 8 | handler/query.go:13-61Gin 工具、middleware/request.go:67-102纯函数、handler/announcement.go parseTime被 export.go 跨域借用) | pkg 或 internal/utils 候选 | 通用无状态逻辑上收 | 3 文件 |
依赖方向合规确认第三轮验证pkg 无 import internal仅 zap_test.go:131 字符串字面量integration 无 import data/serviceservice→biz→data 无反向。
### T3 死代码回归与新增S1 现状核查)
- **S1 清单约 15 组死方法仍存在**biz 接口+data 实现+service 包装三层残留已更正误报service 层 PersistConfig 实为活代码
- mq 零消费者机制未清理且**规模扩大**emqx.go 约 710 行 + mq.go 106 行 + subscription.go 55 行声明式订阅全套无注册者legacy API 转译层又加了一层1-11 恶化)
- websocket Hub 死绑定、s3 五个不可达 endpoint 分支local.go:39-48 只让 minio 进 newS3Storage、namedClient/Client()、`_ mq.Client` 幻影参数main.go:65、isBootstrapPath 死函数——全部未清理
- 新增死代码wechat_v2.go:47 私有 wechatV2Sign 变为仅测试调用可移测试文件pkg/task RegisterAll 仅测试调用;**NewGinEngine 生产死代码**gin.go:23-25wire 用 NewGinEngineWithRuntimeTaskScheduler.Trigger(task) 可未导出(仅 TriggerID 内部用)
- P1-3 修复残留result.go:58-64 与 payment_helpers.go:5-11 两包仍保留同名本地包装转发 paymentutilmicro-shim 未拆)
### 已修复确认清单(第三轮验证后从本文档删除,留痕)
- P0 全部 6 条protoutil 整包删除WechatV2Sign 死实现删除Data 三死方法删除Bootstrap 别名删除;静态任务注册链删除(重建为活的 TimedTasks 单源链路Schedule(task) 删除
- P1-1/2/4/5/6/7/10provider.Database 中性接口task/security 两处仅剩一行别名种子单源化含防回归测试validateMenuRequestsurface.APIsForPrefixpersistConfig Locked 变体删除NewData/reloadConfig 均用 rollback 收集器websocket snapshotHandlers 泛型收敛
- P1-3paymentkit.Text/FirstText 落地(残留 micro-shim 见 T3
- P4-4AGENTS.md 已更新为真实栈
- S0 全部 17 条含回归验证登录日志三态过滤、claims 注入、时间戳服务端生成、x-user-id 删除+CORS 收紧含防回归测试、export 安全查询构建器双保险、ListAuthorities 委托统一入口、版本错误返回、失败关闭、SecurityConfig nil+error、限流白名单闭环、回调错误入链、Excel 文本保真、Apple JWS 固定 Root CA G3 真实指纹(经官方根证书清单核实)+完整链校验+签名时刻验证、ErrAuthoritiesRequired、OriginSetting 错误传播、cache Lua 原子补 TTL语义正确
- S4 两条QueryExport 单次严格解析saveRelations 单 replace 参数
## 处置建议总览(三轮合并,按优先级)
1. **先修 T0 修复回归缺陷**T0-1 资金安全高危优先)
2. **删 S1+T3 死代码**纯减法零风险biz/data/service 三层死方法约 15 组 + mq/websocket 零消费者机制 + 微死码,估算 800+ 行)
3. **T1 过分拆分合并**(透传壳 usecase 12 个、biz/system 小文件归并、SystemConfigService 四合一、provider/providers 双文件 ×4——零行为变更的文件级减法
4. **T2 归属移动**httpx/logging/module/paymentkit 内移 + jsonvalue 上收,按表逐项决策)
5. **S2 重复消除三大块**data/system List 泛型、payment 渠道骨架、storage provider 基座,估算 900+ 行)
6. **P2/P4 结构收敛**service 根门面、Backend 三层缝合、router+routecatalog 表驱动合并)

87
docs/system-pkg-audit.md Normal file
View File

@ -0,0 +1,87 @@
# `` 到 `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

@ -0,0 +1,63 @@
# `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

@ -62,6 +62,10 @@ var genericPaymentFields = []IntegrationConfigField{
integrationField("create_url", "下单接口", true, false, "url"),
integrationField("query_url", "查单接口", true, false, "url"),
integrationField("refund_url", "退款接口", true, false, "url"),
integrationField("refund_status_field", "退款状态字段", false, false, "text"),
integrationField("refund_success_values", "退款受理状态值", false, false, "text"),
integrationField("refund_failure_values", "退款拒绝状态值", false, false, "text"),
integrationField("refund_status_field", "退款状态字段", false, false, "text"),
integrationField("app_key", "签名密钥", true, true, "password"),
integrationField("query_status_field", "查单状态字段", true, false, "text"),
integrationField("query_success_values", "查单成功值", true, false, "text"),

View File

@ -37,7 +37,11 @@ type LoginLog struct {
ErrorMessage, Agent string
UserID uint
User *User
FilterByStatus bool
}
type LoginLogFilter struct {
Username string
Status *bool
}
type DataAccessLog struct {
@ -101,7 +105,7 @@ type AuditQueryRepo interface {
ListOperations(context.Context, int, int, *OperationRecord) ([]*OperationRecord, int64, error)
FindOperation(context.Context, uint) (*OperationRecord, error)
DeleteOperations(context.Context, []int) error
ListLogins(context.Context, int, int, *LoginLog) ([]*LoginLog, int64, error)
ListLogins(context.Context, int, int, *LoginLogFilter) ([]*LoginLog, int64, error)
FindLogin(context.Context, uint) (*LoginLog, error)
DeleteLogins(context.Context, []int) error
ListDataAccess(context.Context, int, int, *DataAccessLog) ([]*DataAccessLog, int64, error)

View File

@ -8,6 +8,7 @@ var (
ErrUserNotFound = errors.NotFound("USER_NOT_FOUND", "user not found")
// ErrInvalidCredentials is returned for any failed login, regardless of
// whether the user exists, to avoid leaking which accounts are registered.
ErrInvalidCredentials = errors.Unauthorized("INVALID_CREDENTIALS", "invalid credentials")
ErrUserDisabled = errors.Forbidden("USER_DISABLED", "user disabled")
ErrInvalidCredentials = errors.Unauthorized("INVALID_CREDENTIALS", "invalid credentials")
ErrAuthoritiesRequired = errors.BadRequest("AUTHORITIES_REQUIRED", "at least one authority is required")
ErrUserDisabled = errors.Forbidden("USER_DISABLED", "user disabled")
)

View File

@ -2,9 +2,21 @@ package system
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
)
var (
exportIdentifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$`)
exportParameterPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
exportColumnPattern = regexp.MustCompile(`(?i)^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?(\s+AS\s+[A-Za-z_][A-Za-z0-9_]*)?$`)
exportJoinOnPattern = regexp.MustCompile(`(?i)^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\s*=\s*[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*(\s+AND\s+[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\s*=\s*[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*)*$`)
)
type ExportCondition struct {
ID uint
CreatedAt, UpdatedAt time.Time
@ -43,3 +55,81 @@ type ExportRepo interface {
type ExportUsecase struct{ ExportRepo }
func NewExportUsecase(repo ExportRepo) *ExportUsecase { return &ExportUsecase{ExportRepo: repo} }
func ValidateExportTemplate(value *ExportTemplate) error {
if value == nil {
return errors.New("导出模板为空")
}
if strings.TrimSpace(value.Name) == "" || strings.TrimSpace(value.TemplateID) == "" {
return errors.New("导出模板名称和模板 ID 不能为空")
}
if !exportIdentifierPattern.MatchString(strings.TrimSpace(value.TableName)) {
return errors.New("导出模板表名不合法")
}
if strings.TrimSpace(value.DBName) != "" && !exportIdentifierPattern.MatchString(strings.TrimSpace(value.DBName)) {
return errors.New("导出模板数据库别名不合法")
}
if strings.TrimSpace(value.SQL) != "" || strings.TrimSpace(value.ImportSQL) != "" {
return errors.New("导出模板不允许自定义 SQL")
}
var columns map[string]string
if err := json.Unmarshal([]byte(value.TemplateInfo), &columns); err != nil {
return fmt.Errorf("导出模板列定义无效: %w", err)
}
if len(columns) == 0 {
return errors.New("导出模板列为空")
}
for column := range columns {
if !exportColumnPattern.MatchString(strings.TrimSpace(column)) {
return fmt.Errorf("导出模板列不合法: %s", column)
}
}
for _, join := range value.Joins {
kind := strings.ToUpper(strings.Join(strings.Fields(join.Join), " "))
switch kind {
case "JOIN", "INNER JOIN", "LEFT JOIN", "RIGHT JOIN":
default:
return fmt.Errorf("导出模板 Join 类型不合法: %s", join.Join)
}
if !exportIdentifierPattern.MatchString(strings.TrimSpace(join.Table)) || !exportJoinOnPattern.MatchString(strings.TrimSpace(join.On)) {
return errors.New("导出模板 Join 表或条件不合法")
}
}
for _, condition := range value.Conditions {
if !exportParameterPattern.MatchString(strings.TrimSpace(condition.From)) || !exportIdentifierPattern.MatchString(strings.TrimSpace(condition.Column)) {
return errors.New("导出模板筛选字段不合法")
}
switch strings.ToUpper(strings.Join(strings.Fields(condition.Operator), " ")) {
case "=", "!=", "<>", ">", ">=", "<", "<=", "LIKE", "IN", "NOT IN", "BETWEEN":
default:
return fmt.Errorf("导出模板筛选运算符不合法: %s", condition.Operator)
}
}
if value.Limit != nil && (*value.Limit < 1 || *value.Limit > 100000) {
return errors.New("导出模板行数限制必须在 1 到 100000 之间")
}
if order := strings.TrimSpace(value.Order); order != "" {
parts := strings.Fields(order)
if len(parts) > 2 || !exportIdentifierPattern.MatchString(parts[0]) {
return errors.New("导出模板排序不合法")
}
if len(parts) == 2 && !strings.EqualFold(parts[1], "asc") && !strings.EqualFold(parts[1], "desc") {
return errors.New("导出模板排序方向不合法")
}
}
return nil
}
func (uc *ExportUsecase) CreateExportTemplate(ctx context.Context, value *ExportTemplate) error {
if err := ValidateExportTemplate(value); err != nil {
return err
}
return uc.ExportRepo.CreateExportTemplate(ctx, value)
}
func (uc *ExportUsecase) UpdateExportTemplate(ctx context.Context, value *ExportTemplate) error {
if err := ValidateExportTemplate(value); err != nil {
return err
}
return uc.ExportRepo.UpdateExportTemplate(ctx, value)
}

View File

@ -0,0 +1,26 @@
package system
import (
"strings"
"testing"
)
func validExportTemplateForTest() *ExportTemplate {
return &ExportTemplate{Name: "users", TemplateID: "users", TableName: "sys_users", TemplateInfo: `{"id":"ID","username":"用户名"}`}
}
func TestValidateExportTemplateRejectsCustomSQL(t *testing.T) {
value := validExportTemplateForTest()
value.SQL = "SELECT * FROM sys_users"
if err := ValidateExportTemplate(value); err == nil || !strings.Contains(err.Error(), "不允许自定义 SQL") {
t.Fatalf("ValidateExportTemplate() error = %v", err)
}
}
func TestValidateExportTemplateRejectsUnsafeJoinAndCondition(t *testing.T) {
value := validExportTemplateForTest()
value.Joins = []ExportJoin{{Join: "LEFT JOIN", Table: "sys_roles", On: "1=1 OR 1=1"}}
if err := ValidateExportTemplate(value); err == nil {
t.Fatal("ValidateExportTemplate accepted unsafe join")
}
}

View File

@ -99,13 +99,6 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
uc.mu.RUnlock()
value, err := uc.repo.SecurityConfig(ctx)
if err != nil {
// Return the default value together with the database-not-ready
// error. Callers such as login/runtime policy consumers intentionally
// ignore the error and continue with that default, while the HTTP
// settings endpoint still reports the failure.
if value != nil {
return value, err
}
return nil, err
}
uc.mu.Lock()

View File

@ -155,7 +155,7 @@ func (uc *UserUsecase) Authorities(ctx context.Context) ([]*Authority, error) {
}
func (uc *UserUsecase) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error {
if len(authorityIDs) == 0 {
return ErrInvalidCredentials
return ErrAuthoritiesRequired
}
return uc.repo.SetUserAuthorities(ctx, id, authorityIDs)
}

View File

@ -52,3 +52,10 @@ func TestLoginRejectsMissingUserWithoutPanic(t *testing.T) {
t.Fatalf("Login() = %#v, %v; want nil, ErrInvalidCredentials", user, err)
}
}
func TestSetUserAuthoritiesRejectsEmptyListAsBadRequest(t *testing.T) {
err := NewUserUsecase(&loginUserRepo{}).SetUserAuthorities(context.Background(), 1, nil)
if !errors.Is(err, ErrAuthoritiesRequired) || errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("SetUserAuthorities() error = %v, want ErrAuthoritiesRequired", err)
}
}

View File

@ -11,8 +11,6 @@ type Config struct {
Admin *Admin `mapstructure:"admin" yaml:"admin" json:"admin"`
}
type Bootstrap = Config
type Server struct {
HTTP *ServerHTTP `mapstructure:"http" yaml:"http" json:"http"`
}

View File

@ -31,10 +31,6 @@ func (d *Data) persistConfig() error {
func (d *Data) persistConfigValues(dataConfig *config.Data, adminConfig *config.Admin) error {
d.configMu.Lock()
defer d.configMu.Unlock()
return d.persistConfigValuesLocked(dataConfig, adminConfig)
}
func (d *Data) persistConfigValuesLocked(dataConfig *config.Data, adminConfig *config.Admin) error {
if adminConfig == nil || adminConfig.ConfigPath == "" {
return nil
}

View File

@ -122,55 +122,6 @@ func (d *Data) RedisClient() redis.UniversalClient {
return d.redis.load()
}
// MongoClient returns the currently active Mongo client. The client remains
// owned by Data and is only exposed here for framework-level resource wiring.
func (d *Data) MongoClient() *mongo.Client {
if d == nil || d.mongo == nil {
return nil
}
return d.mongo.load()
}
// NamedDatabases returns a copy of configured secondary database handles for
// the process-wide resource registry. Data retains lifecycle ownership.
func (d *Data) NamedDatabases() map[string]*gorm.DB {
if d == nil {
return nil
}
d.dbListMu.RLock()
defer d.dbListMu.RUnlock()
if len(d.dbList) == 0 {
return nil
}
result := make(map[string]*gorm.DB, len(d.dbList))
for name, db := range d.dbList {
if name != "" && db != nil {
result[name] = db
}
}
return result
}
// NamedRedisClients returns a copy of configured secondary Redis handles for
// the process-wide resource registry. Data retains lifecycle ownership.
func (d *Data) NamedRedisClients() map[string]redis.UniversalClient {
if d == nil {
return nil
}
d.redisListMu.RLock()
defer d.redisListMu.RUnlock()
if len(d.redisList) == 0 {
return nil
}
result := make(map[string]redis.UniversalClient, len(d.redisList))
for name, client := range d.redisList {
if name != "" && client != nil {
result[name] = client
}
}
return result
}
func (d *Data) logger() *slog.Logger {
if d != nil && d.appLogger != nil {
return d.appLogger
@ -262,12 +213,9 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
}
})
}
initialized := false
defer func() {
if !initialized {
cleanup()
}
}()
var undo rollback
undo.add(cleanup)
defer undo.run()
usingFallback := !databaseConnectionConfigured(c.Database)
var db *gorm.DB
var err error
@ -348,7 +296,7 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
mongoClient = nil
}
d.mongo = newReloadable(mongoClient, closeMongoClient)
initialized = true
undo.commit()
return d, cleanup, nil
}

View File

@ -3,17 +3,11 @@ package payment
import "kra/pkg/paymentkit"
func text(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
return paymentutil.Text(values, key)
}
func firstAny(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && value != "" {
return value
}
}
return ""
return paymentutil.FirstText(values, keys...)
}
// configuredInt64 keeps repository-side validation independent from the

View File

@ -0,0 +1,8 @@
package provider
import "gorm.io/gorm"
type Database interface {
DB() *gorm.DB
DatabaseReady() bool
}

View File

@ -96,7 +96,11 @@ func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID
}
users := make(map[uint]*system.User, len(userPOs))
for i := range userPOs {
users[userPOs[i].ID] = baseBizUser(&userPOs[i])
user, err := baseBizUser(&userPOs[i])
if err != nil {
return nil, 0, err
}
users[userPOs[i].ID] = user
}
out := make([]*system.APIToken, 0, len(pos))
for _, po := range pos {

View File

@ -66,7 +66,11 @@ func (r *departmentRepo) attachDepartmentLeaders(ctx context.Context, department
}
leaderByID := make(map[uint]*system.User, len(leaders))
for i := range leaders {
leaderByID[leaders[i].ID] = baseBizUser(&leaders[i])
user, err := baseBizUser(&leaders[i])
if err != nil {
return err
}
leaderByID[leaders[i].ID] = user
}
for _, department := range departments {
department.Leader = leaderByID[department.LeaderID]

View File

@ -37,16 +37,15 @@ func (r *auditRecorderRepo) CreateError(ctx context.Context, v *system.ErrorReco
if v.Status == "" {
v.Status = "未处理"
}
return r.data.DB().WithContext(ctx).Create(&errorRecordPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status}).Error
po := errorRecordPO{Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status}
if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil {
return err
}
v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
return nil
}
func (r *auditQueryRepo) UpdateError(ctx context.Context, v *system.ErrorRecord) error {
updates := make(map[string]any, 9)
if v.ID != 0 {
updates["id"] = v.ID
}
if !v.CreatedAt.IsZero() {
updates["created_at"] = v.CreatedAt
}
if v.Form != nil {
updates["form"] = v.Form
}

View File

@ -45,7 +45,27 @@ func TestCreateErrorBeforeDatabaseInitializationIsNoop(t *testing.T) {
}
}
func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) {
func TestCreateErrorOwnsServerIdentityAndTimestamps(t *testing.T) {
recorder, query := newErrorRecordRepos(t)
createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC)
form := "前端"
value := &system.ErrorRecord{ID: 41, CreatedAt: createdAt, Form: &form, Level: "error"}
if err := recorder.CreateError(context.Background(), value); err != nil {
t.Fatal(err)
}
if value.ID == 41 || value.CreatedAt.IsZero() || value.CreatedAt.Equal(createdAt) {
t.Fatalf("created metadata = %+v", value)
}
stored, err := query.FindError(context.Background(), value.ID)
if err != nil {
t.Fatal(err)
}
if stored.ID != value.ID || stored.CreatedAt.IsZero() || stored.CreatedAt.Equal(createdAt) {
t.Fatalf("stored metadata = %+v", stored)
}
}
func TestCreateErrorGeneratesMetadataAndPreservesNullableFields(t *testing.T) {
recorder, query := newErrorRecordRepos(t)
createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC)
form := "前端"
@ -58,7 +78,7 @@ func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) {
t.Fatal(err)
}
got, err := query.FindError(context.Background(), 41)
got, err := query.FindError(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
@ -71,8 +91,8 @@ func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) {
if got.Status != "未处理" {
t.Fatalf("status = %q, want 未处理", got.Status)
}
if !got.CreatedAt.Equal(createdAt) {
t.Fatalf("created_at = %v, want %v", got.CreatedAt, createdAt)
if got.ID == 41 || got.CreatedAt.IsZero() || got.CreatedAt.Equal(createdAt) {
t.Fatalf("server metadata = ID %d, created_at %v", got.ID, got.CreatedAt)
}
}
@ -108,6 +128,7 @@ func TestUpdateErrorDistinguishesOmittedAndExplicitEmptyFields(t *testing.T) {
t.Fatalf("omitted fields changed: info=%#v solution=%#v", got.Info, got.Solution)
}
originalCreatedAt := got.CreatedAt
changedCreatedAt := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC)
if err = query.UpdateError(context.Background(), &system.ErrorRecord{
ID: id,
@ -133,7 +154,7 @@ func TestUpdateErrorDistinguishesOmittedAndExplicitEmptyFields(t *testing.T) {
if got.RequestID != "request-new" || got.TraceID != "trace-new" {
t.Fatalf("trace fields = (%q, %q), want updated values", got.RequestID, got.TraceID)
}
if !got.CreatedAt.Equal(changedCreatedAt) {
t.Fatalf("created_at = %v, want %v", got.CreatedAt, changedCreatedAt)
if !got.CreatedAt.Equal(originalCreatedAt) || got.CreatedAt.Equal(changedCreatedAt) {
t.Fatalf("created_at = %v, want unchanged %v", got.CreatedAt, originalCreatedAt)
}
}

View File

@ -11,7 +11,6 @@ import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type exportTemplatePO struct {
@ -78,9 +77,9 @@ func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []e
return v
}
func exportToPO(v *system.ExportTemplate) exportTemplatePO {
return exportTemplatePO{ID: v.ID, DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order}
return exportTemplatePO{DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order}
}
func (r *exportRepo) saveRelations(tx *gorm.DB, v *system.ExportTemplate, resetIDs, replace, forceTemplateID bool) error {
func (r *exportRepo) saveRelations(tx *gorm.DB, v *system.ExportTemplate, replace bool) error {
if replace {
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil {
return err
@ -91,50 +90,19 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *system.ExportTemplate, resetI
}
conditions := make([]exportConditionPO, 0, len(v.Conditions))
for _, x := range v.Conditions {
id := x.ID
if resetIDs {
id = 0
}
templateID := x.TemplateID
// GORM's has-many association on Create always writes the parent's
// TemplateID into the child foreign key (references:TemplateID).
// Preserve that observable behavior for creates; updates intentionally
// keep the request's value after the compatible update deletes and
// recreates relations.
if forceTemplateID || templateID == "" {
templateID = v.TemplateID
}
conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, From: x.From, Column: x.Column, Operator: x.Operator})
conditions = append(conditions, exportConditionPO{TemplateID: v.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator})
}
if len(conditions) > 0 {
create := tx
if !resetIDs {
// GORM saves Create associations with ON CONFLICT DO NOTHING.
// Preserve that behavior for copied templates carrying relation IDs.
create = create.Clauses(clause.OnConflict{DoNothing: true})
}
if err := create.Create(&conditions).Error; err != nil {
if err := tx.Create(&conditions).Error; err != nil {
return err
}
}
joins := make([]exportJoinPO, 0, len(v.Joins))
for _, x := range v.Joins {
id := x.ID
if resetIDs {
id = 0
}
templateID := x.TemplateID
if forceTemplateID || templateID == "" {
templateID = v.TemplateID
}
joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, Join: x.Join, Table: x.Table, On: x.On})
joins = append(joins, exportJoinPO{TemplateID: v.TemplateID, Join: x.Join, Table: x.Table, On: x.On})
}
if len(joins) > 0 {
create := tx
if !resetIDs {
create = create.Clauses(clause.OnConflict{DoNothing: true})
}
return create.Create(&joins).Error
return tx.Create(&joins).Error
}
return nil
}
@ -145,7 +113,7 @@ func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *system.ExportT
return err
}
v.ID = po.ID
return r.saveRelations(tx, v, false, false, true)
return r.saveRelations(tx, v, false)
})
}
func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *system.ExportTemplate) error {
@ -154,7 +122,7 @@ func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *system.ExportT
if err := tx.Model(&exportTemplatePO{}).Where("id = ?", v.ID).Updates(&po).Error; err != nil {
return err
}
return r.saveRelations(tx, v, true, true, false)
return r.saveRelations(tx, v, true)
})
}
func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error {
@ -219,49 +187,70 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q
}
func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate, params map[string]string) ([]map[string]any, string, error) {
selected, err := r.data.Database(t.DBName)
query, err := r.buildExportQuery(ctx, t, params)
if err != nil {
return nil, "", err
}
db := selected.WithContext(ctx)
var rows []map[string]any
if t.SQL != "" {
args := map[string]any{}
for k, v := range params {
args[k] = v
}
err := db.Raw(t.SQL, args).Scan(&rows).Error
return rows, t.SQL, err
}
preview := query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) })
err = query.Find(&rows).Error
return rows, preview, err
}
func exportColumns(raw string) ([]string, error) {
columns := make([]string, 0)
if err := json.Unmarshal([]byte(t.TemplateInfo), &map[string]string{}); err != nil {
return nil, "", err
decoder := json.NewDecoder(strings.NewReader(raw))
if token, err := decoder.Token(); err != nil || token != json.Delim('{') {
return nil, errors.New("导出模板列定义必须是 JSON 对象")
}
decoder := json.NewDecoder(strings.NewReader(t.TemplateInfo))
_, _ = decoder.Token()
for decoder.More() {
key, _ := decoder.Token()
columns = append(columns, key.(string))
key, err := decoder.Token()
if err != nil {
return nil, err
}
column, ok := key.(string)
if !ok {
return nil, errors.New("导出模板列名无效")
}
columns = append(columns, column)
var ignored any
_ = decoder.Decode(&ignored)
if err = decoder.Decode(&ignored); err != nil {
return nil, err
}
}
if len(columns) == 0 {
return nil, "", errors.New("模板列为空")
return nil, errors.New("模板列为空")
}
return columns, nil
}
func (r *exportRepo) buildExportQuery(ctx context.Context, t *system.ExportTemplate, params map[string]string) (*gorm.DB, error) {
if err := system.ValidateExportTemplate(t); err != nil {
return nil, err
}
selected, err := r.data.Database(t.DBName)
if err != nil {
return nil, err
}
db := selected.WithContext(ctx)
columns, err := exportColumns(t.TemplateInfo)
if err != nil {
return nil, err
}
query := db.Table(t.TableName).Select(strings.Join(columns, ","))
for _, j := range t.Joins {
query = query.Joins(j.Join + " " + j.Table + " ON " + j.On)
query = query.Joins(strings.ToUpper(strings.Join(strings.Fields(j.Join), " ")) + " " + j.Table + " ON " + j.On)
}
if params["filterDeleted"] == "true" {
query = query.Where(fmt.Sprintf("%s.deleted_at IS NULL", t.TableName))
query = query.Where(t.TableName + ".deleted_at IS NULL")
for _, join := range t.Joins {
if db.Migrator().HasColumn(join.Table, "deleted_at") {
query = query.Where(fmt.Sprintf("%s.deleted_at IS NULL", join.Table))
query = query.Where(join.Table + ".deleted_at IS NULL")
}
}
}
for _, condition := range t.Conditions {
operator := condition.Operator
operator := strings.ToUpper(strings.Join(strings.Fields(condition.Operator), " "))
value := params[condition.From]
switch operator {
case "LIKE":
@ -270,7 +259,11 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate,
}
case "IN", "NOT IN":
if value != "" {
query = query.Where(condition.Column+" "+operator+" (?)", value)
values := strings.Split(value, ",")
for index := range values {
values[index] = strings.TrimSpace(values[index])
}
query = query.Where(condition.Column+" "+operator+" ?", values)
}
case "BETWEEN":
start, end := params["start"+condition.From], params["end"+condition.From]
@ -285,7 +278,10 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate,
}
limit := 0
if raw := params["limit"]; raw != "" {
limit, _ = strconv.Atoi(raw)
limit, err = strconv.Atoi(raw)
if err != nil || limit < 0 || limit > 100000 {
return nil, errors.New("导出行数限制不合法")
}
} else if t.Limit != nil {
limit = *t.Limit
}
@ -293,139 +289,54 @@ func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate,
query = query.Limit(limit)
}
if raw := params["offset"]; raw != "" {
if offset, parseErr := strconv.Atoi(raw); parseErr == nil {
query = query.Offset(offset)
offset, parseErr := strconv.Atoi(raw)
if parseErr != nil || offset < 0 {
return nil, errors.New("导出偏移量不合法")
}
query = query.Offset(offset)
}
order := params["order"]
if order == "" {
order = t.Order
}
if order != "" {
parts := strings.Split(order, " ")
parts := strings.Fields(order)
if len(parts) > 2 {
return nil, errors.New("导出排序不合法")
}
fields := map[string]bool{}
columnTypes, columnErr := db.Migrator().ColumnTypes(t.TableName)
if columnErr != nil {
return nil, "", columnErr
return nil, columnErr
}
for _, column := range columnTypes {
fields[column.Name()] = true
}
if !fields[parts[0]] {
return nil, "", fmt.Errorf("order by %s is not in the fields", order)
columnName := parts[0]
if dot := strings.LastIndex(columnName, "."); dot >= 0 {
columnName = columnName[dot+1:]
}
if !fields[columnName] {
return nil, fmt.Errorf("order by %s is not in the fields", order)
}
orderSQL := parts[0]
if len(parts) > 1 {
if parts[1] != "asc" && parts[1] != "desc" {
return nil, "", fmt.Errorf("order by %s is not secure", order)
if !strings.EqualFold(parts[1], "asc") && !strings.EqualFold(parts[1], "desc") {
return nil, fmt.Errorf("order by %s is not secure", order)
}
orderSQL += " " + parts[1]
orderSQL += " " + strings.ToUpper(parts[1])
}
query = query.Order(orderSQL)
}
preview := query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) })
err = query.Find(&rows).Error
return rows, preview, err
return query, nil
}
func (r *exportRepo) PreviewExport(ctx context.Context, t *system.ExportTemplate, params map[string]string) (string, error) {
columns := make([]string, 0)
decoder := json.NewDecoder(strings.NewReader(t.TemplateInfo))
if token, err := decoder.Token(); err != nil || token != json.Delim('{') {
query, err := r.buildExportQuery(ctx, t, params)
if err != nil {
return "", err
}
for decoder.More() {
key, err := decoder.Token()
if err != nil {
return "", err
}
columns = append(columns, key.(string))
var ignored any
if err = decoder.Decode(&ignored); err != nil {
return "", err
}
}
var builder strings.Builder
builder.WriteString("SELECT ")
builder.WriteString(strings.Join(columns, ", "))
builder.WriteString(" FROM ")
builder.WriteString(t.TableName)
for _, join := range t.Joins {
builder.WriteString(" " + join.Join + " " + join.Table + " ON " + join.On)
}
wheres := make([]string, 0)
if params["filterDeleted"] == "true" {
wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", t.TableName))
selected, err := r.data.Database(t.DBName)
if err != nil {
return "", err
}
db := selected.WithContext(ctx)
for _, join := range t.Joins {
if db.Migrator().HasColumn(join.Table, "deleted_at") {
wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", join.Table))
}
}
}
for _, condition := range t.Conditions {
op, column, value := strings.ToUpper(strings.TrimSpace(condition.Operator)), strings.TrimSpace(condition.Column), params[condition.From]
switch op {
case "BETWEEN":
start, end := params["start"+condition.From], params["end"+condition.From]
if start != "" && end != "" {
wheres = append(wheres, fmt.Sprintf("%s BETWEEN '%s' AND '%s'", column, start, end))
} else {
wheres = append(wheres, fmt.Sprintf("%s BETWEEN {start%s} AND {end%s}", column, condition.From, condition.From))
}
case "IN", "NOT IN":
if value != "" {
parts := strings.Split(value, ",")
for index := range parts {
parts[index] = strings.TrimSpace(parts[index])
}
wheres = append(wheres, fmt.Sprintf("%s %s ('%s')", column, op, strings.Join(parts, "','")))
} else {
wheres = append(wheres, fmt.Sprintf("%s %s ({%s})", column, op, condition.From))
}
case "LIKE":
if value != "" {
wheres = append(wheres, fmt.Sprintf("%s LIKE '%%%s%%'", column, value))
} else {
wheres = append(wheres, fmt.Sprintf("%s LIKE {%%%s%%}", column, condition.From))
}
default:
if value != "" {
wheres = append(wheres, fmt.Sprintf("%s %s '%s'", column, op, value))
} else {
wheres = append(wheres, fmt.Sprintf("%s %s {%s}", column, op, condition.From))
}
}
}
if len(wheres) > 0 {
builder.WriteString(" WHERE " + strings.Join(wheres, " AND "))
}
order := params["order"]
if order == "" {
order = t.Order
}
if order != "" {
builder.WriteString(" ORDER BY " + order)
}
limitRaw, offsetRaw := params["limit"], params["offset"]
if limitRaw == "" && t.Limit != nil && *t.Limit != 0 {
limitRaw = strconv.Itoa(*t.Limit)
}
limit, _ := strconv.Atoi(limitRaw)
offset, _ := strconv.Atoi(offsetRaw)
if limit > 0 {
builder.WriteString(" LIMIT " + strconv.Itoa(limit))
if offset > 0 {
builder.WriteString(" OFFSET " + strconv.Itoa(offset))
}
} else if offset > 0 {
builder.WriteString(" OFFSET " + strconv.Itoa(offset))
}
return builder.String(), nil
return query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) }), nil
}
func parseTemplateColumns(raw string) map[string]string {
out := map[string]string{}
@ -439,23 +350,14 @@ func parseTemplateColumns(raw string) map[string]string {
return out
}
func (r *exportRepo) ImportExportRows(ctx context.Context, t *system.ExportTemplate, rows []map[string]any) error {
// ImportSQL is checked verbatim. In particular, a whitespace-only value
// is still treated as custom SQL and is allowed to return the driver's
// native error instead of silently falling back to GORM insertion.
sql := t.ImportSQL
if err := system.ValidateExportTemplate(t); err != nil {
return err
}
selected, err := r.data.Database(t.DBName)
if err != nil {
return err
}
return selected.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if sql != "" {
for _, row := range rows {
if err := tx.Exec(sql, row).Error; err != nil {
return err
}
}
return nil
}
needCreated := tx.Migrator().HasColumn(t.TableName, "created_at")
needUpdated := tx.Migrator().HasColumn(t.TableName, "updated_at")
for _, row := range rows {

View File

@ -34,14 +34,14 @@ func (r *auditRecorderRepo) RecordLogin(ctx context.Context, v *system.LoginLog)
func loginFromPO(v loginLogPO) *system.LoginLog {
return &system.LoginLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID}
}
func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *system.LoginLog) ([]*system.LoginLog, int64, error) {
func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *system.LoginLogFilter) ([]*system.LoginLog, int64, error) {
db := r.data.DB().WithContext(ctx).Model(&loginLogPO{})
if q != nil {
if q.Username != "" {
db = db.Where("username LIKE ?", "%"+q.Username+"%")
}
if q.FilterByStatus {
db = db.Where("status = ?", q.Status)
if q.Status != nil {
db = db.Where("status = ?", *q.Status)
}
}
var total int64

View File

@ -0,0 +1,27 @@
package system
import (
"context"
"testing"
bizsystem "kra/internal/biz/system"
)
func TestListLoginsCanFilterFailedStatus(t *testing.T) {
data := newTransactionTestData(t)
ctx := context.Background()
if err := data.gormDB.WithContext(ctx).Create(&[]loginLogPO{
{Username: "ok", Status: true},
{Username: "failed", Status: false},
}).Error; err != nil {
t.Fatal(err)
}
status := false
items, total, err := (&auditQueryRepo{data: data}).ListLogins(ctx, 1, 20, &bizsystem.LoginLogFilter{Status: &status})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].Status {
t.Fatalf("failed login filter = total %d items %#v", total, items)
}
}

View File

@ -23,7 +23,7 @@ func menuFromPO(po menuPO) *system.Menu {
}
func menuToPO(v *system.Menu) menuPO {
return menuPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType}
return menuPO{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType}
}
func replaceMenuRelations(tx *gorm.DB, menu *system.Menu) error {
@ -39,7 +39,7 @@ func replaceMenuRelations(tx *gorm.DB, menu *system.Menu) error {
func createMenuRelations(tx *gorm.DB, menu *system.Menu) error {
parameters := make([]menuParameterPO, 0, len(menu.Parameters))
for _, parameter := range menu.Parameters {
parameters = append(parameters, menuParameterPO{ID: parameter.ID, CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
parameters = append(parameters, menuParameterPO{ID: parameter.ID, MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
}
if len(parameters) > 0 {
if err := tx.Create(&parameters).Error; err != nil {
@ -48,7 +48,7 @@ func createMenuRelations(tx *gorm.DB, menu *system.Menu) error {
}
buttons := make([]menuButtonPO, 0, len(menu.Buttons))
for _, button := range menu.Buttons {
buttons = append(buttons, menuButtonPO{ID: button.ID, CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, Name: button.Name, Description: button.Description, MenuID: menu.ID})
buttons = append(buttons, menuButtonPO{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: menu.ID})
}
if len(buttons) > 0 {
return tx.Create(&buttons).Error
@ -89,6 +89,7 @@ func (r *menuRepo) Create(ctx context.Context, v *system.Menu) error {
}
}
po := menuToPO(v)
po.ID = 0
if err := tx.Create(&po).Error; err != nil {
return err
}

View File

@ -26,7 +26,7 @@ func newMenuTestData(t *testing.T) *Data {
return data
}
func TestMenuCreatePreservesSubmittedTimestamps(t *testing.T) {
func TestMenuCreateGeneratesServerTimestamps(t *testing.T) {
data := newMenuTestData(t)
ctx := context.Background()
createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
@ -40,8 +40,8 @@ func TestMenuCreatePreservesSubmittedTimestamps(t *testing.T) {
if err := data.gormDB.WithContext(ctx).First(&stored, menu.ID).Error; err != nil {
t.Fatal(err)
}
if !stored.CreatedAt.Equal(createdAt) || !stored.UpdatedAt.Equal(updatedAt) {
t.Fatalf("stored timestamps = (%v, %v), want (%v, %v)", stored.CreatedAt, stored.UpdatedAt, createdAt, updatedAt)
if stored.CreatedAt.IsZero() || stored.UpdatedAt.IsZero() || stored.CreatedAt.Equal(createdAt) || stored.UpdatedAt.Equal(updatedAt) {
t.Fatalf("stored timestamps = (%v, %v), want server-generated values", stored.CreatedAt, stored.UpdatedAt)
}
}
@ -114,8 +114,8 @@ func TestMenuUpdateRebuildsRelationsWithoutChangingRequestedIdentity(t *testing.
if parameter.MenuID != 10 || parameter.Type != "params" || parameter.Key != "new" || parameter.Value != "value" {
t.Fatalf("parameter = %+v", parameter)
}
if !parameter.CreatedAt.Equal(createdAt) || !parameter.UpdatedAt.Equal(updatedAt) {
t.Fatalf("parameter timestamps = (%v, %v), want (%v, %v)", parameter.CreatedAt, parameter.UpdatedAt, createdAt, updatedAt)
if parameter.CreatedAt.Equal(createdAt) || parameter.UpdatedAt.Equal(updatedAt) {
t.Fatalf("parameter timestamps = (%v, %v), want server-generated values", parameter.CreatedAt, parameter.UpdatedAt)
}
var button menuButtonPO
@ -125,8 +125,8 @@ func TestMenuUpdateRebuildsRelationsWithoutChangingRequestedIdentity(t *testing.
if button.MenuID != 10 || button.Name != "edit" || button.Description != "new" {
t.Fatalf("button = %+v", button)
}
if !button.CreatedAt.Equal(createdAt) || !button.UpdatedAt.Equal(updatedAt) {
t.Fatalf("button timestamps = (%v, %v), want (%v, %v)", button.CreatedAt, button.UpdatedAt, createdAt, updatedAt)
if button.CreatedAt.Equal(createdAt) || button.UpdatedAt.Equal(updatedAt) {
t.Fatalf("button timestamps = (%v, %v), want server-generated values", button.CreatedAt, button.UpdatedAt)
}
var links int64

View File

@ -29,7 +29,7 @@ type parameterPO struct {
func (parameterPO) TableName() string { return "sys_params" }
func (r *parameterRepo) CreateParameter(ctx context.Context, v *system.SystemParameter) error {
po := parameterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}
po := parameterPO{Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}
if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil {
return err
}
@ -37,9 +37,7 @@ func (r *parameterRepo) CreateParameter(ctx context.Context, v *system.SystemPar
return nil
}
func (r *parameterRepo) UpdateParameter(ctx context.Context, v *system.SystemParameter) error {
// The compatible update uses a struct, so zero-value optional fields (notably desc)
// are intentionally ignored rather than clearing an existing value.
return r.data.DB().WithContext(ctx).Model(&parameterPO{}).Where("id = ?", v.ID).Updates(&parameterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}).Error
return r.data.DB().WithContext(ctx).Model(&parameterPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "key": v.Key, "value": v.Value, "desc": v.Desc}).Error
}
func (r *parameterRepo) DeleteParameters(ctx context.Context, ids []string) error {
if len(ids) == 1 {

View File

@ -7,7 +7,7 @@ import (
"time"
)
func TestParameterRepositoryPreservesModelMetadata(t *testing.T) {
func TestParameterRepositoryOwnsModelMetadata(t *testing.T) {
data := newTransactionTestData(t)
repo := &parameterRepo{data: data}
createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.Local)
@ -16,10 +16,11 @@ func TestParameterRepositoryPreservesModelMetadata(t *testing.T) {
if err := repo.CreateParameter(context.Background(), value); err != nil {
t.Fatal(err)
}
if value.ID != 41 || !value.CreatedAt.Equal(createdAt) {
if value.ID == 0 || value.ID == 41 || value.CreatedAt.IsZero() || value.CreatedAt.Equal(createdAt) {
t.Fatalf("created parameter metadata = ID %d, CreatedAt %v", value.ID, value.CreatedAt)
}
originalCreatedAt := value.CreatedAt
nextCreatedAt := createdAt.Add(time.Hour)
value.CreatedAt = nextCreatedAt
value.Name = "updated"
@ -27,10 +28,10 @@ func TestParameterRepositoryPreservesModelMetadata(t *testing.T) {
t.Fatal(err)
}
var persisted parameterPO
if err := data.gormDB.WithContext(context.Background()).First(&persisted, 41).Error; err != nil {
if err := data.gormDB.WithContext(context.Background()).First(&persisted, value.ID).Error; err != nil {
t.Fatal(err)
}
if persisted.Name != "updated" || !persisted.CreatedAt.Equal(nextCreatedAt) {
if persisted.Name != "updated" || !persisted.CreatedAt.Equal(originalCreatedAt) || persisted.CreatedAt.Equal(nextCreatedAt) {
t.Fatalf("updated parameter = %+v", persisted)
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"kra/internal/biz/system"
dataprovider "kra/internal/data/provider"
"time"
"gorm.io/gorm"
@ -11,10 +12,7 @@ import (
// DatabaseProvider is the smaller seam used by security settings, which also
// need to work before the primary database is initialized.
type DatabaseProvider interface {
DB() *gorm.DB
DatabaseReady() bool
}
type DatabaseProvider = dataprovider.Database
type SecurityConfigPO struct {
ID uint `gorm:"primaryKey"`
@ -62,9 +60,7 @@ func securityToPO(v *system.SecurityConfig) SecurityConfigPO {
func (r *securityRepo) SecurityConfig(ctx context.Context) (*system.SecurityConfig, error) {
if !r.data.DatabaseReady() {
po := DefaultSecurityConfig()
po.ID = 0
return securityFromPO(po), errors.New("数据库未初始化")
return nil, errors.New("数据库未初始化")
}
db := r.data.DB().WithContext(ctx)
var po SecurityConfigPO

View File

@ -0,0 +1,15 @@
package system
import (
"context"
"testing"
)
func TestSecurityConfigReturnsNoValueWhenDatabaseIsUnready(t *testing.T) {
data := newTransactionTestData(t)
data.databaseReady.Store(false)
value, err := (&securityRepo{data: data}).SecurityConfig(context.Background())
if err == nil || value != nil {
t.Fatalf("SecurityConfig() = %#v, %v; want nil and error", value, err)
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"kra/internal/biz/system"
"strconv"
"strings"
@ -62,9 +63,9 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro
for _, item := range authorityPOs {
authorities = append(authorities, toBizAuthority(item))
}
setting := map[string]any(nil)
if po.OriginSetting != "" {
_ = json.Unmarshal([]byte(po.OriginSetting), &setting)
setting, err := parseOriginSetting(po.OriginSetting)
if err != nil {
return nil, err
}
var departmentPOs []departmentPO
if err := r.data.DB().WithContext(ctx).Table("sys_departments").Joins("JOIN sys_user_departments ON sys_user_departments.sys_department_id = sys_departments.id").Where("sys_user_departments.sys_user_id = ? AND sys_departments.deleted_at IS NULL", po.ID).Find(&departmentPOs).Error; err != nil {
@ -104,12 +105,23 @@ func toBizAuthority(po authorityPO) system.Authority {
return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter}
}
func baseBizUser(po *userPO) *system.User {
func parseOriginSetting(raw string) (map[string]any, error) {
setting := map[string]any(nil)
if po.OriginSetting != "" {
_ = json.Unmarshal([]byte(po.OriginSetting), &setting)
if raw == "" {
return setting, nil
}
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt}
if err := json.Unmarshal([]byte(raw), &setting); err != nil {
return nil, fmt.Errorf("解析用户设置: %w", err)
}
return setting, nil
}
func baseBizUser(po *userPO) (*system.User, error) {
setting, err := parseOriginSetting(po.OriginSetting)
if err != nil {
return nil, err
}
return &system.User{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, UUID: po.UUID, Username: po.Username, Password: po.Password, NickName: po.NickName, HeaderImg: po.HeaderImg, AuthorityID: po.AuthorityID, DeptID: po.DeptID, Phone: po.Phone, Email: po.Email, Enable: po.Enable, OriginSetting: setting, MustChangePassword: po.MustChangePassword, PasswordUpdatedAt: po.PasswordUpdatedAt}, nil
}
func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*system.User, error) {
@ -189,7 +201,10 @@ func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*system.User,
users := make([]*system.User, 0, len(pos))
for i := range pos {
po := &pos[i]
user := baseBizUser(po)
user, err := baseBizUser(po)
if err != nil {
return nil, err
}
user.Authority = toBizAuthority(authorityByID[po.AuthorityID])
user.Authorities = make([]system.Authority, 0, len(authorityByUser[po.ID]))
for _, id := range authorityByUser[po.ID] {
@ -376,7 +391,10 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *system.U
// query. The reference handler returns the request's authority IDs and
// leaves related authority/department records at their zero values; keep
// that response contract while retaining the persisted timestamps/UUID.
created := baseBizUser(&po)
created, err := baseBizUser(&po)
if err != nil {
return nil, err
}
created.Authorities = createdAuthorities
return created, nil
}
@ -521,16 +539,7 @@ func (r *userRepo) UpdatePassword(ctx context.Context, id uint, password string,
return r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Updates(updates).Error
}
func (r *userRepo) ListAuthorities(ctx context.Context) ([]*system.Authority, error) {
var pos []authorityPO
if err := r.data.DB().WithContext(ctx).Order("authority_id").Find(&pos).Error; err != nil {
return nil, err
}
result := make([]*system.Authority, 0, len(pos))
for _, po := range pos {
a := toBizAuthority(po)
result = append(result, &a)
}
return result, nil
return (&authorityAccessRepo{data: r.data}).ListAuthorities(ctx)
}
func (r *userRepo) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error {

View File

@ -0,0 +1,16 @@
package system
import (
"context"
"testing"
)
func TestFindUserRejectsCorruptOriginSetting(t *testing.T) {
data := newTransactionTestData(t)
if err := data.gormDB.WithContext(context.Background()).Create(&userPO{Username: "broken-setting", Password: "hash", AuthorityID: 888, OriginSetting: "{"}).Error; err != nil {
t.Fatal(err)
}
if _, err := (&userRepo{data: data}).FindUserByID(context.Background(), 1); err == nil {
t.Fatal("FindUserByID accepted corrupt OriginSetting")
}
}

View File

@ -25,12 +25,6 @@ func SeedDefaults(ctx context.Context, db *gorm.DB, defaults []platformmodule.Ti
if db == nil {
return nil
}
if len(defaults) == 0 {
defaults = []platformmodule.TimedTask{
{Name: "ClearDB", Description: "定时清理数据库过期日志", Spec: "@daily", MethodName: "ClearDB", Enabled: true},
{Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true},
}
}
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, item := range defaults {
if strings.TrimSpace(item.Name) == "" {

View File

@ -1,10 +1,7 @@
package task
import "gorm.io/gorm"
import dataprovider "kra/internal/data/provider"
// Provider is the minimal database seam required by the task tables.
// Task persistence must not depend on the full data runtime or system repos.
type Provider interface {
DB() *gorm.DB
DatabaseReady() bool
}
type Provider = dataprovider.Database

View File

@ -89,15 +89,23 @@ func (s *Store) Delete(ctx context.Context, key string) error {
func (s *Store) Increment(ctx context.Context, key string, expiration time.Duration) (int64, error) {
if client := s.client(); client != nil {
if expiration > 0 {
expirationMS := expiration.Milliseconds()
if expirationMS < 1 {
expirationMS = 1
}
const incrementWithTTL = `
local value = redis.call('INCR', KEYS[1])
if redis.call('PTTL', KEYS[1]) < 0 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return value`
return client.Eval(ctx, incrementWithTTL, []string{key}, expirationMS).Int64()
}
value, err := client.Incr(ctx, key).Result()
if err != nil {
return 0, err
}
if value == 1 && expiration > 0 {
if err := client.Expire(ctx, key, expiration).Err(); err != nil {
return 0, err
}
}
return value, nil
}
s.mu.Lock()
@ -120,6 +128,8 @@ func (s *Store) Increment(ctx context.Context, key string, expiration time.Durat
} else {
entry.expiresAt = time.Time{}
}
} else if entry.expiresAt.IsZero() && expiration > 0 {
entry.expiresAt = time.Now().Add(expiration)
}
s.memory[key] = entry
return value, nil

View File

@ -40,3 +40,18 @@ func TestStoreIncrementRecreatesExpiredKeyWithoutStaleExpiry(t *testing.T) {
t.Fatalf("Get() after recreation = %q, %v, %v", value, ok, err)
}
}
func TestStoreIncrementAddsTTLToExistingPersistentCounter(t *testing.T) {
store := New(nil)
ctx := context.Background()
if err := store.Set(ctx, "counter", "5", 0); err != nil {
t.Fatal(err)
}
if value, err := store.Increment(ctx, "counter", time.Millisecond); err != nil || value != 6 {
t.Fatalf("Increment() = %d, %v", value, err)
}
time.Sleep(5 * time.Millisecond)
if _, ok, err := store.Get(ctx, "counter"); err != nil || ok {
t.Fatalf("persistent counter did not expire: ok=%v err=%v", ok, err)
}
}

View File

@ -1,12 +1,17 @@
package payment
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"time"
)
@ -20,12 +25,16 @@ const (
var (
appleJWSLeafExtensionOID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 11, 1}
appleJWSIntermediateExtensionOID = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 6, 2, 1}
appleTrustedRootFingerprints = map[string]struct{}{
"63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179": {},
}
)
// validateAppleJWSChain binds the leaf certificate used by GoPay for JWS
// signature verification to the intermediate and root certificates that
// GoPay validates against its embedded Apple trust anchor.
func validateAppleJWSChain(signedPayload string) error {
return validateAppleJWSChainWithRoots(signedPayload, appleTrustedRootFingerprints)
}
func validateAppleJWSChainWithRoots(signedPayload string, trustedRoots map[string]struct{}) error {
headerSegment, payloadSegment, signatureSegment, err := splitAppleJWS(signedPayload)
if err != nil {
return err
@ -73,6 +82,10 @@ func validateAppleJWSChain(signedPayload string) error {
}
certificates = append(certificates, certificate)
}
rootFingerprint := sha256.Sum256(certificates[2].Raw)
if _, trusted := trustedRoots[hex.EncodeToString(rootFingerprint[:])]; !trusted {
return errors.New("Apple JWS 根证书不受信任")
}
if err = certificates[0].CheckSignatureFrom(certificates[1]); err != nil {
return fmt.Errorf("Apple JWS 叶子证书不属于声明的证书链: %w", err)
}
@ -101,19 +114,20 @@ func validateAppleJWSChain(signedPayload string) error {
return errors.New("Apple JWS 中间证书缺少 Apple 签名扩展")
}
if hasVerificationTime {
roots := x509.NewCertPool()
roots.AddCert(certificates[2])
intermediates := x509.NewCertPool()
intermediates.AddCert(certificates[1])
if _, err = certificates[0].Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
CurrentTime: verificationTime,
}); err != nil {
return fmt.Errorf("验证 Apple JWS 证书链: %w", err)
}
if !hasVerificationTime {
verificationTime = time.Now()
}
roots := x509.NewCertPool()
roots.AddCert(certificates[2])
intermediates := x509.NewCertPool()
intermediates.AddCert(certificates[1])
if _, err = certificates[0].Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
CurrentTime: verificationTime,
}); err != nil {
return fmt.Errorf("验证 Apple JWS 证书链: %w", err)
}
if certificates[0].IsCA {
return errors.New("Apple JWS 叶子证书不能是 CA")
@ -121,6 +135,18 @@ func validateAppleJWSChain(signedPayload string) error {
if certificates[0].KeyUsage != 0 && certificates[0].KeyUsage&x509.KeyUsageDigitalSignature == 0 {
return errors.New("Apple JWS 叶子证书不允许数字签名")
}
publicKey, ok := certificates[0].PublicKey.(*ecdsa.PublicKey)
if !ok || publicKey.Curve != elliptic.P256() {
return errors.New("Apple JWS 叶子证书不是 P-256 ECDSA 公钥")
}
signature, err := base64.RawURLEncoding.DecodeString(signatureSegment)
if err != nil || len(signature) != 64 {
return errors.New("Apple JWS ES256 签名格式无效")
}
digest := sha256.Sum256([]byte(headerSegment + "." + payloadSegment))
if !ecdsa.Verify(publicKey, digest[:], new(big.Int).SetBytes(signature[:32]), new(big.Int).SetBytes(signature[32:])) {
return errors.New("Apple JWS 签名校验失败")
}
return nil
}

View File

@ -4,10 +4,12 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/hex"
"encoding/json"
"math/big"
"testing"
@ -16,7 +18,8 @@ import (
func TestValidateAppleJWSChain(t *testing.T) {
chain := testAppleCertificateChain(t)
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err != nil {
jws := testAppleJWS(t, "ES256", chain, time.Now())
if err := validateAppleJWSChainWithRoots(jws, testAppleTrustedRoots(t, chain)); err != nil {
t.Fatalf("validateAppleJWSChain() error = %v", err)
}
}
@ -25,17 +28,30 @@ func TestValidateAppleJWSChainRejectsUnrelatedLeaf(t *testing.T) {
chain := testAppleCertificateChain(t)
attackerChain := testAppleCertificateChain(t)
chain[0] = attackerChain[0]
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil {
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, time.Now()), testAppleTrustedRoots(t, chain)); err == nil {
t.Fatal("validateAppleJWSChain() accepted a leaf outside the declared chain")
}
}
func TestValidateAppleJWSChainRejectsInvalidSignature(t *testing.T) {
chain := testAppleCertificateChain(t)
jws := testAppleJWS(t, "ES256", chain, time.Now())
header, payload, _, err := splitAppleJWS(jws)
if err != nil {
t.Fatal(err)
}
tampered := header + "." + payload + "." + base64.RawURLEncoding.EncodeToString(make([]byte, 64))
if err = validateAppleJWSChainWithRoots(tampered, testAppleTrustedRoots(t, chain)); err == nil {
t.Fatal("validateAppleJWSChain accepted an invalid ES256 signature")
}
}
func TestValidateAppleJWSChainRejectsUnexpectedAlgorithmAndShape(t *testing.T) {
chain := testAppleCertificateChain(t)
if err := validateAppleJWSChain(testAppleJWS(t, "ES384", chain, time.Now())); err == nil {
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES384", chain, time.Now()), testAppleTrustedRoots(t, chain)); err == nil {
t.Fatal("validateAppleJWSChain() accepted a non-ES256 algorithm")
}
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain[:2], time.Now())); err == nil {
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain[:2], time.Now()), nil); err == nil {
t.Fatal("validateAppleJWSChain() accepted an incomplete x5c chain")
}
}
@ -43,14 +59,14 @@ func TestValidateAppleJWSChainRejectsUnexpectedAlgorithmAndShape(t *testing.T) {
func TestValidateAppleJWSChainRejectsAdditionalCertificates(t *testing.T) {
chain := testAppleCertificateChain(t)
chain = append(chain, chain[2])
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil {
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, time.Now()), testAppleTrustedRoots(t, chain[:3])); err == nil {
t.Fatal("validateAppleJWSChain() accepted additional x5c certificates")
}
}
func TestValidateAppleJWSChainRejectsNonAppleSigningCertificates(t *testing.T) {
chain := testAppleCertificateChainWithoutAppleExtensions(t)
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, time.Now())); err == nil {
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, time.Now()), testAppleTrustedRoots(t, chain)); err == nil {
t.Fatal("validateAppleJWSChain() accepted certificates without Apple signing extensions")
}
}
@ -58,7 +74,7 @@ func TestValidateAppleJWSChainRejectsNonAppleSigningCertificates(t *testing.T) {
func TestValidateAppleJWSChainUsesSignedDateForHistoricalPayload(t *testing.T) {
signedAt := time.Now().AddDate(-2, 0, 0)
chain := testAppleCertificateChainAt(t, signedAt, true)
if err := validateAppleJWSChain(testAppleJWS(t, "ES256", chain, signedAt)); err != nil {
if err := validateAppleJWSChainWithRoots(testAppleJWS(t, "ES256", chain, signedAt), testAppleTrustedRoots(t, chain)); err != nil {
t.Fatalf("validateAppleJWSChain() rejected historical payload: %v", err)
}
}
@ -78,17 +94,17 @@ func TestValidateAppleJWSChainUsesNestedSignedDateForNotification(t *testing.T)
if err != nil {
t.Fatal(err)
}
outer := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
if err := validateAppleJWSChain(outer); err != nil {
outer := testAppleSignedJWS(t, header, payload, chain)
if err := validateAppleJWSChainWithRoots(outer, testAppleTrustedRoots(t, chain)); err != nil {
t.Fatalf("validateAppleJWSChain() rejected historical notification: %v", err)
}
}
func TestValidateAppleJWSChainAcceptsExpiredChainWithoutSignedDate(t *testing.T) {
func TestValidateAppleJWSChainRejectsExpiredChainWithoutSignedDate(t *testing.T) {
signedAt := time.Now().AddDate(-2, 0, 0)
chain := testAppleCertificateChainAt(t, signedAt, true)
if err := validateAppleJWSChain(testAppleJWSWithoutDate(t, chain)); err != nil {
t.Fatalf("validateAppleJWSChain() rejected an expired chain without signedDate: %v", err)
if err := validateAppleJWSChainWithRoots(testAppleJWSWithoutDate(t, chain), testAppleTrustedRoots(t, chain)); err == nil {
t.Fatal("validateAppleJWSChain() accepted an expired chain without signedDate")
}
}
@ -96,7 +112,7 @@ func TestValidateAppleJWSChainRejectsBrokenChainWithoutSignedDate(t *testing.T)
chain := testAppleCertificateChain(t)
attackerChain := testAppleCertificateChain(t)
chain[0] = attackerChain[0]
if err := validateAppleJWSChain(testAppleJWSWithoutDate(t, chain)); err == nil {
if err := validateAppleJWSChainWithRoots(testAppleJWSWithoutDate(t, chain), testAppleTrustedRoots(t, chain)); err == nil {
t.Fatal("validateAppleJWSChain() accepted a broken chain without signedDate")
}
}
@ -169,11 +185,13 @@ func testAppleCertificateChainAt(t *testing.T, validAt time.Time, includeAppleEx
}}
}
leafDER := testAppleCertificate(t, leafTemplate, intermediate, &leafKey.PublicKey, intermediateKey)
return []string{
chain := []string{
base64.StdEncoding.EncodeToString(leafDER),
base64.StdEncoding.EncodeToString(intermediateDER),
base64.StdEncoding.EncodeToString(rootDER),
}
testAppleLeafKeys[chain[0]] = leafKey
return chain
}
func testAppleECDSAKey(t *testing.T) *ecdsa.PrivateKey {
@ -204,7 +222,7 @@ func testAppleJWS(t *testing.T, algorithm string, chain []string, signedAt time.
if err != nil {
t.Fatal(err)
}
return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
return testAppleSignedJWS(t, header, payload, chain)
}
func testAppleJWSWithoutDate(t *testing.T, chain []string) string {
@ -217,5 +235,36 @@ func testAppleJWSWithoutDate(t *testing.T, chain []string) string {
if err != nil {
t.Fatal(err)
}
return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
return testAppleSignedJWS(t, header, payload, chain)
}
var testAppleLeafKeys = map[string]*ecdsa.PrivateKey{}
func testAppleSignedJWS(t *testing.T, header, payload []byte, chain []string) string {
t.Helper()
headerSegment := base64.RawURLEncoding.EncodeToString(header)
payloadSegment := base64.RawURLEncoding.EncodeToString(payload)
key := testAppleLeafKeys[chain[0]]
if key == nil {
t.Fatal("missing test Apple leaf key")
}
digest := sha256.Sum256([]byte(headerSegment + "." + payloadSegment))
r, s, err := ecdsa.Sign(rand.Reader, key, digest[:])
if err != nil {
t.Fatal(err)
}
signature := make([]byte, 64)
r.FillBytes(signature[:32])
s.FillBytes(signature[32:])
return headerSegment + "." + payloadSegment + "." + base64.RawURLEncoding.EncodeToString(signature)
}
func testAppleTrustedRoots(t *testing.T, chain []string) map[string]struct{} {
t.Helper()
rootDER, err := base64.StdEncoding.DecodeString(chain[2])
if err != nil {
t.Fatal(err)
}
fingerprint := sha256.Sum256(rootDER)
return map[string]struct{}{hex.EncodeToString(fingerprint[:]): {}}
}

View File

@ -56,17 +56,11 @@ func first(values map[string]string, keys ...string) string {
}
func text(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
return paymentutil.Text(values, key)
}
func firstAny(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && value != "" {
return value
}
}
return ""
return paymentutil.FirstText(values, keys...)
}
// Compatibility shims keep channel adapters focused on protocol and

View File

@ -133,6 +133,35 @@ func (a *vendorPaymentAdapter) call(ctx context.Context, endpointKey string, pay
}
result := &bizpayment.PaymentResult{Provider: a.provider, Status: "created", TradeNo: tradeNo, Payload: ensureJSON(body)}
if endpointKey != "query_url" {
if object := jsonObject(body); object != nil {
operation := strings.TrimSuffix(endpointKey, "_url")
statusField := text(c, operation+"_status_field")
if statusField == "" {
statusField = text(c, "query_status_field")
}
state := ""
if statusField != "" {
state = stringAtPath(object, statusField)
}
if state == "" {
for _, key := range []string{"status", "trade_status", "order_status", "pay_status", "refund_status"} {
if value := stringAtPath(object, key); value != "" {
state = value
break
}
}
}
if state != "" {
switch {
case containsFold(configuredValues(c, operation+"_failure_values"), state):
result.Status = "failed"
case containsFold(configuredValues(c, operation+"_success_values"), state):
result.Status = "created"
default:
result.Status = normalizePaymentStatus(state, "created")
}
}
}
return result, nil
}
object := jsonObject(body)

View File

@ -1,10 +1,15 @@
package payment
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"strings"
"testing"
bizpayment "kra/internal/biz/payment"
)
func TestChinaumsCallbackAuthorizationVerification(t *testing.T) {
@ -31,3 +36,23 @@ func TestChinaumsCallbackAuthorizationVerification(t *testing.T) {
t.Fatalf("mismatched Chinaums AppId error = %v", err)
}
}
func TestVendorRefundMapsBusinessFailureStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"FAIL","message":"rejected"}`))
}))
defer server.Close()
adapter := &vendorPaymentAdapter{provider: "vendor", profile: vendorSFT}
result, err := adapter.Refund(context.Background(), &bizpayment.PaymentRefundRequest{TradeNo: "trade-1", RefundNo: "refund-1", Amount: 100, TotalAmount: 100, Currency: "CNY"}, map[string]any{
"refund_url": server.URL,
"app_key": "secret",
})
if err != nil {
t.Fatalf("Refund() error = %v", err)
}
if result == nil || result.Status != "failed" {
t.Fatalf("Refund() result = %#v, want failed status", result)
}
}

View File

@ -3,10 +3,8 @@
package integration
import (
"strings"
dataintegration "kra/internal/data/integration"
"kra/internal/routecatalog"
"kra/internal/modules/surface"
"kra/pkg/module"
)
@ -16,18 +14,7 @@ func Definition() module.Definition {
Migrations: dataintegration.Migrations(),
Surface: module.Surface{
Menus: []module.Menu{{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8}},
APIs: integrationAPIs(),
APIs: surface.APIsForPrefix("/integration/configs", 5),
},
}
}
func integrationAPIs() []module.API {
items := make([]module.API, 0, 5)
for _, descriptor := range routecatalog.Descriptors() {
if descriptor.Public || !strings.HasPrefix(descriptor.Path, "/integration/configs") || descriptor.Description == "" {
continue
}
items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description})
}
return items
}

View File

@ -3,10 +3,8 @@
package payment
import (
"strings"
datapayment "kra/internal/data/payment"
"kra/internal/routecatalog"
"kra/internal/modules/surface"
"kra/pkg/module"
)
@ -20,18 +18,7 @@ func Definition() module.Definition {
{Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6},
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
},
APIs: paymentAPIs(),
APIs: surface.APIsForPrefix("/payment", 10),
},
}
}
func paymentAPIs() []module.API {
items := make([]module.API, 0, 10)
for _, descriptor := range routecatalog.Descriptors() {
if descriptor.Public || !strings.HasPrefix(descriptor.Path, "/payment") || descriptor.Description == "" {
continue
}
items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description})
}
return items
}

View File

@ -0,0 +1,19 @@
package surface
import (
"strings"
"kra/internal/routecatalog"
"kra/pkg/module"
)
func APIsForPrefix(prefix string, capacity int) []module.API {
items := make([]module.API, 0, capacity)
for _, descriptor := range routecatalog.Descriptors() {
if descriptor.Public || !strings.HasPrefix(descriptor.Path, prefix) || descriptor.Description == "" {
continue
}
items = append(items, module.API{Path: descriptor.Path, Method: descriptor.Method, Group: descriptor.Group, Description: descriptor.Description})
}
return items
}

View File

@ -15,12 +15,17 @@ func NewAnnouncement(service *service.AnnouncementService) *Announcement {
}
func (h *Announcement) Create(c *gin.Context) {
claims := Claims(c)
if claims == nil || claims.ID == 0 {
NoAuth(c, "未登录或非法访问")
return
}
var req dto.AnnouncementRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, err.Error())
return
}
if err := h.service.Create(c.Request.Context(), &req); err != nil {
if err := h.service.Create(c.Request.Context(), claims.ID, &req); err != nil {
Fail(c, "创建失败")
return
}
@ -44,12 +49,17 @@ func (h *Announcement) DeleteByIDs(c *gin.Context) {
}
func (h *Announcement) Update(c *gin.Context) {
claims := Claims(c)
if claims == nil || claims.ID == 0 {
NoAuth(c, "未登录或非法访问")
return
}
var req dto.AnnouncementRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, err.Error())
return
}
if err := h.service.Update(c.Request.Context(), &req); err != nil {
if err := h.service.Update(c.Request.Context(), claims.ID, &req); err != nil {
Fail(c, "更新失败")
return
}

View File

@ -139,12 +139,17 @@ func (h *Media) Rename(c *gin.Context) {
Write(c, CodeSuccess, gin.H{}, "编辑成功")
}
func (h *Media) ImportURLs(c *gin.Context) {
claims := Claims(c)
if claims == nil || claims.ID == 0 {
NoAuth(c, "未登录或非法访问")
return
}
var req []dto.ImportMediaRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, err.Error())
return
}
if err := h.service.ImportURLRequests(c.Request.Context(), req); err != nil {
if err := h.service.ImportURLRequests(c.Request.Context(), claims.ID, req); err != nil {
Fail(c, "导入URL失败")
return
}

View File

@ -1,6 +1,7 @@
package handler
import (
"errors"
"kra/internal/service"
"kra/internal/service/dto"
@ -11,6 +12,27 @@ type Menu struct{ service *service.MenuService }
func NewMenu(service *service.MenuService) *Menu { return &Menu{service: service} }
func validateMenuRequest(req *dto.MenuRequest) error {
if req == nil {
return errors.New("菜单参数不能为空")
}
checks := []struct {
value string
field string
}{
{req.Path, "Path"}, {req.Name, "Name"}, {req.Component, "Component"}, {req.Meta.Title, "Title"},
}
for _, check := range checks {
if check.value == "" {
return errors.New(check.field + "值不能为空")
}
}
if req.Sort < 0 {
return errors.New("Sort长度或值不在合法范围,ge=0")
}
return nil
}
func (h *Menu) List(c *gin.Context) {
items, err := h.service.Tree(c.Request.Context())
if err != nil {
@ -35,24 +57,8 @@ func (h *Menu) Create(c *gin.Context) {
Fail(c, err.Error())
return
}
if req.Path == "" {
Fail(c, "Path值不能为空")
return
}
if req.Name == "" {
Fail(c, "Name值不能为空")
return
}
if req.Component == "" {
Fail(c, "Component值不能为空")
return
}
if req.Sort < 0 {
Fail(c, "Sort长度或值不在合法范围,ge=0")
return
}
if req.Meta.Title == "" {
Fail(c, "Title值不能为空")
if err := validateMenuRequest(&req); err != nil {
Fail(c, err.Error())
return
}
if err := h.service.Create(c.Request.Context(), &req); err != nil {
@ -68,24 +74,8 @@ func (h *Menu) Update(c *gin.Context) {
Fail(c, err.Error())
return
}
if req.Path == "" {
Fail(c, "Path值不能为空")
return
}
if req.Name == "" {
Fail(c, "Name值不能为空")
return
}
if req.Component == "" {
Fail(c, "Component值不能为空")
return
}
if req.Sort < 0 {
Fail(c, "Sort长度或值不在合法范围,ge=0")
return
}
if req.Meta.Title == "" {
Fail(c, "Title值不能为空")
if err := validateMenuRequest(&req); err != nil {
Fail(c, err.Error())
return
}
if err := h.service.Update(c.Request.Context(), &req); err != nil {

View File

@ -165,7 +165,9 @@ func (h *Payment) Callback(c *gin.Context) {
}
ack, err := h.service.Callback(c.Request.Context(), &dto.PaymentCallbackRequest{Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query})
writePaymentCallbackAck(c, ack)
_ = err
if err != nil {
_ = c.Error(err)
}
}
func writePaymentCallbackAck(c *gin.Context, ack dto.PaymentCallbackAck) {

View File

@ -8,7 +8,6 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -75,8 +74,6 @@ func OperationAudit(runtime *config.Store, recorder *service.AuditRecorder) gin.
userID := uint(0)
if claims := Claims(c); claims != nil {
userID = claims.ID
} else if value, err := strconv.ParseUint(c.GetHeader("x-user-id"), 10, 64); err == nil {
userID = uint(value)
}
requestID, _ := c.Get("request_id")
status := c.Writer.Status()

View File

@ -10,7 +10,7 @@ import (
)
const (
defaultCORSHeaders = "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id"
defaultCORSHeaders = "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token"
defaultCORSMethods = "POST, GET, OPTIONS,DELETE,PUT"
defaultCORSExpose = "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type, New-Token, New-Expires-At"
)

View File

@ -0,0 +1,12 @@
package middleware
import (
"strings"
"testing"
)
func TestDefaultCORSHeadersDoNotAllowUserIdentityHeader(t *testing.T) {
if strings.Contains(strings.ToLower(defaultCORSHeaders), "x-user-id") {
t.Fatal("default CORS headers still allow the forgeable x-user-id header")
}
}

View File

@ -68,7 +68,7 @@ func failureLogModule(path string) string {
}
func expectedClientFailure(message string) bool {
for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} {
for _, value := range []string{"参数错误", "请输入用户名和密码", "验证码错误", "用户名不存在或者密码错误", "用户被禁止登录", "账号已锁定", "请求太过频繁", "权限不足", "密码已过期", "未登录", "token", "令牌失效"} {
if strings.Contains(message, value) {
return true
}

View File

@ -9,7 +9,6 @@ type AnnouncementRequest struct {
ID uint `json:"ID"`
Title string `json:"title"`
Content string `json:"content"`
UserID *int `json:"userID"`
Attachments json.RawMessage `json:"attachments"`
}

View File

@ -48,7 +48,7 @@ type LoginLogSearchRequest struct {
Page int `form:"page"`
PageSize int `form:"pageSize"`
Username string `form:"username"`
Status bool `form:"status"`
Status *bool `form:"status"`
}
type AuditIDQuery struct {
@ -73,16 +73,14 @@ type ErrorRecordRequest struct {
// fields preserve the distinction between an omitted value and an explicitly
// supplied empty string, which is required by the update contract.
type ErrorRecordMutationRequest struct {
ID uint `json:"ID"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
Form *string `json:"form" binding:"required"`
Info *string `json:"info"`
Level string `json:"level"`
RequestID string `json:"request_id"`
TraceID string `json:"trace_id"`
Solution *string `json:"solution"`
Status string `json:"status"`
ID uint `json:"ID"`
Form *string `json:"form" binding:"required"`
Info *string `json:"info"`
Level string `json:"level"`
RequestID string `json:"request_id"`
TraceID string `json:"trace_id"`
Solution *string `json:"solution"`
Status string `json:"status"`
}
type OperationRecordResponse struct {

View File

@ -3,22 +3,18 @@ package dto
import "time"
type ExportConditionRequest struct {
ID uint `json:"ID"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
TemplateID string `json:"templateID"`
From string `json:"from"`
Column string `json:"column"`
Operator string `json:"operator"`
ID uint `json:"ID"`
TemplateID string `json:"templateID"`
From string `json:"from"`
Column string `json:"column"`
Operator string `json:"operator"`
}
type ExportJoinRequest struct {
ID uint `json:"ID"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
TemplateID string `json:"templateID"`
Join string `json:"joins"`
Table string `json:"table"`
On string `json:"on"`
ID uint `json:"ID"`
TemplateID string `json:"templateID"`
Join string `json:"joins"`
Table string `json:"table"`
On string `json:"on"`
}
type ExportTemplateRequest struct {
ID uint `json:"ID"`

View File

@ -77,7 +77,6 @@ type ImportMediaRequest struct {
Size int64 `json:"size"`
Mime string `json:"mime"`
MD5 string `json:"md5"`
UserID uint `json:"userId"`
}
type StorageListRequest struct {
Prefix string `json:"prefix"`

View File

@ -13,27 +13,21 @@ type MenuMetaRequest struct {
}
type MenuButtonRequest struct {
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
ID uint `json:"ID"`
Name string `json:"name"`
Description string `json:"desc"`
SysBaseMenuID uint `json:"sysBaseMenuID"`
ID uint `json:"ID"`
Name string `json:"name"`
Description string `json:"desc"`
SysBaseMenuID uint `json:"sysBaseMenuID"`
}
type MenuParameterRequest struct {
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
ID uint `json:"ID"`
SysBaseMenuID uint `json:"SysBaseMenuID"`
Type string `json:"type"`
Key string `json:"key"`
Value string `json:"value"`
ID uint `json:"ID"`
SysBaseMenuID uint `json:"SysBaseMenuID"`
Type string `json:"type"`
Key string `json:"key"`
Value string `json:"value"`
}
type MenuRequest struct {
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
ID uint `json:"ID"`
ParentID uint `json:"parentId"`
Path string `json:"path"`

View File

@ -40,13 +40,11 @@ type DictionaryDetailListRequest struct {
Level *int
}
type SystemParameterRequest struct {
ID uint `json:"ID" form:"ID"`
CreatedAt time.Time `json:"CreatedAt" form:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt" form:"UpdatedAt"`
Name string `json:"name" form:"name" binding:"required"`
Key string `json:"key" form:"key" binding:"required"`
Value string `json:"value" form:"value" binding:"required"`
Description string `json:"desc" form:"desc"`
ID uint `json:"ID" form:"ID"`
Name string `json:"name" form:"name" binding:"required"`
Key string `json:"key" form:"key" binding:"required"`
Value string `json:"value" form:"value" binding:"required"`
Description string `json:"desc" form:"desc"`
}
type SystemParameterSearchRequest struct {

View File

@ -19,16 +19,17 @@ func announcementDTO(item *system.Announcement) *dto.AnnouncementResponse {
return &dto.AnnouncementResponse{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, DeletedAt: nil, Title: item.Title, Content: item.Content, UserID: item.UserID, Attachments: json.RawMessage(item.Attachments)}
}
func announcementDO(in *dto.AnnouncementRequest) *system.Announcement {
return &system.Announcement{ID: in.ID, Title: in.Title, Content: in.Content, UserID: in.UserID, Attachments: in.Attachments}
func announcementDO(in *dto.AnnouncementRequest, userID uint) *system.Announcement {
authorID := int(userID)
return &system.Announcement{ID: in.ID, Title: in.Title, Content: in.Content, UserID: &authorID, Attachments: in.Attachments}
}
func (s *AnnouncementService) Create(ctx context.Context, in *dto.AnnouncementRequest) error {
return s.uc.Create(ctx, announcementDO(in))
func (s *AnnouncementService) Create(ctx context.Context, userID uint, in *dto.AnnouncementRequest) error {
return s.uc.Create(ctx, announcementDO(in, userID))
}
func (s *AnnouncementService) Update(ctx context.Context, in *dto.AnnouncementRequest) error {
return s.uc.Update(ctx, announcementDO(in))
func (s *AnnouncementService) Update(ctx context.Context, userID uint, in *dto.AnnouncementRequest) error {
return s.uc.Update(ctx, announcementDO(in, userID))
}
func (s *AnnouncementService) Delete(ctx context.Context, id string) error {

View File

@ -0,0 +1,14 @@
package system
import (
"testing"
"kra/internal/service/dto"
)
func TestAnnouncementDomainUsesAuthenticatedUser(t *testing.T) {
value := announcementDO(&dto.AnnouncementRequest{Title: "notice"}, 42)
if value.UserID == nil || *value.UserID != 42 {
t.Fatalf("announcement user = %#v, want 42", value.UserID)
}
}

View File

@ -56,8 +56,8 @@ func (s *AuditService) DeleteOperations(ctx context.Context, ids []int) error {
func (s *AuditRecorder) RecordLogin(ctx context.Context, v *system.LoginLog) error {
return s.uc.RecordLogin(ctx, v)
}
func (s *AuditService) LoginsFilter(ctx context.Context, page, size int, username string, status bool) ([]*dto.LoginLogResponse, int64, error) {
return s.Logins(ctx, page, size, &system.LoginLog{Username: username, Status: status, FilterByStatus: status})
func (s *AuditService) LoginsFilter(ctx context.Context, page, size int, username string, status *bool) ([]*dto.LoginLogResponse, int64, error) {
return s.Logins(ctx, page, size, &system.LoginLogFilter{Username: username, Status: status})
}
func loginDTO(v *system.LoginLog) *dto.LoginLogResponse {
var user any = map[string]any{}
@ -66,7 +66,7 @@ func loginDTO(v *system.LoginLog) *dto.LoginLogResponse {
}
return &dto.LoginLogResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID, User: user}
}
func (s *AuditService) Logins(ctx context.Context, page, size int, q *system.LoginLog) ([]*dto.LoginLogResponse, int64, error) {
func (s *AuditService) Logins(ctx context.Context, page, size int, q *system.LoginLogFilter) ([]*dto.LoginLogResponse, int64, error) {
items, total, err := s.uc.ListLogins(ctx, page, size, q)
if err != nil {
return nil, 0, err

View File

@ -24,13 +24,15 @@ func recordedErrorDomain(value *dto.ErrorRecordRequest) *system.ErrorRecord {
return result
}
func mutatedErrorDomain(value *dto.ErrorRecordMutationRequest) *system.ErrorRecord {
return &system.ErrorRecord{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, Form: value.Form, Info: value.Info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Solution: value.Solution, Status: value.Status}
return &system.ErrorRecord{ID: value.ID, Form: value.Form, Info: value.Info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Solution: value.Solution, Status: value.Status}
}
func (s *AuditRecorder) CreateErrorRequest(ctx context.Context, req *dto.ErrorRecordRequest) error {
return s.CreateError(ctx, recordedErrorDomain(req))
}
func (s *AuditRecorder) CreateErrorMutationRequest(ctx context.Context, req *dto.ErrorRecordMutationRequest) error {
return s.CreateError(ctx, mutatedErrorDomain(req))
value := mutatedErrorDomain(req)
value.ID = 0
return s.CreateError(ctx, value)
}
func (s *AuditService) UpdateErrorRequest(ctx context.Context, req *dto.ErrorRecordMutationRequest) error {
return s.UpdateError(ctx, mutatedErrorDomain(req))

View File

@ -70,10 +70,10 @@ func (s *ExportService) ConsumeToken(ctx context.Context, token string, expectBl
func exportTemplateDomain(value *dto.ExportTemplateRequest) *system.ExportTemplate {
out := &system.ExportTemplate{ID: value.ID, DBName: value.DBName, Name: value.Name, TableName: value.TableName, TemplateID: value.TemplateID, TemplateInfo: value.TemplateInfo, SQL: value.SQL, ImportSQL: value.ImportSQL, Limit: value.Limit, Order: value.Order}
for _, item := range value.Conditions {
out.Conditions = append(out.Conditions, system.ExportCondition{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, TemplateID: item.TemplateID, From: item.From, Column: item.Column, Operator: item.Operator})
out.Conditions = append(out.Conditions, system.ExportCondition{ID: item.ID, TemplateID: item.TemplateID, From: item.From, Column: item.Column, Operator: item.Operator})
}
for _, item := range value.Joins {
out.Joins = append(out.Joins, system.ExportJoin{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, TemplateID: item.TemplateID, Join: item.Join, Table: item.Table, On: item.On})
out.Joins = append(out.Joins, system.ExportJoin{ID: item.ID, TemplateID: item.TemplateID, Join: item.Join, Table: item.Table, On: item.On})
}
return out
}

View File

@ -4,9 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"time"
@ -69,15 +67,7 @@ func (s *ExportService) Export(ctx context.Context, tid string, params map[strin
lookup = parts[1]
}
}
value := fmt.Sprintf("%v", row[lookup])
if timestamp, ok := row[lookup].(time.Time); ok {
value = timestamp.Format("2006-01-02 15:04:05")
}
if number, parseErr := strconv.ParseFloat(value, 64); parseErr == nil {
_ = file.SetCellValue(sheet, cell, number)
} else {
_ = file.SetCellValue(sheet, cell, value)
}
_ = file.SetCellValue(sheet, cell, exportCellValue(row[lookup]))
}
}
buffer, err := file.WriteToBuffer()
@ -87,6 +77,19 @@ func (s *ExportService) Export(ctx context.Context, tid string, params map[strin
}
return buffer.Bytes(), template.Name + ".xlsx", nil
}
func exportCellValue(value any) any {
switch typed := value.(type) {
case time.Time:
return typed.Format("2006-01-02 15:04:05")
case []byte:
return string(typed)
case string:
return typed
default:
return value
}
}
func (s *ExportService) ExportBlankTemplate(ctx context.Context, tid string) ([]byte, string, error) {
template, err := s.uc.FindExportTemplate(ctx, 0, tid)
if err != nil {

View File

@ -0,0 +1,10 @@
package system
import "testing"
func TestExportCellValuePreservesNumericText(t *testing.T) {
value, ok := exportCellValue("007").(string)
if !ok || value != "007" {
t.Fatalf("exportCellValue() = %#v, want string 007", exportCellValue("007"))
}
}

View File

@ -72,10 +72,10 @@ func (s *MediaService) Rename(ctx context.Context, id uint, name string) error {
func (s *MediaService) ImportURLs(ctx context.Context, items []*system.MediaFile) error {
return s.uc.CreateMediaBatch(ctx, items)
}
func (s *MediaService) ImportURLRequests(ctx context.Context, values []dto.ImportMediaRequest) error {
func (s *MediaService) ImportURLRequests(ctx context.Context, userID uint, values []dto.ImportMediaRequest) error {
items := make([]*system.MediaFile, 0, len(values))
for _, value := range values {
items = append(items, &system.MediaFile{Name: value.Name, CategoryID: value.ClassID, URL: value.URL, Tag: value.Tag, Key: value.Key, Size: value.Size, Mime: value.Mime, MD5: value.MD5, UserID: value.UserID})
items = append(items, &system.MediaFile{Name: value.Name, CategoryID: value.ClassID, URL: value.URL, Tag: value.Tag, Key: value.Key, Size: value.Size, Mime: value.Mime, MD5: value.MD5, UserID: userID})
}
return s.ImportURLs(ctx, items)
}

View File

@ -0,0 +1,30 @@
package system
import (
"context"
"testing"
bizsystem "kra/internal/biz/system"
"kra/internal/service/dto"
)
type mediaImportRepo struct {
bizsystem.MediaRepo
items []*bizsystem.MediaFile
}
func (r *mediaImportRepo) CreateMediaBatch(_ context.Context, items []*bizsystem.MediaFile) error {
r.items = items
return nil
}
func TestImportURLRequestsUsesAuthenticatedUser(t *testing.T) {
repo := &mediaImportRepo{}
service := NewMediaService(bizsystem.NewMediaUsecase(repo, nil, nil), nil)
if err := service.ImportURLRequests(context.Background(), 42, []dto.ImportMediaRequest{{Name: "image", URL: "https://example.test/image.png"}}); err != nil {
t.Fatal(err)
}
if len(repo.items) != 1 || repo.items[0].UserID != 42 {
t.Fatalf("imported media = %#v, want authenticated user 42", repo.items)
}
}

View File

@ -75,14 +75,14 @@ func (s *MenuService) SetRoles(ctx context.Context, req *dto.SetMenuRolesRequest
}
func menuDomain(v *dto.MenuRequest) *system.Menu {
menu := &system.Menu{CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType}
menu := &system.Menu{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType}
menu.Buttons = make([]*system.MenuButton, 0, len(v.MenuButtons))
for _, button := range v.MenuButtons {
menu.Buttons = append(menu.Buttons, &system.MenuButton{CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID})
menu.Buttons = append(menu.Buttons, &system.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID})
}
menu.Parameters = make([]*system.MenuParameter, 0, len(v.Parameters))
for _, parameter := range v.Parameters {
menu.Parameters = append(menu.Parameters, &system.MenuParameter{CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
menu.Parameters = append(menu.Parameters, &system.MenuParameter{ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
}
return menu
}

View File

@ -4,32 +4,27 @@ import (
"encoding/json"
"kra/internal/biz/system"
"testing"
"time"
"kra/internal/service/dto"
)
func TestMenuDomainPreservesRelationIdentity(t *testing.T) {
createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC)
updatedAt := time.Date(2025, time.April, 5, 6, 7, 8, 0, time.UTC)
menu := menuDomain(&dto.MenuRequest{
CreatedAt: createdAt,
UpdatedAt: updatedAt,
ID: 10,
ID: 10,
MenuButtons: []dto.MenuButtonRequest{{
CreatedAt: createdAt, UpdatedAt: updatedAt, ID: 31, Name: "edit", Description: "编辑",
ID: 31, Name: "edit", Description: "编辑",
}},
Parameters: []dto.MenuParameterRequest{{
CreatedAt: createdAt, UpdatedAt: updatedAt, ID: 21, Type: "query", Key: "id", Value: "1",
ID: 21, Type: "query", Key: "id", Value: "1",
}},
})
if !menu.CreatedAt.Equal(createdAt) || !menu.UpdatedAt.Equal(updatedAt) {
t.Fatalf("menu timestamps = (%v, %v), want (%v, %v)", menu.CreatedAt, menu.UpdatedAt, createdAt, updatedAt)
if !menu.CreatedAt.IsZero() || !menu.UpdatedAt.IsZero() {
t.Fatalf("menu timestamps = (%v, %v), want zero before persistence", menu.CreatedAt, menu.UpdatedAt)
}
if len(menu.Buttons) != 1 || menu.Buttons[0].ID != 31 || menu.Buttons[0].MenuID != 10 || !menu.Buttons[0].CreatedAt.Equal(createdAt) || !menu.Buttons[0].UpdatedAt.Equal(updatedAt) {
if len(menu.Buttons) != 1 || menu.Buttons[0].ID != 31 || menu.Buttons[0].MenuID != 10 || !menu.Buttons[0].CreatedAt.IsZero() || !menu.Buttons[0].UpdatedAt.IsZero() {
t.Fatalf("button conversion = %+v", menu.Buttons)
}
if len(menu.Parameters) != 1 || menu.Parameters[0].ID != 21 || menu.Parameters[0].MenuID != 10 || !menu.Parameters[0].CreatedAt.Equal(createdAt) || !menu.Parameters[0].UpdatedAt.Equal(updatedAt) {
if len(menu.Parameters) != 1 || menu.Parameters[0].ID != 21 || menu.Parameters[0].MenuID != 10 || !menu.Parameters[0].CreatedAt.IsZero() || !menu.Parameters[0].UpdatedAt.IsZero() {
t.Fatalf("parameter conversion = %+v", menu.Parameters)
}
}

View File

@ -15,7 +15,7 @@ func NewParameterService(uc *system.ParameterUsecase) *ParameterService {
}
func parameterDomain(value *dto.SystemParameterRequest) *system.SystemParameter {
return &system.SystemParameter{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, Name: value.Name, Key: value.Key, Value: value.Value, Desc: value.Description}
return &system.SystemParameter{ID: value.ID, Name: value.Name, Key: value.Key, Value: value.Value, Desc: value.Description}
}
func (s *ParameterService) CreateParameterRequest(ctx context.Context, req *dto.SystemParameterRequest) error {
return s.CreateParameter(ctx, parameterDomain(req))

View File

@ -55,6 +55,13 @@ func (s *SecurityService) ValidatePassword(ctx context.Context, password string)
if err != nil {
return err
}
return s.ValidatePasswordWithConfig(cfg, password)
}
func (s *SecurityService) ValidatePasswordWithConfig(cfg *system.SecurityConfig, password string) error {
if cfg == nil {
return errors.New("安全配置不可用")
}
if err := s.uc.ValidatePassword(cfg, password); err != nil {
return &PasswordPolicyError{Err: err}
}

View File

@ -29,10 +29,13 @@ func (s *UserService) ListUsersRequest(ctx context.Context, value *dto.UserListR
}
func (s *UserService) CreateUserRequest(ctx context.Context, value *dto.UserRequest) (*dto.UserResponse, error) {
if err := s.settings.ValidatePassword(ctx, value.Password); err != nil {
security, err := s.settings.CurrentSecurity(ctx)
if err != nil {
return nil, err
}
if err := s.settings.ValidatePasswordWithConfig(security, value.Password); err != nil {
return nil, err
}
security, _ := s.settings.CurrentSecurity(ctx)
mustChange := security != nil && security.ForceNewUserChangePassword
user, err := s.uc.CreateUser(ctx, &system.User{Username: value.Username, Password: value.Password, NickName: value.NickName, HeaderImg: value.HeaderImg, AuthorityID: value.AuthorityID, Phone: value.Phone, Email: value.Email, Enable: value.Enable, MustChangePassword: mustChange}, value.AuthorityIDs)
if err != nil {

View File

@ -256,12 +256,17 @@ func (s *VersionService) ImportRequest(ctx context.Context, req *dto.ImportVersi
if err := s.Import(ctx, bundle); err != nil {
return err
}
raw, _ := json.Marshal(req)
_ = s.uc.CreateVersion(ctx, &system.Version{
raw, err := json.Marshal(req)
if err != nil {
return &system.VersionStageError{Stage: system.VersionStageJSON, Err: err}
}
if err = s.uc.CreateVersion(ctx, &system.Version{
Name: versionPointer(req.Version.Name),
Code: versionPointer(req.Version.Code + "_imported_" + time.Now().Format("20060102150405")),
Description: versionPointer("导入版本: " + req.Version.Description),
Data: versionPointer(string(raw)),
})
}); err != nil {
return &system.VersionStageError{Stage: system.VersionStageSave, Err: err}
}
return nil
}

View File

@ -0,0 +1,36 @@
package system
import (
"context"
"errors"
"testing"
bizsystem "kra/internal/biz/system"
"kra/internal/service/dto"
)
type versionImportRepo struct {
bizsystem.VersionRepo
saveErr error
}
func (r *versionImportRepo) ImportVersionBundle(context.Context, *bizsystem.VersionBundle) error {
return nil
}
func (r *versionImportRepo) CreateVersion(context.Context, *bizsystem.Version) error {
return r.saveErr
}
func TestImportRequestReturnsVersionAuditSaveFailure(t *testing.T) {
repo := &versionImportRepo{saveErr: errors.New("database unavailable")}
service := NewVersionService(bizsystem.NewVersionUsecase(repo))
request := &dto.ImportVersionRequest{}
request.Version.Name = "release"
request.Version.Code = "v1"
err := service.ImportRequest(context.Background(), request)
var stageErr *bizsystem.VersionStageError
if !errors.As(err, &stageErr) || stageErr.Stage != bizsystem.VersionStageSave {
t.Fatalf("ImportRequest() error = %v, want save stage error", err)
}
}

View File

@ -75,7 +75,8 @@ func TestReloadPreservesExistingScheduleOnPreparationFailure(t *testing.T) {
repo := &workerTaskRepo{}
scheduler := newTestTaskScheduler(repo)
old := &taskbiz.TimedTask{ID: 1, Name: "old", Spec: "0 0 * * *", Enabled: true}
if err := scheduler.Schedule(old); err != nil {
repo.items = []*taskbiz.TimedTask{old}
if err := scheduler.ScheduleID(context.Background(), old.ID); err != nil {
t.Fatalf("schedule old task: %v", err)
}
@ -116,16 +117,17 @@ func assertOnlyScheduledTask(t *testing.T, scheduler *TaskScheduler, id uint) {
}
}
func TestConcurrentScheduleKeepsSingleEntry(t *testing.T) {
scheduler := newTestTaskScheduler(&workerTaskRepo{})
func TestConcurrentScheduleIDKeepsSingleEntry(t *testing.T) {
repo := &workerTaskRepo{items: []*taskbiz.TimedTask{{ID: 7, Name: "same", Spec: "0 0 * * *", Enabled: true}}}
scheduler := newTestTaskScheduler(repo)
const workers = 32
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := scheduler.Schedule(&taskbiz.TimedTask{ID: 7, Name: "same", Spec: "0 0 * * *", Enabled: true}); err != nil {
t.Errorf("Schedule() error = %v", err)
if err := scheduler.ScheduleID(context.Background(), 7); err != nil {
t.Errorf("ScheduleID() error = %v", err)
}
}()
}

View File

@ -325,34 +325,6 @@ func (s *TaskScheduler) run(ctx context.Context, task *taskbiz.TimedTask, trigge
}
}
func (s *TaskScheduler) Schedule(task *taskbiz.TimedTask) error {
if task == nil {
return errors.New("定时任务不能为空")
}
var schedule cron.Schedule
var err error
if task.Enabled {
schedule, err = parseTaskSchedule(task)
if err != nil {
return err
}
}
s.mu.Lock()
defer s.mu.Unlock()
s.runMu.Lock()
stopping := s.stopping
s.runMu.Unlock()
if stopping {
return errors.New("任务调度器正在停止")
}
s.removeLocked(task.ID)
if !task.Enabled {
return nil
}
s.scheduleLocked(cloneTimedTask(task), schedule)
return nil
}
func parseTaskSchedule(task *taskbiz.TimedTask) (cron.Schedule, error) {
if task.WithSeconds {
return cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor).Parse(task.Spec)

View File

@ -11,26 +11,6 @@ import (
"strings"
)
func WechatV2Sign(values map[string]string, key, signType string) string {
keys := make([]string, 0, len(values))
for k, v := range values {
if k != "sign" && v != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+values[k])
}
raw := strings.Join(parts, "&") + "&key=" + key
if strings.EqualFold(signType, "HMAC-SHA256") {
return HMACSHA256Hex([]byte(raw), key, true)
}
sum := md5.Sum([]byte(raw))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func MD5Canonical(values map[string]any, secret string) string {
keys := make([]string, 0, len(values))
for key, value := range values {

View File

@ -1,12 +0,0 @@
package paymentutil
import "testing"
func TestWechatV2SignIsStableAndExcludesSign(t *testing.T) {
values := map[string]string{"b": "2", "a": "1", "sign": "old"}
first := WechatV2Sign(values, "secret", "MD5")
values["sign"] = "different"
if second := WechatV2Sign(values, "secret", "MD5"); first != second {
t.Fatalf("signature changed when sign field changed: %q != %q", first, second)
}
}

View File

@ -7,11 +7,11 @@ import (
func NormalizeStatus(value, fallback string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "SUCCESS", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS":
case "SUCCESS", "OK", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS", "REFUND_SUCCESS", "REFUNDED":
return "success"
case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "PAYING":
case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "ACCEPTED", "PAYING":
return "pending"
case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "REFUNDED", "FAILED", "FAIL", "PAYERROR":
case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "FAILED", "FAIL", "REJECTED", "DENIED", "DECLINED", "ERROR", "PAYERROR":
return "failed"
default:
return fallback
@ -51,6 +51,20 @@ func ContainsFold(values []string, value string) bool {
return false
}
func Text(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
}
func FirstText(values map[string]any, keys ...string) string {
for _, key := range keys {
if value := Text(values, key); value != "" {
return value
}
}
return ""
}
func toString(value any) string {
if value == nil {
return ""

View File

@ -176,36 +176,26 @@ func (s *Server) OnDisconnect(handler func(*melody.Session)) {
s.mu.Unlock()
}
func snapshotHandlers[T any](mu *sync.RWMutex, handlers []T) []T {
mu.RLock()
defer mu.RUnlock()
return append([]T(nil), handlers...)
}
func (s *Server) messageHandlerSnapshot() []func(*melody.Session, []byte) {
s.mu.RLock()
defer s.mu.RUnlock()
handlers := make([]func(*melody.Session, []byte), len(s.messageHandlers))
copy(handlers, s.messageHandlers)
return handlers
return snapshotHandlers(&s.mu, s.messageHandlers)
}
func (s *Server) binaryHandlerSnapshot() []func(*melody.Session, []byte) {
s.mu.RLock()
defer s.mu.RUnlock()
handlers := make([]func(*melody.Session, []byte), len(s.binaryHandlers))
copy(handlers, s.binaryHandlers)
return handlers
return snapshotHandlers(&s.mu, s.binaryHandlers)
}
func (s *Server) connectHandlerSnapshot() []func(*melody.Session) {
s.mu.RLock()
defer s.mu.RUnlock()
handlers := make([]func(*melody.Session), len(s.connectHandlers))
copy(handlers, s.connectHandlers)
return handlers
return snapshotHandlers(&s.mu, s.connectHandlers)
}
func (s *Server) disconnectHandlerSnapshot() []func(*melody.Session) {
s.mu.RLock()
defer s.mu.RUnlock()
handlers := make([]func(*melody.Session), len(s.disconnectHandlers))
copy(handlers, s.disconnectHandlers)
return handlers
return snapshotHandlers(&s.mu, s.disconnectHandlers)
}
func (s *Server) Send(session *melody.Session, message []byte) error {
if session == nil {

View File

@ -18,21 +18,6 @@
<el-form-item label="内容:" prop="content">
<RichEdit v-model="formData.content" />
</el-form-item>
<el-form-item label="作者:" prop="userID">
<el-select
v-model="formData.userID"
placeholder="请选择作者"
style="width: 100%"
:clearable="true"
>
<el-option
v-for="(item, key) in dataSource.userID"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="附件:" prop="attachments">
<SelectFile v-model="formData.attachments" />
</el-form-item>
@ -47,7 +32,6 @@
<script setup>
import {
getInfoDataSource,
createInfo,
updateInfo,
findInfo
@ -72,21 +56,12 @@
const formData = ref({
title: '',
content: '',
userID: undefined,
attachments: []
})
//
const rule = reactive({})
const elFormRef = ref()
const dataSource = ref([])
const getDataSourceFunc = async () => {
const res = await getInfoDataSource()
if (res.code === 0) {
dataSource.value = res.data
}
}
getDataSourceFunc()
//
const init = async () => {

View File

@ -108,9 +108,7 @@
<el-table-column align="left" label="标题" prop="title" width="120" />
<el-table-column align="left" label="作者" prop="userID" width="120">
<template #default="scope">
<span>{{
filterDataSource(dataSource.userID, scope.row.userID)
}}</span>
<span>{{ filterDataSource(dataSource.userID, scope.row.userID) }}</span>
</template>
</el-table-column>
<el-table-column label="附件" prop="attachments" width="200">
@ -199,21 +197,6 @@
<el-form-item label="内容:" prop="content">
<RichEdit v-model="formData.content" />
</el-form-item>
<el-form-item label="作者:" prop="userID">
<el-select
v-model="formData.userID"
placeholder="请选择作者"
style="width: 100%"
:clearable="true"
>
<el-option
v-for="(item, key) in dataSource.userID"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="附件:" prop="attachments">
<SelectFile v-model="formData.attachments" />
</el-form-item>
@ -254,15 +237,12 @@
const formData = ref({
title: '',
content: '',
userID: undefined,
attachments: []
})
const dataSource = ref([])
const dataSource = ref({ userID: [] })
const getDataSourceFunc = async () => {
const res = await getInfoDataSource()
if (res.code === 0) {
dataSource.value = res.data
}
if (res.code === 0) dataSource.value = res.data
}
getDataSourceFunc()
@ -457,7 +437,6 @@
formData.value = {
title: '',
content: '',
userID: undefined,
attachments: []
}
}