实现审计覆盖门禁与外部集成日志闭环
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m19s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m19s
This commit is contained in:
46
internal/governance/auditcoverage/coverage_test.go
Normal file
46
internal/governance/auditcoverage/coverage_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package auditcoverage_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/governance/auditcoverage"
|
||||
)
|
||||
|
||||
func TestReviewedAuditCoverageManifestMatchesAllRegisteredEntrypoints(t *testing.T) {
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("无法定位覆盖门禁测试文件")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "../../../"))
|
||||
actual, err := auditcoverage.Scan(root)
|
||||
if err != nil {
|
||||
t.Fatalf("扫描全系统入口失败:%v", err)
|
||||
}
|
||||
expected, err := auditcoverage.LoadManifest(filepath.Join(root, ".scratch/tech-global-audit/审计覆盖清单.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("读取经评审审计覆盖清单失败:%v", err)
|
||||
}
|
||||
if len(actual) == 0 {
|
||||
t.Fatal("入口扫描结果为空,覆盖门禁不可用")
|
||||
}
|
||||
if !reflect.DeepEqual(expected, actual) {
|
||||
t.Fatalf("源码入口与审计覆盖清单不一致:清单 %d 项,源码 %d 项;请重新分类并完成评审后更新清单", len(expected), len(actual))
|
||||
}
|
||||
for _, entry := range expected {
|
||||
if entry.AuditEvent == "" || entry.DomainLedger == "" || entry.IntegrationLog == "" || entry.Outbox == "" ||
|
||||
entry.Transaction == "" || entry.FailureStrategy == "" || entry.SensitivePolicy == "" ||
|
||||
entry.BeforeAfterPolicy == "" || entry.TestSeam == "" {
|
||||
t.Fatalf("入口 %s 存在未分类字段", entry.Key)
|
||||
}
|
||||
if entry.AuditEvent == "N/A" && entry.NAReason == "" {
|
||||
t.Fatalf("入口 %s 的 Audit Event 为 N/A 但缺少理由", entry.Key)
|
||||
}
|
||||
if entry.AuditEvent != "N/A" && (entry.ActionCode == "" || entry.ActionName == "" || entry.Category == "" ||
|
||||
entry.Risk == "" || entry.PrimaryResource == "") {
|
||||
t.Fatalf("入口 %s 缺少动作、风险或主要资源", entry.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
448
internal/governance/auditcoverage/scanner.go
Normal file
448
internal/governance/auditcoverage/scanner.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// Package auditcoverage 提供全系统入口的可复核审计覆盖扫描。
|
||||
package auditcoverage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
// Entry 是一个必须经过审计分类的 HTTP、Worker 或定时任务入口。
|
||||
type Entry struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
CodeEntry string `json:"code_entry"`
|
||||
Owner string `json:"owner"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
AuditEvent string `json:"audit_event"`
|
||||
DomainLedger string `json:"domain_ledger"`
|
||||
IntegrationLog string `json:"integration_log"`
|
||||
Outbox string `json:"outbox"`
|
||||
ActionCode string `json:"action_code,omitempty"`
|
||||
ActionName string `json:"action_name,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
PrimaryResource string `json:"primary_resource,omitempty"`
|
||||
AffectedResource string `json:"affected_resource,omitempty"`
|
||||
ActorSource string `json:"actor_source"`
|
||||
Transaction string `json:"transaction"`
|
||||
FailureStrategy string `json:"failure_strategy"`
|
||||
SensitivePolicy string `json:"sensitive_policy"`
|
||||
BeforeAfterPolicy string `json:"before_after_policy"`
|
||||
TestSeam string `json:"test_seam"`
|
||||
NAReason string `json:"na_reason,omitempty"`
|
||||
}
|
||||
|
||||
// Scan 扫描当前仓库中对外 HTTP、Asynq Worker 和定时任务注册入口。
|
||||
func Scan(root string) ([]Entry, error) {
|
||||
var entries []Entry
|
||||
files := []string{"internal/routes", "internal/application", "internal/domain", "internal/service", "pkg/queue", "cmd/worker"}
|
||||
for _, directory := range files {
|
||||
err := filepath.Walk(filepath.Join(root, directory), func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
found, err := scanFile(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, found...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Key < entries[j].Key })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// LoadManifest 读取经评审的显式覆盖快照。
|
||||
func LoadManifest(path string) ([]Entry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []Entry
|
||||
if err := sonic.Unmarshal(data, &entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// MarshalManifest 将扫描结果输出为稳定、便于评审的 JSON。
|
||||
func MarshalManifest(entries []Entry) ([]byte, error) {
|
||||
return sonic.ConfigStd.MarshalIndent(entries, "", " ")
|
||||
}
|
||||
|
||||
func scanFile(root, path string) ([]Entry, error) {
|
||||
set := token.NewFileSet()
|
||||
file, err := parser.ParseFile(set, path, nil, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []Entry
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
position := set.Position(call.Pos())
|
||||
if identifier, ok := call.Fun.(*ast.Ident); ok && identifier.Name == "Register" && len(call.Args) >= 7 {
|
||||
method, methodOK := stringLiteral(call.Args[3])
|
||||
pathSuffix, pathOK := stringLiteral(call.Args[4])
|
||||
if methodOK && pathOK {
|
||||
entry := classifyHTTP(relative, position.Line, method, pathSuffix, expression(call.Args[5]), routeSummary(call.Args[6]))
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch selector.Sel.Name {
|
||||
case "HandleFunc":
|
||||
if len(call.Args) >= 2 {
|
||||
entries = append(entries, classifyWorker(relative, position.Line, expression(call.Args[0]), expression(call.Args[1])))
|
||||
}
|
||||
case "Register":
|
||||
if strings.HasPrefix(relative, "cmd/worker/") {
|
||||
if taskType, schedule, ok := scheduledTask(call); ok {
|
||||
entries = append(entries, classifySchedule(relative, position.Line, taskType, schedule))
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if strings.HasPrefix(relative, "internal/application/") || strings.HasPrefix(relative, "internal/domain/") ||
|
||||
strings.HasPrefix(relative, "internal/service/") {
|
||||
for _, declaration := range file.Decls {
|
||||
function, ok := declaration.(*ast.FuncDecl)
|
||||
if !ok || function.Recv == nil || !isBusinessMethod(function.Name.Name) {
|
||||
continue
|
||||
}
|
||||
position := set.Position(function.Pos())
|
||||
entries = append(entries, classifyBusinessMethod(relative, position.Line, function.Name.Name))
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func classifyHTTP(file string, line int, method, path, handler, summary string) Entry {
|
||||
owner := strings.TrimSuffix(filepath.Base(file), ".go")
|
||||
if summary == "" {
|
||||
summary = handler
|
||||
}
|
||||
entry := Entry{
|
||||
Key: fmt.Sprintf("http:%s:%d:%s:%s", file, line, method, path), Kind: "http",
|
||||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner,
|
||||
Method: method, Path: path, Summary: summary, ActorSource: httpActorSource(file),
|
||||
DomainLedger: ledgerDecision(owner), IntegrationLog: integrationDecision(file, path),
|
||||
Outbox: "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A",
|
||||
SensitivePolicy: "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限",
|
||||
BeforeAfterPolicy: "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用",
|
||||
TestSeam: "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口",
|
||||
}
|
||||
if method == "GET" && !isSensitiveRead(file, path, summary) {
|
||||
entry.AuditEvent = "N/A"
|
||||
entry.Transaction = "N/A"
|
||||
entry.FailureStrategy = "Access Log 记录统一错误;普通读取不创建业务审计"
|
||||
entry.NAReason = "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值"
|
||||
return entry
|
||||
}
|
||||
entry.AuditEvent = "必须"
|
||||
entry.ActionCode = actionCode(owner, handler)
|
||||
entry.ActionName = summary
|
||||
entry.Category = categoryFor(owner)
|
||||
entry.Risk = riskFor(owner, path, summary)
|
||||
entry.PrimaryResource = owner
|
||||
entry.AffectedResource = "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展"
|
||||
entry.Transaction = "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入"
|
||||
entry.FailureStrategy = "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical"
|
||||
return entry
|
||||
}
|
||||
|
||||
func classifyWorker(file string, line int, taskType, handler string) Entry {
|
||||
owner := workerOwner(taskType)
|
||||
entry := Entry{
|
||||
Key: fmt.Sprintf("worker:%s:%d:%s", file, line, taskType), Kind: "worker",
|
||||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner,
|
||||
Summary: "处理异步任务 " + taskType, AuditEvent: "按状态变化、人工触发、连续失败或高风险异常决定",
|
||||
DomainLedger: ledgerDecision(owner), IntegrationLog: workerIntegrationDecision(taskType),
|
||||
Outbox: "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等",
|
||||
ActionCode: actionCode(owner, handler), ActionName: "处理异步任务(" + taskType + ")",
|
||||
Category: categoryFor(owner), Risk: riskFor(owner, taskType, handler),
|
||||
PrimaryResource: owner, AffectedResource: "任务载荷定位的直接业务资源",
|
||||
ActorSource: "system_task/asynq", Transaction: "业务状态变化、领域流水和 Audit Event 按用例原子提交",
|
||||
FailureStrategy: "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计",
|
||||
SensitivePolicy: "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL",
|
||||
BeforeAfterPolicy: "状态变化保存直接前后值;无业务变化时仅保留 Integration Log",
|
||||
TestSeam: "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口",
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func classifySchedule(file string, line int, taskType, schedule string) Entry {
|
||||
return Entry{
|
||||
Key: fmt.Sprintf("schedule:%s:%d:%s", file, line, taskType), Kind: "scheduled_job",
|
||||
CodeEntry: fmt.Sprintf("%s:%d", file, line), Owner: workerOwner(taskType), Summary: "按 " + schedule + " 调度 " + taskType,
|
||||
AuditEvent: "N/A", DomainLedger: "N/A", IntegrationLog: "N/A", Outbox: "N/A",
|
||||
ActorSource: "system_task/scheduled_job", Transaction: "N/A",
|
||||
FailureStrategy: "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责",
|
||||
SensitivePolicy: "调度日志仅记录任务类型与安全时间信息",
|
||||
BeforeAfterPolicy: "N/A:调度入口不修改业务事实",
|
||||
TestSeam: "调度注册公开函数 + 覆盖门禁静态比对本入口",
|
||||
NAReason: "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker",
|
||||
}
|
||||
}
|
||||
|
||||
func classifyBusinessMethod(file string, line int, method string) Entry {
|
||||
parts := strings.Split(file, "/")
|
||||
layer := parts[1]
|
||||
owner := strings.TrimSuffix(filepath.Base(filepath.Dir(file)), ".go")
|
||||
if owner == "service" || owner == "application" || owner == "domain" {
|
||||
owner = strings.TrimSuffix(filepath.Base(file), ".go")
|
||||
}
|
||||
entry := Entry{
|
||||
Key: fmt.Sprintf("%s:%s:%d:%s", layer, file, line, method), Kind: layer,
|
||||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, method), Owner: owner,
|
||||
Summary: "业务方法 " + method, DomainLedger: ledgerDecision(owner),
|
||||
IntegrationLog: businessIntegrationDecision(file, method),
|
||||
Outbox: "存在提交后可靠副作用时必须在同一事务追加;否则 N/A",
|
||||
ActionCode: actionCode(owner, method), Risk: riskFor(owner, file, method),
|
||||
PrimaryResource: owner, AffectedResource: "完整用例直接修改或引用的资源",
|
||||
ActorSource: "由调用入口传入操作者与来源快照",
|
||||
SensitivePolicy: "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品",
|
||||
BeforeAfterPolicy: "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影",
|
||||
TestSeam: "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口",
|
||||
}
|
||||
if layer == "domain" {
|
||||
entry.AuditEvent = "N/A"
|
||||
entry.Transaction = "由 Application 组合根负责"
|
||||
entry.FailureStrategy = "返回领域错误,由 Application 在回滚后裁决 failed/denied 审计"
|
||||
entry.NAReason = "Domain 只维护业务不变量和领域事实,不依赖审计基础设施;Audit Event 由 Application 写入"
|
||||
entry.ActionCode = ""
|
||||
entry.ActionName = ""
|
||||
entry.Category = ""
|
||||
entry.Risk = ""
|
||||
entry.PrimaryResource = ""
|
||||
return entry
|
||||
}
|
||||
entry.AuditEvent = "必须"
|
||||
entry.ActionName = "执行业务方法(" + method + ")"
|
||||
entry.Category = categoryFor(owner)
|
||||
entry.Transaction = "关键成功事件与业务事实同一 GORM 事务"
|
||||
entry.FailureStrategy = "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical"
|
||||
return entry
|
||||
}
|
||||
|
||||
func routeSummary(expr ast.Expr) string {
|
||||
composite, ok := expr.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
for _, element := range composite.Elts {
|
||||
pair, ok := element.(*ast.KeyValueExpr)
|
||||
if !ok || expression(pair.Key) != "Summary" {
|
||||
continue
|
||||
}
|
||||
value, _ := stringLiteral(pair.Value)
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scheduledTask(call *ast.CallExpr) (string, string, bool) {
|
||||
if len(call.Args) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
schedule, _ := stringLiteral(call.Args[0])
|
||||
taskCall, ok := call.Args[1].(*ast.CallExpr)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
selector, ok := taskCall.Fun.(*ast.SelectorExpr)
|
||||
if !ok || selector.Sel.Name != "NewTask" || len(taskCall.Args) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return expression(taskCall.Args[0]), schedule, true
|
||||
}
|
||||
|
||||
func stringLiteral(expr ast.Expr) (string, bool) {
|
||||
literal, ok := expr.(*ast.BasicLit)
|
||||
if !ok || literal.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.Unquote(literal.Value)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func expression(expr ast.Expr) string {
|
||||
switch value := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return value.Name
|
||||
case *ast.SelectorExpr:
|
||||
return expression(value.X) + "." + value.Sel.Name
|
||||
case *ast.BasicLit:
|
||||
return value.Value
|
||||
case *ast.CallExpr:
|
||||
return expression(value.Fun)
|
||||
default:
|
||||
return fmt.Sprintf("%T", expr)
|
||||
}
|
||||
}
|
||||
|
||||
func httpActorSource(file string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(file, "personal.go"):
|
||||
return "personal_customer/personal_api"
|
||||
case strings.HasSuffix(file, "order.go"):
|
||||
return "按路由分为 admin_user/admin_api 或外部回调入口"
|
||||
default:
|
||||
return "登录账号快照/admin_api"
|
||||
}
|
||||
}
|
||||
|
||||
func isSensitiveRead(file, path, summary string) bool {
|
||||
text := strings.ToLower(file + " " + path + " " + summary)
|
||||
for _, marker := range []string{"download", "export", "realname-link", "实名", "敏感", "完整", "operation-password"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func integrationDecision(file, path string) string {
|
||||
text := strings.ToLower(file + " " + path)
|
||||
if strings.Contains(text, "callback") || strings.HasSuffix(file, "order.go") &&
|
||||
(strings.Contains(path, "pay") || strings.Contains(path, "alipay")) {
|
||||
return "必须:业务处理前保存入站安全摘要与幂等标识"
|
||||
}
|
||||
return "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须"
|
||||
}
|
||||
|
||||
func workerIntegrationDecision(taskType string) string {
|
||||
text := strings.ToLower(taskType)
|
||||
if strings.Contains(text, "polling") {
|
||||
return "必须:每次实际请求或未发送裁决均记录"
|
||||
}
|
||||
return "Worker 调用外部系统时必须;纯本地处理 N/A"
|
||||
}
|
||||
|
||||
func businessIntegrationDecision(file, method string) string {
|
||||
text := strings.ToLower(file + " " + method)
|
||||
for _, marker := range []string{"polling", "gateway", "payment", "wechat", "wecom", "carrier", "sms"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return "调用外部系统或处理回调时必须;纯本地分支 N/A"
|
||||
}
|
||||
}
|
||||
return "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类"
|
||||
}
|
||||
|
||||
func ledgerDecision(owner string) string {
|
||||
for _, marker := range []string{"order", "recharge", "refund", "commission", "wallet"} {
|
||||
if strings.Contains(owner, marker) {
|
||||
return "必须:订单、充值、退款、钱包流水等既有业务表是领域权威"
|
||||
}
|
||||
}
|
||||
if strings.Contains(owner, "import") || strings.Contains(owner, "export") {
|
||||
return "业务任务及明细表是批量结果权威"
|
||||
}
|
||||
return "既有业务表是状态事实;Audit Event 不替代业务模型"
|
||||
}
|
||||
|
||||
func riskFor(owner, path, summary string) string {
|
||||
text := strings.ToLower(owner + " " + path + " " + summary)
|
||||
for _, marker := range []string{"wallet", "refund", "recharge", "permission", "role", "password", "config", "权限", "资金", "退款", "充值"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return "high"
|
||||
}
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
|
||||
func categoryFor(owner string) string {
|
||||
text := strings.ToLower(owner)
|
||||
for _, marker := range []string{"wallet", "refund", "recharge", "commission", "order"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return "finance"
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"account", "role", "permission", "auth"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return "security"
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"card", "device", "asset", "polling"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return "asset"
|
||||
}
|
||||
}
|
||||
return "business"
|
||||
}
|
||||
|
||||
func actionCode(owner, handler string) string {
|
||||
method := handler
|
||||
if index := strings.LastIndex(method, "."); index >= 0 {
|
||||
method = method[index+1:]
|
||||
}
|
||||
return normalize(owner) + "." + normalize(method)
|
||||
}
|
||||
|
||||
func workerOwner(taskType string) string {
|
||||
return normalize(strings.TrimPrefix(taskType, "constants.TaskType"))
|
||||
}
|
||||
|
||||
func isBusinessMethod(name string) bool {
|
||||
for _, prefix := range []string{
|
||||
"Create", "Update", "Delete", "Set", "Assign", "Remove", "Cancel", "Reject", "Approve",
|
||||
"Import", "Allocate", "Recall", "Stop", "Resume", "Bind", "Unbind", "Reset", "Activate",
|
||||
"Deactivate", "Trigger", "Handle", "Process", "Execute", "Replay", "Release", "Change", "Pay",
|
||||
"Refund", "Recharge", "Withdraw", "Grant", "Revoke", "Deduct", "Credit", "Debit", "Freeze",
|
||||
"Unfreeze", "Resolve", "Expire", "Invalidate", "Archive", "Cleanup", "Adjust", "Add", "Batch",
|
||||
"Enable", "Disable", "Login", "Logout", "Refresh", "Upload", "Download", "Save", "Restore",
|
||||
"Submit", "Sync", "Migrate", "Send",
|
||||
} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalize(value string) string {
|
||||
value = strings.Trim(value, "\"")
|
||||
var output []rune
|
||||
for index, current := range []rune(value) {
|
||||
if current >= 'A' && current <= 'Z' {
|
||||
if index > 0 {
|
||||
output = append(output, '_')
|
||||
}
|
||||
current += 'a' - 'A'
|
||||
}
|
||||
if current == '-' || current == ':' || current == '/' {
|
||||
current = '_'
|
||||
}
|
||||
output = append(output, current)
|
||||
}
|
||||
return strings.Trim(strings.ReplaceAll(string(output), "__", "_"), "_")
|
||||
}
|
||||
284
internal/infrastructure/integrationlog/repository.go
Normal file
284
internal/infrastructure/integrationlog/repository.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Package integrationlog 提供外部交互尝试的可靠持久化能力。
|
||||
package integrationlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
|
||||
)
|
||||
|
||||
// Attempt 描述一次外部调用前必须持久化的稳定事实。
|
||||
type Attempt struct {
|
||||
IntegrationID string
|
||||
Provider string
|
||||
Direction string
|
||||
Operation string
|
||||
ExternalID *string
|
||||
ResourceType string
|
||||
ResourceID *string
|
||||
ResourceKey *string
|
||||
TriggerSource *string
|
||||
TriggerScene *string
|
||||
TriggerSeries *string
|
||||
ScheduledAt *time.Time
|
||||
StartedAt *time.Time
|
||||
Attempt int
|
||||
RequestSummary any
|
||||
Metadata any
|
||||
RequestID *string
|
||||
CorrelationID *string
|
||||
AuditEventID *uint
|
||||
InitialResult string
|
||||
RecoveryStrategy *string
|
||||
}
|
||||
|
||||
// Completion 描述外部尝试从待处理状态进入终态的结果。
|
||||
type Completion struct {
|
||||
Result string
|
||||
HTTPStatus int
|
||||
ProviderCode string
|
||||
ProviderMessage string
|
||||
ResponseSummary any
|
||||
DurationMS int64
|
||||
StateChanged bool
|
||||
AuditEventID *uint
|
||||
RecoveryStrategy string
|
||||
}
|
||||
|
||||
// InboundAttempt 描述业务处理前必须保存的入站回调安全事实。
|
||||
type InboundAttempt struct {
|
||||
IntegrationID string
|
||||
IdempotencyKey string
|
||||
Provider string
|
||||
Operation string
|
||||
ExternalID string
|
||||
ResourceType string
|
||||
ResourceID *string
|
||||
ResourceKey *string
|
||||
RawPayload []byte
|
||||
ContentType string
|
||||
RequestID *string
|
||||
CorrelationID *string
|
||||
}
|
||||
|
||||
// Repository 负责创建稳定尝试及受控地进入终态。
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewRepository 创建 Integration Log Repository。
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db, now: time.Now}
|
||||
}
|
||||
|
||||
// Start 在实际调用外部系统前持久化尝试事实。
|
||||
func (r *Repository) Start(ctx context.Context, input Attempt) (*model.IntegrationLog, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
|
||||
}
|
||||
if err := validateAttempt(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requestSummary, err := marshalSummary(input.RequestSummary)
|
||||
if err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "Integration Log 请求摘要无效")
|
||||
}
|
||||
metadata, err := marshalSummary(input.Metadata)
|
||||
if err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "Integration Log 元数据无效")
|
||||
}
|
||||
if input.IntegrationID == "" {
|
||||
input.IntegrationID = uuid.NewString()
|
||||
}
|
||||
if input.Attempt <= 0 {
|
||||
input.Attempt = 1
|
||||
}
|
||||
if input.StartedAt == nil {
|
||||
startedAt := r.now().UTC()
|
||||
input.StartedAt = &startedAt
|
||||
}
|
||||
result := input.InitialResult
|
||||
if result == "" {
|
||||
result = constants.IntegrationResultPending
|
||||
}
|
||||
resourceType := optionalString(input.ResourceType)
|
||||
log := &model.IntegrationLog{
|
||||
IntegrationID: input.IntegrationID, Provider: input.Provider, Direction: input.Direction,
|
||||
Operation: input.Operation, ExternalID: input.ExternalID, ResourceType: resourceType,
|
||||
ResourceID: input.ResourceID, ResourceKey: input.ResourceKey, TriggerSource: input.TriggerSource,
|
||||
TriggerScene: input.TriggerScene, TriggerSeries: input.TriggerSeries, ScheduledAt: input.ScheduledAt,
|
||||
StartedAt: input.StartedAt, Attempt: input.Attempt, Result: result,
|
||||
RequestSummary: requestSummary, Metadata: metadata, RequestID: input.RequestID,
|
||||
CorrelationID: input.CorrelationID, AuditEventID: input.AuditEventID,
|
||||
RecoveryStrategy: input.RecoveryStrategy,
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(log).Error; err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "写入 Integration Log 失败")
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// Complete 仅允许把待处理尝试条件更新为一个公开终态。
|
||||
func (r *Repository) Complete(ctx context.Context, integrationID string, completion Completion) (*model.IntegrationLog, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
|
||||
}
|
||||
if integrationID == "" || !isTerminalResult(completion.Result) {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 终态参数无效")
|
||||
}
|
||||
if completion.Result == constants.IntegrationResultUnknown && strings.TrimSpace(completion.RecoveryStrategy) == "" {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "结果未知必须记录明确恢复策略")
|
||||
}
|
||||
responseSummary, err := marshalSummary(completion.ResponseSummary)
|
||||
if err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "Integration Log 响应摘要无效")
|
||||
}
|
||||
updates := map[string]any{
|
||||
"result": completion.Result, "duration_ms": completion.DurationMS,
|
||||
"state_changed": completion.StateChanged, "response_summary": responseSummary,
|
||||
"updated_at": r.now().UTC(),
|
||||
}
|
||||
if completion.HTTPStatus != 0 {
|
||||
updates["http_status"] = completion.HTTPStatus
|
||||
}
|
||||
if completion.ProviderCode != "" {
|
||||
updates["provider_code"] = completion.ProviderCode
|
||||
}
|
||||
if completion.ProviderMessage != "" {
|
||||
updates["provider_message"] = sanitizer.TextSummary(completion.ProviderMessage)
|
||||
}
|
||||
if completion.AuditEventID != nil {
|
||||
updates["audit_event_id"] = completion.AuditEventID
|
||||
}
|
||||
if completion.RecoveryStrategy != "" {
|
||||
updates["recovery_strategy"] = completion.RecoveryStrategy
|
||||
}
|
||||
result := r.db.WithContext(ctx).Model(&model.IntegrationLog{}).
|
||||
Where("integration_id = ? AND result = ?", integrationID, constants.IntegrationResultPending).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, result.Error, "终结 Integration Log 失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeConflict, "Integration Log 已进入终态或不存在")
|
||||
}
|
||||
var saved model.IntegrationLog
|
||||
if err := r.db.WithContext(ctx).Where("integration_id = ?", integrationID).First(&saved).Error; err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "读取 Integration Log 终态失败")
|
||||
}
|
||||
return &saved, nil
|
||||
}
|
||||
|
||||
// RecordInbound 在业务处理前幂等保存入站回调的安全摘要。
|
||||
func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*model.IntegrationLog, bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
|
||||
}
|
||||
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" || len(input.RawPayload) == 0 {
|
||||
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 参数无效")
|
||||
}
|
||||
if input.IntegrationID == "" {
|
||||
input.IntegrationID = uuid.NewString()
|
||||
}
|
||||
hash := sha256.Sum256(input.RawPayload)
|
||||
summary, err := marshalSummary(map[string]any{
|
||||
"content_type": input.ContentType,
|
||||
"payload_bytes": len(input.RawPayload),
|
||||
"content_hash": hex.EncodeToString(hash[:]),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "入站 Integration Log 摘要无效")
|
||||
}
|
||||
now := r.now().UTC()
|
||||
log := &model.IntegrationLog{
|
||||
IntegrationID: input.IntegrationID, IdempotencyKey: &input.IdempotencyKey,
|
||||
Provider: input.Provider, Direction: constants.IntegrationDirectionInbound, Operation: input.Operation,
|
||||
ExternalID: optionalString(input.ExternalID), ResourceType: optionalString(input.ResourceType),
|
||||
ResourceID: input.ResourceID, ResourceKey: input.ResourceKey, StartedAt: &now, Attempt: 1,
|
||||
Result: constants.IntegrationResultPending, RequestSummary: summary,
|
||||
ContentHash: hex.EncodeToString(hash[:]), RequestID: input.RequestID, CorrelationID: input.CorrelationID,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "provider"}, {Name: "operation"}, {Name: "idempotency_key"}},
|
||||
DoNothing: true,
|
||||
}).Create(log)
|
||||
if result.Error != nil {
|
||||
return nil, false, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, result.Error, "写入入站 Integration Log 失败")
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
return log, true, nil
|
||||
}
|
||||
var existing model.IntegrationLog
|
||||
if err := r.db.WithContext(ctx).Where(
|
||||
"provider = ? AND operation = ? AND idempotency_key = ?", input.Provider, input.Operation, input.IdempotencyKey,
|
||||
).First(&existing).Error; err != nil {
|
||||
return nil, false, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "读取重复入站 Integration Log 失败")
|
||||
}
|
||||
if existing.ContentHash != hex.EncodeToString(hash[:]) {
|
||||
return nil, false, pkgerrors.New(pkgerrors.CodeConflict, "入站幂等标识对应的载荷不一致")
|
||||
}
|
||||
return &existing, false, nil
|
||||
}
|
||||
|
||||
func validateAttempt(input Attempt) error {
|
||||
if input.Provider == "" || input.Operation == "" {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 提供方和操作不能为空")
|
||||
}
|
||||
if input.Direction != constants.IntegrationDirectionInbound && input.Direction != constants.IntegrationDirectionOutbound {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 方向无效")
|
||||
}
|
||||
if input.InitialResult != "" && input.InitialResult != constants.IntegrationResultPending && !isUnsentResult(input.InitialResult) {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 初始结果只能是待处理或未发送终态")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTerminalResult(result string) bool {
|
||||
switch result {
|
||||
case constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,
|
||||
constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultIgnored,
|
||||
constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted,
|
||||
constants.IntegrationResultCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isUnsentResult(result string) bool {
|
||||
switch result {
|
||||
case constants.IntegrationResultIgnored, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited,
|
||||
constants.IntegrationResultCompleted, constants.IntegrationResultCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func marshalSummary(value any) (datatypes.JSON, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
encoded, err := sanitizer.MarshalSummary(value)
|
||||
return datatypes.JSON(encoded), err
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package integrationlog_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func TestOutboundAttemptPersistsBeforeConditionalCompletion(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryIntegrationLogTable(t, db)
|
||||
repository := integrationlog.NewRepository(db)
|
||||
|
||||
attempt, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
IntegrationID: "integration-outbound-1",
|
||||
Provider: "gateway",
|
||||
Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: "query_card_status",
|
||||
ResourceType: "iot_card",
|
||||
ResourceID: testutil.StringPointer("1001"),
|
||||
RequestSummary: map[string]any{
|
||||
"iccid": "8986001234567890123",
|
||||
"access_token": "must-not-persist",
|
||||
"credential": "must-not-persist",
|
||||
"private_url": "https://must-not-persist.example",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("持久化外部尝试失败:%v", err)
|
||||
}
|
||||
if attempt.Result != constants.IntegrationResultPending {
|
||||
t.Fatalf("调用前必须是待处理状态,得到 %q", attempt.Result)
|
||||
}
|
||||
if strings.Contains(string(attempt.RequestSummary), "must-not-persist") {
|
||||
t.Fatalf("请求摘要泄露禁止字段:%s", attempt.RequestSummary)
|
||||
}
|
||||
|
||||
auditEventID := uint(77)
|
||||
completed, err := repository.Complete(context.Background(), attempt.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess,
|
||||
HTTPStatus: 200,
|
||||
ProviderCode: "0",
|
||||
ProviderMessage: "查询成功",
|
||||
StateChanged: true,
|
||||
AuditEventID: &auditEventID,
|
||||
ResponseSummary: map[string]any{"status": "active", "secret": "must-not-persist"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("终结外部尝试失败:%v", err)
|
||||
}
|
||||
if completed.Result != constants.IntegrationResultSuccess || !completed.StateChanged ||
|
||||
completed.AuditEventID == nil || *completed.AuditEventID != auditEventID {
|
||||
t.Fatalf("外部尝试终态错误:%+v", completed)
|
||||
}
|
||||
if completed.ProviderMessage == nil || strings.Contains(*completed.ProviderMessage, "查询成功") {
|
||||
t.Fatalf("不可信渠道消息必须转换为不可逆摘要:%+v", completed.ProviderMessage)
|
||||
}
|
||||
|
||||
if _, err := repository.Complete(context.Background(), attempt.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultFailed,
|
||||
}); err == nil {
|
||||
t.Fatal("既有终态不得被重复完成改写")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationResultNamesCoverEveryPublicTerminalState(t *testing.T) {
|
||||
for _, result := range []string{
|
||||
constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,
|
||||
constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultIgnored,
|
||||
constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted,
|
||||
constants.IntegrationResultCancelled,
|
||||
} {
|
||||
if constants.IntegrationResultName(result) == "" {
|
||||
t.Fatalf("公开终态 %q 缺少中文名称", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundAttemptIsIdempotentAndNeverPersistsRawPayload(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryIntegrationLogTable(t, db)
|
||||
repository := integrationlog.NewRepository(db)
|
||||
|
||||
input := integrationlog.InboundAttempt{
|
||||
IntegrationID: "integration-inbound-1",
|
||||
IdempotencyKey: "wechat:callback:transaction-1",
|
||||
Provider: "wechat",
|
||||
Operation: "payment_callback",
|
||||
ExternalID: "transaction-1",
|
||||
RawPayload: []byte(`{"sign":"raw-signature","ciphertext":"raw-ciphertext"}`),
|
||||
ContentType: "application/json",
|
||||
}
|
||||
first, created, err := repository.RecordInbound(context.Background(), input)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首次保存入站尝试失败:created=%v err=%v", created, err)
|
||||
}
|
||||
second, created, err := repository.RecordInbound(context.Background(), input)
|
||||
if err != nil || created {
|
||||
t.Fatalf("重复入站尝试应返回既有事实:created=%v err=%v", created, err)
|
||||
}
|
||||
if first.ID != second.ID || first.ContentHash == "" {
|
||||
t.Fatalf("重复回调未复用稳定事实或缺少内容哈希:first=%+v second=%+v", first, second)
|
||||
}
|
||||
serialized := string(first.RequestSummary)
|
||||
if strings.Contains(serialized, "raw-signature") || strings.Contains(serialized, "raw-ciphertext") {
|
||||
t.Fatalf("入站摘要泄露原始载荷:%s", serialized)
|
||||
}
|
||||
conflict := input
|
||||
conflict.IntegrationID = "integration-inbound-conflict"
|
||||
conflict.RawPayload = []byte(`{"different":true}`)
|
||||
if _, _, err := repository.RecordInbound(context.Background(), conflict); err == nil {
|
||||
t.Fatal("相同入站幂等标识对应不同载荷时必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownResultRequiresExplicitRecoveryAndUnsentAttemptIsTerminal(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryIntegrationLogTable(t, db)
|
||||
repository := integrationlog.NewRepository(db)
|
||||
|
||||
pending, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
IntegrationID: "integration-unknown-1", Provider: "wecom",
|
||||
Direction: constants.IntegrationDirectionOutbound, Operation: "submit_approval",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备结果未知尝试失败:%v", err)
|
||||
}
|
||||
if _, err := repository.Complete(context.Background(), pending.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultUnknown,
|
||||
}); err == nil {
|
||||
t.Fatal("结果未知必须记录明确恢复策略")
|
||||
}
|
||||
unknown, err := repository.Complete(context.Background(), pending.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultUnknown, RecoveryStrategy: "按原外部单号查询结果,禁止盲目重发",
|
||||
})
|
||||
if err != nil || unknown.RecoveryStrategy == nil {
|
||||
t.Fatalf("保存结果未知及恢复策略失败:log=%+v err=%v", unknown, err)
|
||||
}
|
||||
|
||||
for index, terminal := range []string{
|
||||
constants.IntegrationResultIgnored, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited,
|
||||
constants.IntegrationResultCompleted, constants.IntegrationResultCancelled,
|
||||
} {
|
||||
unsent, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
IntegrationID: fmt.Sprintf("integration-unsent-%d", index), Provider: "gateway",
|
||||
Direction: constants.IntegrationDirectionOutbound, Operation: "query_card_status", InitialResult: terminal,
|
||||
})
|
||||
if err != nil || unsent.Result != terminal || unsent.HTTPStatus != nil {
|
||||
t.Fatalf("未发送终态不得伪造 HTTP 结果:log=%+v err=%v", unsent, err)
|
||||
}
|
||||
if _, err := repository.Complete(context.Background(), unsent.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess,
|
||||
}); err == nil {
|
||||
t.Fatal("未发送终态不得被后续完成改写")
|
||||
}
|
||||
}
|
||||
if _, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
Provider: "gateway", Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: "query_card_status", InitialResult: constants.IntegrationResultSuccess,
|
||||
}); err == nil {
|
||||
t.Fatal("实际调用结果不得绕过 pending 直接写终态")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitFailureAndConcurrentCompletionKeepFirstTerminalFact(t *testing.T) {
|
||||
db := testutil.NewPostgresDatabase(t)
|
||||
integrationIDs := []string{"integration-failed-1", "integration-concurrent-1"}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Where("integration_id IN ?", integrationIDs).Delete(&model.IntegrationLog{}).Error; err != nil {
|
||||
t.Errorf("清理 Integration Log 并发测试数据失败:%v", err)
|
||||
}
|
||||
})
|
||||
if err := db.Where("integration_id IN ?", integrationIDs).Delete(&model.IntegrationLog{}).Error; err != nil {
|
||||
t.Fatalf("准备 Integration Log 并发测试隔离数据失败:%v", err)
|
||||
}
|
||||
repository := integrationlog.NewRepository(db)
|
||||
failedAttempt, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
IntegrationID: "integration-failed-1", Provider: "gateway",
|
||||
Direction: constants.IntegrationDirectionOutbound, Operation: "query_card_status",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备明确失败尝试失败:%v", err)
|
||||
}
|
||||
failed, err := repository.Complete(context.Background(), failedAttempt.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultFailed, ProviderCode: "UPSTREAM_REJECTED", ProviderMessage: "上游明确拒绝",
|
||||
})
|
||||
if err != nil || failed.Result != constants.IntegrationResultFailed {
|
||||
t.Fatalf("明确失败未保存为失败终态:log=%+v err=%v", failed, err)
|
||||
}
|
||||
|
||||
attempt, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
IntegrationID: "integration-concurrent-1", Provider: "gateway",
|
||||
Direction: constants.IntegrationDirectionOutbound, Operation: "query_card_status",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备并发终结尝试失败:%v", err)
|
||||
}
|
||||
|
||||
results := make(chan error, 2)
|
||||
var group sync.WaitGroup
|
||||
for _, terminal := range []string{constants.IntegrationResultSuccess, constants.IntegrationResultFailed} {
|
||||
group.Add(1)
|
||||
go func(result string) {
|
||||
defer group.Done()
|
||||
_, completeErr := repository.Complete(context.Background(), attempt.IntegrationID, integrationlog.Completion{Result: result})
|
||||
results <- completeErr
|
||||
}(terminal)
|
||||
}
|
||||
group.Wait()
|
||||
close(results)
|
||||
successes := 0
|
||||
for completeErr := range results {
|
||||
if completeErr == nil {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("并发终结必须且只能一个成功,实际成功 %d 次", successes)
|
||||
}
|
||||
var saved model.IntegrationLog
|
||||
if err := db.Where("integration_id = ?", attempt.IntegrationID).First(&saved).Error; err != nil {
|
||||
t.Fatalf("读取并发终态失败:%v", err)
|
||||
}
|
||||
if saved.Result != constants.IntegrationResultSuccess && saved.Result != constants.IntegrationResultFailed {
|
||||
t.Fatalf("并发终结未保存明确成功或失败:%+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func createTemporaryIntegrationLogTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE tb_integration_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
integration_id varchar(64) NOT NULL UNIQUE,
|
||||
idempotency_key varchar(160),
|
||||
provider varchar(32) NOT NULL,
|
||||
direction varchar(16) NOT NULL,
|
||||
operation varchar(64) NOT NULL,
|
||||
external_id varchar(128),
|
||||
resource_type varchar(64),
|
||||
resource_id varchar(128),
|
||||
resource_key varchar(128),
|
||||
trigger_source varchar(32),
|
||||
trigger_scene varchar(128),
|
||||
trigger_series varchar(64),
|
||||
scheduled_at timestamptz,
|
||||
started_at timestamptz,
|
||||
attempt integer NOT NULL DEFAULT 1,
|
||||
result varchar(20) NOT NULL,
|
||||
http_status integer,
|
||||
provider_code varchar(64),
|
||||
provider_message varchar(500),
|
||||
request_summary jsonb,
|
||||
response_summary jsonb,
|
||||
content_hash varchar(64),
|
||||
duration_ms bigint NOT NULL DEFAULT 0,
|
||||
state_changed boolean NOT NULL DEFAULT false,
|
||||
metadata jsonb,
|
||||
recovery_strategy varchar(255),
|
||||
request_id varchar(64),
|
||||
correlation_id varchar(64),
|
||||
audit_event_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_test_integration_idempotency UNIQUE (provider, operation, idempotency_key)
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建 Integration Log 测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
48
internal/model/integration_log.go
Normal file
48
internal/model/integration_log.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// IntegrationLog 是外部交互及未发送尝试的权威持久化模型。
|
||||
type IntegrationLog struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
IntegrationID string `gorm:"column:integration_id;type:varchar(64);not null;uniqueIndex" json:"integration_id"`
|
||||
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(160)" json:"idempotency_key,omitempty"`
|
||||
Provider string `gorm:"column:provider;type:varchar(32);not null" json:"provider"`
|
||||
Direction string `gorm:"column:direction;type:varchar(16);not null" json:"direction"`
|
||||
Operation string `gorm:"column:operation;type:varchar(64);not null" json:"operation"`
|
||||
ExternalID *string `gorm:"column:external_id;type:varchar(128)" json:"external_id,omitempty"`
|
||||
ResourceType *string `gorm:"column:resource_type;type:varchar(64)" json:"resource_type,omitempty"`
|
||||
ResourceID *string `gorm:"column:resource_id;type:varchar(128)" json:"resource_id,omitempty"`
|
||||
ResourceKey *string `gorm:"column:resource_key;type:varchar(128)" json:"resource_key,omitempty"`
|
||||
TriggerSource *string `gorm:"column:trigger_source;type:varchar(32)" json:"trigger_source,omitempty"`
|
||||
TriggerScene *string `gorm:"column:trigger_scene;type:varchar(128)" json:"trigger_scene,omitempty"`
|
||||
TriggerSeries *string `gorm:"column:trigger_series;type:varchar(64)" json:"trigger_series,omitempty"`
|
||||
ScheduledAt *time.Time `gorm:"column:scheduled_at;type:timestamptz" json:"scheduled_at,omitempty"`
|
||||
StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at,omitempty"`
|
||||
Attempt int `gorm:"column:attempt;type:int;not null;default:1" json:"attempt"`
|
||||
Result string `gorm:"column:result;type:varchar(20);not null" json:"result"`
|
||||
HTTPStatus *int `gorm:"column:http_status" json:"http_status,omitempty"`
|
||||
ProviderCode *string `gorm:"column:provider_code;type:varchar(64)" json:"provider_code,omitempty"`
|
||||
ProviderMessage *string `gorm:"column:provider_message;type:varchar(500)" json:"provider_message,omitempty"`
|
||||
RequestSummary datatypes.JSON `gorm:"column:request_summary;type:jsonb" json:"request_summary,omitempty"`
|
||||
ResponseSummary datatypes.JSON `gorm:"column:response_summary;type:jsonb" json:"response_summary,omitempty"`
|
||||
ContentHash string `gorm:"column:content_hash;type:varchar(64);not null;default:''" json:"content_hash,omitempty"`
|
||||
DurationMS int64 `gorm:"column:duration_ms;not null;default:0" json:"duration_ms"`
|
||||
StateChanged bool `gorm:"column:state_changed;not null;default:false" json:"state_changed"`
|
||||
Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
|
||||
RecoveryStrategy *string `gorm:"column:recovery_strategy;type:varchar(255)" json:"recovery_strategy,omitempty"`
|
||||
RequestID *string `gorm:"column:request_id;type:varchar(64)" json:"request_id,omitempty"`
|
||||
CorrelationID *string `gorm:"column:correlation_id;type:varchar(64)" json:"correlation_id,omitempty"`
|
||||
AuditEventID *uint `gorm:"column:audit_event_id" json:"audit_event_id,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 返回外部集成日志表名。
|
||||
func (IntegrationLog) TableName() string {
|
||||
return "tb_integration_log"
|
||||
}
|
||||
@@ -15,6 +15,19 @@ import (
|
||||
|
||||
// NewPostgresTransaction 创建自动回滚的 PostgreSQL 测试事务。
|
||||
func NewPostgresTransaction(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := NewPostgresDatabase(t)
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("开启测试事务失败:%v", tx.Error)
|
||||
}
|
||||
t.Cleanup(func() { _ = tx.Rollback().Error })
|
||||
return tx
|
||||
}
|
||||
|
||||
// NewPostgresDatabase 创建自动关闭、可使用多个连接的 PostgreSQL 测试数据库。
|
||||
// 仅在需要验证真实并发条件更新时使用;普通集成测试仍优先使用自动回滚事务。
|
||||
func NewPostgresDatabase(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
if os.Getenv("JUNHONG_DATABASE_HOST") == "" {
|
||||
t.Skip("未加载 .env.local,跳过依赖真实 PostgreSQL 的集成测试")
|
||||
@@ -27,17 +40,12 @@ func NewPostgresTransaction(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("连接 PostgreSQL 失败:%v", err)
|
||||
}
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("开启测试事务失败:%v", tx.Error)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = tx.Rollback().Error
|
||||
if sqlDB, dbErr := db.DB(); dbErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return tx
|
||||
return db
|
||||
}
|
||||
|
||||
// NewRedisClient 创建真实 Redis 测试客户端并在测试结束时关闭。
|
||||
|
||||
Reference in New Issue
Block a user