- 计费:19 个金额字段 float64→decimal.Decimal,DB 列保持 numeric(20,8) 零迁移; API 金额仍为 JSON number(MarshalJSONWithoutQuotes),前端零改动 - failover:保留时间预算,新增 OT_PROXY_FAILOVER_CAPS_NET/SERVER/RATE/QUOTA 按失败类型封顶换渠道次数(429/配额类瞬时失败时间预算形同虚设) - 预聚合:新增 usage_channel_hourlies(schema v10),记账事务内同步 upsert, 长窗口渠道统计走桶不再扫明细,spent_last_30d 改扫 usage_daily,v10 一次性回填幂等 - 守护:internal/arch_test.go 用 go/parser 强制分层依赖(fail-closed)
123 lines
3.7 KiB
Go
123 lines
3.7 KiB
Go
// 架构守护测试:用 AST 扫描 internal 下所有非测试代码的 import,强制分层依赖规则。
|
|
//
|
|
// 目标分层(与实际依赖图一致,违规即测试失败):
|
|
//
|
|
// pkg/*、config、store ← 叶子层,不得依赖任何 internal 包
|
|
// channel / usage / passkey ← 只可依赖 store + pkg/*
|
|
// proxy ← 只可依赖 store、channel、usage、proxy/*、pkg/*
|
|
// api ← 不得直接 import channel/usage(必须经 app 持有)
|
|
// app ← 组合根:channel、config、passkey、pkg/*、store、usage
|
|
// cli ← 顶层装配,不限制
|
|
//
|
|
// 本项目没有 arch-go 这类重型工具,一个 go/parser 扫描(毫秒级)足够守住红线;
|
|
// 新增包时在这里登记规则,未登记的包默认禁止一切 internal 依赖(fail-closed)。
|
|
package internal_test
|
|
|
|
import (
|
|
"go/parser"
|
|
"go/token"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const modulePrefix = "github.com/openteam/server/internal/"
|
|
|
|
// layerRules 相对 internal 的包目录 → 允许依赖的 internal 包前缀(空切片 = 禁止一切)。
|
|
// 子包继承最长前缀匹配的规则(如 proxy/convert 按 proxy 的规则校验)。
|
|
var layerRules = map[string][]string{
|
|
"config": {},
|
|
"store": {},
|
|
"pkg": {},
|
|
"channel": {"store", "pkg"},
|
|
"usage": {"store", "pkg"},
|
|
"passkey": {"store", "pkg"},
|
|
"proxy": {"store", "channel", "usage", "proxy", "pkg"},
|
|
"api": {"app", "proxy", "passkey", "store", "api", "pkg"},
|
|
"app": {"channel", "config", "passkey", "store", "usage", "pkg"},
|
|
"cli": nil, // nil = 顶层装配,不限制
|
|
}
|
|
|
|
// ruleFor 取目录的生效规则:精确匹配优先,否则最长前缀;未登记的包 fail-closed。
|
|
func ruleFor(dir string) (allowed []string, unrestricted bool) {
|
|
if r, ok := layerRules[dir]; ok {
|
|
return r, r == nil
|
|
}
|
|
best := ""
|
|
for k := range layerRules {
|
|
if strings.HasPrefix(dir, k+"/") && len(k) > len(best) {
|
|
best = k
|
|
}
|
|
}
|
|
if best != "" {
|
|
r := layerRules[best]
|
|
return r, r == nil
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func dependsOn(imported, allowed string) bool {
|
|
return imported == allowed || strings.HasPrefix(imported, allowed+"/")
|
|
}
|
|
|
|
func TestInternalDependencyLayers(t *testing.T) {
|
|
root := "." // 测试二进制的 cwd 即 server/internal
|
|
var violations []string
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
name := d.Name()
|
|
if name != "." && (strings.HasPrefix(name, ".") || name == "testdata" || name == "vendor") {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
|
return nil
|
|
}
|
|
dir := filepath.ToSlash(filepath.Dir(path))
|
|
if dir == "." {
|
|
dir = ""
|
|
}
|
|
allowed, unrestricted := ruleFor(dir)
|
|
if unrestricted {
|
|
return nil
|
|
}
|
|
f, perr := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
|
if perr != nil {
|
|
t.Errorf("parse %s: %v", path, perr)
|
|
return nil
|
|
}
|
|
for _, imp := range f.Imports {
|
|
p := strings.Trim(imp.Path.Value, `"`)
|
|
if !strings.HasPrefix(p, modulePrefix) {
|
|
continue
|
|
}
|
|
target := strings.TrimPrefix(p, modulePrefix)
|
|
ok := false
|
|
for _, a := range allowed {
|
|
if dependsOn(target, a) {
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
violations = append(violations, path+" → "+p)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(violations) > 0 {
|
|
sort.Strings(violations)
|
|
t.Errorf("internal 分层依赖被破坏(%d 处):\n%s\n新增合法依赖请更新 arch_test.go 的 layerRules",
|
|
len(violations), strings.Join(violations, "\n"))
|
|
}
|
|
}
|