From 83429456a6124fe7917fa9bb89f0665aa15f2e44 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 30 Jul 2026 12:02:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=A4=9A=E5=95=86=E6=88=B7?= =?UTF-8?q?=E5=B1=A5=E7=BA=A6=E5=B9=B3=E5=8F=B0=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + backend/cmd/server/main.go | 28 +- backend/go.sum | 4 - backend/internal/config/config.go | 21 +- backend/internal/database/migrate.go | 168 ++++ .../internal/database/migrations/001_init.sql | 349 +++++++++ backend/internal/handler/merchant.go | 383 +++++++++ backend/internal/handler/open_v1.go | 221 ++++++ backend/internal/handler/order.go | 23 +- backend/internal/handler/skin.go | 19 +- backend/internal/handler/user.go | 12 +- backend/internal/middleware/auth.go | 47 +- backend/internal/middleware/open_auth.go | 266 ++++--- backend/internal/middleware/open_auth_test.go | 162 ++++ .../internal/middleware/source_open_auth.go | 114 +++ backend/internal/model/merchant.go | 270 +++++++ backend/internal/model/models.go | 47 +- backend/internal/router/router.go | 65 +- backend/internal/router/router_test.go | 51 ++ backend/internal/service/audit.go | 27 + backend/internal/service/auth.go | 33 +- backend/internal/service/callback.go | 330 ++++++++ backend/internal/service/fulfillment.go | 530 +++++++++++++ backend/internal/service/fulfillment_test.go | 301 ++++++++ .../internal/service/legacy_tenant_test.go | 95 +++ backend/internal/service/merchant.go | 465 +++++++++++ backend/internal/service/order.go | 44 +- backend/internal/service/secret_codec.go | 56 ++ backend/internal/service/secret_codec_test.go | 24 + backend/internal/service/skin.go | 41 +- backend/internal/service/tenant.go | 120 +++ backend/internal/service/user.go | 56 +- backend/internal/testdb/testdb.go | 76 ++ frontend/src/App.tsx | 11 + frontend/src/api/index.ts | 70 ++ frontend/src/api/request.ts | 4 + frontend/src/layouts/MainLayout.tsx | 3 + frontend/src/pages/MerchantCenter.tsx | 731 ++++++++++++++++++ frontend/src/pages/OpenApiDocs.tsx | 391 ++++------ frontend/src/pages/PlatformMerchants.tsx | 210 +++++ frontend/src/types/index.ts | 122 +++ 41 files changed, 5487 insertions(+), 504 deletions(-) create mode 100644 backend/internal/database/migrate.go create mode 100644 backend/internal/database/migrations/001_init.sql create mode 100644 backend/internal/handler/merchant.go create mode 100644 backend/internal/handler/open_v1.go create mode 100644 backend/internal/middleware/open_auth_test.go create mode 100644 backend/internal/middleware/source_open_auth.go create mode 100644 backend/internal/model/merchant.go create mode 100644 backend/internal/router/router_test.go create mode 100644 backend/internal/service/audit.go create mode 100644 backend/internal/service/callback.go create mode 100644 backend/internal/service/fulfillment.go create mode 100644 backend/internal/service/fulfillment_test.go create mode 100644 backend/internal/service/legacy_tenant_test.go create mode 100644 backend/internal/service/merchant.go create mode 100644 backend/internal/service/secret_codec.go create mode 100644 backend/internal/service/secret_codec_test.go create mode 100644 backend/internal/service/tenant.go create mode 100644 backend/internal/testdb/testdb.go create mode 100644 frontend/src/pages/MerchantCenter.tsx create mode 100644 frontend/src/pages/PlatformMerchants.tsx diff --git a/.env.example b/.env.example index ddf6d1b..779129c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ PORT=8080 JWT_SECRET=your_very_secure_jwt_secret_here_change_me_in_production +DATA_ENCRYPTION_KEY=your_32_plus_char_data_encryption_key_change_me POSTGRES_USER=affiliate POSTGRES_PASSWORD=affiliate_dev_password POSTGRES_DB=affiliate_dash diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 7b0a8ff..6568baa 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,11 +1,12 @@ package main import ( + "context" "log" "affiliate_dash/internal/config" + "affiliate_dash/internal/database" "affiliate_dash/internal/handler" - "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/applog" "affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/pkg/openlog" @@ -38,15 +39,23 @@ func main() { log.Fatalf("open db: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.Skin{}, &model.Order{}, &model.ShipLog{}); err != nil { + if err := database.Migrate(db); err != nil { log.Fatalf("migrate: %v", err) } jm := jwt.NewManager(cfg.JWTSecret) - authSvc := service.NewAuthService(db, jm) + codec, err := service.NewSecretCodec(cfg.DataEncryptionKey) + if err != nil { + log.Fatalf("setup secret codec: %v", err) + } + tenantSvc := service.NewTenantService(db) + authSvc := service.NewAuthService(db, jm, tenantSvc) skinSvc := service.NewSkinService(db) orderSvc := service.NewOrderService(db) - userSvc := service.NewUserService(db) + userSvc := service.NewUserService(db, tenantSvc) + callbackSvc := service.NewCallbackService(db, codec) + fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) + merchantSvc := service.NewMerchantService(db, codec, tenantSvc) if err := authSvc.EnsureAdmin(); err != nil { log.Fatalf("ensure admin: %v", err) @@ -62,20 +71,27 @@ func main() { Skin: handler.NewSkinHandler(skinSvc), Order: handler.NewOrderHandler(orderSvc), User: handler.NewUserHandler(userSvc), - Open: handler.NewOpenHandler(orderSvc), + Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), + SourceOpen: handler.NewOpenHandler(orderSvc), + Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc), JWT: jm, + Tenant: tenantSvc, + OpenDB: db, + SecretCodec: codec, OpenAPIKey: cfg.OpenAPIKey, OpenAPISecret: cfg.OpenAPISecret, OpenSignSkew: cfg.OpenSignSkew, OpenAPIDebug: cfg.OpenAPIDebug, } + go callbackSvc.Run(context.Background()) + r := router.Setup(h) addr := ":" + cfg.Port log.Printf("游戏皮肤分销系统 API 启动: http://localhost%s", addr) log.Printf("数据库: PostgreSQL") log.Printf("默认管理员: admin / admin123") - log.Printf("开放接口鉴权: X-Api-Key + X-Timestamp + X-Nonce + X-Sign (HMAC-SHA256)") + log.Printf("开放接口鉴权: X-App-Key + X-Timestamp + X-Nonce + X-Sign (HMAC-SHA256)") if cfg.OpenAPIDebug { log.Printf("开放接口调试日志: 开启 (OPEN_API_DEBUG=0 可关闭)") } diff --git a/backend/go.sum b/backend/go.sum index e0e64f8..694b1aa 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -58,8 +58,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -114,7 +112,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index bc90928..2612c8e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -14,6 +14,8 @@ type Config struct { JWTSecret string DatabaseURL string Mode string // debug / release + // DataEncryptionKey 用于加密保存 API/回调密钥;为空时由 JWT_SECRET 派生。 + DataEncryptionKey string // OpenAPIKey 皮肤源头开放接口标识(Header: X-Api-Key,可公开给对接方) OpenAPIKey string // OpenAPISecret 签名密钥(仅用于 HMAC,不走 Header) @@ -33,15 +35,16 @@ func Load() *Config { // OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启 debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "") return &Config{ - Port: getEnv("PORT", "8080"), - JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"), - DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"), - Mode: mode, - OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"), - OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"), - OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)), - OpenAPIDebug: debugOpen, - LogFile: getEnv("LOG_FILE", "logs/app.log"), + Port: getEnv("PORT", "8080"), + JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"), + Mode: mode, + DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")), + OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"), + OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"), + OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)), + OpenAPIDebug: debugOpen, + LogFile: getEnv("LOG_FILE", "logs/app.log"), } } diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go new file mode 100644 index 0000000..fc4c1d5 --- /dev/null +++ b/backend/internal/database/migrate.go @@ -0,0 +1,168 @@ +package database + +import ( + "crypto/sha256" + "embed" + "encoding/hex" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" + "time" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +type schemaMigration struct { + Version string `gorm:"primaryKey;size:64"` + Name string `gorm:"size:255;not null"` + Checksum string `gorm:"size:64;not null"` + AppliedAt time.Time `gorm:"not null"` +} + +func (schemaMigration) TableName() string { + return "schema_migrations" +} + +// Migrate 使用显式 SQL 迁移管理表结构,避免大型项目依赖隐式结构同步。 +func Migrate(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", "affiliate_dash_schema_migrations").Error; err != nil { + return fmt.Errorf("lock schema migrations: %w", err) + } + if err := ensureMigrationTable(tx); err != nil { + return err + } + if err := applySQLMigrations(tx); err != nil { + return err + } + merchant, err := ensureSelfMerchant(tx) + if err != nil { + return err + } + return ensureWallet(tx, merchant.ID) + }) +} + +func ensureMigrationTable(tx *gorm.DB) error { + statements := []string{ + ` +CREATE TABLE IF NOT EXISTS schema_migrations ( + version varchar(64) PRIMARY KEY, + name varchar(255) NOT NULL, + checksum varchar(64) NOT NULL, + applied_at timestamptz NOT NULL +)`, + `ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS name varchar(255) NOT NULL DEFAULT ''`, + `ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS checksum varchar(64) NOT NULL DEFAULT ''`, + `ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS applied_at timestamptz NOT NULL DEFAULT now()`, + } + for _, statement := range statements { + if err := tx.Exec(statement).Error; err != nil { + return fmt.Errorf("ensure schema_migrations: %w", err) + } + } + return nil +} + +func applySQLMigrations(tx *gorm.DB) error { + paths, err := fs.Glob(migrationFiles, "migrations/*.sql") + if err != nil { + return fmt.Errorf("list migrations: %w", err) + } + sort.Strings(paths) + for _, path := range paths { + content, err := migrationFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("read migration %s: %w", path, err) + } + version, name := parseMigrationName(path) + checksum := migrationChecksum(content) + + var existing schemaMigration + err = tx.Where("version = ?", version).First(&existing).Error + if err == nil { + if existing.Checksum == "" { + if err := tx.Model(&schemaMigration{}).Where("version = ?", version).Updates(map[string]interface{}{ + "name": name, + "checksum": checksum, + }).Error; err != nil { + return fmt.Errorf("adopt migration %s: %w", version, err) + } + continue + } + if existing.Checksum != checksum { + return fmt.Errorf("migration %s checksum mismatch: 已执行版本不能被修改", version) + } + continue + } + if err != gorm.ErrRecordNotFound { + return fmt.Errorf("query migration %s: %w", version, err) + } + sql := strings.TrimSpace(string(content)) + if sql == "" { + return fmt.Errorf("migration %s is empty", path) + } + if err := tx.Exec(sql).Error; err != nil { + return fmt.Errorf("apply migration %s: %w", path, err) + } + if err := tx.Create(&schemaMigration{ + Version: version, + Name: name, + Checksum: checksum, + AppliedAt: time.Now(), + }).Error; err != nil { + return fmt.Errorf("record migration %s: %w", version, err) + } + } + return nil +} + +func parseMigrationName(path string) (string, string) { + base := filepath.Base(path) + name := strings.TrimSuffix(base, filepath.Ext(base)) + parts := strings.SplitN(name, "_", 2) + if len(parts) == 1 { + return parts[0], name + } + return parts[0], parts[1] +} + +func migrationChecksum(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func ensureSelfMerchant(tx *gorm.DB) (*model.Merchant, error) { + var merchant model.Merchant + err := tx.Where("code = ?", "self-operated").First(&merchant).Error + if err == nil { + return &merchant, nil + } + if err != gorm.ErrRecordNotFound { + return nil, err + } + merchant = model.Merchant{ + Code: "self-operated", + Name: "自营商户", + Status: model.MerchantStatusActive, + } + if err := tx.Create(&merchant).Error; err != nil { + return nil, err + } + return &merchant, nil +} + +func ensureWallet(tx *gorm.DB, merchantID uint) error { + return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.WalletAccount{ + MerchantID: merchantID, + Currency: "CNY", + }).Error +} diff --git a/backend/internal/database/migrations/001_init.sql b/backend/internal/database/migrations/001_init.sql new file mode 100644 index 0000000..1ddefb6 --- /dev/null +++ b/backend/internal/database/migrations/001_init.sql @@ -0,0 +1,349 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + nickname VARCHAR(64), + role VARCHAR(32) NOT NULL DEFAULT 'distributor', + status BIGINT DEFAULT 1, + invite_code VARCHAR(32), + parent_id BIGINT +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_invite_code ON users (invite_code); +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users (deleted_at); +CREATE INDEX IF NOT EXISTS idx_users_parent_id ON users (parent_id); + +CREATE TABLE IF NOT EXISTS merchants ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active', + contact_name VARCHAR(64), + contact_info VARCHAR(128) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_merchants_code ON merchants (code); +CREATE INDEX IF NOT EXISTS idx_merchants_deleted_at ON merchants (deleted_at); +CREATE INDEX IF NOT EXISTS idx_merchants_status ON merchants (status); + +CREATE TABLE IF NOT EXISTS merchant_members ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'operator', + status BIGINT NOT NULL DEFAULT 1, + is_default BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_merchant_member ON merchant_members (merchant_id, user_id); +CREATE INDEX IF NOT EXISTS idx_merchant_members_merchant_id ON merchant_members (merchant_id); +CREATE INDEX IF NOT EXISTS idx_merchant_members_user_id ON merchant_members (user_id); + +CREATE TABLE IF NOT EXISTS skins ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL DEFAULT 0, + name VARCHAR(128) NOT NULL, + sku VARCHAR(64) NOT NULL, + game VARCHAR(64), + category VARCHAR(64), + cover_url VARCHAR(512), + price DOUBLE PRECISION NOT NULL DEFAULT 0, + cost_price DOUBLE PRECISION DEFAULT 0, + commission DOUBLE PRECISION DEFAULT 0, + stock BIGINT DEFAULT 0, + status BIGINT DEFAULT 1, + description TEXT +); + +DROP INDEX IF EXISTS idx_skins_sku; +CREATE UNIQUE INDEX IF NOT EXISTS idx_skins_merchant_sku ON skins (merchant_id, sku); +CREATE INDEX IF NOT EXISTS idx_skins_deleted_at ON skins (deleted_at); +CREATE INDEX IF NOT EXISTS idx_skins_merchant_id ON skins (merchant_id); +CREATE INDEX IF NOT EXISTS idx_skins_game ON skins (game); +CREATE INDEX IF NOT EXISTS idx_skins_category ON skins (category); + +CREATE TABLE IF NOT EXISTS orders ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL DEFAULT 0, + order_no VARCHAR(64) NOT NULL, + skin_id BIGINT NOT NULL, + distributor_id BIGINT NOT NULL, + buyer_name VARCHAR(64), + amount DOUBLE PRECISION NOT NULL, + commission_amt DOUBLE PRECISION DEFAULT 0, + status VARCHAR(32) DEFAULT 'pending', + remark VARCHAR(255), + provider_order_no VARCHAR(64), + shipped_at TIMESTAMPTZ, + ship_fail_reason VARCHAR(512), + game_channel VARCHAR(64), + game_uid VARCHAR(128), + role_name VARCHAR(64), + pay_score BIGINT DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_order_no ON orders (order_no); +CREATE INDEX IF NOT EXISTS idx_orders_deleted_at ON orders (deleted_at); +CREATE INDEX IF NOT EXISTS idx_orders_merchant_id ON orders (merchant_id); +CREATE INDEX IF NOT EXISTS idx_orders_skin_id ON orders (skin_id); +CREATE INDEX IF NOT EXISTS idx_orders_distributor_id ON orders (distributor_id); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders (status); +CREATE INDEX IF NOT EXISTS idx_orders_provider_order_no ON orders (provider_order_no); + +CREATE TABLE IF NOT EXISTS ship_logs ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL DEFAULT 0, + order_no VARCHAR(64) NOT NULL, + order_id BIGINT, + ship_status VARCHAR(32) NOT NULL, + provider_order_no VARCHAR(64), + fail_reason VARCHAR(512), + payload TEXT, + result_status VARCHAR(32), + message VARCHAR(255) +); + +CREATE INDEX IF NOT EXISTS idx_ship_logs_merchant_id ON ship_logs (merchant_id); +CREATE INDEX IF NOT EXISTS idx_ship_logs_order_no ON ship_logs (order_no); +CREATE INDEX IF NOT EXISTS idx_ship_logs_order_id ON ship_logs (order_id); + +CREATE TABLE IF NOT EXISTS api_clients ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + app_key VARCHAR(96) NOT NULL, + secret_ciphertext TEXT NOT NULL, + signature_version VARCHAR(16) NOT NULL DEFAULT 'v1', + scopes TEXT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active', + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_api_clients_app_key ON api_clients (app_key); +CREATE INDEX IF NOT EXISTS idx_api_clients_deleted_at ON api_clients (deleted_at); +CREATE INDEX IF NOT EXISTS idx_api_clients_merchant_id ON api_clients (merchant_id); +CREATE INDEX IF NOT EXISTS idx_api_clients_status ON api_clients (status); + +CREATE TABLE IF NOT EXISTS products ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + code VARCHAR(96) NOT NULL, + name VARCHAR(160) NOT NULL, + category VARCHAR(64), + description TEXT, + attributes TEXT, + status VARCHAR(16) NOT NULL DEFAULT 'active' +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_products_code ON products (code); +CREATE INDEX IF NOT EXISTS idx_products_deleted_at ON products (deleted_at); +CREATE INDEX IF NOT EXISTS idx_products_category ON products (category); +CREATE INDEX IF NOT EXISTS idx_products_status ON products (status); + +CREATE TABLE IF NOT EXISTS merchant_products ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + sku VARCHAR(96) NOT NULL, + display_name VARCHAR(160), + price_amount BIGINT NOT NULL DEFAULT 0, + cost_amount BIGINT NOT NULL DEFAULT 0, + currency VARCHAR(12) NOT NULL DEFAULT 'CNY', + stock BIGINT NOT NULL DEFAULT -1, + status VARCHAR(16) NOT NULL DEFAULT 'active', + fulfillment_config TEXT +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_merchant_product_sku ON merchant_products (merchant_id, sku); +CREATE INDEX IF NOT EXISTS idx_merchant_products_deleted_at ON merchant_products (deleted_at); +CREATE INDEX IF NOT EXISTS idx_merchant_products_merchant_id ON merchant_products (merchant_id); +CREATE INDEX IF NOT EXISTS idx_merchant_products_product_id ON merchant_products (product_id); +CREATE INDEX IF NOT EXISTS idx_merchant_products_status ON merchant_products (status); + +CREATE TABLE IF NOT EXISTS wallet_accounts ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL, + currency VARCHAR(12) NOT NULL DEFAULT 'CNY', + available_balance BIGINT NOT NULL DEFAULT 0, + frozen_balance BIGINT NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_wallet_accounts_merchant_id ON wallet_accounts (merchant_id); + +CREATE TABLE IF NOT EXISTS wallet_ledger_entries ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL, + wallet_account_id BIGINT NOT NULL, + entry_no VARCHAR(64) NOT NULL, + type VARCHAR(24) NOT NULL, + amount BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + reference_type VARCHAR(32) NOT NULL, + reference_no VARCHAR(96) NOT NULL, + idempotency_key VARCHAR(128), + note VARCHAR(255) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_wallet_ledger_entries_entry_no ON wallet_ledger_entries (entry_no); +CREATE UNIQUE INDEX IF NOT EXISTS idx_wallet_ledger_idempotency ON wallet_ledger_entries (merchant_id, idempotency_key); +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_merchant_id ON wallet_ledger_entries (merchant_id); +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_wallet_account_id ON wallet_ledger_entries (wallet_account_id); +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_type ON wallet_ledger_entries (type); +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_reference_no ON wallet_ledger_entries (reference_no); + +CREATE TABLE IF NOT EXISTS fulfillment_orders ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL, + order_no VARCHAR(64) NOT NULL, + client_order_no VARCHAR(96) NOT NULL, + merchant_product_id BIGINT NOT NULL, + product_sku VARCHAR(96) NOT NULL, + product_name VARCHAR(160) NOT NULL, + quantity BIGINT NOT NULL DEFAULT 1, + amount BIGINT NOT NULL, + currency VARCHAR(12) NOT NULL DEFAULT 'CNY', + payment_status VARCHAR(16) NOT NULL DEFAULT 'pending', + fulfillment_status VARCHAR(16) NOT NULL DEFAULT 'pending', + buyer_reference VARCHAR(128), + request_data TEXT, + result_data TEXT, + provider_order_no VARCHAR(96), + failure_reason VARCHAR(512), + cancelled_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_fulfillment_orders_order_no ON fulfillment_orders (order_no); +CREATE UNIQUE INDEX IF NOT EXISTS idx_order_client_no ON fulfillment_orders (merchant_id, client_order_no); +CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_deleted_at ON fulfillment_orders (deleted_at); +CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_merchant_id ON fulfillment_orders (merchant_id); +CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_merchant_product_id ON fulfillment_orders (merchant_product_id); +CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_payment_status ON fulfillment_orders (payment_status); +CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_fulfillment_status ON fulfillment_orders (fulfillment_status); +CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_provider_order_no ON fulfillment_orders (provider_order_no); + +CREATE TABLE IF NOT EXISTS fulfillment_jobs ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL, + order_id BIGINT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + attempts BIGINT NOT NULL DEFAULT 0, + next_run_at TIMESTAMPTZ NOT NULL, + provider_order_no VARCHAR(96), + request_payload TEXT, + result_payload TEXT, + last_error VARCHAR(512) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_fulfillment_jobs_order_id ON fulfillment_jobs (order_id); +CREATE INDEX IF NOT EXISTS idx_fulfillment_jobs_merchant_id ON fulfillment_jobs (merchant_id); +CREATE INDEX IF NOT EXISTS idx_fulfillment_jobs_status ON fulfillment_jobs (status); +CREATE INDEX IF NOT EXISTS idx_fulfillment_jobs_next_run_at ON fulfillment_jobs (next_run_at); +CREATE INDEX IF NOT EXISTS idx_fulfillment_jobs_provider_order_no ON fulfillment_jobs (provider_order_no); + +CREATE TABLE IF NOT EXISTS callback_subscriptions ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + url VARCHAR(1024) NOT NULL, + events TEXT NOT NULL, + secret_ciphertext TEXT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active' +); + +CREATE INDEX IF NOT EXISTS idx_callback_subscriptions_deleted_at ON callback_subscriptions (deleted_at); +CREATE INDEX IF NOT EXISTS idx_callback_subscriptions_merchant_id ON callback_subscriptions (merchant_id); +CREATE INDEX IF NOT EXISTS idx_callback_subscriptions_status ON callback_subscriptions (status); + +CREATE TABLE IF NOT EXISTS callback_deliveries ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + merchant_id BIGINT NOT NULL, + callback_subscription_id BIGINT NOT NULL, + event_id VARCHAR(64) NOT NULL, + event VARCHAR(64) NOT NULL, + payload TEXT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + attempts BIGINT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL, + last_status_code BIGINT NOT NULL DEFAULT 0, + last_response TEXT, + delivered_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_callback_deliveries_event_id ON callback_deliveries (event_id); +CREATE INDEX IF NOT EXISTS idx_callback_deliveries_merchant_id ON callback_deliveries (merchant_id); +CREATE INDEX IF NOT EXISTS idx_callback_deliveries_callback_subscription_id ON callback_deliveries (callback_subscription_id); +CREATE INDEX IF NOT EXISTS idx_callback_deliveries_event ON callback_deliveries (event); +CREATE INDEX IF NOT EXISTS idx_callback_deliveries_status ON callback_deliveries (status); +CREATE INDEX IF NOT EXISTS idx_callback_deliveries_next_attempt_at ON callback_deliveries (next_attempt_at); + +CREATE TABLE IF NOT EXISTS api_request_nonces ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + api_client_id BIGINT NOT NULL, + nonce VARCHAR(96) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_api_nonce ON api_request_nonces (api_client_id, nonce); +CREATE INDEX IF NOT EXISTS idx_api_request_nonces_api_client_id ON api_request_nonces (api_client_id); +CREATE INDEX IF NOT EXISTS idx_api_request_nonces_expires_at ON api_request_nonces (expires_at); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ, + merchant_id BIGINT, + actor_user_id BIGINT, + api_client_id BIGINT, + request_id VARCHAR(64), + action VARCHAR(96) NOT NULL, + entity_type VARCHAR(64) NOT NULL, + entity_id VARCHAR(96) NOT NULL, + metadata TEXT +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_merchant_id ON audit_logs (merchant_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_user_id ON audit_logs (actor_user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_api_client_id ON audit_logs (api_client_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_request_id ON audit_logs (request_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs (action); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity_type ON audit_logs (entity_type); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity_id ON audit_logs (entity_id); diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go new file mode 100644 index 0000000..acb725b --- /dev/null +++ b/backend/internal/handler/merchant.go @@ -0,0 +1,383 @@ +package handler + +import ( + "strconv" + "time" + + "affiliate_dash/internal/middleware" + "affiliate_dash/internal/pkg/response" + "affiliate_dash/internal/service" + + "github.com/gin-gonic/gin" +) + +// MerchantHandler 提供商户后台与平台管理员的多租户管理能力。 +type MerchantHandler struct { + merchantSvc *service.MerchantService + fulfillmentSvc *service.FulfillmentService + callbackSvc *service.CallbackService +} + +func NewMerchantHandler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService, callbackSvc *service.CallbackService) *MerchantHandler { + return &MerchantHandler{ + merchantSvc: merchantSvc, + fulfillmentSvc: fulfillmentSvc, + callbackSvc: callbackSvc, + } +} + +func (h *MerchantHandler) Current(c *gin.Context) { + merchant, err := h.merchantSvc.GetMerchant(middleware.GetMerchantID(c)) + if err != nil { + response.NotFound(c, err.Error()) + return + } + response.OK(c, gin.H{ + "merchant": merchant, + "role": middleware.GetMerchantRole(c), + }) +} + +func (h *MerchantHandler) ListProducts(c *gin.Context) { + page, size := pageParams(c) + list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, false) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.Page(c, list, total, page, size) +} + +type merchantProductReq struct { + ProductCode string `json:"product_code"` + ProductName string `json:"product_name"` + Category string `json:"category"` + Description string `json:"description"` + Attributes string `json:"attributes"` + SKU string `json:"sku" binding:"required"` + DisplayName string `json:"display_name"` + PriceAmount int64 `json:"price_amount"` + CostAmount int64 `json:"cost_amount"` + Currency string `json:"currency"` + Stock int64 `json:"stock"` + Status string `json:"status"` + FulfillmentConfig string `json:"fulfillment_config"` +} + +func (h *MerchantHandler) CreateProduct(c *gin.Context) { + var req merchantProductReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:sku 必填") + return + } + product, err := h.merchantSvc.CreateMerchantProduct(middleware.GetMerchantID(c), service.CreateMerchantProductInput{ + ProductCode: req.ProductCode, + ProductName: req.ProductName, + Category: req.Category, + Description: req.Description, + Attributes: req.Attributes, + SKU: req.SKU, + DisplayName: req.DisplayName, + PriceAmount: req.PriceAmount, + CostAmount: req.CostAmount, + Currency: req.Currency, + Stock: req.Stock, + Status: req.Status, + FulfillmentConfig: req.FulfillmentConfig, + }, middleware.GetUserID(c)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, product) +} + +type merchantProductUpdateReq struct { + DisplayName *string `json:"display_name"` + PriceAmount *int64 `json:"price_amount"` + CostAmount *int64 `json:"cost_amount"` + Stock *int64 `json:"stock"` + Status *string `json:"status"` + FulfillmentConfig *string `json:"fulfillment_config"` +} + +func (h *MerchantHandler) UpdateProduct(c *gin.Context) { + var req merchantProductUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.merchantSvc.UpdateMerchantProduct(middleware.GetMerchantID(c), uint(id), service.UpdateMerchantProductInput{ + DisplayName: req.DisplayName, + PriceAmount: req.PriceAmount, + CostAmount: req.CostAmount, + Stock: req.Stock, + Status: req.Status, + FulfillmentConfig: req.FulfillmentConfig, + }, middleware.GetUserID(c)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} + +func (h *MerchantHandler) ListOrders(c *gin.Context) { + page, size := pageParams(c) + list, total, err := h.fulfillmentSvc.ListOrders(middleware.GetMerchantID(c), page, size, c.Query("fulfillment_status")) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.Page(c, list, total, page, size) +} + +func (h *MerchantHandler) GetWallet(c *gin.Context) { + wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c)) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, wallet) +} + +func (h *MerchantHandler) ListWalletLedger(c *gin.Context) { + page, size := pageParams(c) + list, total, err := h.fulfillmentSvc.ListWalletLedger(middleware.GetMerchantID(c), page, size) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.Page(c, list, total, page, size) +} + +type walletAdjustReq struct { + Amount int64 `json:"amount" binding:"required"` + IdempotencyKey string `json:"idempotency_key" binding:"required"` + Note string `json:"note"` +} + +func (h *MerchantHandler) AdjustWallet(c *gin.Context) { + var req walletAdjustReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:amount 与 idempotency_key 必填") + return + } + wallet, err := h.fulfillmentSvc.AdjustWallet(service.WalletAdjustInput{ + MerchantID: middleware.GetMerchantID(c), + ActorUserID: middleware.GetUserID(c), + Amount: req.Amount, + IdempotencyKey: req.IdempotencyKey, + Note: req.Note, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, wallet) +} + +func (h *MerchantHandler) ListAPIClients(c *gin.Context) { + clients, err := h.merchantSvc.ListAPIClients(middleware.GetMerchantID(c)) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, clients) +} + +type apiClientReq struct { + Name string `json:"name" binding:"required"` + Scopes string `json:"scopes" binding:"required"` + SignatureVersion string `json:"signature_version"` + ExpiresAt string `json:"expires_at"` +} + +func (h *MerchantHandler) CreateAPIClient(c *gin.Context) { + var req apiClientReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:name 与 scopes 必填") + return + } + var expiresAt *time.Time + if req.ExpiresAt != "" { + value, err := time.Parse(time.RFC3339, req.ExpiresAt) + if err != nil { + response.BadRequest(c, "expires_at 必须是 RFC3339 时间") + return + } + expiresAt = &value + } + credential, err := h.merchantSvc.CreateAPIClient(middleware.GetMerchantID(c), service.CreateAPIClientInput{ + Name: req.Name, + Scopes: req.Scopes, + SignatureVersion: req.SignatureVersion, + ExpiresAt: expiresAt, + }, middleware.GetUserID(c)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, credential) +} + +type statusReq struct { + Status string `json:"status" binding:"required"` +} + +func (h *MerchantHandler) UpdateAPIClientStatus(c *gin.Context) { + var req statusReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.merchantSvc.UpdateAPIClientStatus(middleware.GetMerchantID(c), uint(id), req.Status, middleware.GetUserID(c)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} + +func (h *MerchantHandler) ListCallbacks(c *gin.Context) { + list, err := h.callbackSvc.ListSubscriptions(middleware.GetMerchantID(c)) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, list) +} + +type callbackReq struct { + Name string `json:"name" binding:"required"` + URL string `json:"url" binding:"required"` + Events string `json:"events" binding:"required"` +} + +func (h *MerchantHandler) CreateCallback(c *gin.Context) { + var req callbackReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:name、url、events 必填") + return + } + credential, err := h.callbackSvc.CreateSubscription(middleware.GetMerchantID(c), service.CreateCallbackInput{ + Name: req.Name, + URL: req.URL, + Events: req.Events, + }, middleware.GetUserID(c)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, credential) +} + +func (h *MerchantHandler) UpdateCallbackStatus(c *gin.Context) { + var req statusReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := h.callbackSvc.UpdateSubscriptionStatus(middleware.GetMerchantID(c), uint(id), req.Status, middleware.GetUserID(c)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} + +func (h *MerchantHandler) ListMembers(c *gin.Context) { + members, err := h.merchantSvc.ListMembers(middleware.GetMerchantID(c)) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, members) +} + +type addMemberReq struct { + UserID uint `json:"user_id" binding:"required"` + Role string `json:"role" binding:"required"` + IsDefault bool `json:"is_default"` +} + +func (h *MerchantHandler) AddCurrentMerchantMember(c *gin.Context) { + var req addMemberReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:user_id 与 role 必填") + return + } + member, err := h.merchantSvc.AddMember(middleware.GetMerchantID(c), service.AddMemberInput{ + UserID: req.UserID, + Role: req.Role, + IsDefault: req.IsDefault, + }, middleware.GetUserID(c)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, member) +} + +func (h *MerchantHandler) ListPlatformMerchants(c *gin.Context) { + page, size := pageParams(c) + list, total, err := h.merchantSvc.ListMerchants(page, size) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.Page(c, list, total, page, size) +} + +type createMerchantReq struct { + Code string `json:"code" binding:"required"` + Name string `json:"name" binding:"required"` + ContactName string `json:"contact_name"` + ContactInfo string `json:"contact_info"` + OwnerUserID uint `json:"owner_user_id" binding:"required"` +} + +func (h *MerchantHandler) CreateMerchant(c *gin.Context) { + var req createMerchantReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:code、name、owner_user_id 必填") + return + } + merchant, err := h.merchantSvc.CreateMerchant(service.CreateMerchantInput{ + Code: req.Code, + Name: req.Name, + ContactName: req.ContactName, + ContactInfo: req.ContactInfo, + OwnerUserID: req.OwnerUserID, + }, middleware.GetUserID(c)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, merchant) +} + +func (h *MerchantHandler) AddPlatformMerchantMember(c *gin.Context) { + var req addMemberReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:user_id 与 role 必填") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + member, err := h.merchantSvc.AddMember(uint(id), service.AddMemberInput{ + UserID: req.UserID, + Role: req.Role, + IsDefault: req.IsDefault, + }, middleware.GetUserID(c)) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, member) +} + +func pageParams(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + return page, size +} diff --git a/backend/internal/handler/open_v1.go b/backend/internal/handler/open_v1.go new file mode 100644 index 0000000..1a6b309 --- /dev/null +++ b/backend/internal/handler/open_v1.go @@ -0,0 +1,221 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strconv" + + "affiliate_dash/internal/middleware" + "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/response" + "affiliate_dash/internal/service" + + "github.com/gin-gonic/gin" +) + +// OpenV1Handler 提供面向商户系统和履约器的通用开放接口。 +type OpenV1Handler struct { + merchantSvc *service.MerchantService + fulfillmentSvc *service.FulfillmentService +} + +func NewOpenV1Handler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService) *OpenV1Handler { + return &OpenV1Handler{merchantSvc: merchantSvc, fulfillmentSvc: fulfillmentSvc} +} + +func (h *OpenV1Handler) ListProducts(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, true) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.Page(c, list, total, page, size) +} + +type openCreateOrderReq struct { + ClientOrderNo string `json:"client_order_no" binding:"required"` + SKU string `json:"sku" binding:"required"` + Quantity int64 `json:"quantity"` + BuyerReference string `json:"buyer_reference"` + Data json.RawMessage `json:"data"` +} + +func (h *OpenV1Handler) CreateOrder(c *gin.Context) { + var req openCreateOrderReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:client_order_no 与 sku 必填") + return + } + if key := c.GetHeader("Idempotency-Key"); key != "" && key != req.ClientOrderNo { + response.BadRequest(c, "Idempotency-Key 必须与 client_order_no 一致") + return + } + var data interface{} + if len(req.Data) > 0 { + var decoded interface{} + if err := json.Unmarshal(req.Data, &decoded); err != nil { + response.BadRequest(c, "data 必须是有效 JSON") + return + } + data = decoded + } + client := middleware.GetAPIClient(c) + result, err := h.fulfillmentSvc.CreateOrder(service.CreateFulfillmentOrderInput{ + MerchantID: middleware.GetMerchantID(c), + APIClientID: client.ID, + ClientOrderNo: req.ClientOrderNo, + SKU: req.SKU, + Quantity: req.Quantity, + BuyerReference: req.BuyerReference, + RequestData: data, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + status := http.StatusOK + if !result.Idempotent { + status = http.StatusCreated + } + c.JSON(status, response.Body{ + Code: 0, + Message: "ok", + Data: gin.H{ + "order": buildOpenOrderResponse(result.Order), + "idempotent": result.Idempotent, + }, + }) +} + +func (h *OpenV1Handler) QueryOrder(c *gin.Context) { + order, err := h.fulfillmentSvc.GetOrder(middleware.GetMerchantID(c), c.Param("order_no")) + if err != nil { + if err.Error() == "订单不存在" { + response.NotFound(c, err.Error()) + return + } + response.ServerError(c, err.Error()) + return + } + response.OK(c, buildOpenOrderResponse(order)) +} + +type openCancelOrderReq struct { + Reason string `json:"reason"` +} + +func (h *OpenV1Handler) CancelOrder(c *gin.Context) { + var req openCancelOrderReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + client := middleware.GetAPIClient(c) + order, err := h.fulfillmentSvc.CancelOrder(middleware.GetMerchantID(c), client.ID, c.Param("order_no"), req.Reason) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, buildOpenOrderResponse(order)) +} + +type openShipNotifyReq struct { + OrderNo string `json:"order_no"` + ShipStatus string `json:"ship_status" binding:"required"` + ProviderOrderNo string `json:"provider_order_no"` + FailReason string `json:"fail_reason"` + Result json.RawMessage `json:"result"` +} + +func (h *OpenV1Handler) ShipNotify(c *gin.Context) { + var req openShipNotifyReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误:ship_status 必填") + return + } + status := "" + switch req.ShipStatus { + case "processing": + status = model.FulfillmentStatusProcessing + case "success": + status = model.FulfillmentStatusSucceeded + case "failed": + status = model.FulfillmentStatusFailed + default: + response.BadRequest(c, "ship_status 仅支持 processing、success、failed") + return + } + var result interface{} + if len(req.Result) > 0 { + if err := json.Unmarshal(req.Result, &result); err != nil { + response.BadRequest(c, "result 必须是有效 JSON") + return + } + } + client := middleware.GetAPIClient(c) + orderNo := c.Param("order_no") + if orderNo == "" { + orderNo = req.OrderNo + } + if orderNo == "" { + response.BadRequest(c, "order_no 必填") + return + } + order, err := h.fulfillmentSvc.UpdateFulfillment(service.FulfillmentUpdateInput{ + MerchantID: middleware.GetMerchantID(c), + APIClientID: client.ID, + OrderNo: orderNo, + Status: status, + ProviderOrderNo: req.ProviderOrderNo, + FailureReason: req.FailReason, + ResultData: result, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, buildOpenOrderResponse(order)) +} + +func (h *OpenV1Handler) GetWallet(c *gin.Context) { + wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c)) + if err != nil { + response.ServerError(c, err.Error()) + return + } + response.OK(c, wallet) +} + +func buildOpenOrderResponse(order *model.FulfillmentOrder) gin.H { + canFulfill, reason := service.CanFulfill(order) + data := gin.H{ + "order_no": order.OrderNo, + "client_order_no": order.ClientOrderNo, + "payment_status": order.PaymentStatus, + "fulfillment_status": order.FulfillmentStatus, + "can_fulfill": canFulfill, + "cannot_fulfill_reason": reason, + "product": gin.H{ + "sku": order.ProductSKU, + "name": order.ProductName, + }, + "quantity": order.Quantity, + "amount": order.Amount, + "currency": order.Currency, + "buyer_reference": order.BuyerReference, + "provider_order_no": order.ProviderOrderNo, + "failure_reason": order.FailureReason, + "created_at": order.CreatedAt, + "delivered_at": order.DeliveredAt, + "cancelled_at": order.CancelledAt, + } + if json.Valid([]byte(order.RequestData)) { + data["data"] = json.RawMessage(order.RequestData) + } + if json.Valid([]byte(order.ResultData)) { + data["result"] = json.RawMessage(order.ResultData) + } + return data +} diff --git a/backend/internal/handler/order.go b/backend/internal/handler/order.go index 18b4262..cfe59d3 100644 --- a/backend/internal/handler/order.go +++ b/backend/internal/handler/order.go @@ -23,9 +23,10 @@ func (h *OrderHandler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) q := service.OrderListQuery{ - Page: page, - Size: size, - Status: c.Query("status"), + MerchantID: middleware.GetMerchantID(c), + Page: page, + Size: size, + Status: c.Query("status"), } // 分销商只能看自己的订单 if middleware.GetRole(c) == model.RoleDistributor { @@ -45,11 +46,11 @@ func (h *OrderHandler) List(c *gin.Context) { } type createOrderReq struct { - SkinID uint `json:"skin_id" binding:"required"` - BuyerName string `json:"buyer_name"` - Remark string `json:"remark"` - Status string `json:"status"` // 管理员可指定初始状态(联调造异常单) - DistributorID *uint `json:"distributor_id"` // 管理员可指定分销商 + SkinID uint `json:"skin_id" binding:"required"` + BuyerName string `json:"buyer_name"` + Remark string `json:"remark"` + Status string `json:"status"` // 管理员可指定初始状态(联调造异常单) + DistributorID *uint `json:"distributor_id"` // 管理员可指定分销商 } func (h *OrderHandler) Create(c *gin.Context) { @@ -75,6 +76,7 @@ func (h *OrderHandler) Create(c *gin.Context) { req.BuyerName = "测试买家" } order, err := h.svc.Create(service.CreateOrderInput{ + MerchantID: middleware.GetMerchantID(c), SkinID: req.SkinID, DistributorID: distributorID, BuyerName: req.BuyerName, @@ -99,7 +101,7 @@ func (h *OrderHandler) UpdateStatus(c *gin.Context) { response.BadRequest(c, "参数错误") return } - if err := h.svc.UpdateStatus(uint(id), req.Status); err != nil { + if err := h.svc.UpdateStatus(middleware.GetMerchantID(c), uint(id), req.Status); err != nil { response.BadRequest(c, err.Error()) return } @@ -107,7 +109,7 @@ func (h *OrderHandler) UpdateStatus(c *gin.Context) { } func (h *OrderHandler) Dashboard(c *gin.Context) { - stats, err := h.svc.Dashboard() + stats, err := h.svc.Dashboard(middleware.GetMerchantID(c)) if err != nil { response.ServerError(c, err.Error()) return @@ -120,6 +122,7 @@ func (h *OrderHandler) ListShipLogs(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) list, total, err := h.svc.ListShipLogs(service.ShipLogListQuery{ + MerchantID: middleware.GetMerchantID(c), Page: page, Size: size, OrderNo: c.Query("order_no"), diff --git a/backend/internal/handler/skin.go b/backend/internal/handler/skin.go index 02d81f5..638e44b 100644 --- a/backend/internal/handler/skin.go +++ b/backend/internal/handler/skin.go @@ -3,6 +3,7 @@ package handler import ( "strconv" + "affiliate_dash/internal/middleware" "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/response" "affiliate_dash/internal/service" @@ -22,11 +23,12 @@ func (h *SkinHandler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) q := service.SkinListQuery{ - Page: page, - Size: size, - Keyword: c.Query("keyword"), - Game: c.Query("game"), - Category: c.Query("category"), + MerchantID: middleware.GetMerchantID(c), + Page: page, + Size: size, + Keyword: c.Query("keyword"), + Game: c.Query("game"), + Category: c.Query("category"), } if s := c.Query("status"); s != "" { v, _ := strconv.Atoi(s) @@ -42,7 +44,7 @@ func (h *SkinHandler) List(c *gin.Context) { func (h *SkinHandler) Get(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - skin, err := h.svc.Get(uint(id)) + skin, err := h.svc.Get(middleware.GetMerchantID(c), uint(id)) if err != nil { response.NotFound(c, err.Error()) return @@ -79,6 +81,7 @@ func (h *SkinHandler) Create(c *gin.Context) { req.Game = "和平精英" } skin := &model.Skin{ + MerchantID: middleware.GetMerchantID(c), Name: req.Name, SKU: req.SKU, Game: req.Game, @@ -108,7 +111,7 @@ func (h *SkinHandler) Update(c *gin.Context) { delete(updates, "id") delete(updates, "created_at") delete(updates, "updated_at") - if err := h.svc.Update(uint(id), updates); err != nil { + if err := h.svc.Update(middleware.GetMerchantID(c), uint(id), updates); err != nil { response.BadRequest(c, err.Error()) return } @@ -117,7 +120,7 @@ func (h *SkinHandler) Update(c *gin.Context) { func (h *SkinHandler) Delete(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if err := h.svc.Delete(uint(id)); err != nil { + if err := h.svc.Delete(middleware.GetMerchantID(c), uint(id)); err != nil { response.BadRequest(c, err.Error()) return } diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index 1cc0535..b44cf6b 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -3,6 +3,7 @@ package handler import ( "strconv" + "affiliate_dash/internal/middleware" "affiliate_dash/internal/pkg/response" "affiliate_dash/internal/service" @@ -21,10 +22,11 @@ func (h *UserHandler) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) q := service.UserListQuery{ - Page: page, - Size: size, - Keyword: c.Query("keyword"), - Role: c.Query("role"), + MerchantID: middleware.GetMerchantID(c), + Page: page, + Size: size, + Keyword: c.Query("keyword"), + Role: c.Query("role"), } if s := c.Query("status"); s != "" { v, _ := strconv.Atoi(s) @@ -52,7 +54,7 @@ func (h *UserHandler) Create(c *gin.Context) { response.BadRequest(c, "参数错误") return } - user, err := h.svc.Create(req.Username, req.Password, req.Nickname, req.Role, req.ParentID) + user, err := h.svc.Create(req.Username, req.Password, req.Nickname, req.Role, req.ParentID, middleware.GetMerchantID(c)) if err != nil { response.BadRequest(c, err.Error()) return diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index a9faaea..b9cfc18 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -5,14 +5,16 @@ import ( "affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/pkg/response" + "affiliate_dash/internal/service" "github.com/gin-gonic/gin" ) const ( - CtxUserID = "user_id" - CtxUsername = "username" - CtxRole = "role" + CtxUserID = "user_id" + CtxUsername = "username" + CtxRole = "role" + CtxMerchantRole = "merchant_role" ) func Auth(jm *jwt.Manager) gin.HandlerFunc { @@ -70,3 +72,42 @@ func GetRole(c *gin.Context) string { role, _ := v.(string) return role } + +// Tenant 根据 X-Merchant-ID(商户 ID 或编码)解析当前后台请求所属商户。 +// 未指定时选择该账号的默认商户,确保旧后台继续落到“自营商户”。 +func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc { + return func(c *gin.Context) { + member, err := tenantSvc.ResolveMember(GetUserID(c), c.GetHeader("X-Merchant-ID")) + if err != nil { + response.Forbidden(c, err.Error()) + c.Abort() + return + } + c.Set(CtxMerchantID, member.MerchantID) + c.Set(CtxMerchantRole, member.Role) + c.Next() + } +} + +func RequireMerchantRole(roles ...string) gin.HandlerFunc { + allowed := make(map[string]struct{}, len(roles)) + for _, role := range roles { + allowed[role] = struct{}{} + } + return func(c *gin.Context) { + value, _ := c.Get(CtxMerchantRole) + role, _ := value.(string) + if _, ok := allowed[role]; !ok { + response.Forbidden(c, "商户权限不足") + c.Abort() + return + } + c.Next() + } +} + +func GetMerchantRole(c *gin.Context) string { + value, _ := c.Get(CtxMerchantRole) + role, _ := value.(string) + return role +} diff --git a/backend/internal/middleware/open_auth.go b/backend/internal/middleware/open_auth.go index db80ebe..ff5f838 100644 --- a/backend/internal/middleware/open_auth.go +++ b/backend/internal/middleware/open_auth.go @@ -6,191 +6,192 @@ import ( "crypto/sha256" "encoding/hex" "io" - "sort" "strconv" "strings" - "sync" "time" + "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/openlog" "affiliate_dash/internal/pkg/response" + "affiliate_dash/internal/service" "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" ) -// OpenAuthConfig 开放接口鉴权配置 +const ( + CtxAPIClient = "api_client" + CtxAPIClientID = "api_client_id" + CtxMerchantID = "merchant_id" +) + +// OpenAuthConfig 配置数据库化的开放接口认证。 type OpenAuthConfig struct { - APIKey string - APISecret string - // 允许的时间偏差(秒),默认 300 + DB *gorm.DB + Codec *service.SecretCodec SkewSeconds int64 - // Debug 详细日志 - Debug bool + Debug bool } -// nonce 防重放(进程内,重启清空;生产可换 Redis) -type nonceStore struct { - mu sync.Mutex - data map[string]int64 // nonce -> expire unix -} - -func newNonceStore() *nonceStore { - return &nonceStore{data: make(map[string]int64)} -} - -func (s *nonceStore) seen(nonce string, now, ttl int64) bool { - s.mu.Lock() - defer s.mu.Unlock() - for k, exp := range s.data { - if exp < now { - delete(s.data, k) - } - } - if exp, ok := s.data[nonce]; ok && exp >= now { - return true - } - s.data[nonce] = now + ttl - return false -} - -// OpenAuth 校验 X-Api-Key + 时间戳 + nonce + HMAC-SHA256 签名 -// -// 待签名参数(value 原样,不做 URL encode): -// -// api_key, body, method, nonce, path, timestamp -// -// 按 key 字典序排序后拼接 k1=v1&k2=v2&... -// method 大写;path 为 URL.Path(不含 query);GET 时 body 为空串。 -// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。 +// OpenAuth 校验独立 API 客户端、时间戳、持久化 nonce 与 HMAC 签名。 +// 客户侧新接口只使用 X-App-Key 和 body SHA256;上游发货接口独立使用 SourceOpenAuth。 func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc { if cfg.SkewSeconds <= 0 { cfg.SkewSeconds = 300 } - store := newNonceStore() - return func(c *gin.Context) { reqID := openlog.EnsureReqID(c) c.Set(openlog.CtxDebug, cfg.Debug) c.Set(openlog.CtxStart, time.Now()) c.Header("X-Request-Id", reqID) - if cfg.APIKey == "" || cfg.APISecret == "" { - openlog.Warn(c, "auth_fail reason=server_not_configured") - response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET") + if cfg.DB == nil || cfg.Codec == nil { + response.ServerError(c, "开放接口认证服务未初始化") + c.Abort() + return + } + appKey := c.GetHeader("X-App-Key") + timestamp := c.GetHeader("X-Timestamp") + nonce := c.GetHeader("X-Nonce") + sign := c.GetHeader("X-Sign") + if appKey == "" || timestamp == "" || nonce == "" || sign == "" { + response.Unauthorized(c, "缺少鉴权头:X-App-Key、X-Timestamp、X-Nonce、X-Sign") + c.Abort() + return + } + if len(nonce) < 8 || len(nonce) > 96 { + response.Unauthorized(c, "X-Nonce 长度需在 8~96 之间") + c.Abort() + return + } + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil || abs64(time.Now().Unix()-ts) > cfg.SkewSeconds { + response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误") c.Abort() return } - apiKey := c.GetHeader("X-Api-Key") - timestamp := c.GetHeader("X-Timestamp") - nonce := c.GetHeader("X-Nonce") - sign := c.GetHeader("X-Sign") - clientIP := c.ClientIP() - bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { - openlog.Warn(c, "auth_fail reason=read_body_error err=%v ip=%s", err, clientIP) response.BadRequest(c, "读取请求体失败") c.Abort() return } c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - body := string(bodyBytes) - c.Set(openlog.CtxBody, body) - method := strings.ToUpper(c.Request.Method) - path := c.Request.URL.Path - - openlog.Info(c, "request in method=%s path=%s ip=%s key=%s ts=%s nonce=%s sign=%s body=%q body_len=%d", - method, path, clientIP, - openlog.MaskKey(apiKey), timestamp, nonce, openlog.MaskSign(sign), - openlog.Truncate(body, 500), len(bodyBytes), - ) - - if apiKey == "" || timestamp == "" || nonce == "" || sign == "" { - openlog.Warn(c, "auth_fail reason=missing_headers key=%s ts=%s nonce=%s has_sign=%v", - openlog.MaskKey(apiKey), timestamp, nonce, sign != "") - response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign") - c.Abort() - return - } - if apiKey != cfg.APIKey { - openlog.Warn(c, "auth_fail reason=invalid_api_key key=%s", openlog.MaskKey(apiKey)) + var client model.APIClient + if err := cfg.DB.Where("app_key = ? AND status = ?", appKey, model.APIClientStatusActive).First(&client).Error; err != nil { response.Unauthorized(c, "无效的 API Key") c.Abort() return } - if len(nonce) < 8 || len(nonce) > 64 { - openlog.Warn(c, "auth_fail reason=bad_nonce_len len=%d", len(nonce)) - response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间") + if client.ExpiresAt != nil && client.ExpiresAt.Before(time.Now()) { + response.Unauthorized(c, "API Key 已过期") c.Abort() return } - - ts, err := strconv.ParseInt(timestamp, 10, 64) + secret, err := cfg.Codec.Decrypt(client.SecretCiphertext) if err != nil { - openlog.Warn(c, "auth_fail reason=bad_timestamp raw=%s", timestamp) - response.Unauthorized(c, "X-Timestamp 格式错误,需为 Unix 秒级时间戳") - c.Abort() - return - } - now := time.Now().Unix() - skew := now - ts - if abs64(skew) > cfg.SkewSeconds { - openlog.Warn(c, "auth_fail reason=timestamp_skew server_now=%d ts=%d skew=%ds limit=%ds", - now, ts, skew, cfg.SkewSeconds) - response.Unauthorized(c, "请求已过期或时间偏差过大") + response.ServerError(c, "API 客户端密钥不可用") c.Abort() return } - if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) { - openlog.Warn(c, "auth_fail reason=replay_nonce nonce=%s", nonce) - response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)") - c.Abort() - return - } - - stringToSign := BuildSignString(apiKey, timestamp, nonce, method, path, body) - expected := hmacSHA256Hex(cfg.APISecret, stringToSign) + method := strings.ToUpper(c.Request.Method) + path := c.Request.URL.Path + expected := BuildOpenV1Sign(secret, timestamp, nonce, method, path, bodyBytes) if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) { - // 调试时输出待签名串(不含 secret),便于源头对齐 - openlog.Warn(c, "auth_fail reason=sign_mismatch sign=%s expected_prefix=%s string_to_sign=%q", - openlog.MaskSign(sign), openlog.MaskSign(expected), openlog.Truncate(stringToSign, 800)) response.Unauthorized(c, "签名校验失败") c.Abort() return } - c.Set(openlog.CtxAPIKey, apiKey) - openlog.Info(c, "auth_ok skew=%ds string_to_sign_len=%d", skew, len(stringToSign)) - c.Next() + now := time.Now() + nonceTTL := time.Duration(cfg.SkewSeconds) * time.Second + _ = cfg.DB.Where("expires_at < ?", now).Delete(&model.APIRequestNonce{}).Error + nonceRow := model.APIRequestNonce{ + APIClientID: client.ID, + Nonce: nonce, + ExpiresAt: now.Add(nonceTTL), + } + created := cfg.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&nonceRow) + if created.Error != nil { + response.ServerError(c, "记录请求 nonce 失败") + c.Abort() + return + } + if created.RowsAffected == 0 { + response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)") + c.Abort() + return + } - // 请求结束后补一条总耗时(handler 里会打业务结果) - openlog.Info(c, "request done status=%d cost=%s", c.Writer.Status(), openlog.Elapsed(c).Round(time.Microsecond)) + c.Set(CtxAPIClient, &client) + c.Set(CtxAPIClientID, client.ID) + c.Set(CtxMerchantID, client.MerchantID) + c.Set(openlog.CtxAPIKey, appKey) + _ = cfg.DB.Model(&model.APIClient{}).Where("id = ?", client.ID).Update("last_used_at", now).Error + c.Next() } } -// BuildSignString 生成待签名字符串:参数字典序 + & 拼接 +// RequireAPIScope 要求客户端至少拥有一个指定权限。 +func RequireAPIScope(scopes ...string) gin.HandlerFunc { + return func(c *gin.Context) { + client := GetAPIClient(c) + if client == nil || !service.HasAnyScope(client.Scopes, scopes...) { + response.Forbidden(c, "API 客户端权限不足") + c.Abort() + return + } + c.Next() + } +} + +func GetAPIClient(c *gin.Context) *model.APIClient { + value, ok := c.Get(CtxAPIClient) + if !ok { + return nil + } + client, _ := value.(*model.APIClient) + return client +} + +func GetMerchantID(c *gin.Context) uint { + value, _ := c.Get(CtxMerchantID) + merchantID, _ := value.(uint) + return merchantID +} + +// BuildOpenV1Sign 生成新开放接口签名:timestamp、nonce、method、path 与 body SHA256。 +func BuildOpenV1Sign(secret, timestamp, nonce, method, path string, body []byte) string { + bodyHash := sha256.Sum256(body) + content := strings.Join([]string{ + timestamp, + nonce, + strings.ToUpper(method), + path, + hex.EncodeToString(bodyHash[:]), + }, "\n") + return hmacSHA256Hex(secret, content) +} + +// BuildSignString 保留旧接口的字典序签名算法,供兼容客户端和测试使用。 func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string { - params := map[string]string{ - "api_key": apiKey, - "body": body, - "method": strings.ToUpper(method), - "nonce": nonce, - "path": path, - "timestamp": timestamp, - } - keys := make([]string, 0, len(params)) - for k := range params { - keys = append(keys, k) - } - sort.Strings(keys) - parts := make([]string, 0, len(keys)) - for _, k := range keys { - parts = append(parts, k+"="+params[k]) - } - return strings.Join(parts, "&") + return strings.Join([]string{ + "api_key=" + apiKey, + "body=" + body, + "method=" + strings.ToUpper(method), + "nonce=" + nonce, + "path=" + path, + "timestamp=" + timestamp, + }, "&") +} + +// BuildOpenSign 供旧发货系统兼容使用。 +func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string { + return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body)) } func hmacSHA256Hex(secret, content string) string { @@ -199,14 +200,9 @@ func hmacSHA256Hex(secret, content string) string { return hex.EncodeToString(mac.Sum(nil)) } -func abs64(v int64) int64 { - if v < 0 { - return -v +func abs64(value int64) int64 { + if value < 0 { + return -value } - return v -} - -// BuildOpenSign 供测试或内部生成签名(与 OpenAuth 规则一致) -func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string { - return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body)) + return value } diff --git a/backend/internal/middleware/open_auth_test.go b/backend/internal/middleware/open_auth_test.go new file mode 100644 index 0000000..e73644f --- /dev/null +++ b/backend/internal/middleware/open_auth_test.go @@ -0,0 +1,162 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/service" + "affiliate_dash/internal/testdb" + + "github.com/gin-gonic/gin" +) + +func newOpenAuthTestServer(t *testing.T, signatureVersion string) (*gin.Engine, string, string) { + t.Helper() + gin.SetMode(gin.TestMode) + db := testdb.New(t, &model.Merchant{}, &model.APIClient{}, &model.APIRequestNonce{}) + codec, err := service.NewSecretCodec("test-master-key") + if err != nil { + t.Fatalf("codec: %v", err) + } + merchant := model.Merchant{Code: "merchant-open", Name: "开放测试商户", Status: model.MerchantStatusActive} + if err := db.Create(&merchant).Error; err != nil { + t.Fatalf("create merchant: %v", err) + } + secret := "client-secret" + ciphertext, err := codec.Encrypt(secret) + if err != nil { + t.Fatalf("encrypt secret: %v", err) + } + client := model.APIClient{ + MerchantID: merchant.ID, + Name: "测试客户端", + AppKey: "ak_test", + SecretCiphertext: ciphertext, + SignatureVersion: signatureVersion, + Scopes: "*", + Status: model.APIClientStatusActive, + } + if err := db.Create(&client).Error; err != nil { + t.Fatalf("create client: %v", err) + } + + r := gin.New() + r.Use(OpenAuth(OpenAuthConfig{ + DB: db, + Codec: codec, + SkewSeconds: 300, + })) + r.POST("/api/client/v1/orders", RequireAPIScope("orders:write"), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "merchant_id": GetMerchantID(c), + "client_id": GetAPIClient(c).ID, + }) + }) + return r, client.AppKey, secret +} + +func TestOpenAuthV1AcceptsSignedRequestAndRejectsReplay(t *testing.T) { + r, appKey, secret := newOpenAuthTestServer(t, "v1") + body := `{"client_order_no":"client-001","sku":"sku-basic"}` + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := "nonce-123456" + path := "/api/client/v1/orders" + sign := BuildOpenV1Sign(secret, ts, nonce, http.MethodPost, path, []byte(body)) + + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("X-App-Key", appKey) + req.Header.Set("X-Timestamp", ts) + req.Header.Set("X-Nonce", nonce) + req.Header.Set("X-Sign", sign) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("signed request should pass, code=%d body=%s", w.Code, w.Body.String()) + } + + replay := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + replay.Header = req.Header.Clone() + w = httptest.NewRecorder() + r.ServeHTTP(w, replay) + if w.Code != http.StatusUnauthorized { + t.Fatalf("replay should be rejected, code=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestOpenAuthRejectsSignatureMismatch(t *testing.T) { + r, appKey, _ := newOpenAuthTestServer(t, "v1") + req := httptest.NewRequest(http.MethodPost, "/api/client/v1/orders", strings.NewReader(`{"a":1}`)) + req.Header.Set("X-App-Key", appKey) + req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + req.Header.Set("X-Nonce", "nonce-bad-sign") + req.Header.Set("X-Sign", "bad-sign") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("bad signature should be rejected, code=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestSourceOpenAuthKeepsLegacyUpstreamSignature(t *testing.T) { + gin.SetMode(gin.TestMode) + appKey := "source-key" + secret := "source-secret" + r := gin.New() + r.Use(SourceOpenAuth(SourceOpenAuthConfig{ + APIKey: appKey, + APISecret: secret, + SkewSeconds: 300, + })) + r.POST("/api/open/v1/orders/ship-notify", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + body := `{"client_order_no":"client-legacy","sku":"sku-basic"}` + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := "legacy-nonce-123" + path := "/api/open/v1/orders/ship-notify" + sign := BuildOpenSign(appKey, secret, ts, nonce, http.MethodPost, path, body) + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("X-Api-Key", appKey) + req.Header.Set("X-Timestamp", ts) + req.Header.Set("X-Nonce", nonce) + req.Header.Set("X-Sign", sign) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("legacy signed request should pass, code=%d body=%s", w.Code, w.Body.String()) + } +} + +func TestSourceOpenAuthAcceptsUppercaseLegacySignature(t *testing.T) { + gin.SetMode(gin.TestMode) + appKey := "source-key" + secret := "source-secret" + r := gin.New() + r.Use(SourceOpenAuth(SourceOpenAuthConfig{ + APIKey: appKey, + APISecret: secret, + SkewSeconds: 300, + })) + r.GET("/api/open/v1/orders/O123", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := "legacy-nonce-upper" + path := "/api/open/v1/orders/O123" + sign := strings.ToUpper(BuildOpenSign(appKey, secret, ts, nonce, http.MethodGet, path, "")) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-Api-Key", appKey) + req.Header.Set("X-Timestamp", ts) + req.Header.Set("X-Nonce", nonce) + req.Header.Set("X-Sign", sign) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("uppercase legacy signature should pass, code=%d body=%s", w.Code, w.Body.String()) + } +} diff --git a/backend/internal/middleware/source_open_auth.go b/backend/internal/middleware/source_open_auth.go new file mode 100644 index 0000000..e26669c --- /dev/null +++ b/backend/internal/middleware/source_open_auth.go @@ -0,0 +1,114 @@ +package middleware + +import ( + "bytes" + "crypto/hmac" + "io" + "strconv" + "strings" + "sync" + "time" + + "affiliate_dash/internal/pkg/openlog" + "affiliate_dash/internal/pkg/response" + + "github.com/gin-gonic/gin" +) + +// SourceOpenAuthConfig 是原上游发货接口鉴权配置,保持 X-Api-Key 兼容。 +type SourceOpenAuthConfig struct { + APIKey string + APISecret string + SkewSeconds int64 + Debug bool +} + +type sourceNonceStore struct { + mu sync.Mutex + data map[string]int64 +} + +func newSourceNonceStore() *sourceNonceStore { + return &sourceNonceStore{data: make(map[string]int64)} +} + +func (s *sourceNonceStore) seen(nonce string, now, ttl int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + for key, expiresAt := range s.data { + if expiresAt < now { + delete(s.data, key) + } + } + if expiresAt, ok := s.data[nonce]; ok && expiresAt >= now { + return true + } + s.data[nonce] = now + ttl + return false +} + +// SourceOpenAuth 保持现有上游对接签名算法不变:X-Api-Key + 字典序 HMAC。 +func SourceOpenAuth(cfg SourceOpenAuthConfig) gin.HandlerFunc { + if cfg.SkewSeconds <= 0 { + cfg.SkewSeconds = 300 + } + store := newSourceNonceStore() + return func(c *gin.Context) { + reqID := openlog.EnsureReqID(c) + c.Set(openlog.CtxDebug, cfg.Debug) + c.Set(openlog.CtxStart, time.Now()) + c.Header("X-Request-Id", reqID) + + if cfg.APIKey == "" || cfg.APISecret == "" { + response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET") + c.Abort() + return + } + apiKey := c.GetHeader("X-Api-Key") + timestamp := c.GetHeader("X-Timestamp") + nonce := c.GetHeader("X-Nonce") + sign := c.GetHeader("X-Sign") + if apiKey == "" || timestamp == "" || nonce == "" || sign == "" { + response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign") + c.Abort() + return + } + if apiKey != cfg.APIKey { + response.Unauthorized(c, "无效的 API Key") + c.Abort() + return + } + if len(nonce) < 8 || len(nonce) > 64 { + response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间") + c.Abort() + return + } + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil || abs64(time.Now().Unix()-ts) > cfg.SkewSeconds { + response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误") + c.Abort() + return + } + + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + response.BadRequest(c, "读取请求体失败") + c.Abort() + return + } + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + if store.seen(apiKey+":"+nonce, time.Now().Unix(), cfg.SkewSeconds) { + response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)") + c.Abort() + return + } + expected := BuildOpenSign(apiKey, cfg.APISecret, timestamp, nonce, c.Request.Method, c.Request.URL.Path, string(bodyBytes)) + if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) { + response.Unauthorized(c, "签名校验失败") + c.Abort() + return + } + c.Next() + } +} diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go new file mode 100644 index 0000000..eeb4ea6 --- /dev/null +++ b/backend/internal/model/merchant.go @@ -0,0 +1,270 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + MerchantStatusActive = "active" + MerchantStatusDisabled = "disabled" + + MemberRoleOwner = "owner" + MemberRoleOperator = "operator" + MemberRoleFinance = "finance" + MemberRoleViewer = "viewer" + + APIClientStatusActive = "active" + APIClientStatusDisabled = "disabled" + + ProductStatusActive = "active" + ProductStatusInactive = "inactive" + + PaymentStatusPending = "pending" + PaymentStatusPaid = "paid" + PaymentStatusRefunded = "refunded" + PaymentStatusCancelled = "cancelled" + + FulfillmentStatusPending = "pending" + FulfillmentStatusProcessing = "processing" + FulfillmentStatusSucceeded = "succeeded" + FulfillmentStatusFailed = "failed" + FulfillmentStatusCancelled = "cancelled" + + FulfillmentJobStatusPending = "pending" + FulfillmentJobStatusProcessing = "processing" + FulfillmentJobStatusSucceeded = "succeeded" + FulfillmentJobStatusFailed = "failed" + + WalletLedgerCredit = "credit" + WalletLedgerDebit = "debit" + WalletLedgerRefund = "refund" + WalletLedgerAdjust = "adjust" + + CallbackStatusActive = "active" + CallbackStatusDisabled = "disabled" + + CallbackDeliveryPending = "pending" + CallbackDeliverySending = "sending" + CallbackDeliveryDelivered = "delivered" + CallbackDeliveryFailed = "failed" +) + +// Merchant 商户租户,是所有新业务数据的隔离边界。 +type Merchant struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Code string `gorm:"uniqueIndex;size:64;not null" json:"code"` + Name string `gorm:"size:128;not null" json:"name"` + Status string `gorm:"size:16;not null;default:active;index" json:"status"` + ContactName string `gorm:"size:64" json:"contact_name"` + ContactInfo string `gorm:"size:128" json:"contact_info"` +} + +// MerchantMember 将系统账号和商户权限分离,账号可以属于多个商户。 +type MerchantMember struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + MerchantID uint `gorm:"not null;uniqueIndex:idx_merchant_member;index" json:"merchant_id"` + UserID uint `gorm:"not null;uniqueIndex:idx_merchant_member;index" json:"user_id"` + Role string `gorm:"size:32;not null;default:operator" json:"role"` + Status int `gorm:"not null;default:1" json:"status"` + IsDefault bool `gorm:"not null;default:false" json:"is_default"` + + Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"` + User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// APIClient 为一个商户的外部系统集成凭证。SecretCiphertext 仅保存加密后的密钥。 +type APIClient struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + MerchantID uint `gorm:"not null;index" json:"merchant_id"` + Name string `gorm:"size:128;not null" json:"name"` + AppKey string `gorm:"uniqueIndex;size:96;not null" json:"app_key"` + SecretCiphertext string `gorm:"type:text;not null" json:"-"` + SignatureVersion string `gorm:"size:16;not null;default:v1" json:"signature_version"` + Scopes string `gorm:"type:text;not null" json:"scopes"` + Status string `gorm:"size:16;not null;default:active;index" json:"status"` + ExpiresAt *time.Time `json:"expires_at"` + LastUsedAt *time.Time `json:"last_used_at"` + + Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"` +} + +// Product 是平台通用商品目录;商品特性通过 Attributes 保存,避免把游戏、角色等字段写死。 +type Product struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Code string `gorm:"uniqueIndex;size:96;not null" json:"code"` + Name string `gorm:"size:160;not null" json:"name"` + Category string `gorm:"size:64;index" json:"category"` + Description string `gorm:"type:text" json:"description"` + Attributes string `gorm:"type:text" json:"attributes"` + Status string `gorm:"size:16;not null;default:active;index" json:"status"` +} + +// MerchantProduct 是商户可售商品及其价格、库存和履约配置。 +type MerchantProduct struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + MerchantID uint `gorm:"not null;uniqueIndex:idx_merchant_product_sku;index" json:"merchant_id"` + ProductID uint `gorm:"not null;index" json:"product_id"` + SKU string `gorm:"size:96;not null;uniqueIndex:idx_merchant_product_sku" json:"sku"` + DisplayName string `gorm:"size:160" json:"display_name"` + PriceAmount int64 `gorm:"not null;default:0" json:"price_amount"` + CostAmount int64 `gorm:"not null;default:0" json:"cost_amount"` + Currency string `gorm:"size:12;not null;default:CNY" json:"currency"` + Stock int64 `gorm:"not null;default:-1" json:"stock"` + Status string `gorm:"size:16;not null;default:active;index" json:"status"` + FulfillmentConfig string `gorm:"type:text" json:"fulfillment_config"` + + Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"` + Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"` +} + +// WalletAccount 以最小货币单位记录商户余额,绝不使用 float64 做账。 +type WalletAccount struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MerchantID uint `gorm:"not null;uniqueIndex" json:"merchant_id"` + Currency string `gorm:"size:12;not null;default:CNY" json:"currency"` + AvailableBalance int64 `gorm:"not null;default:0" json:"available_balance"` + FrozenBalance int64 `gorm:"not null;default:0" json:"frozen_balance"` +} + +// WalletLedgerEntry 记录每一笔余额变化,BalanceAfter 方便审计与对账。 +type WalletLedgerEntry struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + MerchantID uint `gorm:"not null;index;uniqueIndex:idx_wallet_ledger_idempotency" json:"merchant_id"` + WalletAccountID uint `gorm:"not null;index" json:"wallet_account_id"` + EntryNo string `gorm:"uniqueIndex;size:64;not null" json:"entry_no"` + Type string `gorm:"size:24;not null;index" json:"type"` + Amount int64 `gorm:"not null" json:"amount"` + BalanceAfter int64 `gorm:"not null" json:"balance_after"` + ReferenceType string `gorm:"size:32;not null" json:"reference_type"` + ReferenceNo string `gorm:"size:96;not null;index" json:"reference_no"` + IdempotencyKey *string `gorm:"uniqueIndex:idx_wallet_ledger_idempotency;size:128" json:"-"` + Note string `gorm:"size:255" json:"note"` +} + +// FulfillmentOrder 将支付和履约状态拆分,并保留商品及请求快照。 +type FulfillmentOrder struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + MerchantID uint `gorm:"not null;index;uniqueIndex:idx_order_client_no" json:"merchant_id"` + OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"` + ClientOrderNo string `gorm:"size:96;not null;uniqueIndex:idx_order_client_no" json:"client_order_no"` + MerchantProductID uint `gorm:"not null;index" json:"merchant_product_id"` + ProductSKU string `gorm:"size:96;not null" json:"product_sku"` + ProductName string `gorm:"size:160;not null" json:"product_name"` + Quantity int64 `gorm:"not null;default:1" json:"quantity"` + Amount int64 `gorm:"not null" json:"amount"` + Currency string `gorm:"size:12;not null;default:CNY" json:"currency"` + PaymentStatus string `gorm:"size:16;not null;default:pending;index" json:"payment_status"` + FulfillmentStatus string `gorm:"size:16;not null;default:pending;index" json:"fulfillment_status"` + BuyerReference string `gorm:"size:128" json:"buyer_reference"` + RequestData string `gorm:"type:text" json:"request_data"` + ResultData string `gorm:"type:text" json:"result_data"` + ProviderOrderNo string `gorm:"size:96;index" json:"provider_order_no"` + FailureReason string `gorm:"size:512" json:"failure_reason"` + CancelledAt *time.Time `json:"cancelled_at"` + DeliveredAt *time.Time `json:"delivered_at"` + + MerchantProduct *MerchantProduct `gorm:"foreignKey:MerchantProductID" json:"merchant_product,omitempty"` +} + +// FulfillmentJob 为后续履约器保留可重试的持久化任务边界。 +type FulfillmentJob struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + MerchantID uint `gorm:"not null;index" json:"merchant_id"` + OrderID uint `gorm:"not null;uniqueIndex" json:"order_id"` + Status string `gorm:"size:16;not null;default:pending;index" json:"status"` + Attempts int `gorm:"not null;default:0" json:"attempts"` + NextRunAt time.Time `gorm:"not null;index" json:"next_run_at"` + ProviderOrderNo string `gorm:"size:96;index" json:"provider_order_no"` + RequestPayload string `gorm:"type:text" json:"request_payload"` + ResultPayload string `gorm:"type:text" json:"result_payload"` + LastError string `gorm:"size:512" json:"last_error"` +} + +// CallbackSubscription 为商户提供独立、可禁用的事件订阅。 +type CallbackSubscription struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + MerchantID uint `gorm:"not null;index" json:"merchant_id"` + Name string `gorm:"size:128;not null" json:"name"` + URL string `gorm:"size:1024;not null" json:"url"` + Events string `gorm:"type:text;not null" json:"events"` + SecretCiphertext string `gorm:"type:text;not null" json:"-"` + Status string `gorm:"size:16;not null;default:active;index" json:"status"` +} + +// CallbackDelivery 是事务性 outbox 记录;事件 ID 可供接收方做幂等。 +type CallbackDelivery struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MerchantID uint `gorm:"not null;index" json:"merchant_id"` + CallbackSubscriptionID uint `gorm:"not null;index" json:"callback_subscription_id"` + EventID string `gorm:"uniqueIndex;size:64;not null" json:"event_id"` + Event string `gorm:"size:64;not null;index" json:"event"` + Payload string `gorm:"type:text;not null" json:"payload"` + Status string `gorm:"size:16;not null;default:pending;index" json:"status"` + Attempts int `gorm:"not null;default:0" json:"attempts"` + NextAttemptAt time.Time `gorm:"not null;index" json:"next_attempt_at"` + LastStatusCode int `gorm:"not null;default:0" json:"last_status_code"` + LastResponse string `gorm:"type:text" json:"last_response"` + DeliveredAt *time.Time `json:"delivered_at"` + + CallbackSubscription *CallbackSubscription `gorm:"foreignKey:CallbackSubscriptionID" json:"callback_subscription,omitempty"` +} + +// APIRequestNonce 使 API 重放保护跨进程、跨重启生效。 +type APIRequestNonce struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + APIClientID uint `gorm:"not null;uniqueIndex:idx_api_nonce;index" json:"api_client_id"` + Nonce string `gorm:"size:96;not null;uniqueIndex:idx_api_nonce" json:"nonce"` + ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"` +} + +// AuditLog 留存后台和开放接口的重要业务操作。 +type AuditLog struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + MerchantID *uint `gorm:"index" json:"merchant_id"` + ActorUserID *uint `gorm:"index" json:"actor_user_id"` + APIClientID *uint `gorm:"index" json:"api_client_id"` + RequestID string `gorm:"size:64;index" json:"request_id"` + Action string `gorm:"size:96;not null;index" json:"action"` + EntityType string `gorm:"size:64;not null;index" json:"entity_type"` + EntityID string `gorm:"size:96;not null;index" json:"entity_id"` + Metadata string `gorm:"type:text" json:"metadata"` +} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index d0ace07..1144f21 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -8,7 +8,7 @@ import ( // 用户角色 const ( - RoleAdmin = "admin" // 管理员 + RoleAdmin = "admin" // 管理员 RoleDistributor = "distributor" // 分销商 ) @@ -35,16 +35,17 @@ type Skin struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Name string `gorm:"size:128;not null" json:"name"` // 中文名(展示用,保持原名) - SKU string `gorm:"uniqueIndex;size:64;not null" json:"sku"` // 英文固定标识 - Game string `gorm:"size:64;index" json:"game"` // 所属游戏 - Category string `gorm:"size:64;index" json:"category"` // 品类:套装/背包/... + MerchantID uint `gorm:"index;uniqueIndex:idx_skins_merchant_sku;not null;default:0" json:"merchant_id"` + Name string `gorm:"size:128;not null" json:"name"` // 中文名(展示用,保持原名) + SKU string `gorm:"uniqueIndex:idx_skins_merchant_sku;size:64;not null" json:"sku"` // 英文固定标识 + Game string `gorm:"size:64;index" json:"game"` // 所属游戏 + Category string `gorm:"size:64;index" json:"category"` // 品类:套装/背包/... CoverURL string `gorm:"size:512" json:"cover_url"` - Price float64 `gorm:"not null;default:0" json:"price"` // 售价 - CostPrice float64 `gorm:"default:0" json:"cost_price"` // 成本价 - Commission float64 `gorm:"default:0" json:"commission"` // 佣金比例 0-1 - Stock int `gorm:"default:0" json:"stock"` // -1 无限 - Status int `gorm:"default:1" json:"status"` // 1上架 0下架 + Price float64 `gorm:"not null;default:0" json:"price"` // 售价 + CostPrice float64 `gorm:"default:0" json:"cost_price"` // 成本价 + Commission float64 `gorm:"default:0" json:"commission"` // 佣金比例 0-1 + Stock int `gorm:"default:0" json:"stock"` // -1 无限 + Status int `gorm:"default:1" json:"status"` // 1上架 0下架 Description string `gorm:"type:text" json:"description"` } @@ -55,16 +56,17 @@ type Order struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"` - SkinID uint `gorm:"index;not null" json:"skin_id"` - Skin *Skin `gorm:"foreignKey:SkinID" json:"skin,omitempty"` - DistributorID uint `gorm:"index;not null" json:"distributor_id"` - Distributor *User `gorm:"foreignKey:DistributorID" json:"distributor,omitempty"` - BuyerName string `gorm:"size:64" json:"buyer_name"` - Amount float64 `gorm:"not null" json:"amount"` - CommissionAmt float64 `gorm:"default:0" json:"commission_amt"` - Status string `gorm:"size:32;default:pending;index" json:"status"` - Remark string `gorm:"size:255" json:"remark"` + MerchantID uint `gorm:"index;not null;default:0" json:"merchant_id"` + OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"` + SkinID uint `gorm:"index;not null" json:"skin_id"` + Skin *Skin `gorm:"foreignKey:SkinID" json:"skin,omitempty"` + DistributorID uint `gorm:"index;not null" json:"distributor_id"` + Distributor *User `gorm:"foreignKey:DistributorID" json:"distributor,omitempty"` + BuyerName string `gorm:"size:64" json:"buyer_name"` + Amount float64 `gorm:"not null" json:"amount"` + CommissionAmt float64 `gorm:"default:0" json:"commission_amt"` + Status string `gorm:"size:32;default:pending;index" json:"status"` + Remark string `gorm:"size:255" json:"remark"` // 发货相关(上游皮肤源头对接) ProviderOrderNo string `gorm:"size:64;index" json:"provider_order_no"` // 上游单号 @@ -74,7 +76,7 @@ type Order struct { GameChannel string `gorm:"size:64" json:"game_channel"` // 账号区服(安卓/IOS-微信/QQ) GameUID string `gorm:"size:128" json:"game_uid"` // 游戏角色UUID RoleName string `gorm:"size:64" json:"role_name"` // 角色名 - PayScore int `gorm:"default:0" json:"pay_score"` // 消耗积分 + PayScore int `gorm:"default:0" json:"pay_score"` // 消耗积分 } // 订单状态 @@ -99,12 +101,13 @@ type ShipLog struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` + MerchantID uint `gorm:"index;not null;default:0" json:"merchant_id"` OrderNo string `gorm:"size:64;index;not null" json:"order_no"` OrderID uint `gorm:"index" json:"order_id"` ShipStatus string `gorm:"size:32;not null" json:"ship_status"` // success/failed/processing ProviderOrderNo string `gorm:"size:64" json:"provider_order_no"` FailReason string `gorm:"size:512" json:"fail_reason"` - Payload string `gorm:"type:text" json:"payload"` // 原始请求 JSON + Payload string `gorm:"type:text" json:"payload"` // 原始请求 JSON ResultStatus string `gorm:"size:32" json:"result_status"` // 处理后订单状态 Message string `gorm:"size:255" json:"message"` } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 0fd6898..92b113b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -5,9 +5,11 @@ import ( "affiliate_dash/internal/middleware" "affiliate_dash/internal/model" "affiliate_dash/internal/pkg/jwt" + "affiliate_dash/internal/service" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) type Handlers struct { @@ -15,8 +17,13 @@ type Handlers struct { Skin *handler.SkinHandler Order *handler.OrderHandler User *handler.UserHandler - Open *handler.OpenHandler + Open *handler.OpenV1Handler + SourceOpen *handler.OpenHandler + Merchant *handler.MerchantHandler JWT *jwt.Manager + Tenant *service.TenantService + OpenDB *gorm.DB + SecretCodec *service.SecretCodec OpenAPIKey string OpenAPISecret string OpenSignSkew int64 @@ -29,8 +36,8 @@ func Setup(h *Handlers) *gin.Engine { r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign"}, - ExposeHeaders: []string{"Content-Length"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Merchant-ID", "X-App-Key", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign", "Idempotency-Key", "X-Request-ID"}, + ExposeHeaders: []string{"Content-Length", "X-Request-ID"}, AllowCredentials: true, })) @@ -43,21 +50,39 @@ func Setup(h *Handlers) *gin.Engine { api.POST("/auth/login", h.Auth.Login) api.POST("/auth/register", h.Auth.Register) - // 皮肤源头开放接口(ApiKey + HMAC 签名) - open := api.Group("/open/v1") - open.Use(middleware.OpenAuth(middleware.OpenAuthConfig{ + // 原上游发货接口:路径、鉴权和字段保持不变。 + sourceOpen := api.Group("/open/v1") + sourceOpen.Use(middleware.SourceOpenAuth(middleware.SourceOpenAuthConfig{ APIKey: h.OpenAPIKey, APISecret: h.OpenAPISecret, SkewSeconds: h.OpenSignSkew, Debug: h.OpenAPIDebug, })) { - open.GET("/orders/:order_no", h.Open.QueryOrder) - open.POST("/orders/ship-notify", h.Open.ShipNotify) + sourceOpen.GET("/orders/:order_no", h.SourceOpen.QueryOrder) + sourceOpen.POST("/orders/ship-notify", h.SourceOpen.ShipNotify) + } + + // 客户侧通用开放接口:独立 API 客户端 + HMAC 签名。 + clientOpen := api.Group("/client/v1") + clientOpen.Use(middleware.OpenAuth(middleware.OpenAuthConfig{ + DB: h.OpenDB, + Codec: h.SecretCodec, + SkewSeconds: h.OpenSignSkew, + Debug: h.OpenAPIDebug, + })) + { + clientOpen.GET("/products", middleware.RequireAPIScope("products:read"), h.Open.ListProducts) + clientOpen.POST("/orders", middleware.RequireAPIScope("orders:write"), h.Open.CreateOrder) + clientOpen.GET("/orders/:order_no", middleware.RequireAPIScope("orders:read", "fulfillment:read"), h.Open.QueryOrder) + clientOpen.POST("/orders/:order_no/cancel", middleware.RequireAPIScope("orders:write"), h.Open.CancelOrder) + clientOpen.POST("/orders/:order_no/ship-notify", middleware.RequireAPIScope("fulfillment:write"), h.Open.ShipNotify) + clientOpen.GET("/wallet", middleware.RequireAPIScope("wallet:read"), h.Open.GetWallet) } auth := api.Group("") auth.Use(middleware.Auth(h.JWT)) + auth.Use(middleware.Tenant(h.Tenant)) { auth.GET("/auth/profile", h.Auth.Profile) auth.GET("/dashboard", h.Order.Dashboard) @@ -74,6 +99,27 @@ func Setup(h *Handlers) *gin.Engine { auth.POST("/orders", h.Order.Create) auth.PATCH("/orders/:id/status", middleware.RequireRole(model.RoleAdmin), h.Order.UpdateStatus) + // 新商户后台:不依赖旧皮肤/分销商模型。 + merchant := auth.Group("/merchant") + { + merchant.GET("", h.Merchant.Current) + merchant.GET("/products", h.Merchant.ListProducts) + merchant.POST("/products", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateProduct) + merchant.PATCH("/products/:id", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProduct) + merchant.GET("/orders", h.Merchant.ListOrders) + merchant.GET("/wallet", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet) + merchant.GET("/wallet/ledger", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger) + merchant.POST("/wallet/adjust", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.AdjustWallet) + merchant.GET("/api-clients", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListAPIClients) + merchant.POST("/api-clients", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateAPIClient) + merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateAPIClientStatus) + merchant.GET("/callbacks", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListCallbacks) + merchant.POST("/callbacks", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateCallback) + merchant.PATCH("/callbacks/:id/status", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateCallbackStatus) + merchant.GET("/members", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListMembers) + merchant.POST("/members", middleware.RequireMerchantRole(model.MemberRoleOwner), h.Merchant.AddCurrentMerchantMember) + } + // 用户 / 分销商 / 发货记录(仅管理员) admin := auth.Group("") admin.Use(middleware.RequireRole(model.RoleAdmin)) @@ -82,6 +128,9 @@ func Setup(h *Handlers) *gin.Engine { admin.POST("/users", h.User.Create) admin.PATCH("/users/:id/status", h.User.UpdateStatus) admin.GET("/ship-logs", h.Order.ListShipLogs) + admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) + admin.POST("/platform/merchants", h.Merchant.CreateMerchant) + admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember) } } } diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go new file mode 100644 index 0000000..6a306af --- /dev/null +++ b/backend/internal/router/router_test.go @@ -0,0 +1,51 @@ +package router + +import ( + "testing" + + "affiliate_dash/internal/handler" + "affiliate_dash/internal/pkg/jwt" + "affiliate_dash/internal/service" + "affiliate_dash/internal/testdb" + + "github.com/gin-gonic/gin" +) + +func TestSetupDoesNotPanic(t *testing.T) { + gin.SetMode(gin.TestMode) + db := testdb.New(t) + codec, err := service.NewSecretCodec("test-master-key") + if err != nil { + t.Fatalf("codec: %v", err) + } + tenantSvc := service.NewTenantService(db) + authSvc := service.NewAuthService(db, jwt.NewManager("test-jwt"), tenantSvc) + skinSvc := service.NewSkinService(db) + orderSvc := service.NewOrderService(db) + userSvc := service.NewUserService(db, tenantSvc) + callbackSvc := service.NewCallbackService(db, codec) + fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) + merchantSvc := service.NewMerchantService(db, codec, tenantSvc) + + defer func() { + if recovered := recover(); recovered != nil { + t.Fatalf("setup should not panic: %v", recovered) + } + }() + _ = Setup(&Handlers{ + Auth: handler.NewAuthHandler(authSvc), + Skin: handler.NewSkinHandler(skinSvc), + Order: handler.NewOrderHandler(orderSvc), + User: handler.NewUserHandler(userSvc), + Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), + SourceOpen: handler.NewOpenHandler(orderSvc), + Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc), + JWT: jwt.NewManager("test-jwt"), + Tenant: tenantSvc, + OpenDB: db, + SecretCodec: codec, + OpenAPIKey: "source-key", + OpenAPISecret: "source-secret", + OpenSignSkew: 300, + }) +} diff --git a/backend/internal/service/audit.go b/backend/internal/service/audit.go new file mode 100644 index 0000000..7ac63fb --- /dev/null +++ b/backend/internal/service/audit.go @@ -0,0 +1,27 @@ +package service + +import ( + "encoding/json" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" +) + +func writeAudit(tx *gorm.DB, merchantID, actorUserID, apiClientID *uint, action, entityType, entityID string, metadata interface{}) error { + raw := "" + if metadata != nil { + if bytes, err := json.Marshal(metadata); err == nil { + raw = string(bytes) + } + } + return tx.Create(&model.AuditLog{ + MerchantID: merchantID, + ActorUserID: actorUserID, + APIClientID: apiClientID, + Action: action, + EntityType: entityType, + EntityID: entityID, + Metadata: raw, + }).Error +} diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 1d5ed03..f3dd56f 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -14,12 +14,13 @@ import ( ) type AuthService struct { - db *gorm.DB - jwt *jwt.Manager + db *gorm.DB + jwt *jwt.Manager + tenant *TenantService } -func NewAuthService(db *gorm.DB, jm *jwt.Manager) *AuthService { - return &AuthService{db: db, jwt: jm} +func NewAuthService(db *gorm.DB, jm *jwt.Manager, tenant *TenantService) *AuthService { + return &AuthService{db: db, jwt: jm, tenant: tenant} } type LoginResult struct { @@ -72,6 +73,11 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User if err := s.db.Create(user).Error; err != nil { return nil, err } + if s.tenant != nil { + if err := s.tenant.EnsureSelfMember(user.ID, model.MemberRoleOperator, user.Status); err != nil { + return nil, err + } + } return user, nil } @@ -87,6 +93,17 @@ func (s *AuthService) EnsureAdmin() error { var count int64 s.db.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&count) if count > 0 { + if s.tenant != nil { + var admins []model.User + if err := s.db.Where("role = ?", model.RoleAdmin).Find(&admins).Error; err != nil { + return err + } + for _, admin := range admins { + if err := s.tenant.EnsureSelfMember(admin.ID, model.MemberRoleOwner, admin.Status); err != nil { + return err + } + } + } return nil } hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) @@ -101,7 +118,13 @@ func (s *AuthService) EnsureAdmin() error { Status: 1, InviteCode: "ADMIN001", } - return s.db.Create(admin).Error + if err := s.db.Create(admin).Error; err != nil { + return err + } + if s.tenant != nil { + return s.tenant.EnsureSelfMember(admin.ID, model.MemberRoleOwner, admin.Status) + } + return nil } func generateInviteCode() string { diff --git a/backend/internal/service/callback.go b/backend/internal/service/callback.go new file mode 100644 index 0000000..52cdb9c --- /dev/null +++ b/backend/internal/service/callback.go @@ -0,0 +1,330 @@ +package service + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "affiliate_dash/internal/model" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const maxCallbackAttempts = 8 + +// CallbackService 以数据库 outbox 方式管理回调,进程重启不会丢失待发送事件。 +type CallbackService struct { + db *gorm.DB + codec *SecretCodec + httpClient *http.Client +} + +func NewCallbackService(db *gorm.DB, codec *SecretCodec) *CallbackService { + return &CallbackService{ + db: db, + codec: codec, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (s *CallbackService) SetHTTPClient(client *http.Client) { + if client != nil { + s.httpClient = client + } +} + +type CreateCallbackInput struct { + Name string + URL string + Events string +} + +type CallbackCredential struct { + Subscription *model.CallbackSubscription `json:"subscription"` + Secret string `json:"secret"` +} + +func (s *CallbackService) CreateSubscription(merchantID uint, in CreateCallbackInput, actorUserID uint) (*CallbackCredential, error) { + in.Name = strings.TrimSpace(in.Name) + in.URL = strings.TrimSpace(in.URL) + if in.Name == "" { + return nil, errors.New("回调名称不能为空") + } + if err := validateCallbackURL(in.URL); err != nil { + return nil, err + } + events := scopeList(in.Events) + if len(events) == 0 { + return nil, errors.New("至少订阅一个事件") + } + secret, err := randomToken("cb_", 32) + if err != nil { + return nil, err + } + ciphertext, err := s.codec.Encrypt(secret) + if err != nil { + return nil, err + } + subscription := &model.CallbackSubscription{ + MerchantID: merchantID, + Name: in.Name, + URL: in.URL, + Events: strings.Join(events, ","), + SecretCiphertext: ciphertext, + Status: model.CallbackStatusActive, + } + err = s.db.Transaction(func(tx *gorm.DB) error { + var merchant model.Merchant + if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { + return errors.New("商户不存在或已禁用") + } + if err := tx.Create(subscription).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "callback_subscription.create", "callback_subscription", fmt.Sprint(subscription.ID), map[string]string{"url": subscription.URL}) + }) + if err != nil { + return nil, err + } + return &CallbackCredential{Subscription: subscription, Secret: secret}, nil +} + +func (s *CallbackService) ListSubscriptions(merchantID uint) ([]model.CallbackSubscription, error) { + var subscriptions []model.CallbackSubscription + err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&subscriptions).Error + return subscriptions, err +} + +func (s *CallbackService) UpdateSubscriptionStatus(merchantID, id uint, status string, actorUserID uint) error { + if status != model.CallbackStatusActive && status != model.CallbackStatusDisabled { + return errors.New("无效的回调状态") + } + return s.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.CallbackSubscription{}). + Where("id = ? AND merchant_id = ?", id, merchantID). + Update("status", status) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("回调订阅不存在") + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "callback_subscription.status.update", "callback_subscription", fmt.Sprint(id), map[string]string{"status": status}) + }) +} + +// Enqueue 在调用方事务中写入回调 outbox,只有订单事务成功才会发送事件。 +func (s *CallbackService) Enqueue(tx *gorm.DB, merchantID uint, event string, data interface{}) error { + var subscriptions []model.CallbackSubscription + if err := tx.Where("merchant_id = ? AND status = ?", merchantID, model.CallbackStatusActive).Find(&subscriptions).Error; err != nil { + return err + } + now := time.Now() + for _, subscription := range subscriptions { + if !subscribesTo(subscription.Events, event) { + continue + } + eventID := uuid.NewString() + payload, err := json.Marshal(map[string]interface{}{ + "event_id": eventID, + "event": event, + "occurred_at": now.UTC().Format(time.RFC3339Nano), + "data": data, + }) + if err != nil { + return err + } + delivery := model.CallbackDelivery{ + MerchantID: merchantID, + CallbackSubscriptionID: subscription.ID, + EventID: eventID, + Event: event, + Payload: string(payload), + Status: model.CallbackDeliveryPending, + NextAttemptAt: now, + } + if err := tx.Create(&delivery).Error; err != nil { + return err + } + } + return nil +} + +// DispatchDue 执行一批可发送的 outbox 记录。返回成功/失败尝试数,便于日志监控。 +func (s *CallbackService) DispatchDue(ctx context.Context, limit int) (int, int, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + now := time.Now() + var deliveries []model.CallbackDelivery + err := s.db.Preload("CallbackSubscription"). + Where("(status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at <= ?)", + model.CallbackDeliveryPending, now, + model.CallbackDeliverySending, now.Add(-5*time.Minute), + ). + Order("next_attempt_at ASC, id ASC"). + Limit(limit). + Find(&deliveries).Error + if err != nil { + return 0, 0, err + } + successes, failures := 0, 0 + for _, delivery := range deliveries { + ok, attempted, err := s.dispatchOne(ctx, delivery.ID) + if err != nil { + return successes, failures, err + } + if !attempted { + continue + } + if ok { + successes++ + } else { + failures++ + } + } + return successes, failures, nil +} + +func (s *CallbackService) dispatchOne(ctx context.Context, id uint) (bool, bool, error) { + now := time.Now() + claimed := s.db.Model(&model.CallbackDelivery{}). + Where("id = ? AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at <= ?))", + id, model.CallbackDeliveryPending, now, + model.CallbackDeliverySending, now.Add(-5*time.Minute), + ). + Update("status", model.CallbackDeliverySending) + if claimed.Error != nil { + return false, false, claimed.Error + } + if claimed.RowsAffected == 0 { + return false, false, nil + } + + var delivery model.CallbackDelivery + if err := s.db.Preload("CallbackSubscription").First(&delivery, id).Error; err != nil { + return false, true, err + } + if delivery.CallbackSubscription == nil || delivery.CallbackSubscription.Status != model.CallbackStatusActive { + return false, true, s.recordCallbackFailure(delivery, 0, "回调订阅已禁用") + } + secret, err := s.codec.Decrypt(delivery.CallbackSubscription.SecretCiphertext) + if err != nil { + return false, true, s.recordCallbackFailure(delivery, 0, "回调密钥不可用") + } + + timestamp := fmt.Sprint(time.Now().Unix()) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, delivery.CallbackSubscription.URL, bytes.NewBufferString(delivery.Payload)) + if err != nil { + return false, true, s.recordCallbackFailure(delivery, 0, "创建回调请求失败") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Event-ID", delivery.EventID) + req.Header.Set("X-Timestamp", timestamp) + req.Header.Set("X-Sign", BuildCallbackSign(secret, timestamp, delivery.Payload)) + + resp, err := s.httpClient.Do(req) + if err != nil { + return false, true, s.recordCallbackFailure(delivery, 0, truncateCallbackError(err.Error())) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return true, true, s.db.Model(&model.CallbackDelivery{}).Where("id = ?", delivery.ID).Updates(map[string]interface{}{ + "status": model.CallbackDeliveryDelivered, + "attempts": delivery.Attempts + 1, + "last_status_code": resp.StatusCode, + "last_response": string(body), + "delivered_at": time.Now(), + }).Error + } + return false, true, s.recordCallbackFailure(delivery, resp.StatusCode, string(body)) +} + +func (s *CallbackService) recordCallbackFailure(delivery model.CallbackDelivery, statusCode int, response string) error { + attempts := delivery.Attempts + 1 + status := model.CallbackDeliveryPending + nextAttempt := time.Now().Add(callbackRetryDelay(attempts)) + if attempts >= maxCallbackAttempts { + status = model.CallbackDeliveryFailed + nextAttempt = time.Now() + } + return s.db.Model(&model.CallbackDelivery{}).Where("id = ?", delivery.ID).Updates(map[string]interface{}{ + "status": status, + "attempts": attempts, + "next_attempt_at": nextAttempt, + "last_status_code": statusCode, + "last_response": truncateCallbackError(response), + }).Error +} + +func (s *CallbackService) Run(ctx context.Context) { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for { + _, _, _ = s.DispatchDue(ctx, 50) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// BuildCallbackSign 供接收方校验:HMAC-SHA256(secret, timestamp + "\n" + sha256(body))。 +func BuildCallbackSign(secret, timestamp, body string) string { + bodyHash := sha256.Sum256([]byte(body)) + content := timestamp + "\n" + hex.EncodeToString(bodyHash[:]) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(content)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func subscribesTo(events, event string) bool { + for subscribed := range ParseScopes(events) { + if subscribed == "*" || subscribed == event { + return true + } + } + return false +} + +func validateCallbackURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return errors.New("回调地址格式错误") + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return errors.New("回调地址仅支持 http 或 https") + } + return nil +} + +func callbackRetryDelay(attempts int) time.Duration { + if attempts < 1 { + attempts = 1 + } + delay := time.Second * time.Duration(1<<(attempts-1)) + if delay > 15*time.Minute { + return 15 * time.Minute + } + return delay +} + +func truncateCallbackError(value string) string { + if len(value) <= 4096 { + return value + } + return value[:4096] +} diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go new file mode 100644 index 0000000..ee7aab2 --- /dev/null +++ b/backend/internal/service/fulfillment.go @@ -0,0 +1,530 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "strings" + "time" + + "affiliate_dash/internal/model" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type FulfillmentService struct { + db *gorm.DB + callbacks *CallbackService +} + +func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *FulfillmentService { + return &FulfillmentService{db: db, callbacks: callbacks} +} + +type CreateFulfillmentOrderInput struct { + MerchantID uint + APIClientID uint + ClientOrderNo string + SKU string + Quantity int64 + BuyerReference string + RequestData interface{} +} + +type CreateFulfillmentOrderResult struct { + Order *model.FulfillmentOrder `json:"order"` + Idempotent bool `json:"idempotent"` +} + +func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*CreateFulfillmentOrderResult, error) { + in.ClientOrderNo = strings.TrimSpace(in.ClientOrderNo) + in.SKU = strings.TrimSpace(in.SKU) + if in.MerchantID == 0 || in.APIClientID == 0 { + return nil, errors.New("无效的商户或 API 客户端") + } + if in.ClientOrderNo == "" || len(in.ClientOrderNo) > 96 { + return nil, errors.New("client_order_no 不能为空且最长 96 位") + } + if in.SKU == "" { + return nil, errors.New("sku 不能为空") + } + if in.Quantity == 0 { + in.Quantity = 1 + } + if in.Quantity < 1 { + return nil, errors.New("quantity 必须大于零") + } + requestData := "" + if in.RequestData != nil { + raw, err := json.Marshal(in.RequestData) + if err != nil { + return nil, errors.New("订单请求数据无法序列化") + } + requestData = string(raw) + } + + result := &CreateFulfillmentOrderResult{} + err := s.db.Transaction(func(tx *gorm.DB) error { + var existing model.FulfillmentOrder + err := tx.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error + if err == nil { + result.Order = &existing + result.Idempotent = true + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var product model.MerchantProduct + if err := tx.Preload("Product").Clauses(clause.Locking{Strength: "UPDATE"}). + Where("merchant_id = ? AND sku = ? AND status = ?", in.MerchantID, in.SKU, model.ProductStatusActive). + First(&product).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("商品不存在或已下架") + } + return err + } + if product.Product == nil || product.Product.Status != model.ProductStatusActive { + return errors.New("商品目录已下架") + } + if product.Stock >= 0 && product.Stock < in.Quantity { + return errors.New("商品库存不足") + } + if product.PriceAmount > 0 && in.Quantity > math.MaxInt64/product.PriceAmount { + return errors.New("订单金额超出范围") + } + totalAmount := product.PriceAmount * in.Quantity + + var wallet model.WalletAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil { + return err + } + if wallet.AvailableBalance < totalAmount { + return errors.New("商户钱包余额不足") + } + newBalance := wallet.AvailableBalance - totalAmount + if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil { + return err + } + + order := &model.FulfillmentOrder{ + MerchantID: in.MerchantID, + OrderNo: newFulfillmentOrderNo(), + ClientOrderNo: in.ClientOrderNo, + MerchantProductID: product.ID, + ProductSKU: product.SKU, + ProductName: fallbackName(product.DisplayName, product.Product.Name), + Quantity: in.Quantity, + Amount: totalAmount, + Currency: product.Currency, + PaymentStatus: model.PaymentStatusPaid, + FulfillmentStatus: model.FulfillmentStatusPending, + BuyerReference: in.BuyerReference, + RequestData: requestData, + } + if err := tx.Create(order).Error; err != nil { + return err + } + idempotencyKey := in.ClientOrderNo + if err := tx.Create(&model.WalletLedgerEntry{ + MerchantID: in.MerchantID, + WalletAccountID: wallet.ID, + EntryNo: "WL" + uuid.NewString(), + Type: model.WalletLedgerDebit, + Amount: -totalAmount, + BalanceAfter: newBalance, + ReferenceType: "fulfillment_order", + ReferenceNo: order.OrderNo, + IdempotencyKey: &idempotencyKey, + Note: "开放接口下单扣款", + }).Error; err != nil { + return err + } + if product.Stock >= 0 { + if err := tx.Model(&model.MerchantProduct{}).Where("id = ? AND stock >= ?", product.ID, in.Quantity). + Update("stock", gorm.Expr("stock - ?", in.Quantity)).Error; err != nil { + return err + } + } + if err := tx.Create(&model.FulfillmentJob{ + MerchantID: in.MerchantID, + OrderID: order.ID, + Status: model.FulfillmentJobStatusPending, + NextRunAt: time.Now(), + }).Error; err != nil { + return err + } + if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "open_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{"client_order_no": in.ClientOrderNo, "sku": in.SKU}); err != nil { + return err + } + if s.callbacks != nil { + if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.created", orderCallbackData(order)); err != nil { + return err + } + } + result.Order = order + return nil + }) + if err != nil { + // 并发请求恰好同时通过首次查询时,唯一约束冲突后返回既有订单。 + if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "UNIQUE") { + var existing model.FulfillmentOrder + if queryErr := s.db.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error; queryErr == nil { + return &CreateFulfillmentOrderResult{Order: &existing, Idempotent: true}, nil + } + } + return nil, err + } + return result, nil +} + +func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) { + var order model.FulfillmentOrder + err := s.db.Preload("MerchantProduct.Product"). + Where("merchant_id = ? AND order_no = ?", merchantID, orderNo). + First(&order).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("订单不存在") + } + if err != nil { + return nil, err + } + return &order, nil +} + +func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, fulfillmentStatus string) ([]model.FulfillmentOrder, int64, error) { + page, size = normalizePage(page, size) + tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID) + if fulfillmentStatus != "" { + tx = tx.Where("fulfillment_status = ?", fulfillmentStatus) + } + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + var orders []model.FulfillmentOrder + err := tx.Preload("MerchantProduct.Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error + return orders, total, err +} + +type FulfillmentUpdateInput struct { + MerchantID uint + APIClientID uint + OrderNo string + Status string + ProviderOrderNo string + FailureReason string + ResultData interface{} +} + +func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*model.FulfillmentOrder, error) { + switch in.Status { + case model.FulfillmentStatusProcessing, model.FulfillmentStatusSucceeded, model.FulfillmentStatusFailed: + default: + return nil, errors.New("无效的履约状态") + } + resultData := "" + if in.ResultData != nil { + raw, err := json.Marshal(in.ResultData) + if err != nil { + return nil, errors.New("履约结果无法序列化") + } + resultData = string(raw) + } + var out model.FulfillmentOrder + err := s.db.Transaction(func(tx *gorm.DB) error { + var order model.FulfillmentOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("merchant_id = ? AND order_no = ?", in.MerchantID, in.OrderNo). + First(&order).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("订单不存在") + } + return err + } + if order.FulfillmentStatus == model.FulfillmentStatusCancelled { + return errors.New("订单已取消,不能更新履约状态") + } + if order.PaymentStatus != model.PaymentStatusPaid { + return errors.New("订单未支付,不能履约") + } + if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.Status == model.FulfillmentStatusSucceeded { + out = order + return nil + } + if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { + return errors.New("订单已履约成功,不能回退状态") + } + now := time.Now() + updates := map[string]interface{}{ + "fulfillment_status": in.Status, + "result_data": resultData, + } + if in.ProviderOrderNo != "" { + updates["provider_order_no"] = in.ProviderOrderNo + } + switch in.Status { + case model.FulfillmentStatusSucceeded: + updates["delivered_at"] = now + updates["failure_reason"] = "" + case model.FulfillmentStatusFailed: + updates["failure_reason"] = in.FailureReason + } + if err := tx.Model(&order).Updates(updates).Error; err != nil { + return err + } + jobStatus := model.FulfillmentJobStatusProcessing + if in.Status == model.FulfillmentStatusSucceeded { + jobStatus = model.FulfillmentJobStatusSucceeded + } else if in.Status == model.FulfillmentStatusFailed { + jobStatus = model.FulfillmentJobStatusFailed + } + if err := tx.Model(&model.FulfillmentJob{}).Where("order_id = ?", order.ID).Updates(map[string]interface{}{ + "status": jobStatus, + "provider_order_no": in.ProviderOrderNo, + "result_payload": resultData, + "last_error": in.FailureReason, + }).Error; err != nil { + return err + } + if err := tx.First(&out, order.ID).Error; err != nil { + return err + } + if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "fulfillment.update", "fulfillment_order", order.OrderNo, map[string]string{"status": in.Status}); err != nil { + return err + } + if s.callbacks != nil { + if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.fulfillment.updated", orderCallbackData(&out)); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) { + var out model.FulfillmentOrder + err := s.db.Transaction(func(tx *gorm.DB) error { + var order model.FulfillmentOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("merchant_id = ? AND order_no = ?", merchantID, orderNo). + First(&order).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("订单不存在") + } + return err + } + if order.FulfillmentStatus == model.FulfillmentStatusCancelled { + out = order + return nil + } + if order.FulfillmentStatus == model.FulfillmentStatusProcessing || order.FulfillmentStatus == model.FulfillmentStatusSucceeded { + return errors.New("订单已进入履约流程,不能取消") + } + now := time.Now() + updates := map[string]interface{}{ + "payment_status": model.PaymentStatusRefunded, + "fulfillment_status": model.FulfillmentStatusCancelled, + "failure_reason": reason, + "cancelled_at": now, + } + if err := tx.Model(&order).Updates(updates).Error; err != nil { + return err + } + var wallet model.WalletAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { + return err + } + newBalance := wallet.AvailableBalance + order.Amount + if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil { + return err + } + idempotencyKey := "cancel:" + order.OrderNo + if err := tx.Create(&model.WalletLedgerEntry{ + MerchantID: merchantID, + WalletAccountID: wallet.ID, + EntryNo: "WL" + uuid.NewString(), + Type: model.WalletLedgerRefund, + Amount: order.Amount, + BalanceAfter: newBalance, + ReferenceType: "fulfillment_order", + ReferenceNo: order.OrderNo, + IdempotencyKey: &idempotencyKey, + Note: "订单取消退款", + }).Error; err != nil { + return err + } + var product model.MerchantProduct + if err := tx.Where("id = ?", order.MerchantProductID).First(&product).Error; err != nil { + return err + } + if product.Stock >= 0 { + if err := tx.Model(&product).Update("stock", gorm.Expr("stock + ?", order.Quantity)).Error; err != nil { + return err + } + } + if err := tx.Model(&model.FulfillmentJob{}).Where("order_id = ?", order.ID).Updates(map[string]interface{}{ + "status": model.FulfillmentJobStatusFailed, + "last_error": "订单已取消", + }).Error; err != nil { + return err + } + if err := tx.First(&out, order.ID).Error; err != nil { + return err + } + if err := writeAudit(tx, &merchantID, nil, &apiClientID, "open_order.cancel", "fulfillment_order", order.OrderNo, nil); err != nil { + return err + } + if s.callbacks != nil { + if err := s.callbacks.Enqueue(tx, merchantID, "order.cancelled", orderCallbackData(&out)); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +type WalletAdjustInput struct { + MerchantID uint + ActorUserID uint + Amount int64 + IdempotencyKey string + Note string +} + +func (s *FulfillmentService) AdjustWallet(in WalletAdjustInput) (*model.WalletAccount, error) { + if in.Amount == 0 { + return nil, errors.New("调整金额不能为零") + } + if in.IdempotencyKey == "" { + return nil, errors.New("账务调整必须提供幂等键") + } + var out model.WalletAccount + err := s.db.Transaction(func(tx *gorm.DB) error { + var existing model.WalletLedgerEntry + if err := tx.Where("merchant_id = ? AND idempotency_key = ?", in.MerchantID, in.IdempotencyKey).First(&existing).Error; err == nil { + if err := tx.First(&out, existing.WalletAccountID).Error; err != nil { + return err + } + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var wallet model.WalletAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil { + return err + } + newBalance := wallet.AvailableBalance + in.Amount + if newBalance < 0 { + return errors.New("调整后余额不能小于零") + } + if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil { + return err + } + entryType := model.WalletLedgerAdjust + if in.Amount > 0 { + entryType = model.WalletLedgerCredit + } else { + entryType = model.WalletLedgerDebit + } + idempotencyKey := in.IdempotencyKey + if err := tx.Create(&model.WalletLedgerEntry{ + MerchantID: in.MerchantID, + WalletAccountID: wallet.ID, + EntryNo: "WL" + uuid.NewString(), + Type: entryType, + Amount: in.Amount, + BalanceAfter: newBalance, + ReferenceType: "manual_adjustment", + ReferenceNo: in.IdempotencyKey, + IdempotencyKey: &idempotencyKey, + Note: in.Note, + }).Error; err != nil { + return err + } + out = wallet + out.AvailableBalance = newBalance + return writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "wallet.adjust", "wallet_account", fmt.Sprint(wallet.ID), map[string]int64{"amount": in.Amount}) + }) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *FulfillmentService) GetWallet(merchantID uint) (*model.WalletAccount, error) { + var wallet model.WalletAccount + if err := s.db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("商户钱包不存在") + } + return nil, err + } + return &wallet, nil +} + +func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int) ([]model.WalletLedgerEntry, int64, error) { + page, size = normalizePage(page, size) + tx := s.db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID) + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + var entries []model.WalletLedgerEntry + err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&entries).Error + return entries, total, err +} + +func newFulfillmentOrderNo() string { + return "FO" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "") +} + +func CanFulfill(order *model.FulfillmentOrder) (bool, string) { + if order.PaymentStatus != model.PaymentStatusPaid { + return false, "订单未支付或已退款" + } + switch order.FulfillmentStatus { + case model.FulfillmentStatusPending, model.FulfillmentStatusFailed: + return true, "" + case model.FulfillmentStatusProcessing: + return false, "订单履约中" + case model.FulfillmentStatusSucceeded: + return false, "订单已履约成功" + case model.FulfillmentStatusCancelled: + return false, "订单已取消" + default: + return false, "订单状态不可履约" + } +} + +func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} { + canFulfill, cannotFulfillReason := CanFulfill(order) + return map[string]interface{}{ + "order_no": order.OrderNo, + "client_order_no": order.ClientOrderNo, + "product_sku": order.ProductSKU, + "quantity": order.Quantity, + "amount": order.Amount, + "currency": order.Currency, + "payment_status": order.PaymentStatus, + "fulfillment_status": order.FulfillmentStatus, + "can_fulfill": canFulfill, + "cannot_fulfill_reason": cannotFulfillReason, + "provider_order_no": order.ProviderOrderNo, + "failure_reason": order.FailureReason, + } +} diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go new file mode 100644 index 0000000..e57f13c --- /dev/null +++ b/backend/internal/service/fulfillment_test.go @@ -0,0 +1,301 @@ +package service + +import ( + "strings" + "testing" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/testdb" + + "gorm.io/gorm" +) + +func newServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + return testdb.New(t, + &model.Merchant{}, + &model.Product{}, + &model.MerchantProduct{}, + &model.WalletAccount{}, + &model.WalletLedgerEntry{}, + &model.FulfillmentOrder{}, + &model.FulfillmentJob{}, + &model.AuditLog{}, + ) +} + +func seedFulfillmentMerchant(t *testing.T, db *gorm.DB, code string, balance, stock, price int64) (uint, model.MerchantProduct) { + t.Helper() + merchant := model.Merchant{Code: code, Name: code, Status: model.MerchantStatusActive} + if err := db.Create(&merchant).Error; err != nil { + t.Fatalf("create merchant: %v", err) + } + if err := db.Create(&model.WalletAccount{ + MerchantID: merchant.ID, + Currency: "CNY", + AvailableBalance: balance, + }).Error; err != nil { + t.Fatalf("create wallet: %v", err) + } + product := model.Product{Code: code + "-product", Name: "测试商品", Status: model.ProductStatusActive} + if err := db.Create(&product).Error; err != nil { + t.Fatalf("create product: %v", err) + } + merchantProduct := model.MerchantProduct{ + MerchantID: merchant.ID, + ProductID: product.ID, + SKU: "sku-basic", + DisplayName: "测试商品", + PriceAmount: price, + Currency: "CNY", + Stock: stock, + Status: model.ProductStatusActive, + } + if err := db.Create(&merchantProduct).Error; err != nil { + t.Fatalf("create merchant product: %v", err) + } + return merchant.ID, merchantProduct +} + +func TestFulfillmentCreateOrderDebitsWalletAndIsIdempotent(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-a", 1000, 5, 200) + svc := NewFulfillmentService(db, nil) + + first, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 11, + ClientOrderNo: "client-001", + SKU: product.SKU, + Quantity: 2, + RequestData: map[string]string{ + "account": "player-1", + }, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + if first.Idempotent { + t.Fatalf("first create should not be idempotent") + } + if first.Order.Amount != 400 || first.Order.PaymentStatus != model.PaymentStatusPaid || first.Order.FulfillmentStatus != model.FulfillmentStatusPending { + t.Fatalf("unexpected order: %+v", first.Order) + } + + second, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 11, + ClientOrderNo: "client-001", + SKU: product.SKU, + Quantity: 2, + }) + if err != nil { + t.Fatalf("idempotent create: %v", err) + } + if !second.Idempotent || second.Order.OrderNo != first.Order.OrderNo { + t.Fatalf("expected existing order, got %+v", second) + } + + var wallet model.WalletAccount + if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { + t.Fatalf("query wallet: %v", err) + } + if wallet.AvailableBalance != 600 { + t.Fatalf("wallet should debit once, got %d", wallet.AvailableBalance) + } + var refreshed model.MerchantProduct + if err := db.First(&refreshed, product.ID).Error; err != nil { + t.Fatalf("query product: %v", err) + } + if refreshed.Stock != 3 { + t.Fatalf("stock should decrease once, got %d", refreshed.Stock) + } + var ledgerCount int64 + db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount) + if ledgerCount != 1 { + t.Fatalf("ledger should have one debit entry, got %d", ledgerCount) + } +} + +func TestFulfillmentCancelRefundsOnceAndRestoresStock(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-b", 1000, 2, 300) + svc := NewFulfillmentService(db, nil) + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 12, + ClientOrderNo: "client-cancel", + SKU: product.SKU, + Quantity: 1, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + cancelled, err := svc.CancelOrder(merchantID, 12, created.Order.OrderNo, "用户取消") + if err != nil { + t.Fatalf("cancel order: %v", err) + } + if cancelled.PaymentStatus != model.PaymentStatusRefunded || cancelled.FulfillmentStatus != model.FulfillmentStatusCancelled { + t.Fatalf("unexpected cancelled order: %+v", cancelled) + } + if _, err := svc.CancelOrder(merchantID, 12, created.Order.OrderNo, "重复取消"); err != nil { + t.Fatalf("repeat cancel should be idempotent: %v", err) + } + + var wallet model.WalletAccount + _ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error + if wallet.AvailableBalance != 1000 { + t.Fatalf("wallet should refund once, got %d", wallet.AvailableBalance) + } + var refreshed model.MerchantProduct + _ = db.First(&refreshed, product.ID).Error + if refreshed.Stock != 2 { + t.Fatalf("stock should restore once, got %d", refreshed.Stock) + } + var ledgerCount int64 + db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount) + if ledgerCount != 2 { + t.Fatalf("ledger should have debit and refund, got %d", ledgerCount) + } +} + +func TestFulfillmentStatusTransitions(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-c", 1000, -1, 100) + svc := NewFulfillmentService(db, nil) + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 13, + ClientOrderNo: "client-status", + SKU: product.SKU, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + processing, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: merchantID, + APIClientID: 13, + OrderNo: created.Order.OrderNo, + Status: model.FulfillmentStatusProcessing, + }) + if err != nil { + t.Fatalf("mark processing: %v", err) + } + if processing.FulfillmentStatus != model.FulfillmentStatusProcessing { + t.Fatalf("expected processing, got %s", processing.FulfillmentStatus) + } + succeeded, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: merchantID, + APIClientID: 13, + OrderNo: created.Order.OrderNo, + Status: model.FulfillmentStatusSucceeded, + ProviderOrderNo: "provider-1", + ResultData: map[string]string{"ok": "true"}, + }) + if err != nil { + t.Fatalf("mark succeeded: %v", err) + } + if succeeded.FulfillmentStatus != model.FulfillmentStatusSucceeded || succeeded.DeliveredAt == nil { + t.Fatalf("unexpected succeeded order: %+v", succeeded) + } + _, err = svc.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: merchantID, + APIClientID: 13, + OrderNo: created.Order.OrderNo, + Status: model.FulfillmentStatusFailed, + }) + if err == nil { + t.Fatalf("should reject rollback after success") + } +} + +func TestFulfillmentMerchantIsolation(t *testing.T) { + db := newServiceTestDB(t) + merchantA, productA := seedFulfillmentMerchant(t, db, "merchant-d", 1000, 1, 100) + merchantB, _ := seedFulfillmentMerchant(t, db, "merchant-e", 1000, 1, 100) + svc := NewFulfillmentService(db, nil) + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantA, + APIClientID: 14, + ClientOrderNo: "client-isolation", + SKU: productA.SKU, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + if _, err := svc.GetOrder(merchantB, created.Order.OrderNo); err == nil { + t.Fatalf("other merchant should not read the order") + } +} + +func TestWalletAdjustIsIdempotentPerMerchant(t *testing.T) { + db := newServiceTestDB(t) + merchantA, _ := seedFulfillmentMerchant(t, db, "merchant-f", 0, -1, 100) + merchantB, _ := seedFulfillmentMerchant(t, db, "merchant-g", 0, -1, 100) + svc := NewFulfillmentService(db, nil) + for _, merchantID := range []uint{merchantA, merchantB} { + wallet, err := svc.AdjustWallet(WalletAdjustInput{ + MerchantID: merchantID, + ActorUserID: 1, + Amount: 500, + IdempotencyKey: "same-key", + Note: "充值", + }) + if err != nil { + t.Fatalf("adjust wallet merchant %d: %v", merchantID, err) + } + if wallet.AvailableBalance != 500 { + t.Fatalf("unexpected balance for merchant %d: %d", merchantID, wallet.AvailableBalance) + } + } + wallet, err := svc.AdjustWallet(WalletAdjustInput{ + MerchantID: merchantA, + ActorUserID: 1, + Amount: 500, + IdempotencyKey: "same-key", + Note: "重复充值", + }) + if err != nil { + t.Fatalf("repeat adjust: %v", err) + } + if wallet.AvailableBalance != 500 { + t.Fatalf("repeat adjust should not change balance, got %d", wallet.AvailableBalance) + } +} + +func TestCanFulfill(t *testing.T) { + ok, reason := CanFulfill(&model.FulfillmentOrder{ + PaymentStatus: model.PaymentStatusPaid, + FulfillmentStatus: model.FulfillmentStatusFailed, + }) + if !ok || reason != "" { + t.Fatalf("failed paid order should be fulfillable") + } + ok, _ = CanFulfill(&model.FulfillmentOrder{ + PaymentStatus: model.PaymentStatusRefunded, + FulfillmentStatus: model.FulfillmentStatusPending, + }) + if ok { + t.Fatalf("refunded order should not be fulfillable") + } +} + +func TestCreateOrderRejectsInsufficientBalance(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-h", 50, 1, 100) + svc := NewFulfillmentService(db, nil) + _, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 15, + ClientOrderNo: "client-low-balance", + SKU: product.SKU, + }) + if err == nil || !strings.Contains(err.Error(), "余额不足") { + t.Fatalf("expected insufficient balance error, got %v", err) + } + var wallet model.WalletAccount + _ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error + if wallet.AvailableBalance != 50 { + t.Fatalf("balance should remain unchanged, got %d", wallet.AvailableBalance) + } +} diff --git a/backend/internal/service/legacy_tenant_test.go b/backend/internal/service/legacy_tenant_test.go new file mode 100644 index 0000000..7e5da82 --- /dev/null +++ b/backend/internal/service/legacy_tenant_test.go @@ -0,0 +1,95 @@ +package service + +import ( + "strings" + "testing" + + "affiliate_dash/internal/model" +) + +func TestLegacyOrderCreateRejectsDistributorOutsideMerchant(t *testing.T) { + db := newServiceTestDB(t) + merchant := model.Merchant{Code: "legacy-merchant", Name: "旧后台商户", Status: model.MerchantStatusActive} + if err := db.Create(&merchant).Error; err != nil { + t.Fatalf("create merchant: %v", err) + } + inside := model.User{Username: "inside", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "INSIDE"} + outside := model.User{Username: "outside", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "OUTSIDE"} + if err := db.Create(&inside).Error; err != nil { + t.Fatalf("create inside user: %v", err) + } + if err := db.Create(&outside).Error; err != nil { + t.Fatalf("create outside user: %v", err) + } + if err := db.Create(&model.MerchantMember{ + MerchantID: merchant.ID, + UserID: inside.ID, + Role: model.MemberRoleOperator, + Status: 1, + }).Error; err != nil { + t.Fatalf("create member: %v", err) + } + skin := model.Skin{ + MerchantID: merchant.ID, + Name: "旧皮肤", + SKU: "legacy-skin", + Price: 10, + Stock: -1, + Status: 1, + } + if err := db.Create(&skin).Error; err != nil { + t.Fatalf("create skin: %v", err) + } + + _, err := NewOrderService(db).Create(CreateOrderInput{ + MerchantID: merchant.ID, + SkinID: skin.ID, + DistributorID: outside.ID, + BuyerName: "买家", + Status: model.OrderStatusPaid, + }) + if err == nil || !strings.Contains(err.Error(), "不属于当前商户") { + t.Fatalf("expected tenant boundary error, got %v", err) + } +} + +func TestUserListFiltersByMerchantMembership(t *testing.T) { + db := newServiceTestDB(t) + merchantA := model.Merchant{Code: "user-merchant-a", Name: "商户 A", Status: model.MerchantStatusActive} + merchantB := model.Merchant{Code: "user-merchant-b", Name: "商户 B", Status: model.MerchantStatusActive} + if err := db.Create(&merchantA).Error; err != nil { + t.Fatalf("create merchant a: %v", err) + } + if err := db.Create(&merchantB).Error; err != nil { + t.Fatalf("create merchant b: %v", err) + } + userA := model.User{Username: "user-a", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "USERA"} + userB := model.User{Username: "user-b", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "USERB"} + if err := db.Create(&userA).Error; err != nil { + t.Fatalf("create user a: %v", err) + } + if err := db.Create(&userB).Error; err != nil { + t.Fatalf("create user b: %v", err) + } + if err := db.Create(&model.MerchantMember{MerchantID: merchantA.ID, UserID: userA.ID, Role: model.MemberRoleOperator, Status: 1}).Error; err != nil { + t.Fatalf("create member a: %v", err) + } + if err := db.Create(&model.MerchantMember{MerchantID: merchantB.ID, UserID: userB.ID, Role: model.MemberRoleOperator, Status: 1}).Error; err != nil { + t.Fatalf("create member b: %v", err) + } + + active := 1 + list, total, err := NewUserService(db, nil).List(UserListQuery{ + MerchantID: merchantA.ID, + Page: 1, + Size: 20, + Role: model.RoleDistributor, + Status: &active, + }) + if err != nil { + t.Fatalf("list users: %v", err) + } + if total != 1 || len(list) != 1 || list[0].Username != "user-a" { + t.Fatalf("expected only merchant A user, total=%d list=%+v", total, list) + } +} diff --git a/backend/internal/service/merchant.go b/backend/internal/service/merchant.go new file mode 100644 index 0000000..e9abb0a --- /dev/null +++ b/backend/internal/service/merchant.go @@ -0,0 +1,465 @@ +package service + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var merchantCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,63}$`) + +type MerchantService struct { + db *gorm.DB + codec *SecretCodec + tenant *TenantService +} + +func NewMerchantService(db *gorm.DB, codec *SecretCodec, tenant *TenantService) *MerchantService { + return &MerchantService{db: db, codec: codec, tenant: tenant} +} + +type CreateMerchantInput struct { + Code string + Name string + ContactName string + ContactInfo string + OwnerUserID uint +} + +func (s *MerchantService) CreateMerchant(in CreateMerchantInput, actorUserID uint) (*model.Merchant, error) { + in.Code = strings.ToLower(strings.TrimSpace(in.Code)) + in.Name = strings.TrimSpace(in.Name) + if !merchantCodePattern.MatchString(in.Code) { + return nil, errors.New("商户编码需为 3-64 位小写字母、数字或连字符") + } + if in.Name == "" { + return nil, errors.New("商户名称不能为空") + } + if in.OwnerUserID == 0 { + return nil, errors.New("商户负责人不能为空") + } + merchant := &model.Merchant{ + Code: in.Code, + Name: in.Name, + Status: model.MerchantStatusActive, + ContactName: in.ContactName, + ContactInfo: in.ContactInfo, + } + err := s.db.Transaction(func(tx *gorm.DB) error { + var owner model.User + if err := tx.First(&owner, in.OwnerUserID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("商户负责人不存在") + } + return err + } + if owner.Status != 1 { + return errors.New("商户负责人已禁用") + } + if err := tx.Create(merchant).Error; err != nil { + return err + } + if err := tx.Create(&model.WalletAccount{MerchantID: merchant.ID, Currency: "CNY"}).Error; err != nil { + return err + } + if err := tx.Create(&model.MerchantMember{ + MerchantID: merchant.ID, + UserID: owner.ID, + Role: model.MemberRoleOwner, + Status: 1, + IsDefault: true, + }).Error; err != nil { + return err + } + return writeAudit(tx, &merchant.ID, &actorUserID, nil, "merchant.create", "merchant", fmt.Sprint(merchant.ID), map[string]string{"code": merchant.Code}) + }) + if err != nil { + return nil, err + } + return merchant, nil +} + +func (s *MerchantService) ListMerchants(page, size int) ([]model.Merchant, int64, error) { + page, size = normalizePage(page, size) + tx := s.db.Model(&model.Merchant{}) + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + var merchants []model.Merchant + err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&merchants).Error + return merchants, total, err +} + +func (s *MerchantService) GetMerchant(merchantID uint) (*model.Merchant, error) { + var merchant model.Merchant + if err := s.db.First(&merchant, merchantID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("商户不存在") + } + return nil, err + } + return &merchant, nil +} + +type AddMemberInput struct { + UserID uint + Role string + IsDefault bool +} + +func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) { + if !isValidMemberRole(in.Role) { + return nil, errors.New("无效的商户成员角色") + } + member := &model.MerchantMember{ + MerchantID: merchantID, + UserID: in.UserID, + Role: in.Role, + Status: 1, + IsDefault: in.IsDefault, + } + err := s.db.Transaction(func(tx *gorm.DB) error { + var merchant model.Merchant + if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { + return errors.New("商户不存在或已禁用") + } + var user model.User + if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil { + return errors.New("用户不存在或已禁用") + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "role": in.Role, + "status": 1, + "is_default": in.IsDefault, + }), + }).Create(member).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, in.UserID), map[string]string{"role": in.Role}) + }) + if err != nil { + return nil, err + } + if err := s.db.Where("merchant_id = ? AND user_id = ?", merchantID, in.UserID).First(member).Error; err != nil { + return nil, err + } + return member, nil +} + +func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) { + var members []model.MerchantMember + err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error + return members, err +} + +type CreateMerchantProductInput struct { + ProductCode string + ProductName string + Category string + Description string + Attributes string + SKU string + DisplayName string + PriceAmount int64 + CostAmount int64 + Currency string + Stock int64 + Status string + FulfillmentConfig string +} + +func (s *MerchantService) CreateMerchantProduct(merchantID uint, in CreateMerchantProductInput, actorUserID uint) (*model.MerchantProduct, error) { + in.SKU = strings.TrimSpace(in.SKU) + in.ProductCode = strings.TrimSpace(in.ProductCode) + in.ProductName = strings.TrimSpace(in.ProductName) + if in.SKU == "" { + return nil, errors.New("商户商品 SKU 不能为空") + } + if in.PriceAmount < 0 || in.CostAmount < 0 { + return nil, errors.New("商品金额不能小于零") + } + if in.Stock < -1 { + return nil, errors.New("库存只能为 -1 或非负整数") + } + if in.Currency == "" { + in.Currency = "CNY" + } + in.Currency = strings.ToUpper(in.Currency) + if in.Status == "" { + in.Status = model.ProductStatusActive + } + if in.Status != model.ProductStatusActive && in.Status != model.ProductStatusInactive { + return nil, errors.New("无效的商品状态") + } + + merchantProduct := &model.MerchantProduct{} + err := s.db.Transaction(func(tx *gorm.DB) error { + var merchant model.Merchant + if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { + return errors.New("商户不存在或已禁用") + } + product, err := ensureProduct(tx, in) + if err != nil { + return err + } + merchantProduct = &model.MerchantProduct{ + MerchantID: merchantID, + ProductID: product.ID, + SKU: in.SKU, + DisplayName: fallbackName(in.DisplayName, product.Name), + PriceAmount: in.PriceAmount, + CostAmount: in.CostAmount, + Currency: in.Currency, + Stock: in.Stock, + Status: in.Status, + FulfillmentConfig: in.FulfillmentConfig, + } + if err := tx.Create(merchantProduct).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.create", "merchant_product", fmt.Sprint(merchantProduct.ID), map[string]string{"sku": in.SKU}) + }) + if err != nil { + return nil, err + } + return merchantProduct, nil +} + +func (s *MerchantService) ListMerchantProducts(merchantID uint, page, size int, activeOnly bool) ([]model.MerchantProduct, int64, error) { + page, size = normalizePage(page, size) + tx := s.db.Model(&model.MerchantProduct{}).Where("merchant_id = ?", merchantID) + if activeOnly { + tx = tx.Where("status = ?", model.ProductStatusActive) + } + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + var products []model.MerchantProduct + err := tx.Preload("Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&products).Error + return products, total, err +} + +type UpdateMerchantProductInput struct { + DisplayName *string + PriceAmount *int64 + CostAmount *int64 + Stock *int64 + Status *string + FulfillmentConfig *string +} + +func (s *MerchantService) UpdateMerchantProduct(merchantID, id uint, in UpdateMerchantProductInput, actorUserID uint) error { + updates := make(map[string]interface{}) + if in.DisplayName != nil { + updates["display_name"] = *in.DisplayName + } + if in.PriceAmount != nil { + if *in.PriceAmount < 0 { + return errors.New("商品售价不能小于零") + } + updates["price_amount"] = *in.PriceAmount + } + if in.CostAmount != nil { + if *in.CostAmount < 0 { + return errors.New("商品成本不能小于零") + } + updates["cost_amount"] = *in.CostAmount + } + if in.Stock != nil { + if *in.Stock < -1 { + return errors.New("库存只能为 -1 或非负整数") + } + updates["stock"] = *in.Stock + } + if in.Status != nil { + if *in.Status != model.ProductStatusActive && *in.Status != model.ProductStatusInactive { + return errors.New("无效的商品状态") + } + updates["status"] = *in.Status + } + if in.FulfillmentConfig != nil { + updates["fulfillment_config"] = *in.FulfillmentConfig + } + if len(updates) == 0 { + return errors.New("没有可更新字段") + } + return s.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.MerchantProduct{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("商户商品不存在") + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.update", "merchant_product", fmt.Sprint(id), nil) + }) +} + +type APICredential struct { + Client *model.APIClient `json:"client"` + Secret string `json:"secret"` +} + +type CreateAPIClientInput struct { + Name string + Scopes string + SignatureVersion string + ExpiresAt *time.Time +} + +func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInput, actorUserID uint) (*APICredential, error) { + in.Name = strings.TrimSpace(in.Name) + if in.Name == "" { + return nil, errors.New("API 客户端名称不能为空") + } + if len(ParseScopes(in.Scopes)) == 0 { + return nil, errors.New("至少配置一个 API 权限") + } + if in.SignatureVersion == "" { + in.SignatureVersion = "v1" + } + if in.SignatureVersion != "v1" { + return nil, errors.New("无效的签名版本") + } + appKey, err := randomToken("ak_", 24) + if err != nil { + return nil, err + } + secret, err := randomToken("sk_", 32) + if err != nil { + return nil, err + } + ciphertext, err := s.codec.Encrypt(secret) + if err != nil { + return nil, err + } + client := &model.APIClient{ + MerchantID: merchantID, + Name: in.Name, + AppKey: appKey, + SecretCiphertext: ciphertext, + SignatureVersion: in.SignatureVersion, + Scopes: strings.Join(scopeList(in.Scopes), ","), + Status: model.APIClientStatusActive, + ExpiresAt: in.ExpiresAt, + } + err = s.db.Transaction(func(tx *gorm.DB) error { + var merchant model.Merchant + if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { + return errors.New("商户不存在或已禁用") + } + if err := tx.Create(client).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.create", "api_client", fmt.Sprint(client.ID), map[string]string{"name": in.Name}) + }) + if err != nil { + return nil, err + } + return &APICredential{Client: client, Secret: secret}, nil +} + +func (s *MerchantService) ListAPIClients(merchantID uint) ([]model.APIClient, error) { + var clients []model.APIClient + err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&clients).Error + return clients, err +} + +func (s *MerchantService) UpdateAPIClientStatus(merchantID, id uint, status string, actorUserID uint) error { + if status != model.APIClientStatusActive && status != model.APIClientStatusDisabled { + return errors.New("无效的 API 客户端状态") + } + return s.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.APIClient{}).Where("id = ? AND merchant_id = ?", id, merchantID).Update("status", status) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("API 客户端不存在") + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.status.update", "api_client", fmt.Sprint(id), map[string]string{"status": status}) + }) +} + +func ensureProduct(tx *gorm.DB, in CreateMerchantProductInput) (*model.Product, error) { + if in.ProductCode != "" { + var product model.Product + err := tx.Where("code = ?", in.ProductCode).First(&product).Error + if err == nil { + return &product, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + if in.ProductName == "" { + return nil, errors.New("新建平台商品时商品名称不能为空") + } + code := in.ProductCode + if code == "" { + token, err := randomToken("prd_", 12) + if err != nil { + return nil, err + } + code = token + } + product := &model.Product{ + Code: code, + Name: in.ProductName, + Category: in.Category, + Description: in.Description, + Attributes: in.Attributes, + Status: model.ProductStatusActive, + } + if err := tx.Create(product).Error; err != nil { + return nil, err + } + return product, nil +} + +func normalizePage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + return page, size +} + +func randomToken(prefix string, byteCount int) (string, error) { + raw := make([]byte, byteCount) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return prefix + base64.RawURLEncoding.EncodeToString(raw), nil +} + +func scopeList(scopes string) []string { + set := ParseScopes(scopes) + items := make([]string, 0, len(set)) + for scope := range set { + items = append(items, scope) + } + return items +} + +func fallbackName(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return fallback +} diff --git a/backend/internal/service/order.go b/backend/internal/service/order.go index 58c7a7f..3bd32b4 100644 --- a/backend/internal/service/order.go +++ b/backend/internal/service/order.go @@ -20,6 +20,7 @@ func NewOrderService(db *gorm.DB) *OrderService { } type OrderListQuery struct { + MerchantID uint Page int Size int Status string @@ -27,6 +28,7 @@ type OrderListQuery struct { } type CreateOrderInput struct { + MerchantID uint SkinID uint DistributorID uint BuyerName string @@ -43,6 +45,9 @@ func (s *OrderService) List(q OrderListQuery) ([]model.Order, int64, error) { q.Size = 20 } tx := s.db.Model(&model.Order{}) + if q.MerchantID != 0 { + tx = tx.Where("merchant_id = ?", q.MerchantID) + } if q.Status != "" { tx = tx.Where("status = ?", q.Status) } @@ -63,7 +68,10 @@ func (s *OrderService) List(q OrderListQuery) ([]model.Order, int64, error) { func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) { var skin model.Skin - if err := s.db.First(&skin, in.SkinID).Error; err != nil { + if in.MerchantID == 0 { + return nil, errors.New("商户不能为空") + } + if err := s.db.Where("id = ? AND merchant_id = ?", in.SkinID, in.MerchantID).First(&skin).Error; err != nil { return nil, errors.New("皮肤不存在") } if skin.Status != 1 { @@ -72,6 +80,15 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) { if skin.Stock == 0 { return nil, errors.New("库存不足") } + var memberCount int64 + if err := s.db.Model(&model.MerchantMember{}). + Where("merchant_id = ? AND user_id = ? AND status = ?", in.MerchantID, in.DistributorID, 1). + Count(&memberCount).Error; err != nil { + return nil, err + } + if memberCount == 0 { + return nil, errors.New("分销商不属于当前商户") + } status := model.OrderStatusPending switch in.Status { @@ -89,6 +106,7 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) { } order := &model.Order{ + MerchantID: in.MerchantID, OrderNo: generateOrderNo(), SkinID: in.SkinID, DistributorID: in.DistributorID, @@ -121,7 +139,7 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) { return order, nil } -func (s *OrderService) UpdateStatus(id uint, status string) error { +func (s *OrderService) UpdateStatus(merchantID, id uint, status string) error { allowed := map[string]bool{ model.OrderStatusPending: true, model.OrderStatusPaid: true, @@ -133,7 +151,7 @@ func (s *OrderService) UpdateStatus(id uint, status string) error { if !allowed[status] { return errors.New("无效的订单状态") } - res := s.db.Model(&model.Order{}).Where("id = ?", id).Update("status", status) + res := s.db.Model(&model.Order{}).Where("id = ? AND merchant_id = ?", id, merchantID).Update("status", status) if res.Error != nil { return res.Error } @@ -381,6 +399,7 @@ func (s *OrderService) appendShipLog(order *model.Order, in ShipNotifyInput, res payload = string(b) } log := &model.ShipLog{ + MerchantID: order.MerchantID, OrderNo: order.OrderNo, OrderID: order.ID, ShipStatus: in.ShipStatus, @@ -394,6 +413,7 @@ func (s *OrderService) appendShipLog(order *model.Order, in ShipNotifyInput, res } type ShipLogListQuery struct { + MerchantID uint Page int Size int OrderNo string @@ -408,6 +428,9 @@ func (s *OrderService) ListShipLogs(q ShipLogListQuery) ([]model.ShipLog, int64, q.Size = 20 } tx := s.db.Model(&model.ShipLog{}) + if q.MerchantID != 0 { + tx = tx.Where("merchant_id = ?", q.MerchantID) + } if q.OrderNo != "" { tx = tx.Where("order_no LIKE ?", "%"+q.OrderNo+"%") } @@ -432,16 +455,21 @@ type DashboardStats struct { PendingOrderCount int64 `json:"pending_order_count"` } -func (s *OrderService) Dashboard() (*DashboardStats, error) { +func (s *OrderService) Dashboard(merchantID uint) (*DashboardStats, error) { stats := &DashboardStats{} - s.db.Model(&model.Skin{}).Count(&stats.SkinCount) - s.db.Model(&model.User{}).Where("role = ?", model.RoleDistributor).Count(&stats.DistributorCount) - s.db.Model(&model.Order{}).Count(&stats.OrderCount) - s.db.Model(&model.Order{}).Where("status = ?", model.OrderStatusPending).Count(&stats.PendingOrderCount) + s.db.Model(&model.Skin{}).Where("merchant_id = ?", merchantID).Count(&stats.SkinCount) + s.db.Model(&model.User{}). + Joins("JOIN merchant_members ON merchant_members.user_id = users.id"). + Where("merchant_members.merchant_id = ? AND users.role = ?", merchantID, model.RoleDistributor). + Count(&stats.DistributorCount) + s.db.Model(&model.Order{}).Where("merchant_id = ?", merchantID).Count(&stats.OrderCount) + s.db.Model(&model.Order{}).Where("merchant_id = ? AND status = ?", merchantID, model.OrderStatusPending).Count(&stats.PendingOrderCount) s.db.Model(&model.Order{}). + Where("merchant_id = ?", merchantID). Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}). Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales) s.db.Model(&model.Order{}). + Where("merchant_id = ?", merchantID). Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}). Select("COALESCE(SUM(commission_amt),0)").Scan(&stats.TotalCommission) return stats, nil diff --git a/backend/internal/service/secret_codec.go b/backend/internal/service/secret_codec.go new file mode 100644 index 0000000..3b48f22 --- /dev/null +++ b/backend/internal/service/secret_codec.go @@ -0,0 +1,56 @@ +package service + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" +) + +// SecretCodec 使用应用主密钥加密落库的第三方密钥,避免明文存储。 +type SecretCodec struct { + gcm cipher.AEAD +} + +func NewSecretCodec(masterKey string) (*SecretCodec, error) { + if masterKey == "" { + return nil, errors.New("数据加密主密钥不能为空") + } + sum := sha256.Sum256([]byte(masterKey)) + block, err := aes.NewCipher(sum[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &SecretCodec{gcm: gcm}, nil +} + +func (c *SecretCodec) Encrypt(plain string) (string, error) { + nonce := make([]byte, c.gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := c.gcm.Seal(nil, nonce, []byte(plain), nil) + return base64.RawURLEncoding.EncodeToString(append(nonce, sealed...)), nil +} + +func (c *SecretCodec) Decrypt(ciphertext string) (string, error) { + raw, err := base64.RawURLEncoding.DecodeString(ciphertext) + if err != nil { + return "", errors.New("密钥密文格式错误") + } + if len(raw) < c.gcm.NonceSize() { + return "", errors.New("密钥密文长度错误") + } + plain, err := c.gcm.Open(nil, raw[:c.gcm.NonceSize()], raw[c.gcm.NonceSize():], nil) + if err != nil { + return "", errors.New("密钥解密失败") + } + return string(plain), nil +} diff --git a/backend/internal/service/secret_codec_test.go b/backend/internal/service/secret_codec_test.go new file mode 100644 index 0000000..5b8563b --- /dev/null +++ b/backend/internal/service/secret_codec_test.go @@ -0,0 +1,24 @@ +package service + +import "testing" + +func TestSecretCodecRoundTrip(t *testing.T) { + codec, err := NewSecretCodec("test-master-key") + if err != nil { + t.Fatalf("new codec: %v", err) + } + ciphertext, err := codec.Encrypt("plain-secret") + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if ciphertext == "plain-secret" { + t.Fatalf("secret should not be stored as plaintext") + } + plain, err := codec.Decrypt(ciphertext) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if plain != "plain-secret" { + t.Fatalf("unexpected plaintext: %s", plain) + } +} diff --git a/backend/internal/service/skin.go b/backend/internal/service/skin.go index 3817e66..7a4649a 100644 --- a/backend/internal/service/skin.go +++ b/backend/internal/service/skin.go @@ -17,12 +17,13 @@ func NewSkinService(db *gorm.DB) *SkinService { } type SkinListQuery struct { - Page int - Size int - Keyword string - Game string - Category string - Status *int + MerchantID uint + Page int + Size int + Keyword string + Game string + Category string + Status *int } func (s *SkinService) List(q SkinListQuery) ([]model.Skin, int64, error) { @@ -33,6 +34,9 @@ func (s *SkinService) List(q SkinListQuery) ([]model.Skin, int64, error) { q.Size = 20 } tx := s.db.Model(&model.Skin{}) + if q.MerchantID != 0 { + tx = tx.Where("merchant_id = ?", q.MerchantID) + } if q.Keyword != "" { like := "%" + q.Keyword + "%" tx = tx.Where("name LIKE ? OR sku LIKE ?", like, like) @@ -55,9 +59,13 @@ func (s *SkinService) List(q SkinListQuery) ([]model.Skin, int64, error) { return list, total, err } -func (s *SkinService) Get(id uint) (*model.Skin, error) { +func (s *SkinService) Get(merchantID, id uint) (*model.Skin, error) { var skin model.Skin - if err := s.db.First(&skin, id).Error; err != nil { + tx := s.db.Where("id = ?", id) + if merchantID != 0 { + tx = tx.Where("merchant_id = ?", merchantID) + } + if err := tx.First(&skin).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("皮肤不存在") } @@ -70,8 +78,8 @@ func (s *SkinService) Create(skin *model.Skin) error { return s.db.Create(skin).Error } -func (s *SkinService) Update(id uint, updates map[string]interface{}) error { - res := s.db.Model(&model.Skin{}).Where("id = ?", id).Updates(updates) +func (s *SkinService) Update(merchantID, id uint, updates map[string]interface{}) error { + res := s.db.Model(&model.Skin{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates) if res.Error != nil { return res.Error } @@ -81,8 +89,8 @@ func (s *SkinService) Update(id uint, updates map[string]interface{}) error { return nil } -func (s *SkinService) Delete(id uint) error { - res := s.db.Delete(&model.Skin{}, id) +func (s *SkinService) Delete(merchantID, id uint) error { + res := s.db.Where("merchant_id = ?", merchantID).Delete(&model.Skin{}, id) if res.Error != nil { return res.Error } @@ -94,12 +102,16 @@ func (s *SkinService) Delete(id uint) error { // SeedCatalog 按 sku 幂等导入商品目录(已存在则跳过) func (s *SkinService) SeedCatalog() error { + var merchant model.Merchant + if err := s.db.Where("code = ?", "self-operated").First(&merchant).Error; err != nil { + return err + } // 清理早期无 sku 的演示数据,避免唯一索引冲突 - _ = s.db.Where("sku = ? OR sku IS NULL", "").Delete(&model.Skin{}).Error + _ = s.db.Where("merchant_id = ? AND (sku = ? OR sku IS NULL)", merchant.ID, "").Delete(&model.Skin{}).Error for _, item := range peaceEliteCatalog { var existing model.Skin - err := s.db.Where("sku = ?", item.SKU).First(&existing).Error + err := s.db.Where("merchant_id = ? AND sku = ?", merchant.ID, item.SKU).First(&existing).Error if err == nil { // 已存在:仅同步默认佣金为 0(不改价格等业务字段) if existing.Commission != 0 { @@ -111,6 +123,7 @@ func (s *SkinService) SeedCatalog() error { return err } skin := model.Skin{ + MerchantID: merchant.ID, Name: item.Name, SKU: item.SKU, Game: "和平精英", diff --git a/backend/internal/service/tenant.go b/backend/internal/service/tenant.go new file mode 100644 index 0000000..66898dc --- /dev/null +++ b/backend/internal/service/tenant.go @@ -0,0 +1,120 @@ +package service + +import ( + "errors" + "strconv" + "strings" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// TenantService 负责商户成员关系与请求租户解析。 +type TenantService struct { + db *gorm.DB +} + +func NewTenantService(db *gorm.DB) *TenantService { + return &TenantService{db: db} +} + +func (s *TenantService) ResolveMember(userID uint, merchantRef string) (*model.MerchantMember, error) { + if userID == 0 { + return nil, errors.New("无效的用户身份") + } + tx := s.db.Preload("Merchant"). + Where("merchant_members.user_id = ? AND merchant_members.status = ?", userID, 1). + Joins("JOIN merchants ON merchants.id = merchant_members.merchant_id AND merchants.status = ?", model.MerchantStatusActive) + if merchantRef != "" { + if id, err := strconv.ParseUint(merchantRef, 10, 64); err == nil { + tx = tx.Where("merchant_members.merchant_id = ?", uint(id)) + } else { + tx = tx.Where("merchants.code = ?", merchantRef) + } + } + var member model.MerchantMember + err := tx.Order("merchant_members.is_default DESC, merchant_members.id ASC").First(&member).Error + if err == nil { + return &member, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("当前账号无权访问该商户") + } + return nil, err +} + +func (s *TenantService) EnsureSelfMember(userID uint, role string, status int) error { + var merchant model.Merchant + if err := s.db.Where("code = ?", "self-operated").First(&merchant).Error; err != nil { + return err + } + return s.EnsureMember(merchant.ID, userID, role, status, role == model.MemberRoleOwner) +} + +func (s *TenantService) EnsureMember(merchantID, userID uint, role string, status int, isDefault bool) error { + if merchantID == 0 || userID == 0 { + return errors.New("商户和用户不能为空") + } + if !isValidMemberRole(role) { + return errors.New("无效的商户成员角色") + } + member := model.MerchantMember{ + MerchantID: merchantID, + UserID: userID, + Role: role, + Status: status, + IsDefault: isDefault, + } + return s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "role": role, + "status": status, + "is_default": isDefault, + }), + }).Create(&member).Error +} + +func isValidMemberRole(role string) bool { + switch role { + case model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance, model.MemberRoleViewer: + return true + default: + return false + } +} + +func MemberCanManage(memberRole string) bool { + return memberRole == model.MemberRoleOwner || memberRole == model.MemberRoleOperator +} + +func MemberCanManageFinance(memberRole string) bool { + return memberRole == model.MemberRoleOwner || memberRole == model.MemberRoleFinance +} + +func ParseScopes(scopes string) map[string]struct{} { + out := make(map[string]struct{}) + for _, scope := range strings.FieldsFunc(scopes, func(r rune) bool { + return r == ',' || r == ' ' || r == '\n' || r == '\t' + }) { + if scope != "" { + out[scope] = struct{}{} + } + } + return out +} + +func HasAnyScope(scopes string, wanted ...string) bool { + set := ParseScopes(scopes) + if _, ok := set["*"]; ok { + return true + } + for _, scope := range wanted { + if _, ok := set[scope]; ok { + return true + } + } + return false +} diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index e1ab035..5a03281 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -10,19 +10,21 @@ import ( ) type UserService struct { - db *gorm.DB + db *gorm.DB + tenant *TenantService } -func NewUserService(db *gorm.DB) *UserService { - return &UserService{db: db} +func NewUserService(db *gorm.DB, tenant *TenantService) *UserService { + return &UserService{db: db, tenant: tenant} } type UserListQuery struct { - Page int - Size int - Keyword string - Role string - Status *int + MerchantID uint + Page int + Size int + Keyword string + Role string + Status *int } func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) { @@ -33,26 +35,30 @@ func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) { q.Size = 20 } tx := s.db.Model(&model.User{}) + if q.MerchantID != 0 { + tx = tx.Joins("JOIN merchant_members ON merchant_members.user_id = users.id"). + Where("merchant_members.merchant_id = ?", q.MerchantID) + } if q.Keyword != "" { like := "%" + q.Keyword + "%" - tx = tx.Where("username LIKE ? OR nickname LIKE ?", like, like) + tx = tx.Where("users.username LIKE ? OR users.nickname LIKE ?", like, like) } if q.Role != "" { - tx = tx.Where("role = ?", q.Role) + tx = tx.Where("users.role = ?", q.Role) } if q.Status != nil { - tx = tx.Where("status = ?", *q.Status) + tx = tx.Where("users.status = ?", *q.Status) } var total int64 if err := tx.Count(&total).Error; err != nil { return nil, 0, err } var list []model.User - err := tx.Order("id DESC").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&list).Error + err := tx.Order("users.id DESC").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&list).Error return list, total, err } -func (s *UserService) Create(username, password, nickname, role string, parentID *uint) (*model.User, error) { +func (s *UserService) Create(username, password, nickname, role string, parentID *uint, merchantID uint) (*model.User, error) { var count int64 s.db.Model(&model.User{}).Where("username = ?", username).Count(&count) if count > 0 { @@ -77,7 +83,29 @@ func (s *UserService) Create(username, password, nickname, role string, parentID if user.Nickname == "" { user.Nickname = username } - if err := s.db.Create(user).Error; err != nil { + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(user).Error; err != nil { + return err + } + if s.tenant != nil && merchantID != 0 { + memberRole := model.MemberRoleOperator + if role == model.RoleAdmin { + memberRole = model.MemberRoleOwner + } + member := model.MerchantMember{ + MerchantID: merchantID, + UserID: user.ID, + Role: memberRole, + Status: user.Status, + IsDefault: role == model.RoleAdmin, + } + if err := tx.Create(&member).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { return nil, err } return user, nil diff --git a/backend/internal/testdb/testdb.go b/backend/internal/testdb/testdb.go new file mode 100644 index 0000000..6ab1cf0 --- /dev/null +++ b/backend/internal/testdb/testdb.go @@ -0,0 +1,76 @@ +package testdb + +import ( + "fmt" + "net/url" + "os" + "strings" + "testing" + "time" + + "affiliate_dash/internal/database" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// New 使用真实 PostgreSQL 创建测试隔离 schema,避免 SQLite 与生产 SQL 行为不一致。 +func New(t testing.TB, _ ...interface{}) *gorm.DB { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = os.Getenv("DATABASE_URL") + } + if dsn == "" { + dsn = "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable" + } + + cfg := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)} + adminDB, err := gorm.Open(postgres.Open(dsn), cfg) + if err != nil { + t.Fatalf("连接 PostgreSQL 测试库失败: %v", err) + } + adminSQL, err := adminDB.DB() + if err != nil { + t.Fatalf("获取 PostgreSQL 连接失败: %v", err) + } + + schema := fmt.Sprintf("test_%d", time.Now().UnixNano()) + if err := adminDB.Exec(`CREATE SCHEMA "` + schema + `"`).Error; err != nil { + t.Fatalf("创建测试 schema 失败: %v", err) + } + t.Cleanup(func() { + _ = adminDB.Exec(`DROP SCHEMA IF EXISTS "` + schema + `" CASCADE`).Error + _ = adminSQL.Close() + }) + + db, err := gorm.Open(postgres.Open(withSearchPath(dsn, schema)), cfg) + if err != nil { + t.Fatalf("连接测试 schema 失败: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("获取测试 schema 连接失败: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + + if err := database.Migrate(db); err != nil { + t.Fatalf("迁移测试 schema 失败: %v", err) + } + return db +} + +func withSearchPath(dsn, schema string) string { + parsed, err := url.Parse(dsn) + if err == nil && strings.HasPrefix(parsed.Scheme, "postgres") { + query := parsed.Query() + query.Set("search_path", schema) + parsed.RawQuery = query.Encode() + return parsed.String() + } + if strings.TrimSpace(dsn) == "" { + return dsn + } + return dsn + " search_path=" + schema +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e663a28..aee2232 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,8 @@ import Orders from './pages/Orders' import Distributors from './pages/Distributors' import ShipLogs from './pages/ShipLogs' import OpenApiDocs from './pages/OpenApiDocs' +import MerchantCenter from './pages/MerchantCenter' +import PlatformMerchants from './pages/PlatformMerchants' import type { ReactNode } from 'react' function PrivateRoute({ children }: { children: ReactNode }) { @@ -41,6 +43,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> + + + + } + /> ) => request.get('/ship-logs', { params }).then((r) => r.data.data as PageResult), } + +export const merchantApi = { + current: () => + request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: MerchantMember['role'] }), + products: (params?: Record) => + request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult), + createProduct: (data: Partial & { + product_code?: string + product_name?: string + category?: string + description?: string + attributes?: string + }) => request.post('/merchant/products', data).then((r) => r.data.data as MerchantProduct), + updateProduct: (id: number, data: Partial) => + request.patch(`/merchant/products/${id}`, data).then((r) => r.data.data), + orders: (params?: Record) => + request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult), + wallet: () => + request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount), + ledger: (params?: Record) => + request.get('/merchant/wallet/ledger', { params }).then((r) => r.data.data as PageResult), + adjustWallet: (data: { amount: number; idempotency_key: string; note?: string }) => + request.post('/merchant/wallet/adjust', data).then((r) => r.data.data as WalletAccount), + apiClients: () => + request.get('/merchant/api-clients').then((r) => r.data.data as ApiClient[]), + createApiClient: (data: { + name: string + scopes: string + signature_version?: string + expires_at?: string + }) => request.post('/merchant/api-clients', data).then((r) => r.data.data as ApiCredential), + updateApiClientStatus: (id: number, status: ApiClient['status']) => + request.patch(`/merchant/api-clients/${id}/status`, { status }).then((r) => r.data.data), + callbacks: () => + request.get('/merchant/callbacks').then((r) => r.data.data as CallbackSubscription[]), + createCallback: (data: { name: string; url: string; events: string }) => + request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential), + updateCallbackStatus: (id: number, status: CallbackSubscription['status']) => + request.patch(`/merchant/callbacks/${id}/status`, { status }).then((r) => r.data.data), + members: () => + request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]), + addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) => + request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember), +} + +export const platformApi = { + merchants: (params?: Record) => + request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult), + createMerchant: (data: { + code: string + name: string + contact_name?: string + contact_info?: string + owner_user_id: number + }) => request.post('/platform/merchants', data).then((r) => r.data.data as Merchant), + addMember: ( + merchantId: number, + data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }, + ) => request.post(`/platform/merchants/${merchantId}/members`, data).then((r) => r.data.data as MerchantMember), +} diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 7719ba7..5171b01 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -11,6 +11,10 @@ request.interceptors.request.use((config) => { if (token) { config.headers.Authorization = `Bearer ${token}` } + const merchantId = localStorage.getItem('merchant_id') + if (merchantId) { + config.headers['X-Merchant-ID'] = merchantId + } return config }) diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 3fcc086..3051d2d 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -20,6 +20,7 @@ import { MenuUnfoldOutlined, SendOutlined, ApiOutlined, + ShopOutlined, } from '@ant-design/icons' import { useAuth } from '../store/auth' import type { MenuProps } from 'antd' @@ -40,10 +41,12 @@ export default function MainLayout() { { key: '/', icon: , label: '数据概览' }, { key: '/skins', icon: , label: '皮肤商品' }, { key: '/orders', icon: , label: '订单管理' }, + { key: '/merchant-center', icon: , label: '商户中心' }, ] if (isAdmin) { items.push( { key: '/distributors', icon: , label: '分销商' }, + { key: '/platform-merchants', icon: , label: '商户管理' }, { key: '/ship-logs', icon: , label: '发货记录' }, { key: '/open-api', icon: , label: '开放接口' }, ) diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx new file mode 100644 index 0000000..a9a1e67 --- /dev/null +++ b/frontend/src/pages/MerchantCenter.tsx @@ -0,0 +1,731 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Button, + Descriptions, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Table, + Tabs, + Tag, + Typography, + message, +} from 'antd' +import { + ApiOutlined, + CopyOutlined, + PlusOutlined, + ReloadOutlined, + WalletOutlined, +} from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import dayjs from 'dayjs' +import { merchantApi } from '../api' +import type { + ApiClient, + ApiCredential, + CallbackCredential, + CallbackSubscription, + FulfillmentOrder, + Merchant, + MerchantMember, + MerchantProduct, + PageResult, + WalletAccount, + WalletLedgerEntry, +} from '../types' + +const fulfillmentStatusMap: Record = { + pending: { color: 'orange', text: '待履约' }, + processing: { color: 'cyan', text: '履约中' }, + succeeded: { color: 'green', text: '已成功' }, + failed: { color: 'red', text: '已失败' }, + cancelled: { color: 'default', text: '已取消' }, +} + +const paymentStatusMap: Record = { + pending: { color: 'orange', text: '待支付' }, + paid: { color: 'blue', text: '已支付' }, + refunded: { color: 'purple', text: '已退款' }, + cancelled: { color: 'default', text: '已取消' }, +} + +const memberRoleOptions = [ + { value: 'owner', label: '负责人' }, + { value: 'operator', label: '运营' }, + { value: 'finance', label: '财务' }, + { value: 'viewer', label: '只读' }, +] + +const scopeOptions = [ + { value: 'products:read', label: '商品读取' }, + { value: 'orders:read', label: '订单读取' }, + { value: 'orders:write', label: '订单写入' }, + { value: 'fulfillment:read', label: '履约读取' }, + { value: 'fulfillment:write', label: '履约写入' }, + { value: 'wallet:read', label: '钱包读取' }, +] + +const eventOptions = [ + { value: 'order.created', label: '订单创建' }, + { value: 'order.fulfillment.updated', label: '履约更新' }, + { value: 'order.cancelled', label: '订单取消' }, +] + +export default function MerchantCenter() { + const [merchant, setMerchant] = useState(null) + const [merchantRole, setMerchantRole] = useState() + const [activeTab, setActiveTab] = useState('products') + const [products, setProducts] = useState>({ list: [], total: 0, page: 1, size: 10 }) + const [orders, setOrders] = useState>({ list: [], total: 0, page: 1, size: 10 }) + const [ledger, setLedger] = useState>({ list: [], total: 0, page: 1, size: 10 }) + const [wallet, setWallet] = useState(null) + const [apiClients, setApiClients] = useState([]) + const [callbacks, setCallbacks] = useState([]) + const [members, setMembers] = useState([]) + const [loading, setLoading] = useState(false) + const [productOpen, setProductOpen] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) + const [walletOpen, setWalletOpen] = useState(false) + const [apiClientOpen, setApiClientOpen] = useState(false) + const [apiCredential, setApiCredential] = useState(null) + const [callbackOpen, setCallbackOpen] = useState(false) + const [callbackCredential, setCallbackCredential] = useState(null) + const [memberOpen, setMemberOpen] = useState(false) + const [productForm] = Form.useForm() + const [walletForm] = Form.useForm() + const [apiClientForm] = Form.useForm() + const [callbackForm] = Form.useForm() + const [memberForm] = Form.useForm() + + const canManage = merchantRole === 'owner' || merchantRole === 'operator' + const canFinance = merchantRole === 'owner' || merchantRole === 'finance' + + const loadCurrent = useCallback(async () => { + const data = await merchantApi.current() + setMerchant(data.merchant) + setMerchantRole(data.role) + }, []) + + const loadProducts = useCallback(async (page = products.page, size = products.size) => { + const data = await merchantApi.products({ page, size }) + setProducts(data) + }, [products.page, products.size]) + + const loadOrders = useCallback(async (page = orders.page, size = orders.size) => { + const data = await merchantApi.orders({ page, size }) + setOrders(data) + }, [orders.page, orders.size]) + + const loadWallet = useCallback(async (page = ledger.page, size = ledger.size) => { + const [walletData, ledgerData] = await Promise.all([ + merchantApi.wallet(), + merchantApi.ledger({ page, size }), + ]) + setWallet(walletData) + setLedger(ledgerData) + }, [ledger.page, ledger.size]) + + const loadIntegrations = useCallback(async () => { + const [clientData, callbackData, memberData] = await Promise.all([ + merchantApi.apiClients(), + merchantApi.callbacks(), + merchantApi.members(), + ]) + setApiClients(clientData || []) + setCallbacks(callbackData || []) + setMembers(memberData || []) + }, []) + + const loadAll = useCallback(async () => { + setLoading(true) + try { + await loadCurrent() + await Promise.all([loadProducts(), loadOrders(), loadWallet(), loadIntegrations()]) + } catch (e) { + message.error(e instanceof Error ? e.message : '加载失败') + } finally { + setLoading(false) + } + }, [loadCurrent, loadProducts, loadOrders, loadWallet, loadIntegrations]) + + useEffect(() => { + loadAll() + }, [loadAll]) + + const openProductCreate = () => { + setEditingProduct(null) + productForm.resetFields() + productForm.setFieldsValue({ + currency: 'CNY', + stock: -1, + status: 'active', + price_yuan: 0, + cost_yuan: 0, + }) + setProductOpen(true) + } + + const openProductEdit = (record: MerchantProduct) => { + setEditingProduct(record) + productForm.setFieldsValue({ + ...record, + price_yuan: centsToYuan(record.price_amount), + cost_yuan: centsToYuan(record.cost_amount), + }) + setProductOpen(true) + } + + const submitProduct = async () => { + const values = await productForm.validateFields() + try { + const payload = { + ...values, + price_amount: yuanToCents(values.price_yuan), + cost_amount: yuanToCents(values.cost_yuan), + } + delete payload.price_yuan + delete payload.cost_yuan + if (editingProduct) { + await merchantApi.updateProduct(editingProduct.id, payload) + message.success('商品已更新') + } else { + await merchantApi.createProduct(payload) + message.success('商品已创建') + } + setProductOpen(false) + loadProducts() + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败') + } + } + + const submitWalletAdjust = async () => { + const values = await walletForm.validateFields() + try { + const walletData = await merchantApi.adjustWallet({ + amount: yuanToCents(values.amount_yuan), + idempotency_key: values.idempotency_key, + note: values.note, + }) + setWallet(walletData) + setWalletOpen(false) + message.success('钱包已调整') + loadWallet() + } catch (e) { + message.error(e instanceof Error ? e.message : '调整失败') + } + } + + const submitAPIClient = async () => { + const values = await apiClientForm.validateFields() + try { + const credential = await merchantApi.createApiClient({ + name: values.name, + scopes: (values.scopes || []).join(','), + signature_version: values.signature_version, + expires_at: values.expires_at, + }) + setApiCredential(credential) + setApiClientOpen(false) + message.success('API 客户端已创建') + loadIntegrations() + } catch (e) { + message.error(e instanceof Error ? e.message : '创建失败') + } + } + + const submitCallback = async () => { + const values = await callbackForm.validateFields() + try { + const credential = await merchantApi.createCallback({ + name: values.name, + url: values.url, + events: (values.events || []).join(','), + }) + setCallbackCredential(credential) + setCallbackOpen(false) + message.success('回调已创建') + loadIntegrations() + } catch (e) { + message.error(e instanceof Error ? e.message : '创建失败') + } + } + + const submitMember = async () => { + const values = await memberForm.validateFields() + try { + await merchantApi.addMember({ + user_id: values.user_id, + role: values.role, + is_default: values.is_default, + }) + setMemberOpen(false) + message.success('成员已添加') + loadIntegrations() + } catch (e) { + message.error(e instanceof Error ? e.message : '添加失败') + } + } + + const toggleAPIClient = async (record: ApiClient) => { + try { + await merchantApi.updateApiClientStatus(record.id, record.status === 'active' ? 'disabled' : 'active') + message.success('状态已更新') + loadIntegrations() + } catch (e) { + message.error(e instanceof Error ? e.message : '更新失败') + } + } + + const toggleCallback = async (record: CallbackSubscription) => { + try { + await merchantApi.updateCallbackStatus(record.id, record.status === 'active' ? 'disabled' : 'active') + message.success('状态已更新') + loadIntegrations() + } catch (e) { + message.error(e instanceof Error ? e.message : '更新失败') + } + } + + const productColumns: ColumnsType = [ + { title: 'SKU', dataIndex: 'sku', width: 180, render: (v) => {v} }, + { title: '名称', dataIndex: 'display_name', ellipsis: true, render: (_, r) => r.display_name || r.product?.name || '-' }, + { title: '目录编码', dataIndex: ['product', 'code'], width: 160, ellipsis: true, render: (_, r) => r.product?.code || '-' }, + { title: '售价', dataIndex: 'price_amount', width: 110, render: money }, + { title: '成本', dataIndex: 'cost_amount', width: 110, render: money }, + { title: '库存', dataIndex: 'stock', width: 90, render: (v) => (v < 0 ? '无限' : v) }, + { title: '状态', dataIndex: 'status', width: 90, render: productStatusTag }, + { + title: '操作', + key: 'action', + width: 90, + render: (_, record) => canManage ? ( + + ) : '-', + }, + ] + + const orderColumns: ColumnsType = [ + { title: '平台订单号', dataIndex: 'order_no', width: 210, render: (v) => {v} }, + { title: '商户单号', dataIndex: 'client_order_no', width: 160, ellipsis: true }, + { title: 'SKU', dataIndex: 'product_sku', width: 150, render: (v) => {v} }, + { title: '商品', dataIndex: 'product_name', ellipsis: true }, + { title: '金额', dataIndex: 'amount', width: 100, render: money }, + { title: '支付', dataIndex: 'payment_status', width: 90, render: paymentStatusTag }, + { title: '履约', dataIndex: 'fulfillment_status', width: 100, render: fulfillmentStatusTag }, + { title: '上游单号', dataIndex: 'provider_order_no', width: 140, ellipsis: true, render: (v) => v || '-' }, + { title: '时间', dataIndex: 'created_at', width: 160, render: formatTime }, + ] + + const ledgerColumns: ColumnsType = [ + { title: '流水号', dataIndex: 'entry_no', width: 210, ellipsis: true }, + { title: '类型', dataIndex: 'type', width: 90, render: ledgerTypeTag }, + { title: '金额', dataIndex: 'amount', width: 110, render: moneyWithSign }, + { title: '余额', dataIndex: 'balance_after', width: 110, render: money }, + { title: '关联单号', dataIndex: 'reference_no', ellipsis: true }, + { title: '备注', dataIndex: 'note', ellipsis: true, render: (v) => v || '-' }, + { title: '时间', dataIndex: 'created_at', width: 160, render: formatTime }, + ] + + const apiClientColumns: ColumnsType = [ + { title: '名称', dataIndex: 'name', width: 180, ellipsis: true }, + { title: 'App Key', dataIndex: 'app_key', width: 260, render: (v) => {v} }, + { title: '签名', dataIndex: 'signature_version', width: 110, render: (v) => {v} }, + { title: '权限', dataIndex: 'scopes', ellipsis: true }, + { title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag }, + { title: '最后使用', dataIndex: 'last_used_at', width: 160, render: formatTime }, + { + title: '操作', + key: 'action', + width: 90, + render: (_, record) => canManage ? ( + + ) : '-', + }, + ] + + const callbackColumns: ColumnsType = [ + { title: '名称', dataIndex: 'name', width: 180, ellipsis: true }, + { title: 'URL', dataIndex: 'url', ellipsis: true }, + { title: '事件', dataIndex: 'events', width: 260, ellipsis: true }, + { title: '状态', dataIndex: 'status', width: 90, render: activeStatusTag }, + { title: '创建时间', dataIndex: 'created_at', width: 160, render: formatTime }, + { + title: '操作', + key: 'action', + width: 90, + render: (_, record) => canManage ? ( + + ) : '-', + }, + ] + + const memberColumns: ColumnsType = [ + { title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` }, + { title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' }, + { title: '角色', dataIndex: 'role', width: 120, render: memberRoleTag }, + { title: '默认', dataIndex: 'is_default', width: 80, render: (v) => (v ? 默认 : '-') }, + { title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? 启用 : 禁用) }, + ] + + return ( +
+ +
+ + 商户中心 + + + {merchant ? `${merchant.name} / ${merchant.code}` : '加载中'} · {merchantRole ? roleText(merchantRole) : '-'} + +
+ +
+ + + + 通用商品以商户 SKU 对外销售。 + {canManage && } + + + + ), + }, + { + key: 'orders', + label: '履约订单', + children: ( +
+ ), + }, + { + key: 'wallet', + label: '钱包', + children: ( + + + + {money(wallet?.available_balance ?? 0)} + {money(wallet?.frozen_balance ?? 0)} + {wallet?.currency || 'CNY'} + + {canFinance && ( + + )} + +
+ + ), + }, + { + key: 'api', + label: 'API 客户端', + children: ( + + + 新密钥只在创建后显示一次。 + {canManage && } + +
+ + ), + }, + { + key: 'callbacks', + label: '回调', + children: ( + + + 回调事件通过 outbox 持久化发送。 + {canManage && } + +
+ + ), + }, + { + key: 'members', + label: '成员', + children: ( + + + 成员角色独立于平台账号角色。 + {merchantRole === 'owner' && } + +
+ + ), + }, + ]} + /> + + setProductOpen(false)} destroyOnClose width={620}> +
+ {!editingProduct && ( + <> + + + + + + + + )} + + + + + + + + + + + + + + + + + + + {!editingProduct && ( + + + + )} + + + + + +
+ + setWalletOpen(false)} destroyOnClose> +
+ + + + + + + + + + +
+ + setApiClientOpen(false)} destroyOnClose> +
+ + + + + + + +
+ + setApiCredential(null)} footer={}> + {apiCredential && } + + + setCallbackOpen(false)} destroyOnClose> +
+ + + + + + + + + + +
{v}, - }, - { title: '说明', dataIndex: 'desc' }, - ]} - /> - - -
{v}, - }, - { - title: 'can_ship', - dataIndex: 'ship', - render: (v) => - v === 'true' ? true : false, - }, - { title: '说明', dataIndex: 'note' }, - ]} - /> - - - ), - }, - { - key: 'notify', - label: '发货推送', - children: ( - - - - POST - /api/open/v1/orders/ship-notify - - - 除签名头外,Body 参与签名。Body 示例: - -
{`{
-  "order_no": "O202607201550038000",
-  "ship_status": "success",
-  "provider_order_no": "SRC20260720001",
-  "shipped_at": "2026-07-20T16:00:00+08:00",
-  "fail_reason": ""
-}`}
-
- - - 必填,店铺订单号 - - 必填:success / failed / processing - - 可选,上游单号 - - 可选,RFC3339;success 缺省用服务端时间 - - 可选,失败原因 - - - -
{v}, - }, - { - title: '订单状态', - dataIndex: 'order', - render: (v) => {v}, - }, - { title: '说明', dataIndex: 'note' }, - ]} - /> - - - - ), - }, - { - key: 'errors', - label: '错误码', + key: 'endpoints', + label: '接口', children: (
{v} }, + { title: '路径', dataIndex: 'path', render: (v) => {v} }, + { title: '权限', dataIndex: 'scope', width: 150, render: (v) => {v} }, + { title: '说明', dataIndex: 'desc' }, ]} /> - - 商品 sku 对照: - 皮肤商品列表 - (字段「英文名」)或仓库 docs/商品英文名对照.md - + + ), + }, + { + key: 'order', + label: '下单', + children: ( + + + + POST + /api/client/v1/orders + +
{`{
+  "client_order_no": "shop-10001",
+  "sku": "sku-basic",
+  "quantity": 1,
+  "buyer_reference": "buyer-or-account",
+  "data": {
+    "server": "ios-wechat",
+    "uid": "player-id"
+  }
+}`}
+
+ +
{`{
+  "code": 0,
+  "message": "ok",
+  "data": {
+    "idempotent": false,
+    "order": {
+      "order_no": "FO202607300001...",
+      "client_order_no": "shop-10001",
+      "payment_status": "paid",
+      "fulfillment_status": "pending",
+      "can_fulfill": true
+    }
+  }
+}`}
+
+
+ ), + }, + { + key: 'status', + label: '状态', + children: ( + +
{v} }, + { title: 'can_fulfill', dataIndex: 'can', width: 120, render: (v) => v === 'true' ? true : false }, + { title: '说明', dataIndex: 'desc' }, + ]} + /> + + ), + }, + { + key: 'callback', + label: '回调', + children: ( + + + order.created / order.fulfillment.updated / order.cancelled + X-Event-ID、X-Timestamp、X-Sign + hex(HMAC-SHA256(callback_secret, timestamp + "\n" + sha256(body))) + 持久化 outbox,失败指数退避重试 + ), }, diff --git a/frontend/src/pages/PlatformMerchants.tsx b/frontend/src/pages/PlatformMerchants.tsx new file mode 100644 index 0000000..1d3a645 --- /dev/null +++ b/frontend/src/pages/PlatformMerchants.tsx @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Button, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd' +import { PlusOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import dayjs from 'dayjs' +import { useNavigate } from 'react-router-dom' +import { platformApi } from '../api' +import type { Merchant, MerchantMember, PageResult } from '../types' + +const memberRoleOptions = [ + { value: 'owner', label: '负责人' }, + { value: 'operator', label: '运营' }, + { value: 'finance', label: '财务' }, + { value: 'viewer', label: '只读' }, +] + +export default function PlatformMerchants() { + const navigate = useNavigate() + const [data, setData] = useState>({ list: [], total: 0, page: 1, size: 10 }) + const [loading, setLoading] = useState(false) + const [createOpen, setCreateOpen] = useState(false) + const [memberOpen, setMemberOpen] = useState(false) + const [selectedMerchant, setSelectedMerchant] = useState(null) + const [createForm] = Form.useForm() + const [memberForm] = Form.useForm() + + const load = useCallback(async (page = data.page, size = data.size) => { + setLoading(true) + try { + const result = await platformApi.merchants({ page, size }) + setData(result) + } catch (e) { + message.error(e instanceof Error ? e.message : '加载失败') + } finally { + setLoading(false) + } + }, [data.page, data.size]) + + useEffect(() => { + load() + }, [load]) + + const submitCreate = async () => { + const values = await createForm.validateFields() + try { + await platformApi.createMerchant(values) + message.success('商户已创建') + setCreateOpen(false) + createForm.resetFields() + load() + } catch (e) { + message.error(e instanceof Error ? e.message : '创建失败') + } + } + + const submitMember = async () => { + if (!selectedMerchant) return + const values = await memberForm.validateFields() + try { + await platformApi.addMember(selectedMerchant.id, { + user_id: values.user_id, + role: values.role as MerchantMember['role'], + is_default: values.is_default, + }) + message.success('成员已添加') + setMemberOpen(false) + } catch (e) { + message.error(e instanceof Error ? e.message : '添加失败') + } + } + + const columns: ColumnsType = [ + { title: 'ID', dataIndex: 'id', width: 80 }, + { title: '编码', dataIndex: 'code', width: 180, render: (v) => {v} }, + { title: '名称', dataIndex: 'name', ellipsis: true }, + { title: '联系人', dataIndex: 'contact_name', width: 120, render: (v) => v || '-' }, + { title: '联系方式', dataIndex: 'contact_info', width: 160, render: (v) => v || '-' }, + { title: '状态', dataIndex: 'status', width: 90, render: (v) => v === 'active' ? 启用 : 禁用 }, + { title: '创建时间', dataIndex: 'created_at', width: 160, render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm') }, + { + title: '操作', + key: 'action', + width: 190, + render: (_, record) => ( + + + + + ), + }, + ] + + return ( +
+ +
+ + 商户管理 + + 平台管理员维护租户与授权关系。 +
+ + + + +
+ +
`共 ${total} 条`, + onChange: load, + }} + /> + + setCreateOpen(false)} destroyOnClose> + + + + + + + + + + + + + + + + + + + + setMemberOpen(false)} + destroyOnClose + > +
+ + + + + + + +
+ + ) +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6de201a..070486d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -9,6 +9,128 @@ export interface User { created_at: string } +export interface Merchant { + id: number + code: string + name: string + status: 'active' | 'disabled' + contact_name?: string + contact_info?: string + created_at: string +} + +export interface MerchantMember { + id: number + merchant_id: number + user_id: number + role: 'owner' | 'operator' | 'finance' | 'viewer' + status: number + is_default: boolean + user?: User + merchant?: Merchant + created_at: string +} + +export interface Product { + id: number + code: string + name: string + category: string + description: string + attributes: string + status: 'active' | 'inactive' +} + +export interface MerchantProduct { + id: number + merchant_id: number + product_id: number + sku: string + display_name: string + price_amount: number + cost_amount: number + currency: string + stock: number + status: 'active' | 'inactive' + fulfillment_config: string + product?: Product + created_at: string +} + +export interface WalletAccount { + id: number + merchant_id: number + currency: string + available_balance: number + frozen_balance: number + updated_at: string +} + +export interface WalletLedgerEntry { + id: number + merchant_id: number + entry_no: string + type: 'credit' | 'debit' | 'refund' | 'adjust' + amount: number + balance_after: number + reference_type: string + reference_no: string + note: string + created_at: string +} + +export interface FulfillmentOrder { + id: number + order_no: string + client_order_no: string + product_sku: string + product_name: string + quantity: number + amount: number + currency: string + payment_status: string + fulfillment_status: string + buyer_reference: string + provider_order_no: string + failure_reason: string + created_at: string + delivered_at?: string | null + cancelled_at?: string | null +} + +export interface ApiClient { + id: number + merchant_id: number + name: string + app_key: string + signature_version: string + scopes: string + status: 'active' | 'disabled' + expires_at?: string | null + last_used_at?: string | null + created_at: string +} + +export interface ApiCredential { + client: ApiClient + secret: string +} + +export interface CallbackSubscription { + id: number + merchant_id: number + name: string + url: string + events: string + status: 'active' | 'disabled' + created_at: string +} + +export interface CallbackCredential { + subscription: CallbackSubscription + secret: string +} + export interface Skin { id: number name: string