Files
Sakurasan b2e500ff51 feat: 金额 decimal 精确计费、failover 按失败类型封顶、渠道小时桶预聚合、分层守护测试
- 计费: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)
2026-11-09 00:41:48 +08:00

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"))
}
}