fix: close refactor audit findings

This commit is contained in:
dujiao-next committed 2026-07-26 14:48:03 +08:00
1 parent 3da93ba7ed
commit 1581d98692
16 files changed
+640 -81

No files matched your search

-4
View File
@@ -14,7 +14,6 @@ import (
"github.com/dujiao-next/internal/logger"
adminapplication "github.com/dujiao-next/internal/modules/identity/admin/application"
adminstore "github.com/dujiao-next/internal/modules/identity/admin/infrastructure/gormstore"
resellerstore "github.com/dujiao-next/internal/modules/reseller/infrastructure/gormstore"
"github.com/dujiao-next/internal/platform/database/gormdb"
"github.com/dujiao-next/internal/shared/passwordpolicy"
"github.com/dujiao-next/internal/version"
@@ -95,9 +94,6 @@ func main() {
if err := databasemigrations.AutoMigrate(); err != nil {
stdLog.Fatalf("数据库迁移失败: %v", err)
}
if err := resellerstore.Migrate(gormdb.DB); err != nil {
stdLog.Fatalf("分销模块数据库迁移失败: %v", err)
}
// 初始化默认管理员账号
if cfg.Server.Mode == "release" && defaultAdminPass == "" {
+147 -69
View File
@@ -1,10 +1,16 @@
package httpserver
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"testing"
@@ -19,17 +25,19 @@ import (
// 目的:避免新增 admin 接口时忘记同步 RBAC 预置角色,导致非超管角色无法通过
// 角色分配获得该权限(catalog UI 上能看到,但任何角色都拿不到)。
//
// 实现:静态扫描 routes_admin*.go 提取 authorized.METHOD("/path", ...) 与
// paymentProtected.METHOD("/path", ...) 调用,与
// builtin role seeds 用 keyMatch2 比对(与运行时 Casbin 模型一致)。
// 实现:静态扫描 routes_admin*.go、internal/modules/**/routes.go
// 以及 internal/platform/http/**/routes.go,
// 提取 admin/authorized/paymentProtected.METHOD("/path", ...) 调用,
// 与 builtin role seeds 用 keyMatch2 比对(与运行时 Casbin 模型一致)。
func TestAllAdminRoutesCoveredByBuiltinRoles(t *testing.T) {
routes, err := extractAdminRoutesFromSource()
if err != nil {
t.Fatalf("extract admin routes: %v", err)
}
if len(routes) == 0 {
t.Fatalf("no admin routes extracted; regex or source layout changed?")
t.Fatalf("no admin routes extracted; parser or source layout changed?")
}
t.Logf("validating RBAC coverage for %d admin routes", len(routes))
seeds := authz.BuiltinRoleSeeds()
if len(seeds) == 0 {
@@ -82,93 +90,163 @@ type adminRoute struct {
object string // 例如 "/admin/users/:id"
}
// extractAdminRoutesFromSource 从 admin 路由文件和已抽取的模块路由文件中读取调用。
var adminRouteMethods = map[string]struct{}{
"GET": {},
"POST": {},
"PUT": {},
"PATCH": {},
"DELETE": {},
}
var adminRouteReceivers = map[string]struct{}{
"admin": {},
"authorized": {},
"paymentProtected": {},
}
var publicAdminRoutes = map[string]struct{}{
"POST /admin/login": {},
"POST /admin/login/verify-2fa": {},
}
// extractAdminRoutesFromSource 从应用 admin 路由、模块路由和平台 HTTP 路由文件中读取调用。
// 方法范围:GET / POST / PUT / PATCH / DELETE。HEAD/OPTIONS 不参与 RBAC。
func extractAdminRoutesFromSource() ([]adminRoute, error) {
_, thisFile, _, _ := runtime.Caller(0)
routerDirectory := filepath.Dir(thisFile)
type routeSource struct {
path string
expression *regexp.Regexp
}
adminSources, err := filepath.Glob(filepath.Join(routerDirectory, "routes_admin*.go"))
sources, err := discoverAdminRouteSources(routerDirectory)
if err != nil {
return nil, err
}
if len(adminSources) == 0 {
return nil, os.ErrNotExist
}
sources := make([]routeSource, 0, len(adminSources)+8)
for _, path := range adminSources {
if strings.HasSuffix(path, "_test.go") {
continue
}
sources = append(sources, routeSource{
path: path,
expression: regexp.MustCompile(`(?:authorized|paymentProtected)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
})
}
if len(sources) == 0 {
return nil, os.ErrNotExist
}
sources = append(sources,
routeSource{
path: filepath.Join(routerDirectory, "..", "..", "modules", "content", "transport", "http", "routes.go"),
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
},
routeSource{
path: filepath.Join(routerDirectory, "..", "..", "modules", "dashboard", "transport", "http", "routes.go"),
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
},
routeSource{
path: filepath.Join(routerDirectory, "..", "..", "modules", "memberlevel", "transport", "http", "routes.go"),
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
},
routeSource{
path: filepath.Join(routerDirectory, "..", "..", "modules", "apicredential", "transport", "http", "routes.go"),
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
},
routeSource{
path: filepath.Join(routerDirectory, "..", "..", "modules", "auditlog", "transport", "http", "routes.go"),
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
},
routeSource{
path: filepath.Join(routerDirectory, "..", "..", "modules", "coupon", "transport", "http", "routes.go"),
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
},
routeSource{
path: filepath.Join(routerDirectory, "..", "..", "modules", "promotion", "transport", "http", "routes.go"),
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
},
routeSource{
path: filepath.Join(routerDirectory, "..", "..", "modules", "giftcard", "transport", "http", "routes.go"),
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
},
routeSource{
path: filepath.Join(routerDirectory, "..", "..", "modules", "notification", "transport", "http", "routes.go"),
expression: regexp.MustCompile(`admin\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"`),
},
)
seen := make(map[string]struct{})
var out []adminRoute
for _, source := range sources {
raw, err := os.ReadFile(source.path)
routes, err := extractAdminRoutesFromFile(source)
if err != nil {
return nil, err
}
for _, match := range source.expression.FindAllStringSubmatch(string(raw), -1) {
method := match[1]
object := authz.NormalizeObject("/admin" + match[2])
key := method + " " + object
for _, route := range routes {
key := route.method + " " + route.object
if _, public := publicAdminRoutes[key]; public {
continue
}
if _, duplicate := seen[key]; duplicate {
continue
}
seen[key] = struct{}{}
out = append(out, adminRoute{method: method, object: object})
out = append(out, route)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].object == out[j].object {
return out[i].method < out[j].method
}
return out[i].object < out[j].object
})
return out, nil
}
func discoverAdminRouteSources(routerDirectory string) ([]string, error) {
adminSources, err := filepath.Glob(filepath.Join(routerDirectory, "routes_admin*.go"))
if err != nil {
return nil, err
}
sources := make([]string, 0, len(adminSources)+32)
for _, path := range adminSources {
if !strings.HasSuffix(path, "_test.go") {
sources = append(sources, path)
}
}
for _, directory := range []string{
filepath.Join(routerDirectory, "..", "..", "modules"),
filepath.Join(routerDirectory, "..", "..", "platform", "http"),
} {
if err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !entry.IsDir() && entry.Name() == "routes.go" {
sources = append(sources, path)
}
return nil
}); err != nil {
return nil, err
}
}
sort.Strings(sources)
return sources, nil
}
func TestExtractAdminRoutesIncludesPlatformHTTPRoutes(t *testing.T) {
routes, err := extractAdminRoutesFromSource()
if err != nil {
t.Fatalf("extract admin routes: %v", err)
}
for _, route := range routes {
if route.method == "GET" && route.object == "/admin/system/version/check" {
return
}
}
t.Fatal("platform HTTP route GET /admin/system/version/check was not discovered")
}
func extractAdminRoutesFromFile(path string) ([]adminRoute, error) {
fileSet := token.NewFileSet()
file, err := parser.ParseFile(fileSet, path, nil, 0)
if err != nil {
return nil, err
}
var routes []adminRoute
var routeErr error
ast.Inspect(file, func(node ast.Node) bool {
if routeErr != nil {
return false
}
call, ok := node.(*ast.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
receiver, ok := selector.X.(*ast.Ident)
if !ok {
return true
}
if _, ok := adminRouteReceivers[receiver.Name]; !ok {
return true
}
if _, ok := adminRouteMethods[selector.Sel.Name]; !ok {
return true
}
pathLiteral, ok := call.Args[0].(*ast.BasicLit)
if !ok || pathLiteral.Kind != token.STRING {
routeErr = fmt.Errorf("%s:%d: admin route path must be a string literal", path, fileSet.Position(call.Args[0].Pos()).Line)
return false
}
routePath, err := strconv.Unquote(pathLiteral.Value)
if err != nil {
routeErr = fmt.Errorf("%s:%d: decode admin route path: %w", path, fileSet.Position(pathLiteral.Pos()).Line, err)
return false
}
routes = append(routes, adminRoute{
method: selector.Sel.Name,
object: authz.NormalizeObject("/admin" + routePath),
})
return true
})
if routeErr != nil {
return nil, routeErr
}
return routes, nil
}
+1 -1
View File
@@ -11,8 +11,8 @@ import (
admincontract "github.com/dujiao-next/internal/modules/identity/admin/contract"
admindomain "github.com/dujiao-next/internal/modules/identity/admin/domain"
adminauthapp "github.com/dujiao-next/internal/modules/identity/adminauth/application"
"github.com/dujiao-next/internal/shared/passwordpolicy"
adminauthztransport "github.com/dujiao-next/internal/modules/identity/adminauthorization/transport/http"
"github.com/dujiao-next/internal/shared/passwordpolicy"
)
type adminAuthzRolePolicyAdapter struct {
+1 -1
View File
@@ -18,11 +18,11 @@ import (
couponcontract "github.com/dujiao-next/internal/modules/coupon/contract"
externalidentitycontract "github.com/dujiao-next/internal/modules/identity/externalidentity/contract"
externalidentitydomain "github.com/dujiao-next/internal/modules/identity/externalidentity/domain"
adminusertransport "github.com/dujiao-next/internal/modules/identity/user/transport/http/admin"
userauthapp "github.com/dujiao-next/internal/modules/identity/userauth/application"
walletapp "github.com/dujiao-next/internal/modules/wallet/application"
walletdomain "github.com/dujiao-next/internal/modules/wallet/domain"
"github.com/dujiao-next/internal/shared/money"
adminusertransport "github.com/dujiao-next/internal/modules/identity/user/transport/http/admin"
)
type adminUserDirectoryAdapter struct {
@@ -13,6 +13,7 @@ import (
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
paymentdomain "github.com/dujiao-next/internal/modules/payment/domain"
procurementdomain "github.com/dujiao-next/internal/modules/procurement/domain"
settingsstore "github.com/dujiao-next/internal/modules/settings/infrastructure/gormstore"
"github.com/dujiao-next/internal/platform/database/gormdb"
"github.com/dujiao-next/internal/shared/jsonmap"
@@ -27,8 +28,120 @@ const (
paymentChannelBepusdtConfigMigrationSettingKey = "migration/payment_channel_bepusdt_config_v2"
orderItemOriginalPriceMigrationKey = "migration/order_item_original_price_v1"
manualStockUnlimitedValue = -1
cartProductForeignKeyConstraint = "fk_cart_items_product"
cartSKUForeignKeyConstraint = "fk_cart_items_sku"
procurementOrderForeignKeyConstraint = "fk_procurement_orders_local_order"
supersededProcurementOrderForeignKeyConstraint = "fk_procurement_orders_local_order_reference"
)
type cartItemConstraintSchema struct {
cartdomain.Item
Product *cartProductReference `gorm:"foreignKey:ProductID;references:ID;constraint:fk_cart_items_product"`
SKU *cartProductSKUReference `gorm:"foreignKey:SKUID;references:ID;constraint:fk_cart_items_sku"`
}
func (cartItemConstraintSchema) TableName() string { return "cart_items" }
type cartProductReference struct {
ID uint `gorm:"primarykey"`
}
func (cartProductReference) TableName() string { return "products" }
type cartProductSKUReference struct {
ID uint `gorm:"primarykey"`
}
func (cartProductSKUReference) TableName() string { return "product_skus" }
func ensureCartForeignKeyConstraints() error {
if gormdb.DB == nil {
return errors.New("database is not initialized")
}
migrator := gormdb.DB.Migrator()
missing := make([]struct {
name string
relation string
}, 0, 2)
for _, constraint := range []struct {
name string
relation string
}{
{name: cartProductForeignKeyConstraint, relation: "Product"},
{name: cartSKUForeignKeyConstraint, relation: "SKU"},
} {
if migrator.HasConstraint(&cartItemConstraintSchema{}, constraint.name) {
continue
}
missing = append(missing, constraint)
}
if len(missing) > 0 {
if err := validateCartForeignKeyReferences(); err != nil {
return err
}
for _, constraint := range missing {
if err := migrator.CreateConstraint(&cartItemConstraintSchema{}, constraint.relation); err != nil {
return err
}
}
}
// SQLite 通过重建表来增加外键,重建过程不会保留原表索引。
// 即使外键已经存在也要执行,以修复曾被旧迁移版本移除的索引。
return gormdb.DB.AutoMigrate(&cartdomain.Item{})
}
func validateCartForeignKeyReferences() error {
for _, reference := range []struct {
constraint string
table string
column string
}{
{constraint: cartProductForeignKeyConstraint, table: "products", column: "product_id"},
{constraint: cartSKUForeignKeyConstraint, table: "product_skus", column: "sku_id"},
} {
var orphanCount int64
query := fmt.Sprintf(
"SELECT COUNT(*) FROM cart_items AS cart LEFT JOIN %s AS target ON target.id = cart.%s WHERE target.id IS NULL",
reference.table,
reference.column,
)
if err := gormdb.DB.Raw(query).Scan(&orphanCount).Error; err != nil {
return err
}
if orphanCount > 0 {
return fmt.Errorf(
"cannot create %s: %d cart_items rows reference missing %s",
reference.constraint,
orphanCount,
reference.table,
)
}
}
return nil
}
func ensureProcurementOrderForeignKeyConstraint() error {
if gormdb.DB == nil {
return errors.New("database is not initialized")
}
migrator := gormdb.DB.Migrator()
if migrator.HasConstraint(&procurementdomain.Order{}, supersededProcurementOrderForeignKeyConstraint) {
if err := migrator.DropConstraint(&procurementdomain.Order{}, supersededProcurementOrderForeignKeyConstraint); err != nil {
return err
}
}
if !migrator.HasConstraint(&procurementdomain.Order{}, procurementOrderForeignKeyConstraint) {
if err := migrator.CreateConstraint(&procurementdomain.Order{}, "LocalOrderReference"); err != nil {
return err
}
}
// SQLite 的 DropConstraint/CreateConstraint 都可能重建表并丢失索引。
// 无条件同步模型索引,同时让已经运行过旧迁移的数据库可以自愈。
return gormdb.DB.AutoMigrate(&procurementdomain.Order{})
}
// ensureManualStockRemainingMigration 将历史“总量库存”迁移为“剩余库存”语义,仅执行一次。
func ensureManualStockRemainingMigration() error {
if gormdb.DB == nil {
@@ -26,6 +26,7 @@ import (
procurementdomain "github.com/dujiao-next/internal/modules/procurement/domain"
promotiondomain "github.com/dujiao-next/internal/modules/promotion/domain"
reconciliationdomain "github.com/dujiao-next/internal/modules/reconciliation/domain"
resellerstore "github.com/dujiao-next/internal/modules/reseller/infrastructure/gormstore"
settingsstore "github.com/dujiao-next/internal/modules/settings/infrastructure/gormstore"
siteconnectiondomain "github.com/dujiao-next/internal/modules/siteconnection/domain"
broadcastdomain "github.com/dujiao-next/internal/modules/telegram/broadcast/domain"
@@ -91,6 +92,9 @@ func AutoMigrate() error {
return err
}
if err := resellerstore.Migrate(db); err != nil {
return err
}
if err := migrateCartSKUUniqueIndex(); err != nil {
return err
}
@@ -112,6 +116,12 @@ func AutoMigrate() error {
if err := ensureOrderItemOriginalPriceMigration(); err != nil {
return err
}
if err := ensureCartForeignKeyConstraints(); err != nil {
return err
}
if err := ensureProcurementOrderForeignKeyConstraint(); err != nil {
return err
}
if db.Migrator().HasColumn(&productdomain.Product{}, "price_currency") {
if err := db.Migrator().DropColumn(&productdomain.Product{}, "price_currency"); err != nil {
return err
@@ -2,6 +2,7 @@ package migrations
import (
"fmt"
"strings"
"testing"
"time"
@@ -10,6 +11,8 @@ import (
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
procurementdomain "github.com/dujiao-next/internal/modules/procurement/domain"
resellerdomain "github.com/dujiao-next/internal/modules/reseller/domain"
settingsstore "github.com/dujiao-next/internal/modules/settings/infrastructure/gormstore"
"github.com/dujiao-next/internal/platform/database/gormdb"
"github.com/dujiao-next/internal/shared/jsonmap"
@@ -21,6 +24,10 @@ import (
func setupSKUMigrationTestDB(t *testing.T) *gorm.DB {
t.Helper()
previousDB := gormdb.DB
t.Cleanup(func() {
gormdb.DB = previousDB
})
dsn := fmt.Sprintf("file:sku_migration_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
@@ -32,6 +39,59 @@ func setupSKUMigrationTestDB(t *testing.T) *gorm.DB {
return db
}
func setupRegistryMigrationTestDB(t *testing.T) *gorm.DB {
t.Helper()
previousDB := gormdb.DB
t.Cleanup(func() {
gormdb.DB = previousDB
})
dsn := fmt.Sprintf("file:registry_migration_test_%d?mode=memory&cache=shared&_pragma=foreign_keys(1)", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
gormdb.DB = db
return db
}
func modelIndexNames(t *testing.T, db *gorm.DB, model interface{}) []string {
t.Helper()
statement := &gorm.Statement{DB: db}
if err := statement.Parse(model); err != nil {
t.Fatalf("parse model indexes: %v", err)
}
indexes := statement.Schema.ParseIndexes()
names := make([]string, 0, len(indexes))
for _, index := range indexes {
names = append(names, index.Name)
}
if len(names) == 0 {
t.Fatalf("model %T does not declare any indexes", model)
}
return names
}
func assertModelIndexesExist(t *testing.T, db *gorm.DB, model interface{}) {
t.Helper()
for _, name := range modelIndexNames(t, db, model) {
if !db.Migrator().HasIndex(model, name) {
t.Errorf("model %T index %s does not exist", model, name)
}
}
}
func dropModelIndexes(t *testing.T, db *gorm.DB, model interface{}) {
t.Helper()
for _, name := range modelIndexNames(t, db, model) {
if !db.Migrator().HasIndex(model, name) {
continue
}
if err := db.Migrator().DropIndex(model, name); err != nil {
t.Fatalf("drop model %T index %s: %v", model, name, err)
}
}
}
func TestEnsureProductSKUMigrationBackfillLegacyData(t *testing.T) {
db := setupSKUMigrationTestDB(t)
@@ -201,3 +261,198 @@ func TestMigrateCartSKUUniqueIndex(t *testing.T) {
t.Fatalf("new unique index idx_cart_user_product_sku should exist")
}
}
func TestAutoMigrateOwnsResellerSchemaAndCrossModuleConstraints(t *testing.T) {
db := setupRegistryMigrationTestDB(t)
if err := AutoMigrate(); err != nil {
t.Fatalf("auto migrate failed: %v", err)
}
if err := AutoMigrate(); err != nil {
t.Fatalf("idempotent auto migrate failed: %v", err)
}
for _, table := range []string{
"reseller_profiles",
"reseller_domains",
"reseller_site_configs",
"reseller_product_settings",
"reseller_order_snapshots",
"reseller_ledger_entries",
"reseller_withdraw_requests",
"reseller_balance_accounts",
"reseller_related_accounts",
} {
if !db.Migrator().HasTable(table) {
t.Errorf("central AutoMigrate did not create reseller table %s", table)
}
}
for _, index := range []struct {
model interface{}
name string
}{
{model: &resellerdomain.Domain{}, name: "idx_reseller_domains_active_domain"},
{model: &resellerdomain.SiteConfig{}, name: "idx_reseller_site_configs_active_reseller"},
{model: &resellerdomain.ProductSetting{}, name: "idx_reseller_product_settings_active_scope"},
{model: &resellerdomain.BalanceAccount{}, name: "idx_reseller_balance_accounts_active_currency"},
{model: &resellerdomain.RelatedAccount{}, name: "idx_reseller_related_accounts_active_user"},
} {
if !db.Migrator().HasIndex(index.model, index.name) {
t.Errorf("central AutoMigrate did not create reseller index %s", index.name)
}
}
for _, constraint := range []string{
cartProductForeignKeyConstraint,
cartSKUForeignKeyConstraint,
} {
if !db.Migrator().HasConstraint(&cartItemConstraintSchema{}, constraint) {
t.Errorf("central AutoMigrate did not create cart constraint %s", constraint)
}
}
if !db.Migrator().HasConstraint(&procurementdomain.Order{}, procurementOrderForeignKeyConstraint) {
t.Errorf("central AutoMigrate did not create procurement constraint %s", procurementOrderForeignKeyConstraint)
}
if db.Migrator().HasConstraint(&procurementdomain.Order{}, supersededProcurementOrderForeignKeyConstraint) {
t.Errorf("central AutoMigrate retained superseded procurement constraint %s", supersededProcurementOrderForeignKeyConstraint)
}
}
func TestEnsureCartForeignKeyConstraintsPreservesRowsAndEnforcesReferences(t *testing.T) {
db := setupRegistryMigrationTestDB(t)
for _, statement := range []string{
`CREATE TABLE products (id integer PRIMARY KEY)`,
`CREATE TABLE product_skus (id integer PRIMARY KEY)`,
`INSERT INTO products (id) VALUES (1)`,
`INSERT INTO product_skus (id) VALUES (2)`,
} {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("prepare cart schema: %v", err)
}
}
if err := db.AutoMigrate(&cartdomain.Item{}); err != nil {
t.Fatalf("prepare indexed cart schema: %v", err)
}
if err := db.Exec(`INSERT INTO cart_items (id, user_id, product_id, sku_id, quantity) VALUES (3, 4, 1, 2, 1)`).Error; err != nil {
t.Fatalf("prepare cart row: %v", err)
}
assertModelIndexesExist(t, db, &cartdomain.Item{})
if err := ensureCartForeignKeyConstraints(); err != nil {
t.Fatalf("ensure cart constraints: %v", err)
}
assertModelIndexesExist(t, db, &cartdomain.Item{})
var rowCount int64
if err := db.Table("cart_items").Where("id = ?", 3).Count(&rowCount).Error; err != nil {
t.Fatalf("count preserved cart rows: %v", err)
}
if rowCount != 1 {
t.Fatalf("cart constraint migration lost existing row: count=%d", rowCount)
}
if err := db.Exec(`INSERT INTO cart_items (id, user_id, product_id, sku_id, quantity) VALUES (5, 6, 999, 2, 1)`).Error; err == nil {
t.Fatal("cart product foreign key did not reject missing product")
}
if err := db.Exec(`INSERT INTO cart_items (id, user_id, product_id, sku_id, quantity) VALUES (7, 8, 1, 999, 1)`).Error; err == nil {
t.Fatal("cart SKU foreign key did not reject missing SKU")
}
dropModelIndexes(t, db, &cartdomain.Item{})
if err := ensureCartForeignKeyConstraints(); err != nil {
t.Fatalf("repair cart indexes with existing constraints: %v", err)
}
assertModelIndexesExist(t, db, &cartdomain.Item{})
for _, constraint := range []string{cartProductForeignKeyConstraint, cartSKUForeignKeyConstraint} {
if !db.Migrator().HasConstraint(&cartItemConstraintSchema{}, constraint) {
t.Errorf("cart index repair removed constraint %s", constraint)
}
}
if err := db.Exec(`INSERT INTO cart_items (id, user_id, product_id, sku_id, quantity) VALUES (9, 4, 1, 2, 1)`).Error; err == nil {
t.Fatal("repaired cart unique index did not reject duplicate user/product/SKU")
}
}
func TestEnsureCartForeignKeyConstraintsRejectsOrphansBeforeSchemaChanges(t *testing.T) {
db := setupRegistryMigrationTestDB(t)
for _, statement := range []string{
`CREATE TABLE products (id integer PRIMARY KEY)`,
`CREATE TABLE product_skus (id integer PRIMARY KEY)`,
`CREATE TABLE cart_items (id integer PRIMARY KEY, user_id integer NOT NULL, product_id integer NOT NULL, sku_id integer NOT NULL, quantity integer NOT NULL)`,
`INSERT INTO product_skus (id) VALUES (2)`,
`INSERT INTO cart_items (id, user_id, product_id, sku_id, quantity) VALUES (3, 4, 999, 2, 1)`,
} {
if err := db.Exec(statement).Error; err != nil {
t.Fatalf("prepare orphaned cart schema: %v", err)
}
}
err := ensureCartForeignKeyConstraints()
if err == nil || !strings.Contains(err.Error(), cartProductForeignKeyConstraint) {
t.Fatalf("expected descriptive product reference error, got %v", err)
}
for _, constraint := range []string{cartProductForeignKeyConstraint, cartSKUForeignKeyConstraint} {
if db.Migrator().HasConstraint(&cartItemConstraintSchema{}, constraint) {
t.Fatalf("orphan preflight left partial constraint %s", constraint)
}
}
}
type supersededProcurementOrderSchema struct {
ID uint `gorm:"primarykey"`
LocalOrderID uint
LocalOrderReference *supersededProcurementOrderReference `gorm:"foreignKey:LocalOrderID;references:ID;constraint:fk_procurement_orders_local_order_reference"`
}
func (supersededProcurementOrderSchema) TableName() string { return "procurement_orders" }
type supersededProcurementOrderReference struct {
ID uint `gorm:"primarykey"`
}
func (supersededProcurementOrderReference) TableName() string { return "orders" }
type procurementOrderIndexSchema struct {
ID uint `gorm:"primarykey"`
ConnectionID uint `gorm:"index"`
LocalOrderID uint `gorm:"index"`
LocalOrderNo string `gorm:"index"`
UpstreamOrderNo string `gorm:"index"`
Status string `gorm:"index"`
NextRetryAt *time.Time `gorm:"index"`
TraceID string `gorm:"index"`
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time `gorm:"index"`
DeletedAt *time.Time `gorm:"index"`
}
func (procurementOrderIndexSchema) TableName() string { return "procurement_orders" }
func TestAutoMigrateReplacesSupersededProcurementConstraintName(t *testing.T) {
db := setupRegistryMigrationTestDB(t)
if err := db.AutoMigrate(&supersededProcurementOrderSchema{}); err != nil {
t.Fatalf("create superseded procurement schema failed: %v", err)
}
if err := db.AutoMigrate(&procurementOrderIndexSchema{}); err != nil {
t.Fatalf("create indexed superseded procurement schema failed: %v", err)
}
assertModelIndexesExist(t, db, &procurementdomain.Order{})
if !db.Migrator().HasConstraint(&supersededProcurementOrderSchema{}, supersededProcurementOrderForeignKeyConstraint) {
t.Fatalf("test setup did not create superseded procurement constraint")
}
if err := AutoMigrate(); err != nil {
t.Fatalf("auto migrate superseded schema failed: %v", err)
}
if db.Migrator().HasConstraint(&procurementdomain.Order{}, supersededProcurementOrderForeignKeyConstraint) {
t.Errorf("superseded procurement constraint %s still exists", supersededProcurementOrderForeignKeyConstraint)
}
if !db.Migrator().HasConstraint(&procurementdomain.Order{}, procurementOrderForeignKeyConstraint) {
t.Errorf("canonical procurement constraint %s does not exist", procurementOrderForeignKeyConstraint)
}
assertModelIndexesExist(t, db, &procurementdomain.Order{})
dropModelIndexes(t, db, &procurementdomain.Order{})
if err := ensureProcurementOrderForeignKeyConstraint(); err != nil {
t.Fatalf("repair procurement indexes with canonical constraint: %v", err)
}
assertModelIndexesExist(t, db, &procurementdomain.Order{})
if !db.Migrator().HasConstraint(&procurementdomain.Order{}, procurementOrderForeignKeyConstraint) {
t.Errorf("procurement index repair removed canonical constraint %s", procurementOrderForeignKeyConstraint)
}
}
+1 -1
View File
@@ -23,8 +23,8 @@ import (
userauthapp "github.com/dujiao-next/internal/modules/identity/userauth/application"
"github.com/dujiao-next/internal/modules/identity/userauth/challenge"
usertotpapp "github.com/dujiao-next/internal/modules/identity/userauth/totp/application"
notificationcontract "github.com/dujiao-next/internal/modules/notification/contract"
userauthtransport "github.com/dujiao-next/internal/modules/identity/userauth/transport/http"
notificationcontract "github.com/dujiao-next/internal/modules/notification/contract"
)
// userProfileTransportAdapter 将用户认证服务适配为用户资料 transport 端口。
@@ -250,7 +250,7 @@ func (r *Store) ListCommissions(filter affiliatecontract.CommissionListFilter) (
Where("affiliate_commissions.deleted_at IS NULL").
Preload("AffiliateProfile", "deleted_at IS NULL").
Preload("AffiliateProfile.User", "deleted_at IS NULL").
Preload("Order")
Preload("Order", "deleted_at IS NULL")
if filter.AffiliateProfileID != 0 {
query = query.Where("affiliate_commissions.affiliate_profile_id = ?", filter.AffiliateProfileID)
}
@@ -10,6 +10,7 @@ import (
affiliatecontract "github.com/dujiao-next/internal/modules/affiliate/contract"
affiliatedomain "github.com/dujiao-next/internal/modules/affiliate/domain"
userdomain "github.com/dujiao-next/internal/modules/identity/user/domain"
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
"github.com/dujiao-next/internal/shared/money"
"github.com/glebarez/sqlite"
@@ -26,6 +27,7 @@ func openTestStore(t *testing.T) (*Store, *gorm.DB) {
}
if err := db.AutoMigrate(
&userdomain.User{},
&orderdomain.Order{},
&affiliatedomain.Profile{},
&affiliatedomain.Click{},
&affiliatedomain.Commission{},
@@ -109,6 +111,52 @@ func TestStoreExcludesSoftDeletedAffiliateRows(t *testing.T) {
}
}
func TestStoreDoesNotPreloadSoftDeletedCommissionOrder(t *testing.T) {
store, db := openTestStore(t)
user := &userdomain.User{Email: "affiliate-deleted-order@example.com", PasswordHash: "x", Status: constants.UserStatusActive}
if err := db.Create(user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
profile := createTestProfile(t, store, user.ID, "AFFDELETEDORDER")
order := &orderdomain.Order{
OrderNo: "AFF-DELETED-ORDER",
Status: constants.OrderStatusPaid,
Currency: "USD",
}
if err := db.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
commission := &affiliatedomain.Commission{
AffiliateProfileID: profile.ID,
OrderID: order.ID,
CommissionType: constants.AffiliateCommissionTypeOrder,
CommissionAmount: money.FromDecimal(decimal.NewFromInt(12)),
Status: constants.AffiliateCommissionStatusAvailable,
}
if err := store.CreateCommission(commission); err != nil {
t.Fatalf("create commission: %v", err)
}
deletedAt := time.Now()
if err := db.Model(order).Update("deleted_at", &deletedAt).Error; err != nil {
t.Fatalf("soft delete order: %v", err)
}
rows, total, err := store.ListCommissions(affiliatecontract.CommissionListFilter{
Page: 1,
PageSize: 20,
OrderID: order.ID,
})
if err != nil {
t.Fatalf("list commissions: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("expected commission to remain visible, total=%d rows=%d", total, len(rows))
}
if rows[0].Order.ID != 0 {
t.Fatalf("soft-deleted order leaked through preload: %+v", rows[0].Order)
}
}
func TestStoreWithinTransactionRollsBack(t *testing.T) {
store, db := openTestStore(t)
wantErr := errors.New("rollback")
@@ -21,9 +21,9 @@ import (
"github.com/dujiao-next/internal/constants"
categorygormstore "github.com/dujiao-next/internal/modules/catalog/category/infrastructure/gormstore"
productapplication "github.com/dujiao-next/internal/modules/catalog/product/application"
channeltransport "github.com/dujiao-next/internal/modules/channelapi/transport/http"
"github.com/dujiao-next/internal/shared/jsonmap"
"github.com/dujiao-next/internal/shared/money"
channeltransport "github.com/dujiao-next/internal/modules/channelapi/transport/http"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/shopspring/decimal"
+1 -1
View File
@@ -36,7 +36,7 @@ type Order struct {
Connection *siteconnectiondomain.Connection `gorm:"foreignKey:ConnectionID" json:"connection,omitempty"`
LocalOrder *LocalOrder `gorm:"-" json:"local_order,omitempty"`
LocalOrderReference *LocalOrderReference `gorm:"foreignKey:LocalOrderID;references:ID" json:"-"`
LocalOrderReference *LocalOrderReference `gorm:"foreignKey:LocalOrderID;references:ID;constraint:fk_procurement_orders_local_order,OnUpdate:NO ACTION,OnDelete:NO ACTION" json:"-"`
ParentOrderNo string `gorm:"-" json:"parent_order_no,omitempty"`
UpstreamRefundRecords []jsonmap.JSON `gorm:"-" json:"upstream_refund_records,omitempty"`
UpstreamRefundedAmount string `gorm:"-" json:"upstream_refunded_amount,omitempty"`
@@ -239,6 +239,44 @@ func TestResellerAccountingRepositoryListAdminLedgerEntriesFiltersByKeywordAndOr
}
}
func TestResellerAccountingRepositoryDoesNotPreloadSoftDeletedOrder(t *testing.T) {
db := openResellerAccountingRepoTestDB(t)
repo := New(db)
profile := seedResellerAccountingProfileWithEmail(t, db, "deleted-order-ledger@example.com")
order := seedResellerAccountingOrder(t, db, "DELETED-LEDGER-ORDER")
entry := resellerdomain.LedgerEntry{
ResellerID: profile.ID,
OrderID: &order.ID,
Type: resellerdomain.LedgerTypeOrderProfit,
Amount: money.FromDecimal(decimal.NewFromInt(12)),
Currency: "USD",
IdempotencyKey: "deleted-order-ledger",
Status: resellerdomain.LedgerStatusAvailable,
}
if err := db.Create(&entry).Error; err != nil {
t.Fatalf("create ledger failed: %v", err)
}
deletedAt := time.Now()
if err := db.Model(&order).Update("deleted_at", &deletedAt).Error; err != nil {
t.Fatalf("soft delete order failed: %v", err)
}
rows, total, err := repo.ListAdminResellerLedgerEntries(resellercontract.AdminLedgerListFilter{
Page: 1,
PageSize: 20,
OrderID: order.ID,
})
if err != nil {
t.Fatalf("ListAdminResellerLedgerEntries failed: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("expected ledger to remain visible, total=%d rows=%d", total, len(rows))
}
if rows[0].Order != nil {
t.Fatalf("soft-deleted order leaked through preload: %+v", rows[0].Order)
}
}
func TestResellerAccountingRepositoryListAdminBalanceAccountsFiltersAndPreloadsProfile(t *testing.T) {
db := openResellerAccountingRepoTestDB(t)
repo := New(db)
@@ -17,7 +17,7 @@ func (r *Store) ListAdminResellerLedgerEntries(filter resellercontract.AdminLedg
query := r.db.Model(&resellerdomain.LedgerEntry{}).
Preload("Profile", "deleted_at IS NULL").
Preload("Profile.User", "deleted_at IS NULL").
Preload("Order").
Preload("Order", "deleted_at IS NULL").
Where("reseller_ledger_entries.deleted_at IS NULL")
query = r.applyAdminResellerProfileFilters(query, "reseller_ledger_entries", filter.ResellerID, filter.UserID, filter.Keyword, "")
@@ -126,7 +126,7 @@ func (r *Store) FindActiveVerifiedDomain(host string) (*resellerdomain.Domain, e
return nil, nil
}
var row resellerdomain.Domain
err := r.db.Preload("Profile").
err := r.db.Preload("Profile", "deleted_at IS NULL").
Joins("JOIN reseller_profiles ON reseller_profiles.id = reseller_domains.reseller_id AND reseller_profiles.deleted_at IS NULL").
Where("reseller_domains.domain = ? AND reseller_domains.status = ? AND reseller_domains.verification_status = ? AND reseller_domains.deleted_at IS NULL", domain, resellerdomain.DomainStatusActive, resellerdomain.DomainVerificationVerified).
Where("reseller_profiles.status = ?", resellerdomain.ProfileStatusActive).
@@ -91,6 +91,7 @@ func TestResellerRepositoryFindActiveVerifiedDomain(t *testing.T) {
db := openResellerRepoTestDB(t)
profile := seedResellerProfile(t, db, "owner2@example.com")
inactiveProfile := seedResellerProfile(t, db, "disabled-owner@example.com")
deletedProfile := seedResellerProfile(t, db, "deleted-owner@example.com")
if err := db.Model(&inactiveProfile).Update("status", resellerdomain.ProfileStatusDisabled).Error; err != nil {
t.Fatalf("disable profile failed: %v", err)
}
@@ -123,6 +124,19 @@ func TestResellerRepositoryFindActiveVerifiedDomain(t *testing.T) {
}); err != nil {
t.Fatalf("create disabled profile domain failed: %v", err)
}
if _, err := repo.UpsertDomain(resellerdomain.Domain{
ResellerID: deletedProfile.ID,
Domain: "deleted-profile.example.test",
Type: resellerdomain.DomainTypeCustom,
Status: resellerdomain.DomainStatusActive,
VerificationStatus: resellerdomain.DomainVerificationVerified,
}); err != nil {
t.Fatalf("create deleted profile domain failed: %v", err)
}
deletedAt := time.Now()
if err := db.Model(&deletedProfile).Update("deleted_at", &deletedAt).Error; err != nil {
t.Fatalf("soft delete profile failed: %v", err)
}
disabled, err := repo.FindActiveVerifiedDomain("inactive.example.test")
if err != nil {
t.Fatalf("lookup disabled failed: %v", err)
@@ -137,6 +151,13 @@ func TestResellerRepositoryFindActiveVerifiedDomain(t *testing.T) {
if inactiveProfileDomain != nil {
t.Fatalf("active domain for disabled profile should not resolve: %+v", inactiveProfileDomain)
}
deletedProfileDomain, err := repo.FindActiveVerifiedDomain("deleted-profile.example.test")
if err != nil {
t.Fatalf("lookup deleted profile domain failed: %v", err)
}
if deletedProfileDomain != nil {
t.Fatalf("active domain for soft-deleted profile should not resolve: %+v", deletedProfileDomain)
}
active, err := repo.FindActiveVerifiedDomain("active.example.test")
if err != nil {
t.Fatalf("lookup active failed: %v", err)