All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m31s
577 lines
24 KiB
Go
577 lines
24 KiB
Go
// 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"`
|
||
Visibility string `json:"visibility"`
|
||
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",
|
||
"internal/handler", "internal/infrastructure", "internal/polling", "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 !pathOK {
|
||
pathSuffix = expression(call.Args[4])
|
||
}
|
||
if methodOK {
|
||
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))
|
||
} else if strings.Contains(expression(selector.X), "outboxConsumers") && len(call.Args) >= 2 {
|
||
entries = append(entries, classifyOutboxConsumer(relative, position.Line, expression(call.Args[0]), expression(call.Args[1])))
|
||
}
|
||
}
|
||
case "LogOperation":
|
||
entries = append(entries, classifyLegacyWriter(relative, position.Line, expression(call.Fun)))
|
||
case "Start", "Complete", "RecordInbound", "ClaimExpiredInboundPending":
|
||
if selector.Sel.Name == "ClaimExpiredInboundPending" || isIntegrationLogCall(relative, expression(selector.X)) {
|
||
entries = append(entries, classifyIntegrationLog(relative, position.Line, expression(call.Fun)))
|
||
}
|
||
}
|
||
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) {
|
||
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, path, handler),
|
||
DomainLedger: ledgerDecision(owner), IntegrationLog: integrationDecision(file, path),
|
||
Outbox: "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A",
|
||
Visibility: httpVisibility(file, path, handler),
|
||
SensitivePolicy: "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限",
|
||
BeforeAfterPolicy: "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用",
|
||
TestSeam: "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口",
|
||
}
|
||
if isReadOnlyHTTP(method, path) && !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 按用例原子提交",
|
||
Visibility: "内部系统入口;外部主体只读取对应业务安全投影",
|
||
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",
|
||
Visibility: "内部系统入口,不直接对用户展示",
|
||
FailureStrategy: "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责",
|
||
SensitivePolicy: "调度日志仅记录任务类型与安全时间信息",
|
||
BeforeAfterPolicy: "N/A:调度入口不修改业务事实",
|
||
TestSeam: "调度注册公开函数 + 覆盖门禁静态比对本入口",
|
||
NAReason: "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker",
|
||
}
|
||
}
|
||
|
||
func classifyOutboxConsumer(file string, line int, eventType, consumer string) Entry {
|
||
return Entry{
|
||
Key: fmt.Sprintf("outbox_consumer:%s:%d:%s", file, line, eventType), Kind: "outbox_consumer",
|
||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, consumer), Owner: workerOwner(eventType),
|
||
Summary: "注册 Outbox 消费者 " + eventType,
|
||
AuditEvent: "N/A", DomainLedger: "N/A", IntegrationLog: "N/A", Outbox: "必须:消费已提交的可靠事件",
|
||
ActorSource: "system_task/outbox_consumer", Transaction: "N/A",
|
||
Visibility: "内部系统装配入口,不直接对用户展示",
|
||
FailureStrategy: "注册失败阻止 Worker 启动;实际消费失败由 Outbox 重试,业务审计由消费者用例负责",
|
||
SensitivePolicy: "注册入口不读取或记录事件载荷与安全凭据",
|
||
BeforeAfterPolicy: "N/A:注册入口不修改业务事实",
|
||
TestSeam: "静态扫描注册点、消费者实现和对应业务动作",
|
||
NAReason: "本入口只注册事件类型与消费者;实际业务事实和 Audit Event 由对应 Consumer/Application 完整用例负责",
|
||
}
|
||
}
|
||
|
||
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: "由调用入口传入操作者与来源快照",
|
||
Visibility: "由完整用例决定平台完整视图、主体安全投影或 internal_only",
|
||
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 classifyLegacyWriter(file string, line int, call string) Entry {
|
||
return Entry{
|
||
Key: fmt.Sprintf("legacy_writer:%s:%d:%s", file, line, call), Kind: "legacy_writer",
|
||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, call), Owner: filepath.Base(filepath.Dir(file)),
|
||
Summary: "调用旧 Operation Log Writer", AuditEvent: "必须迁移到统一 Audit Event 后停写旧表",
|
||
DomainLedger: "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger",
|
||
IntegrationLog: "N/A:旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录",
|
||
Outbox: "由原完整用例决定,旧 Writer 不得替代 Outbox",
|
||
ActionCode: actionCode(filepath.Base(filepath.Dir(file)), call), ActionName: "迁移旧审计写入",
|
||
Category: categoryFor(file), Risk: riskFor(file, call, ""), PrimaryResource: filepath.Base(filepath.Dir(file)),
|
||
AffectedResource: "按原完整业务用例登记实际资源", ActorSource: "沿用原调用入口真实操作者",
|
||
Visibility: "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影",
|
||
Transaction: "迁移后关键成功与业务事实同事务,旧异步 Writer 停写",
|
||
FailureStrategy: "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine",
|
||
SensitivePolicy: "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据",
|
||
BeforeAfterPolicy: "按资源保存本次直接变化,不复制旧单体 JSON",
|
||
TestSeam: "静态调用归零扫描 + 对应业务入口与数据库抽样核对",
|
||
}
|
||
}
|
||
|
||
func classifyIntegrationLog(file string, line int, call string) Entry {
|
||
return Entry{
|
||
Key: fmt.Sprintf("integration_log:%s:%d:%s", file, line, call), Kind: "integration_log",
|
||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, call), Owner: filepath.Base(filepath.Dir(file)),
|
||
Summary: "记录外部交互尝试或终态", AuditEvent: "N/A",
|
||
DomainLedger: "N/A:Integration Log 只记录外部交互事实,不替代内部业务表",
|
||
IntegrationLog: "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要",
|
||
Outbox: "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox",
|
||
ActorSource: "external_system 或发起外呼的真实 Application/Worker/Callback",
|
||
Visibility: "仅平台内部调查完整可见;代理/企业不得读取外部交互细节",
|
||
Transaction: "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event",
|
||
FailureStrategy: "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功",
|
||
SensitivePolicy: "请求、响应和 metadata 写入前删除凭据,历史读取再次清理",
|
||
BeforeAfterPolicy: "N/A:保存外部尝试结构化摘要和本地状态是否变化",
|
||
TestSeam: "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对",
|
||
NAReason: "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event",
|
||
}
|
||
}
|
||
|
||
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)
|
||
case *ast.BinaryExpr:
|
||
return expression(value.X) + value.Op.String() + expression(value.Y)
|
||
default:
|
||
return fmt.Sprintf("%T", expr)
|
||
}
|
||
}
|
||
|
||
func httpActorSource(file, path, handler string) string {
|
||
text := strings.ToLower(file + " " + path + " " + handler)
|
||
switch {
|
||
case strings.Contains(text, "callback") || strings.Contains(path, "/carriers/"):
|
||
return "external_system/callback"
|
||
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 httpVisibility(file, path, handler string) string {
|
||
text := strings.ToLower(file + " " + path + " " + handler)
|
||
switch {
|
||
case strings.Contains(text, "callback"):
|
||
return "外部回调入口;只记录内部完整事实,不直接向外部主体展示"
|
||
case strings.Contains(text, "/audit"):
|
||
return "仅超级管理员和平台账号可见"
|
||
case strings.Contains(text, "enterprise"):
|
||
return "企业认证上下文范围内可见;内部审计字段不可见"
|
||
case strings.Contains(text, "personal"):
|
||
return "当前个人客户本人范围内可见"
|
||
default:
|
||
return "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离"
|
||
}
|
||
}
|
||
|
||
func isIntegrationLogCall(file, receiver string) bool {
|
||
if strings.Contains(file, "/integrationlog/") {
|
||
return false
|
||
}
|
||
receiver = strings.ToLower(receiver)
|
||
return strings.Contains(receiver, "integration")
|
||
}
|
||
|
||
func isSensitiveRead(file, path, summary string) bool {
|
||
text := strings.ToLower(file + " " + path + " " + summary)
|
||
for _, marker := range []string{"download", "realname-link", "realname/link", "实名链接", "敏感", "realtime-status"} {
|
||
if strings.Contains(text, marker) {
|
||
return true
|
||
}
|
||
}
|
||
return strings.HasSuffix(file, "wecom.go") && path == "/applications" ||
|
||
strings.HasSuffix(file, "export_task.go") && path == "/:id"
|
||
}
|
||
|
||
func isReadOnlyHTTP(method, path string) bool {
|
||
if method == "GET" {
|
||
return true
|
||
}
|
||
return strings.Contains(path, "purchase-check") || strings.Contains(path, "verify-asset")
|
||
}
|
||
|
||
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(function *ast.FuncDecl) bool {
|
||
name := function.Name.Name
|
||
if strings.HasPrefix(name, "Set") && !hasContextParameter(function) {
|
||
return false
|
||
}
|
||
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 hasContextParameter(function *ast.FuncDecl) bool {
|
||
if function.Type.Params == nil {
|
||
return false
|
||
}
|
||
for _, field := range function.Type.Params.List {
|
||
selector, ok := field.Type.(*ast.SelectorExpr)
|
||
if ok && expression(selector) == "context.Context" {
|
||
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), "__", "_"), "_")
|
||
}
|