实现多商户履约平台基础

This commit is contained in:
yml2213
2026-07-30 12:02:32 +08:00
parent dde6f2c269
commit 83429456a6
41 changed files with 5487 additions and 504 deletions
+1
View File
@@ -1,5 +1,6 @@
PORT=8080 PORT=8080
JWT_SECRET=your_very_secure_jwt_secret_here_change_me_in_production 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_USER=affiliate
POSTGRES_PASSWORD=affiliate_dev_password POSTGRES_PASSWORD=affiliate_dev_password
POSTGRES_DB=affiliate_dash POSTGRES_DB=affiliate_dash
+22 -6
View File
@@ -1,11 +1,12 @@
package main package main
import ( import (
"context"
"log" "log"
"affiliate_dash/internal/config" "affiliate_dash/internal/config"
"affiliate_dash/internal/database"
"affiliate_dash/internal/handler" "affiliate_dash/internal/handler"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/applog" "affiliate_dash/internal/pkg/applog"
"affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/pkg/openlog" "affiliate_dash/internal/pkg/openlog"
@@ -38,15 +39,23 @@ func main() {
log.Fatalf("open db: %v", err) 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) log.Fatalf("migrate: %v", err)
} }
jm := jwt.NewManager(cfg.JWTSecret) 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) skinSvc := service.NewSkinService(db)
orderSvc := service.NewOrderService(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 { if err := authSvc.EnsureAdmin(); err != nil {
log.Fatalf("ensure admin: %v", err) log.Fatalf("ensure admin: %v", err)
@@ -62,20 +71,27 @@ func main() {
Skin: handler.NewSkinHandler(skinSvc), Skin: handler.NewSkinHandler(skinSvc),
Order: handler.NewOrderHandler(orderSvc), Order: handler.NewOrderHandler(orderSvc),
User: handler.NewUserHandler(userSvc), 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, JWT: jm,
Tenant: tenantSvc,
OpenDB: db,
SecretCodec: codec,
OpenAPIKey: cfg.OpenAPIKey, OpenAPIKey: cfg.OpenAPIKey,
OpenAPISecret: cfg.OpenAPISecret, OpenAPISecret: cfg.OpenAPISecret,
OpenSignSkew: cfg.OpenSignSkew, OpenSignSkew: cfg.OpenSignSkew,
OpenAPIDebug: cfg.OpenAPIDebug, OpenAPIDebug: cfg.OpenAPIDebug,
} }
go callbackSvc.Run(context.Background())
r := router.Setup(h) r := router.Setup(h)
addr := ":" + cfg.Port addr := ":" + cfg.Port
log.Printf("游戏皮肤分销系统 API 启动: http://localhost%s", addr) log.Printf("游戏皮肤分销系统 API 启动: http://localhost%s", addr)
log.Printf("数据库: PostgreSQL") log.Printf("数据库: PostgreSQL")
log.Printf("默认管理员: admin / admin123") 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 { if cfg.OpenAPIDebug {
log.Printf("开放接口调试日志: 开启 (OPEN_API_DEBUG=0 可关闭)") log.Printf("开放接口调试日志: 开启 (OPEN_API_DEBUG=0 可关闭)")
} }
-4
View File
@@ -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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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= 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 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= 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 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+12 -9
View File
@@ -14,6 +14,8 @@ type Config struct {
JWTSecret string JWTSecret string
DatabaseURL string DatabaseURL string
Mode string // debug / release Mode string // debug / release
// DataEncryptionKey 用于加密保存 API/回调密钥;为空时由 JWT_SECRET 派生。
DataEncryptionKey string
// OpenAPIKey 皮肤源头开放接口标识(Header: X-Api-Key,可公开给对接方) // OpenAPIKey 皮肤源头开放接口标识(Header: X-Api-Key,可公开给对接方)
OpenAPIKey string OpenAPIKey string
// OpenAPISecret 签名密钥(仅用于 HMAC,不走 Header // OpenAPISecret 签名密钥(仅用于 HMAC,不走 Header
@@ -33,15 +35,16 @@ func Load() *Config {
// OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启 // OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启
debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "") debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "")
return &Config{ return &Config{
Port: getEnv("PORT", "8080"), Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"), 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"), DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"),
Mode: mode, Mode: mode,
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"), DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")),
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"), OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)), OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
OpenAPIDebug: debugOpen, OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
LogFile: getEnv("LOG_FILE", "logs/app.log"), OpenAPIDebug: debugOpen,
LogFile: getEnv("LOG_FILE", "logs/app.log"),
} }
} }
+168
View File
@@ -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
}
@@ -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);
+383
View File
@@ -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
}
+221
View File
@@ -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
}
+13 -10
View File
@@ -23,9 +23,10 @@ func (h *OrderHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
q := service.OrderListQuery{ q := service.OrderListQuery{
Page: page, MerchantID: middleware.GetMerchantID(c),
Size: size, Page: page,
Status: c.Query("status"), Size: size,
Status: c.Query("status"),
} }
// 分销商只能看自己的订单 // 分销商只能看自己的订单
if middleware.GetRole(c) == model.RoleDistributor { if middleware.GetRole(c) == model.RoleDistributor {
@@ -45,11 +46,11 @@ func (h *OrderHandler) List(c *gin.Context) {
} }
type createOrderReq struct { type createOrderReq struct {
SkinID uint `json:"skin_id" binding:"required"` SkinID uint `json:"skin_id" binding:"required"`
BuyerName string `json:"buyer_name"` BuyerName string `json:"buyer_name"`
Remark string `json:"remark"` Remark string `json:"remark"`
Status string `json:"status"` // 管理员可指定初始状态(联调造异常单) Status string `json:"status"` // 管理员可指定初始状态(联调造异常单)
DistributorID *uint `json:"distributor_id"` // 管理员可指定分销商 DistributorID *uint `json:"distributor_id"` // 管理员可指定分销商
} }
func (h *OrderHandler) Create(c *gin.Context) { func (h *OrderHandler) Create(c *gin.Context) {
@@ -75,6 +76,7 @@ func (h *OrderHandler) Create(c *gin.Context) {
req.BuyerName = "测试买家" req.BuyerName = "测试买家"
} }
order, err := h.svc.Create(service.CreateOrderInput{ order, err := h.svc.Create(service.CreateOrderInput{
MerchantID: middleware.GetMerchantID(c),
SkinID: req.SkinID, SkinID: req.SkinID,
DistributorID: distributorID, DistributorID: distributorID,
BuyerName: req.BuyerName, BuyerName: req.BuyerName,
@@ -99,7 +101,7 @@ func (h *OrderHandler) UpdateStatus(c *gin.Context) {
response.BadRequest(c, "参数错误") response.BadRequest(c, "参数错误")
return 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()) response.BadRequest(c, err.Error())
return return
} }
@@ -107,7 +109,7 @@ func (h *OrderHandler) UpdateStatus(c *gin.Context) {
} }
func (h *OrderHandler) Dashboard(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 { if err != nil {
response.ServerError(c, err.Error()) response.ServerError(c, err.Error())
return return
@@ -120,6 +122,7 @@ func (h *OrderHandler) ListShipLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
list, total, err := h.svc.ListShipLogs(service.ShipLogListQuery{ list, total, err := h.svc.ListShipLogs(service.ShipLogListQuery{
MerchantID: middleware.GetMerchantID(c),
Page: page, Page: page,
Size: size, Size: size,
OrderNo: c.Query("order_no"), OrderNo: c.Query("order_no"),
+11 -8
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"strconv" "strconv"
"affiliate_dash/internal/middleware"
"affiliate_dash/internal/model" "affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/response" "affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service" "affiliate_dash/internal/service"
@@ -22,11 +23,12 @@ func (h *SkinHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
q := service.SkinListQuery{ q := service.SkinListQuery{
Page: page, MerchantID: middleware.GetMerchantID(c),
Size: size, Page: page,
Keyword: c.Query("keyword"), Size: size,
Game: c.Query("game"), Keyword: c.Query("keyword"),
Category: c.Query("category"), Game: c.Query("game"),
Category: c.Query("category"),
} }
if s := c.Query("status"); s != "" { if s := c.Query("status"); s != "" {
v, _ := strconv.Atoi(s) v, _ := strconv.Atoi(s)
@@ -42,7 +44,7 @@ func (h *SkinHandler) List(c *gin.Context) {
func (h *SkinHandler) Get(c *gin.Context) { func (h *SkinHandler) Get(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) 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 { if err != nil {
response.NotFound(c, err.Error()) response.NotFound(c, err.Error())
return return
@@ -79,6 +81,7 @@ func (h *SkinHandler) Create(c *gin.Context) {
req.Game = "和平精英" req.Game = "和平精英"
} }
skin := &model.Skin{ skin := &model.Skin{
MerchantID: middleware.GetMerchantID(c),
Name: req.Name, Name: req.Name,
SKU: req.SKU, SKU: req.SKU,
Game: req.Game, Game: req.Game,
@@ -108,7 +111,7 @@ func (h *SkinHandler) Update(c *gin.Context) {
delete(updates, "id") delete(updates, "id")
delete(updates, "created_at") delete(updates, "created_at")
delete(updates, "updated_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()) response.BadRequest(c, err.Error())
return return
} }
@@ -117,7 +120,7 @@ func (h *SkinHandler) Update(c *gin.Context) {
func (h *SkinHandler) Delete(c *gin.Context) { func (h *SkinHandler) Delete(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64) 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()) response.BadRequest(c, err.Error())
return return
} }
+7 -5
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"strconv" "strconv"
"affiliate_dash/internal/middleware"
"affiliate_dash/internal/pkg/response" "affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service" "affiliate_dash/internal/service"
@@ -21,10 +22,11 @@ func (h *UserHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
q := service.UserListQuery{ q := service.UserListQuery{
Page: page, MerchantID: middleware.GetMerchantID(c),
Size: size, Page: page,
Keyword: c.Query("keyword"), Size: size,
Role: c.Query("role"), Keyword: c.Query("keyword"),
Role: c.Query("role"),
} }
if s := c.Query("status"); s != "" { if s := c.Query("status"); s != "" {
v, _ := strconv.Atoi(s) v, _ := strconv.Atoi(s)
@@ -52,7 +54,7 @@ func (h *UserHandler) Create(c *gin.Context) {
response.BadRequest(c, "参数错误") response.BadRequest(c, "参数错误")
return 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 { if err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
+44 -3
View File
@@ -5,14 +5,16 @@ import (
"affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/pkg/response" "affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
const ( const (
CtxUserID = "user_id" CtxUserID = "user_id"
CtxUsername = "username" CtxUsername = "username"
CtxRole = "role" CtxRole = "role"
CtxMerchantRole = "merchant_role"
) )
func Auth(jm *jwt.Manager) gin.HandlerFunc { func Auth(jm *jwt.Manager) gin.HandlerFunc {
@@ -70,3 +72,42 @@ func GetRole(c *gin.Context) string {
role, _ := v.(string) role, _ := v.(string)
return role 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
}
+131 -135
View File
@@ -6,191 +6,192 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"io" "io"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/openlog" "affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/response" "affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin" "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 { type OpenAuthConfig struct {
APIKey string DB *gorm.DB
APISecret string Codec *service.SecretCodec
// 允许的时间偏差(秒),默认 300
SkewSeconds int64 SkewSeconds int64
// Debug 详细日志 Debug bool
Debug bool
} }
// nonce 防重放(进程内,重启清空;生产可换 Redis) // OpenAuth 校验独立 API 客户端、时间戳、持久化 nonce 与 HMAC 签名。
type nonceStore struct { // 客户侧新接口只使用 X-App-Key 和 body SHA256;上游发货接口独立使用 SourceOpenAuth。
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)),小写十六进制。
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc { func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
if cfg.SkewSeconds <= 0 { if cfg.SkewSeconds <= 0 {
cfg.SkewSeconds = 300 cfg.SkewSeconds = 300
} }
store := newNonceStore()
return func(c *gin.Context) { return func(c *gin.Context) {
reqID := openlog.EnsureReqID(c) reqID := openlog.EnsureReqID(c)
c.Set(openlog.CtxDebug, cfg.Debug) c.Set(openlog.CtxDebug, cfg.Debug)
c.Set(openlog.CtxStart, time.Now()) c.Set(openlog.CtxStart, time.Now())
c.Header("X-Request-Id", reqID) c.Header("X-Request-Id", reqID)
if cfg.APIKey == "" || cfg.APISecret == "" { if cfg.DB == nil || cfg.Codec == nil {
openlog.Warn(c, "auth_fail reason=server_not_configured") response.ServerError(c, "开放接口认证服务未初始化")
response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET") 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() c.Abort()
return 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) bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil { if err != nil {
openlog.Warn(c, "auth_fail reason=read_body_error err=%v ip=%s", err, clientIP)
response.BadRequest(c, "读取请求体失败") response.BadRequest(c, "读取请求体失败")
c.Abort() c.Abort()
return return
} }
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
body := string(bodyBytes)
c.Set(openlog.CtxBody, body)
method := strings.ToUpper(c.Request.Method) var client model.APIClient
path := c.Request.URL.Path if err := cfg.DB.Where("app_key = ? AND status = ?", appKey, model.APIClientStatusActive).First(&client).Error; err != nil {
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))
response.Unauthorized(c, "无效的 API Key") response.Unauthorized(c, "无效的 API Key")
c.Abort() c.Abort()
return return
} }
if len(nonce) < 8 || len(nonce) > 64 { if client.ExpiresAt != nil && client.ExpiresAt.Before(time.Now()) {
openlog.Warn(c, "auth_fail reason=bad_nonce_len len=%d", len(nonce)) response.Unauthorized(c, "API Key 已过期")
response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间")
c.Abort() c.Abort()
return return
} }
secret, err := cfg.Codec.Decrypt(client.SecretCiphertext)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil { if err != nil {
openlog.Warn(c, "auth_fail reason=bad_timestamp raw=%s", timestamp) response.ServerError(c, "API 客户端密钥不可用")
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, "请求已过期或时间偏差过大")
c.Abort() c.Abort()
return return
} }
if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) { method := strings.ToUpper(c.Request.Method)
openlog.Warn(c, "auth_fail reason=replay_nonce nonce=%s", nonce) path := c.Request.URL.Path
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)") expected := BuildOpenV1Sign(secret, timestamp, nonce, method, path, bodyBytes)
c.Abort()
return
}
stringToSign := BuildSignString(apiKey, timestamp, nonce, method, path, body)
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) { 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, "签名校验失败") response.Unauthorized(c, "签名校验失败")
c.Abort() c.Abort()
return return
} }
c.Set(openlog.CtxAPIKey, apiKey) now := time.Now()
openlog.Info(c, "auth_ok skew=%ds string_to_sign_len=%d", skew, len(stringToSign)) nonceTTL := time.Duration(cfg.SkewSeconds) * time.Second
c.Next() _ = 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 里会打业务结果) c.Set(CtxAPIClient, &client)
openlog.Info(c, "request done status=%d cost=%s", c.Writer.Status(), openlog.Elapsed(c).Round(time.Microsecond)) 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 { func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string {
params := map[string]string{ return strings.Join([]string{
"api_key": apiKey, "api_key=" + apiKey,
"body": body, "body=" + body,
"method": strings.ToUpper(method), "method=" + strings.ToUpper(method),
"nonce": nonce, "nonce=" + nonce,
"path": path, "path=" + path,
"timestamp": timestamp, "timestamp=" + timestamp,
} }, "&")
keys := make([]string, 0, len(params)) }
for k := range params {
keys = append(keys, k) // BuildOpenSign 供旧发货系统兼容使用。
} func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
sort.Strings(keys) return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+params[k])
}
return strings.Join(parts, "&")
} }
func hmacSHA256Hex(secret, content string) string { func hmacSHA256Hex(secret, content string) string {
@@ -199,14 +200,9 @@ func hmacSHA256Hex(secret, content string) string {
return hex.EncodeToString(mac.Sum(nil)) return hex.EncodeToString(mac.Sum(nil))
} }
func abs64(v int64) int64 { func abs64(value int64) int64 {
if v < 0 { if value < 0 {
return -v return -value
} }
return v return value
}
// BuildOpenSign 供测试或内部生成签名(与 OpenAuth 规则一致)
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
} }
@@ -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())
}
}
@@ -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()
}
}
+270
View File
@@ -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"`
}
+25 -22
View File
@@ -8,7 +8,7 @@ import (
// 用户角色 // 用户角色
const ( const (
RoleAdmin = "admin" // 管理员 RoleAdmin = "admin" // 管理员
RoleDistributor = "distributor" // 分销商 RoleDistributor = "distributor" // 分销商
) )
@@ -35,16 +35,17 @@ type Skin struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Name string `gorm:"size:128;not null" json:"name"` // 中文名(展示用,保持原名) MerchantID uint `gorm:"index;uniqueIndex:idx_skins_merchant_sku;not null;default:0" json:"merchant_id"`
SKU string `gorm:"uniqueIndex;size:64;not null" json:"sku"` // 英文固定标识 Name string `gorm:"size:128;not null" json:"name"` // 中文名(展示用,保持原名)
Game string `gorm:"size:64;index" json:"game"` // 所属游戏 SKU string `gorm:"uniqueIndex:idx_skins_merchant_sku;size:64;not null" json:"sku"` // 英文固定标识
Category string `gorm:"size:64;index" json:"category"` // 品类:套装/背包/... Game string `gorm:"size:64;index" json:"game"` // 所属游戏
Category string `gorm:"size:64;index" json:"category"` // 品类:套装/背包/...
CoverURL string `gorm:"size:512" json:"cover_url"` CoverURL string `gorm:"size:512" json:"cover_url"`
Price float64 `gorm:"not null;default:0" json:"price"` // 售价 Price float64 `gorm:"not null;default:0" json:"price"` // 售价
CostPrice float64 `gorm:"default:0" json:"cost_price"` // 成本价 CostPrice float64 `gorm:"default:0" json:"cost_price"` // 成本价
Commission float64 `gorm:"default:0" json:"commission"` // 佣金比例 0-1 Commission float64 `gorm:"default:0" json:"commission"` // 佣金比例 0-1
Stock int `gorm:"default:0" json:"stock"` // -1 无限 Stock int `gorm:"default:0" json:"stock"` // -1 无限
Status int `gorm:"default:1" json:"status"` // 1上架 0下架 Status int `gorm:"default:1" json:"status"` // 1上架 0下架
Description string `gorm:"type:text" json:"description"` Description string `gorm:"type:text" json:"description"`
} }
@@ -55,16 +56,17 @@ type Order struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"` MerchantID uint `gorm:"index;not null;default:0" json:"merchant_id"`
SkinID uint `gorm:"index;not null" json:"skin_id"` OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"`
Skin *Skin `gorm:"foreignKey:SkinID" json:"skin,omitempty"` SkinID uint `gorm:"index;not null" json:"skin_id"`
DistributorID uint `gorm:"index;not null" json:"distributor_id"` Skin *Skin `gorm:"foreignKey:SkinID" json:"skin,omitempty"`
Distributor *User `gorm:"foreignKey:DistributorID" json:"distributor,omitempty"` DistributorID uint `gorm:"index;not null" json:"distributor_id"`
BuyerName string `gorm:"size:64" json:"buyer_name"` Distributor *User `gorm:"foreignKey:DistributorID" json:"distributor,omitempty"`
Amount float64 `gorm:"not null" json:"amount"` BuyerName string `gorm:"size:64" json:"buyer_name"`
CommissionAmt float64 `gorm:"default:0" json:"commission_amt"` Amount float64 `gorm:"not null" json:"amount"`
Status string `gorm:"size:32;default:pending;index" json:"status"` CommissionAmt float64 `gorm:"default:0" json:"commission_amt"`
Remark string `gorm:"size:255" json:"remark"` 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"` // 上游单号 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) GameChannel string `gorm:"size:64" json:"game_channel"` // 账号区服(安卓/IOS-微信/QQ)
GameUID string `gorm:"size:128" json:"game_uid"` // 游戏角色UUID GameUID string `gorm:"size:128" json:"game_uid"` // 游戏角色UUID
RoleName string `gorm:"size:64" json:"role_name"` // 角色名 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"` ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"` 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"` OrderNo string `gorm:"size:64;index;not null" json:"order_no"`
OrderID uint `gorm:"index" json:"order_id"` OrderID uint `gorm:"index" json:"order_id"`
ShipStatus string `gorm:"size:32;not null" json:"ship_status"` // success/failed/processing ShipStatus string `gorm:"size:32;not null" json:"ship_status"` // success/failed/processing
ProviderOrderNo string `gorm:"size:64" json:"provider_order_no"` ProviderOrderNo string `gorm:"size:64" json:"provider_order_no"`
FailReason string `gorm:"size:512" json:"fail_reason"` 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"` // 处理后订单状态 ResultStatus string `gorm:"size:32" json:"result_status"` // 处理后订单状态
Message string `gorm:"size:255" json:"message"` Message string `gorm:"size:255" json:"message"`
} }
+57 -8
View File
@@ -5,9 +5,11 @@ import (
"affiliate_dash/internal/middleware" "affiliate_dash/internal/middleware"
"affiliate_dash/internal/model" "affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/jwt" "affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/service"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
type Handlers struct { type Handlers struct {
@@ -15,8 +17,13 @@ type Handlers struct {
Skin *handler.SkinHandler Skin *handler.SkinHandler
Order *handler.OrderHandler Order *handler.OrderHandler
User *handler.UserHandler User *handler.UserHandler
Open *handler.OpenHandler Open *handler.OpenV1Handler
SourceOpen *handler.OpenHandler
Merchant *handler.MerchantHandler
JWT *jwt.Manager JWT *jwt.Manager
Tenant *service.TenantService
OpenDB *gorm.DB
SecretCodec *service.SecretCodec
OpenAPIKey string OpenAPIKey string
OpenAPISecret string OpenAPISecret string
OpenSignSkew int64 OpenSignSkew int64
@@ -29,8 +36,8 @@ func Setup(h *Handlers) *gin.Engine {
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign"}, 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"}, ExposeHeaders: []string{"Content-Length", "X-Request-ID"},
AllowCredentials: true, AllowCredentials: true,
})) }))
@@ -43,21 +50,39 @@ func Setup(h *Handlers) *gin.Engine {
api.POST("/auth/login", h.Auth.Login) api.POST("/auth/login", h.Auth.Login)
api.POST("/auth/register", h.Auth.Register) api.POST("/auth/register", h.Auth.Register)
// 皮肤源头开放接口(ApiKey + HMAC 签名) // 原上游发货接口:路径、鉴权和字段保持不变。
open := api.Group("/open/v1") sourceOpen := api.Group("/open/v1")
open.Use(middleware.OpenAuth(middleware.OpenAuthConfig{ sourceOpen.Use(middleware.SourceOpenAuth(middleware.SourceOpenAuthConfig{
APIKey: h.OpenAPIKey, APIKey: h.OpenAPIKey,
APISecret: h.OpenAPISecret, APISecret: h.OpenAPISecret,
SkewSeconds: h.OpenSignSkew, SkewSeconds: h.OpenSignSkew,
Debug: h.OpenAPIDebug, Debug: h.OpenAPIDebug,
})) }))
{ {
open.GET("/orders/:order_no", h.Open.QueryOrder) sourceOpen.GET("/orders/:order_no", h.SourceOpen.QueryOrder)
open.POST("/orders/ship-notify", h.Open.ShipNotify) 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 := api.Group("")
auth.Use(middleware.Auth(h.JWT)) auth.Use(middleware.Auth(h.JWT))
auth.Use(middleware.Tenant(h.Tenant))
{ {
auth.GET("/auth/profile", h.Auth.Profile) auth.GET("/auth/profile", h.Auth.Profile)
auth.GET("/dashboard", h.Order.Dashboard) auth.GET("/dashboard", h.Order.Dashboard)
@@ -74,6 +99,27 @@ func Setup(h *Handlers) *gin.Engine {
auth.POST("/orders", h.Order.Create) auth.POST("/orders", h.Order.Create)
auth.PATCH("/orders/:id/status", middleware.RequireRole(model.RoleAdmin), h.Order.UpdateStatus) 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 := auth.Group("")
admin.Use(middleware.RequireRole(model.RoleAdmin)) admin.Use(middleware.RequireRole(model.RoleAdmin))
@@ -82,6 +128,9 @@ func Setup(h *Handlers) *gin.Engine {
admin.POST("/users", h.User.Create) admin.POST("/users", h.User.Create)
admin.PATCH("/users/:id/status", h.User.UpdateStatus) admin.PATCH("/users/:id/status", h.User.UpdateStatus)
admin.GET("/ship-logs", h.Order.ListShipLogs) 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)
} }
} }
} }
+51
View File
@@ -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,
})
}
+27
View File
@@ -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
}
+28 -5
View File
@@ -14,12 +14,13 @@ import (
) )
type AuthService struct { type AuthService struct {
db *gorm.DB db *gorm.DB
jwt *jwt.Manager jwt *jwt.Manager
tenant *TenantService
} }
func NewAuthService(db *gorm.DB, jm *jwt.Manager) *AuthService { func NewAuthService(db *gorm.DB, jm *jwt.Manager, tenant *TenantService) *AuthService {
return &AuthService{db: db, jwt: jm} return &AuthService{db: db, jwt: jm, tenant: tenant}
} }
type LoginResult struct { 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 { if err := s.db.Create(user).Error; err != nil {
return nil, err 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 return user, nil
} }
@@ -87,6 +93,17 @@ func (s *AuthService) EnsureAdmin() error {
var count int64 var count int64
s.db.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&count) s.db.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&count)
if count > 0 { 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 return nil
} }
hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
@@ -101,7 +118,13 @@ func (s *AuthService) EnsureAdmin() error {
Status: 1, Status: 1,
InviteCode: "ADMIN001", 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 { func generateInviteCode() string {
+330
View File
@@ -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]
}
+530
View File
@@ -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,
}
}
@@ -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)
}
}
@@ -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)
}
}
+465
View File
@@ -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
}
+36 -8
View File
@@ -20,6 +20,7 @@ func NewOrderService(db *gorm.DB) *OrderService {
} }
type OrderListQuery struct { type OrderListQuery struct {
MerchantID uint
Page int Page int
Size int Size int
Status string Status string
@@ -27,6 +28,7 @@ type OrderListQuery struct {
} }
type CreateOrderInput struct { type CreateOrderInput struct {
MerchantID uint
SkinID uint SkinID uint
DistributorID uint DistributorID uint
BuyerName string BuyerName string
@@ -43,6 +45,9 @@ func (s *OrderService) List(q OrderListQuery) ([]model.Order, int64, error) {
q.Size = 20 q.Size = 20
} }
tx := s.db.Model(&model.Order{}) tx := s.db.Model(&model.Order{})
if q.MerchantID != 0 {
tx = tx.Where("merchant_id = ?", q.MerchantID)
}
if q.Status != "" { if q.Status != "" {
tx = tx.Where("status = ?", 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) { func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
var skin model.Skin 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("皮肤不存在") return nil, errors.New("皮肤不存在")
} }
if skin.Status != 1 { if skin.Status != 1 {
@@ -72,6 +80,15 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
if skin.Stock == 0 { if skin.Stock == 0 {
return nil, errors.New("库存不足") 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 status := model.OrderStatusPending
switch in.Status { switch in.Status {
@@ -89,6 +106,7 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
} }
order := &model.Order{ order := &model.Order{
MerchantID: in.MerchantID,
OrderNo: generateOrderNo(), OrderNo: generateOrderNo(),
SkinID: in.SkinID, SkinID: in.SkinID,
DistributorID: in.DistributorID, DistributorID: in.DistributorID,
@@ -121,7 +139,7 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
return order, nil 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{ allowed := map[string]bool{
model.OrderStatusPending: true, model.OrderStatusPending: true,
model.OrderStatusPaid: true, model.OrderStatusPaid: true,
@@ -133,7 +151,7 @@ func (s *OrderService) UpdateStatus(id uint, status string) error {
if !allowed[status] { if !allowed[status] {
return errors.New("无效的订单状态") 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 { if res.Error != nil {
return res.Error return res.Error
} }
@@ -381,6 +399,7 @@ func (s *OrderService) appendShipLog(order *model.Order, in ShipNotifyInput, res
payload = string(b) payload = string(b)
} }
log := &model.ShipLog{ log := &model.ShipLog{
MerchantID: order.MerchantID,
OrderNo: order.OrderNo, OrderNo: order.OrderNo,
OrderID: order.ID, OrderID: order.ID,
ShipStatus: in.ShipStatus, ShipStatus: in.ShipStatus,
@@ -394,6 +413,7 @@ func (s *OrderService) appendShipLog(order *model.Order, in ShipNotifyInput, res
} }
type ShipLogListQuery struct { type ShipLogListQuery struct {
MerchantID uint
Page int Page int
Size int Size int
OrderNo string OrderNo string
@@ -408,6 +428,9 @@ func (s *OrderService) ListShipLogs(q ShipLogListQuery) ([]model.ShipLog, int64,
q.Size = 20 q.Size = 20
} }
tx := s.db.Model(&model.ShipLog{}) tx := s.db.Model(&model.ShipLog{})
if q.MerchantID != 0 {
tx = tx.Where("merchant_id = ?", q.MerchantID)
}
if q.OrderNo != "" { if q.OrderNo != "" {
tx = tx.Where("order_no LIKE ?", "%"+q.OrderNo+"%") tx = tx.Where("order_no LIKE ?", "%"+q.OrderNo+"%")
} }
@@ -432,16 +455,21 @@ type DashboardStats struct {
PendingOrderCount int64 `json:"pending_order_count"` PendingOrderCount int64 `json:"pending_order_count"`
} }
func (s *OrderService) Dashboard() (*DashboardStats, error) { func (s *OrderService) Dashboard(merchantID uint) (*DashboardStats, error) {
stats := &DashboardStats{} stats := &DashboardStats{}
s.db.Model(&model.Skin{}).Count(&stats.SkinCount) s.db.Model(&model.Skin{}).Where("merchant_id = ?", merchantID).Count(&stats.SkinCount)
s.db.Model(&model.User{}).Where("role = ?", model.RoleDistributor).Count(&stats.DistributorCount) s.db.Model(&model.User{}).
s.db.Model(&model.Order{}).Count(&stats.OrderCount) Joins("JOIN merchant_members ON merchant_members.user_id = users.id").
s.db.Model(&model.Order{}).Where("status = ?", model.OrderStatusPending).Count(&stats.PendingOrderCount) 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{}). s.db.Model(&model.Order{}).
Where("merchant_id = ?", merchantID).
Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}). Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}).
Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales) Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales)
s.db.Model(&model.Order{}). s.db.Model(&model.Order{}).
Where("merchant_id = ?", merchantID).
Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}). Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}).
Select("COALESCE(SUM(commission_amt),0)").Scan(&stats.TotalCommission) Select("COALESCE(SUM(commission_amt),0)").Scan(&stats.TotalCommission)
return stats, nil return stats, nil
+56
View File
@@ -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
}
@@ -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)
}
}
+27 -14
View File
@@ -17,12 +17,13 @@ func NewSkinService(db *gorm.DB) *SkinService {
} }
type SkinListQuery struct { type SkinListQuery struct {
Page int MerchantID uint
Size int Page int
Keyword string Size int
Game string Keyword string
Category string Game string
Status *int Category string
Status *int
} }
func (s *SkinService) List(q SkinListQuery) ([]model.Skin, int64, error) { 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 q.Size = 20
} }
tx := s.db.Model(&model.Skin{}) tx := s.db.Model(&model.Skin{})
if q.MerchantID != 0 {
tx = tx.Where("merchant_id = ?", q.MerchantID)
}
if q.Keyword != "" { if q.Keyword != "" {
like := "%" + q.Keyword + "%" like := "%" + q.Keyword + "%"
tx = tx.Where("name LIKE ? OR sku LIKE ?", like, like) 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 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 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("皮肤不存在") return nil, errors.New("皮肤不存在")
} }
@@ -70,8 +78,8 @@ func (s *SkinService) Create(skin *model.Skin) error {
return s.db.Create(skin).Error return s.db.Create(skin).Error
} }
func (s *SkinService) Update(id uint, updates map[string]interface{}) error { func (s *SkinService) Update(merchantID, id uint, updates map[string]interface{}) error {
res := s.db.Model(&model.Skin{}).Where("id = ?", id).Updates(updates) res := s.db.Model(&model.Skin{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates)
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
@@ -81,8 +89,8 @@ func (s *SkinService) Update(id uint, updates map[string]interface{}) error {
return nil return nil
} }
func (s *SkinService) Delete(id uint) error { func (s *SkinService) Delete(merchantID, id uint) error {
res := s.db.Delete(&model.Skin{}, id) res := s.db.Where("merchant_id = ?", merchantID).Delete(&model.Skin{}, id)
if res.Error != nil { if res.Error != nil {
return res.Error return res.Error
} }
@@ -94,12 +102,16 @@ func (s *SkinService) Delete(id uint) error {
// SeedCatalog 按 sku 幂等导入商品目录(已存在则跳过) // SeedCatalog 按 sku 幂等导入商品目录(已存在则跳过)
func (s *SkinService) SeedCatalog() error { 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 的演示数据,避免唯一索引冲突 // 清理早期无 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 { for _, item := range peaceEliteCatalog {
var existing model.Skin 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 { if err == nil {
// 已存在:仅同步默认佣金为 0(不改价格等业务字段) // 已存在:仅同步默认佣金为 0(不改价格等业务字段)
if existing.Commission != 0 { if existing.Commission != 0 {
@@ -111,6 +123,7 @@ func (s *SkinService) SeedCatalog() error {
return err return err
} }
skin := model.Skin{ skin := model.Skin{
MerchantID: merchant.ID,
Name: item.Name, Name: item.Name,
SKU: item.SKU, SKU: item.SKU,
Game: "和平精英", Game: "和平精英",
+120
View File
@@ -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
}
+42 -14
View File
@@ -10,19 +10,21 @@ import (
) )
type UserService struct { type UserService struct {
db *gorm.DB db *gorm.DB
tenant *TenantService
} }
func NewUserService(db *gorm.DB) *UserService { func NewUserService(db *gorm.DB, tenant *TenantService) *UserService {
return &UserService{db: db} return &UserService{db: db, tenant: tenant}
} }
type UserListQuery struct { type UserListQuery struct {
Page int MerchantID uint
Size int Page int
Keyword string Size int
Role string Keyword string
Status *int Role string
Status *int
} }
func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) { 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 q.Size = 20
} }
tx := s.db.Model(&model.User{}) 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 != "" { if q.Keyword != "" {
like := "%" + 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 != "" { if q.Role != "" {
tx = tx.Where("role = ?", q.Role) tx = tx.Where("users.role = ?", q.Role)
} }
if q.Status != nil { if q.Status != nil {
tx = tx.Where("status = ?", *q.Status) tx = tx.Where("users.status = ?", *q.Status)
} }
var total int64 var total int64
if err := tx.Count(&total).Error; err != nil { if err := tx.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
var list []model.User 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 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 var count int64
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count) s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
if count > 0 { if count > 0 {
@@ -77,7 +83,29 @@ func (s *UserService) Create(username, password, nickname, role string, parentID
if user.Nickname == "" { if user.Nickname == "" {
user.Nickname = username 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 nil, err
} }
return user, nil return user, nil
+76
View File
@@ -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
}
+11
View File
@@ -11,6 +11,8 @@ import Orders from './pages/Orders'
import Distributors from './pages/Distributors' import Distributors from './pages/Distributors'
import ShipLogs from './pages/ShipLogs' import ShipLogs from './pages/ShipLogs'
import OpenApiDocs from './pages/OpenApiDocs' import OpenApiDocs from './pages/OpenApiDocs'
import MerchantCenter from './pages/MerchantCenter'
import PlatformMerchants from './pages/PlatformMerchants'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
function PrivateRoute({ children }: { children: ReactNode }) { function PrivateRoute({ children }: { children: ReactNode }) {
@@ -41,6 +43,7 @@ function AppRoutes() {
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="skins" element={<Skins />} /> <Route path="skins" element={<Skins />} />
<Route path="orders" element={<Orders />} /> <Route path="orders" element={<Orders />} />
<Route path="merchant-center" element={<MerchantCenter />} />
<Route <Route
path="distributors" path="distributors"
element={ element={
@@ -49,6 +52,14 @@ function AppRoutes() {
</AdminRoute> </AdminRoute>
} }
/> />
<Route
path="platform-merchants"
element={
<AdminRoute>
<PlatformMerchants />
</AdminRoute>
}
/>
<Route <Route
path="ship-logs" path="ship-logs"
element={ element={
+70
View File
@@ -1,12 +1,22 @@
import request from './request' import request from './request'
import type { import type {
DashboardStats, DashboardStats,
ApiClient,
ApiCredential,
CallbackCredential,
CallbackSubscription,
FulfillmentOrder,
LoginResult, LoginResult,
Merchant,
MerchantMember,
MerchantProduct,
Order, Order,
PageResult, PageResult,
ShipLog, ShipLog,
Skin, Skin,
User, User,
WalletAccount,
WalletLedgerEntry,
} from '../types' } from '../types'
export const authApi = { export const authApi = {
@@ -69,3 +79,63 @@ export const shipLogApi = {
list: (params?: Record<string, unknown>) => list: (params?: Record<string, unknown>) =>
request.get('/ship-logs', { params }).then((r) => r.data.data as PageResult<ShipLog>), request.get('/ship-logs', { params }).then((r) => r.data.data as PageResult<ShipLog>),
} }
export const merchantApi = {
current: () =>
request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: MerchantMember['role'] }),
products: (params?: Record<string, unknown>) =>
request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult<MerchantProduct>),
createProduct: (data: Partial<MerchantProduct> & {
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<MerchantProduct>) =>
request.patch(`/merchant/products/${id}`, data).then((r) => r.data.data),
orders: (params?: Record<string, unknown>) =>
request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult<FulfillmentOrder>),
wallet: () =>
request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount),
ledger: (params?: Record<string, unknown>) =>
request.get('/merchant/wallet/ledger', { params }).then((r) => r.data.data as PageResult<WalletLedgerEntry>),
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<string, unknown>) =>
request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult<Merchant>),
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),
}
+4
View File
@@ -11,6 +11,10 @@ request.interceptors.request.use((config) => {
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
const merchantId = localStorage.getItem('merchant_id')
if (merchantId) {
config.headers['X-Merchant-ID'] = merchantId
}
return config return config
}) })
+3
View File
@@ -20,6 +20,7 @@ import {
MenuUnfoldOutlined, MenuUnfoldOutlined,
SendOutlined, SendOutlined,
ApiOutlined, ApiOutlined,
ShopOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { useAuth } from '../store/auth' import { useAuth } from '../store/auth'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
@@ -40,10 +41,12 @@ export default function MainLayout() {
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' }, { key: '/', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/skins', icon: <SkinOutlined />, label: '皮肤商品' }, { key: '/skins', icon: <SkinOutlined />, label: '皮肤商品' },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单管理' }, { key: '/orders', icon: <ShoppingOutlined />, label: '订单管理' },
{ key: '/merchant-center', icon: <ShopOutlined />, label: '商户中心' },
] ]
if (isAdmin) { if (isAdmin) {
items.push( items.push(
{ key: '/distributors', icon: <TeamOutlined />, label: '分销商' }, { key: '/distributors', icon: <TeamOutlined />, label: '分销商' },
{ key: '/platform-merchants', icon: <ShopOutlined />, label: '商户管理' },
{ key: '/ship-logs', icon: <SendOutlined />, label: '发货记录' }, { key: '/ship-logs', icon: <SendOutlined />, label: '发货记录' },
{ key: '/open-api', icon: <ApiOutlined />, label: '开放接口' }, { key: '/open-api', icon: <ApiOutlined />, label: '开放接口' },
) )
+731
View File
@@ -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<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待履约' },
processing: { color: 'cyan', text: '履约中' },
succeeded: { color: 'green', text: '已成功' },
failed: { color: 'red', text: '已失败' },
cancelled: { color: 'default', text: '已取消' },
}
const paymentStatusMap: Record<string, { color: string; text: string }> = {
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<Merchant | null>(null)
const [merchantRole, setMerchantRole] = useState<MerchantMember['role']>()
const [activeTab, setActiveTab] = useState('products')
const [products, setProducts] = useState<PageResult<MerchantProduct>>({ list: [], total: 0, page: 1, size: 10 })
const [orders, setOrders] = useState<PageResult<FulfillmentOrder>>({ list: [], total: 0, page: 1, size: 10 })
const [ledger, setLedger] = useState<PageResult<WalletLedgerEntry>>({ list: [], total: 0, page: 1, size: 10 })
const [wallet, setWallet] = useState<WalletAccount | null>(null)
const [apiClients, setApiClients] = useState<ApiClient[]>([])
const [callbacks, setCallbacks] = useState<CallbackSubscription[]>([])
const [members, setMembers] = useState<MerchantMember[]>([])
const [loading, setLoading] = useState(false)
const [productOpen, setProductOpen] = useState(false)
const [editingProduct, setEditingProduct] = useState<MerchantProduct | null>(null)
const [walletOpen, setWalletOpen] = useState(false)
const [apiClientOpen, setApiClientOpen] = useState(false)
const [apiCredential, setApiCredential] = useState<ApiCredential | null>(null)
const [callbackOpen, setCallbackOpen] = useState(false)
const [callbackCredential, setCallbackCredential] = useState<CallbackCredential | null>(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<MerchantProduct> = [
{ title: 'SKU', dataIndex: 'sku', width: 180, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
{ 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 ? (
<Button type="link" size="small" onClick={() => openProductEdit(record)}></Button>
) : '-',
},
]
const orderColumns: ColumnsType<FulfillmentOrder> = [
{ title: '平台订单号', dataIndex: 'order_no', width: 210, render: (v) => <Typography.Text copyable>{v}</Typography.Text> },
{ title: '商户单号', dataIndex: 'client_order_no', width: 160, ellipsis: true },
{ title: 'SKU', dataIndex: 'product_sku', width: 150, render: (v) => <Typography.Text code>{v}</Typography.Text> },
{ 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<WalletLedgerEntry> = [
{ 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<ApiClient> = [
{ title: '名称', dataIndex: 'name', width: 180, ellipsis: true },
{ title: 'App Key', dataIndex: 'app_key', width: 260, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
{ title: '签名', dataIndex: 'signature_version', width: 110, render: (v) => <Tag>{v}</Tag> },
{ 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 ? (
<Button type="link" size="small" onClick={() => toggleAPIClient(record)}>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
) : '-',
},
]
const callbackColumns: ColumnsType<CallbackSubscription> = [
{ 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 ? (
<Button type="link" size="small" onClick={() => toggleCallback(record)}>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
) : '-',
},
]
const memberColumns: ColumnsType<MerchantMember> = [
{ 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 ? <Tag color="blue"></Tag> : '-') },
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag></Tag>) },
]
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
{merchant ? `${merchant.name} / ${merchant.code}` : '加载中'} · {merchantRole ? roleText(merchantRole) : '-'}
</Typography.Text>
</div>
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadAll}>
</Button>
</Space>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'products',
label: '商品',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary"> SKU </Typography.Text>
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={openProductCreate}></Button>}
</Space>
<Table
rowKey="id"
loading={loading}
columns={productColumns}
dataSource={products.list}
tableLayout="fixed"
pagination={pageConfig(products, loadProducts)}
/>
</Space>
),
},
{
key: 'orders',
label: '履约订单',
children: (
<Table
rowKey="id"
loading={loading}
columns={orderColumns}
dataSource={orders.list}
tableLayout="fixed"
scroll={{ x: 1300 }}
pagination={pageConfig(orders, loadOrders)}
/>
),
},
{
key: 'wallet',
label: '钱包',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Descriptions size="small" bordered column={3} style={{ flex: 1 }}>
<Descriptions.Item label="可用余额">{money(wallet?.available_balance ?? 0)}</Descriptions.Item>
<Descriptions.Item label="冻结余额">{money(wallet?.frozen_balance ?? 0)}</Descriptions.Item>
<Descriptions.Item label="币种">{wallet?.currency || 'CNY'}</Descriptions.Item>
</Descriptions>
{canFinance && (
<Button
icon={<WalletOutlined />}
onClick={() => {
walletForm.resetFields()
walletForm.setFieldsValue({ idempotency_key: `manual-${Date.now()}` })
setWalletOpen(true)
}}
>
</Button>
)}
</Space>
<Table
rowKey="id"
loading={loading}
columns={ledgerColumns}
dataSource={ledger.list}
tableLayout="fixed"
pagination={pageConfig(ledger, loadWallet)}
/>
</Space>
),
},
{
key: 'api',
label: 'API 客户端',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary"></Typography.Text>
{canManage && <Button type="primary" icon={<ApiOutlined />} onClick={() => {
apiClientForm.resetFields()
apiClientForm.setFieldsValue({ signature_version: 'v1', scopes: ['products:read', 'orders:read', 'orders:write'] })
setApiClientOpen(true)
}}></Button>}
</Space>
<Table rowKey="id" loading={loading} columns={apiClientColumns} dataSource={apiClients} tableLayout="fixed" />
</Space>
),
},
{
key: 'callbacks',
label: '回调',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary"> outbox </Typography.Text>
{canManage && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
callbackForm.resetFields()
callbackForm.setFieldsValue({ events: ['order.fulfillment.updated'] })
setCallbackOpen(true)
}}></Button>}
</Space>
<Table rowKey="id" loading={loading} columns={callbackColumns} dataSource={callbacks} tableLayout="fixed" />
</Space>
),
},
{
key: 'members',
label: '成员',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary"></Typography.Text>
{merchantRole === 'owner' && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
memberForm.resetFields()
memberForm.setFieldsValue({ role: 'operator' })
setMemberOpen(true)
}}></Button>}
</Space>
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
</Space>
),
},
]}
/>
<Modal title={editingProduct ? '编辑商品' : '新增商品'} open={productOpen} onOk={submitProduct} onCancel={() => setProductOpen(false)} destroyOnClose width={620}>
<Form form={productForm} layout="vertical" style={{ marginTop: 16 }}>
{!editingProduct && (
<>
<Form.Item name="product_code" label="目录编码">
<Input placeholder="已有目录编码可选填" />
</Form.Item>
<Form.Item name="product_name" label="商品名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
</>
)}
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="sku" label="商户 SKU" rules={[{ required: true }]} style={{ width: 280 }}>
<Input disabled={!!editingProduct} />
</Form.Item>
<Form.Item name="display_name" label="展示名" style={{ width: 280 }}>
<Input />
</Form.Item>
</Space>
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="price_yuan" label="售价" rules={[{ required: true }]} style={{ width: 180 }}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="cost_yuan" label="成本" style={{ width: 180 }}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="currency" label="币种" style={{ width: 180 }}>
<Select options={[{ value: 'CNY', label: 'CNY' }]} />
</Form.Item>
</Space>
<Space size="middle" style={{ width: '100%' }}>
<Form.Item name="stock" label="库存(-1 无限)" style={{ width: 180 }}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" style={{ width: 180 }}>
<Select options={[{ value: 'active', label: '上架' }, { value: 'inactive', label: '下架' }]} />
</Form.Item>
{!editingProduct && (
<Form.Item name="category" label="品类" style={{ width: 180 }}>
<Input />
</Form.Item>
)}
</Space>
<Form.Item name="fulfillment_config" label="履约配置">
<Input.TextArea rows={3} placeholder='{"provider":"manual"}' />
</Form.Item>
</Form>
</Modal>
<Modal title="钱包调整" open={walletOpen} onOk={submitWalletAdjust} onCancel={() => setWalletOpen(false)} destroyOnClose>
<Form form={walletForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="amount_yuan" label="调整金额" rules={[{ required: true }]}>
<InputNumber precision={2} style={{ width: '100%' }} placeholder="正数充值,负数扣减" />
</Form.Item>
<Form.Item name="idempotency_key" label="幂等键" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="note" label="备注">
<Input />
</Form.Item>
</Form>
</Modal>
<Modal title="新增 API 客户端" open={apiClientOpen} onOk={submitAPIClient} onCancel={() => setApiClientOpen(false)} destroyOnClose>
<Form form={apiClientForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="scopes" label="权限" rules={[{ required: true }]}>
<Select mode="multiple" options={scopeOptions} />
</Form.Item>
<Form.Item name="signature_version" label="签名版本">
<Select options={[{ value: 'v1', label: 'v1' }]} />
</Form.Item>
</Form>
</Modal>
<Modal title="API 密钥" open={!!apiCredential} onCancel={() => setApiCredential(null)} footer={<Button type="primary" onClick={() => setApiCredential(null)}></Button>}>
{apiCredential && <SecretBlock appKey={apiCredential.client.app_key} secret={apiCredential.secret} />}
</Modal>
<Modal title="新增回调" open={callbackOpen} onOk={submitCallback} onCancel={() => setCallbackOpen(false)} destroyOnClose>
<Form form={callbackForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="url" label="回调 URL" rules={[{ required: true }]}>
<Input placeholder="https://example.com/callback" />
</Form.Item>
<Form.Item name="events" label="事件" rules={[{ required: true }]}>
<Select mode="multiple" options={eventOptions} />
</Form.Item>
</Form>
</Modal>
<Modal title="回调密钥" open={!!callbackCredential} onCancel={() => setCallbackCredential(null)} footer={<Button type="primary" onClick={() => setCallbackCredential(null)}></Button>}>
{callbackCredential && <SecretBlock secret={callbackCredential.secret} />}
</Modal>
<Modal title="添加成员" open={memberOpen} onOk={submitMember} onCancel={() => setMemberOpen(false)} destroyOnClose>
<Form form={memberForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="user_id" label="用户 ID" rules={[{ required: true }]}>
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select options={memberRoleOptions} />
</Form.Item>
<Form.Item name="is_default" label="默认商户">
<Select options={[{ value: true, label: '是' }, { value: false, label: '否' }]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
function pageConfig<T extends { page: number; size: number; total: number }>(
data: T,
load: (page: number, size: number) => void,
) {
return {
current: data.page,
pageSize: data.size,
total: data.total,
showSizeChanger: true,
showTotal: (total: number) => `${total}`,
onChange: load,
}
}
function SecretBlock({ appKey, secret }: { appKey?: string; secret: string }) {
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{appKey && (
<div>
<Typography.Text type="secondary">App Key</Typography.Text>
<Typography.Paragraph copyable={{ text: appKey }} code style={{ marginTop: 8 }}>{appKey}</Typography.Paragraph>
</div>
)}
<div>
<Typography.Text type="secondary">Secret</Typography.Text>
<Typography.Paragraph copyable={{ text: secret }} code style={{ marginTop: 8 }}>{secret}</Typography.Paragraph>
</div>
<Button icon={<CopyOutlined />} onClick={() => navigator.clipboard.writeText(secret).then(() => message.success('已复制'))}>
Secret
</Button>
</Space>
)
}
function yuanToCents(value?: number | null) {
return Math.round(Number(value || 0) * 100)
}
function centsToYuan(value?: number | null) {
return Number(((value || 0) / 100).toFixed(2))
}
function money(value?: number | null) {
return `¥${centsToYuan(value).toFixed(2)}`
}
function moneyWithSign(value?: number | null) {
const amount = Number(value || 0)
const prefix = amount > 0 ? '+' : ''
return `${prefix}${money(amount)}`
}
function formatTime(value?: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
}
function productStatusTag(value: string) {
return value === 'active' ? <Tag color="green"></Tag> : <Tag></Tag>
}
function activeStatusTag(value: string) {
return value === 'active' ? <Tag color="green"></Tag> : <Tag></Tag>
}
function paymentStatusTag(value: string) {
const item = paymentStatusMap[value] || { color: 'default', text: value }
return <Tag color={item.color}>{item.text}</Tag>
}
function fulfillmentStatusTag(value: string) {
const item = fulfillmentStatusMap[value] || { color: 'default', text: value }
return <Tag color={item.color}>{item.text}</Tag>
}
function ledgerTypeTag(value: string) {
const map: Record<string, { color: string; text: string }> = {
credit: { color: 'green', text: '入账' },
debit: { color: 'red', text: '扣款' },
refund: { color: 'purple', text: '退款' },
adjust: { color: 'blue', text: '调整' },
}
const item = map[value] || { color: 'default', text: value }
return <Tag color={item.color}>{item.text}</Tag>
}
function memberRoleTag(value: MerchantMember['role']) {
return <Tag color={value === 'owner' ? 'gold' : 'blue'}>{roleText(value)}</Tag>
}
function roleText(value: MerchantMember['role']) {
return memberRoleOptions.find((item) => item.value === value)?.label || value
}
+138 -253
View File
@@ -1,86 +1,67 @@
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
import { Alert, Card, Descriptions, Space, Table, Tabs, Tag, Typography } from 'antd' import { Alert, Card, Descriptions, Space, Table, Tabs, Tag, Typography } from 'antd'
const { Title, Paragraph, Text, Link } = Typography const { Title, Paragraph, Text } = Typography
const baseUrl = const baseUrl =
typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':8080') : 'http://localhost:8080' typeof window !== 'undefined'
? window.location.origin.replace(':5173', ':8080')
: 'http://localhost:8080'
const signPython = `import hmac, hashlib, time, uuid, requests const signPython = `import hashlib, hmac, json, time, uuid, requests
API_KEY = "sk_source_dev_key_change_me" APP_KEY = "ak_xxx"
API_SECRET = "sk_source_dev_secret_change_me" APP_SECRET = "sk_xxx"
BASE = "${baseUrl}" BASE = "${baseUrl}"
def build_sign_string(api_key, timestamp, nonce, method, path, body=""): def sign_headers(method: str, path: str, body: bytes = b"") -> dict:
params = {
"api_key": api_key,
"body": body,
"method": method.upper(),
"nonce": nonce,
"path": path,
"timestamp": timestamp,
}
# 字典序 + & 拼接,value 不 URL encode
return "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
def sign_headers(method: str, path: str, body: str = "") -> dict:
ts = str(int(time.time())) ts = str(int(time.time()))
nonce = uuid.uuid4().hex nonce = uuid.uuid4().hex
raw = build_sign_string(API_KEY, ts, nonce, method, path, body) body_hash = hashlib.sha256(body).hexdigest()
sign = hmac.new(API_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest() raw = "\\n".join([ts, nonce, method.upper(), path, body_hash])
sign = hmac.new(APP_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
return { return {
"X-Api-Key": API_KEY, "X-App-Key": APP_KEY,
"X-Timestamp": ts, "X-Timestamp": ts,
"X-Nonce": nonce, "X-Nonce": nonce,
"X-Sign": sign, "X-Sign": sign,
} }
# 查询 path = "/api/client/v1/products"
path = "/api/open/v1/orders/O你的订单号"
print(requests.get(BASE + path, headers=sign_headers("GET", path)).json()) print(requests.get(BASE + path, headers=sign_headers("GET", path)).json())
# 推送(body 必须与签名一致) path = "/api/client/v1/orders"
path = "/api/open/v1/orders/ship-notify" payload = {"client_order_no": "shop-10001", "sku": "sku-basic", "quantity": 1}
body = '{"order_no":"O你的订单号","ship_status":"success","provider_order_no":"SRC001"}' body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
headers = {"Content-Type": "application/json", **sign_headers("POST", path, body)} headers = {"Content-Type": "application/json", "Idempotency-Key": payload["client_order_no"], **sign_headers("POST", path, body)}
print(requests.post(BASE + path, headers=headers, data=body.encode()).json())` print(requests.post(BASE + path, headers=headers, data=body).json())`
export default function OpenApiDocs() { export default function OpenApiDocs() {
return ( return (
<div> <div>
<Title level={4} style={{ marginTop: 0 }}> <Title level={4} style={{ marginTop: 0 }}>
</Title> </Title>
<Paragraph type="secondary"> <Paragraph type="secondary">
使 Markdown {' '} / API
<Text code>docs/-.md</Text> <Text code>/api/open/v1</Text>
</Paragraph> </Paragraph>
<Alert <Alert
type="warning" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="鉴权:X-Api-Key + HMAC 签名(必填)" message="v1 鉴权X-App-Key / X-Timestamp / X-Nonce / X-Sign"
description={ description={
<div> <Space direction="vertical" size={4}>
<Text>
<Text code>X-Api-Key</Text><Text code>X-Timestamp</Text> <Text code>timestamp\nnonce\nMETHOD\npath\nsha256(body)</Text>
<Text code>X-Nonce</Text><Text code>X-Sign</Text>
<br />
Key
<Text code copyable>
sk_source_dev_key_change_me
</Text> </Text>
Secret <Text>
<Text code copyable> <Text code>X-Api-Key</Text> 使使{' '}
sk_source_dev_secret_change_me <Text code>X-App-Key</Text>
</Text> </Text>
<br /> </Space>
<Text code>OPEN_API_KEY</Text> / <Text code>OPEN_API_SECRET</Text> /{' '}
<Text code>OPEN_SIGN_SKEW</Text>
</div>
} }
/> />
@@ -88,231 +69,135 @@ export default function OpenApiDocs() {
items={[ items={[
{ {
key: 'auth', key: 'auth',
label: '签名规则', label: '签名',
children: ( children: (
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" title="签名字符串(字典序 + & 拼接)"> <Card size="small" title="签名规则">
<Paragraph type="secondary" style={{ marginBottom: 8 }}> <Descriptions size="small" column={1} bordered>
api_key / body / method / nonce / path / timestamp key <Descriptions.Item label="参与字段">
k=v&k=vvalue <Text strong></Text> URL encode timestampnonceMETHODpathsha256(body)
</Paragraph>
<pre style={preStyle}>{`api_key=sk_xxx&body=&method=GET&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/O123&timestamp=1721450000`}</pre>
<pre style={{ ...preStyle, marginTop: 8 }}>{`api_key=sk_xxx&body={"order_no":"O123","ship_status":"success"}&method=POST&nonce=...&path=/api/open/v1/orders/ship-notify&timestamp=1721450000`}</pre>
<Descriptions size="small" column={1} bordered style={{ marginTop: 12 }}>
<Descriptions.Item label="method"> GET / POST</Descriptions.Item>
<Descriptions.Item label="path">
URL.Path query /api/open/v1/orders/O123
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="body"> <Descriptions.Item label="拼接方式"></Descriptions.Item>
GET POST body <Descriptions.Item label="path"> URL.Path query</Descriptions.Item>
</Descriptions.Item> <Descriptions.Item label="body">GET POST body </Descriptions.Item>
<Descriptions.Item label="X-Sign"> <Descriptions.Item label="X-Sign">hex(HMAC-SHA256(app_secret, raw)) </Descriptions.Item>
hex(HMAC-SHA256(api_secret, string_to_sign)) <Descriptions.Item label="Nonce">8~96 </Descriptions.Item>
</Descriptions.Item>
<Descriptions.Item label="时间窗"> ±300 </Descriptions.Item>
<Descriptions.Item label="Nonce">8~64 Key </Descriptions.Item>
</Descriptions> </Descriptions>
<pre style={{ ...preStyle, marginTop: 12 }}>{`timestamp
nonce
POST
/api/open/v1/orders
sha256(body)`}</pre>
</Card> </Card>
<Card size="small" title="Python 完整示例"> <Card size="small" title="Python 示例">
<pre style={preStyle}>{signPython}</pre> <pre style={preStyle}>{signPython}</pre>
</Card> </Card>
</Space> </Space>
), ),
}, },
{ {
key: 'flow', key: 'endpoints',
label: '对接流程', label: '接口',
children: (
<Card size="small">
<Paragraph>
<ol>
<li> status = paid</li>
<li> <Text code>order_no</Text></li>
<li>
<Text code>product.sku</Text> {' '}
<Text code>can_ship</Text>
</li>
<li>
<Text code>can_ship=true</Text> sku
</li>
<li> success / failed / processing</li>
<li> success </li>
</ol>
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
Base URL <Text code>{baseUrl}</Text>
</Paragraph>
</Card>
),
},
{
key: 'query',
label: '订单查询',
children: (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" title="请求">
<Paragraph>
<Tag color="blue">GET</Tag>
<Text code>/api/open/v1/orders/&#123;order_no&#125;</Text>
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
HeaderX-Api-Key / X-Timestamp / X-Nonce / X-Sign
</Paragraph>
</Card>
<Card size="small" title="响应字段">
<Table
size="small"
pagination={false}
rowKey="field"
dataSource={[
{ field: 'order_no', desc: '店铺订单号' },
{ field: 'status', desc: '订单状态' },
{ field: 'can_ship', desc: '是否可发货(发货前置请以此为准)' },
{ field: 'cannot_ship_reason', desc: '不可发货原因' },
{ field: 'product.sku', desc: '商品英文固定标识(发货用)' },
{ field: 'product.name', desc: '商品中文名' },
{ field: 'product.game', desc: '游戏,如和平精英' },
{ field: 'buyer_name', desc: '买家名' },
{ field: 'amount', desc: '金额' },
{ field: 'shipped_at', desc: '发货成功时间' },
]}
columns={[
{
title: '字段',
dataIndex: 'field',
width: 200,
render: (v) => <Text code>{v}</Text>,
},
{ title: '说明', dataIndex: 'desc' },
]}
/>
</Card>
<Card size="small" title="can_ship 规则">
<Table
size="small"
pagination={false}
rowKey="status"
dataSource={[
{ status: 'pending', ship: 'false', note: '未支付' },
{ status: 'paid', ship: 'true', note: '可发' },
{ status: 'delivering', ship: 'false', note: '发货中' },
{ status: 'delivered', ship: 'false', note: '已完成' },
{ status: 'ship_failed', ship: 'true', note: '可重试' },
{ status: 'cancelled', ship: 'false', note: '已取消' },
]}
columns={[
{
title: 'status',
dataIndex: 'status',
render: (v) => <Text code>{v}</Text>,
},
{
title: 'can_ship',
dataIndex: 'ship',
render: (v) =>
v === 'true' ? <Tag color="green">true</Tag> : <Tag>false</Tag>,
},
{ title: '说明', dataIndex: 'note' },
]}
/>
</Card>
</Space>
),
},
{
key: 'notify',
label: '发货推送',
children: (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" title="请求">
<Paragraph>
<Tag color="green">POST</Tag>
<Text code>/api/open/v1/orders/ship-notify</Text>
</Paragraph>
<Paragraph type="secondary">
Body Body
</Paragraph>
<pre style={preStyle}>{`{
"order_no": "O202607201550038000",
"ship_status": "success",
"provider_order_no": "SRC20260720001",
"shipped_at": "2026-07-20T16:00:00+08:00",
"fail_reason": ""
}`}</pre>
</Card>
<Card size="small" title="请求参数">
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="order_no"></Descriptions.Item>
<Descriptions.Item label="ship_status">
success / failed / processing
</Descriptions.Item>
<Descriptions.Item label="provider_order_no"></Descriptions.Item>
<Descriptions.Item label="shipped_at">
RFC3339success
</Descriptions.Item>
<Descriptions.Item label="fail_reason"></Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="状态映射与幂等">
<Table
size="small"
pagination={false}
rowKey="ship"
style={{ marginBottom: 12 }}
dataSource={[
{ ship: 'processing', order: 'delivering', note: '已接单/发货中' },
{ ship: 'success', order: 'delivered', note: '发货成功' },
{ ship: 'failed', order: 'ship_failed', note: '失败可重试' },
]}
columns={[
{
title: 'ship_status',
dataIndex: 'ship',
render: (v) => <Text code>{v}</Text>,
},
{
title: '订单状态',
dataIndex: 'order',
render: (v) => <Text code>{v}</Text>,
},
{ title: '说明', dataIndex: 'note' },
]}
/>
<Alert
type="warning"
showIcon
message="幂等:订单已 delivered 时再次推送 success 仍返回成功,不会重复处理。"
/>
</Card>
</Space>
),
},
{
key: 'errors',
label: '错误码',
children: ( children: (
<Card size="small"> <Card size="small">
<Table <Table
size="small" size="small"
pagination={false} pagination={false}
rowKey="code" rowKey="path"
dataSource={[ dataSource={[
{ code: 0, http: 200, msg: '成功' }, { method: 'GET', path: '/api/client/v1/products', scope: 'products:read', desc: '查询已授权商品' },
{ code: 401, http: 401, msg: '鉴权失败:Key/签名/时间/Nonce' }, { method: 'POST', path: '/api/client/v1/orders', scope: 'orders:write', desc: '幂等创建订单并扣款' },
{ code: 404, http: 404, msg: '订单不存在' }, { method: 'GET', path: '/api/client/v1/orders/{order_no}', scope: 'orders:read', desc: '查询订单状态' },
{ code: 400, http: 400, msg: '参数错误 / 状态不允许' }, { method: 'POST', path: '/api/client/v1/orders/{order_no}/cancel', scope: 'orders:write', desc: '取消未履约订单并退款' },
{ method: 'POST', path: '/api/client/v1/orders/{order_no}/ship-notify', scope: 'fulfillment:write', desc: '履约器回传 processing/success/failed' },
{ method: 'GET', path: '/api/client/v1/wallet', scope: 'wallet:read', desc: '查询商户钱包' },
]} ]}
columns={[ columns={[
{ title: 'code', dataIndex: 'code', width: 80 }, { title: '方法', dataIndex: 'method', width: 90, render: (v) => <Tag color={v === 'GET' ? 'blue' : 'green'}>{v}</Tag> },
{ title: 'HTTP', dataIndex: 'http', width: 80 }, { title: '路径', dataIndex: 'path', render: (v) => <Text code>{v}</Text> },
{ title: '说明', dataIndex: 'msg' }, { title: '权限', dataIndex: 'scope', width: 150, render: (v) => <Text code>{v}</Text> },
{ title: '说明', dataIndex: 'desc' },
]} ]}
/> />
<Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}> </Card>
sku ),
<Link href="/skins"> </Link> },
docs/.md {
</Paragraph> key: 'order',
label: '下单',
children: (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" title="请求">
<Paragraph>
<Tag color="green">POST</Tag>
<Text code>/api/client/v1/orders</Text>
</Paragraph>
<pre style={preStyle}>{`{
"client_order_no": "shop-10001",
"sku": "sku-basic",
"quantity": 1,
"buyer_reference": "buyer-or-account",
"data": {
"server": "ios-wechat",
"uid": "player-id"
}
}`}</pre>
</Card>
<Card size="small" title="响应">
<pre style={preStyle}>{`{
"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
}
}
}`}</pre>
</Card>
</Space>
),
},
{
key: 'status',
label: '状态',
children: (
<Card size="small">
<Table
size="small"
pagination={false}
rowKey="status"
dataSource={[
{ status: 'pending', can: 'true', desc: '待履约,可被履约器接单' },
{ status: 'processing', can: 'false', desc: '履约中' },
{ status: 'succeeded', can: 'false', desc: '履约成功' },
{ status: 'failed', can: 'true', desc: '履约失败,可重试' },
{ status: 'cancelled', can: 'false', desc: '已取消' },
]}
columns={[
{ title: 'fulfillment_status', dataIndex: 'status', render: (v) => <Text code>{v}</Text> },
{ title: 'can_fulfill', dataIndex: 'can', width: 120, render: (v) => v === 'true' ? <Tag color="green">true</Tag> : <Tag>false</Tag> },
{ title: '说明', dataIndex: 'desc' },
]}
/>
</Card>
),
},
{
key: 'callback',
label: '回调',
children: (
<Card size="small">
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="事件">order.created / order.fulfillment.updated / order.cancelled</Descriptions.Item>
<Descriptions.Item label="Header">X-Event-IDX-TimestampX-Sign</Descriptions.Item>
<Descriptions.Item label="签名">hex(HMAC-SHA256(callback_secret, timestamp + "\n" + sha256(body)))</Descriptions.Item>
<Descriptions.Item label="投递"> outbox退</Descriptions.Item>
</Descriptions>
</Card> </Card>
), ),
}, },
+210
View File
@@ -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<PageResult<Merchant>>({ 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<Merchant | null>(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<Merchant> = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '编码', dataIndex: 'code', width: 180, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
{ 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' ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', width: 160, render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm') },
{
title: '操作',
key: 'action',
width: 190,
render: (_, record) => (
<Space size={0}>
<Button
type="link"
size="small"
onClick={() => {
localStorage.setItem('merchant_id', String(record.id))
message.success('当前商户已切换')
navigate('/merchant-center')
}}
>
</Button>
<Button
type="link"
size="small"
icon={<TeamOutlined />}
onClick={() => {
setSelectedMerchant(record)
memberForm.resetFields()
memberForm.setFieldsValue({ role: 'operator', is_default: false })
setMemberOpen(true)
}}
>
</Button>
</Space>
),
},
]
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => load()}>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields()
setCreateOpen(true)
}}
>
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data.list}
tableLayout="fixed"
pagination={{
current: data.page,
pageSize: data.size,
total: data.total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: load,
}}
/>
<Modal title="新增商户" open={createOpen} onOk={submitCreate} onCancel={() => setCreateOpen(false)} destroyOnClose>
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="code" label="商户编码" rules={[{ required: true }]}>
<Input placeholder="lower-case-code" />
</Form.Item>
<Form.Item name="name" label="商户名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="owner_user_id" label="负责人用户 ID" rules={[{ required: true }]}>
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="contact_name" label="联系人">
<Input />
</Form.Item>
<Form.Item name="contact_info" label="联系方式">
<Input />
</Form.Item>
</Form>
</Modal>
<Modal
title={selectedMerchant ? `添加成员:${selectedMerchant.name}` : '添加成员'}
open={memberOpen}
onOk={submitMember}
onCancel={() => setMemberOpen(false)}
destroyOnClose
>
<Form form={memberForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="user_id" label="用户 ID" rules={[{ required: true }]}>
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select options={memberRoleOptions} />
</Form.Item>
<Form.Item name="is_default" label="默认商户">
<Select options={[{ value: true, label: '是' }, { value: false, label: '否' }]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+122
View File
@@ -9,6 +9,128 @@ export interface User {
created_at: string 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 { export interface Skin {
id: number id: number
name: string name: string