实现多商户履约平台基础
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 可关闭)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -37,6 +39,7 @@ func Load() *Config {
|
|||||||
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,
|
||||||
|
DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")),
|
||||||
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
|
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
|
||||||
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
|
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
|
||||||
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
|
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ 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{
|
||||||
|
MerchantID: middleware.GetMerchantID(c),
|
||||||
Page: page,
|
Page: page,
|
||||||
Size: size,
|
Size: size,
|
||||||
Status: c.Query("status"),
|
Status: c.Query("status"),
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,6 +23,7 @@ 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{
|
||||||
|
MerchantID: middleware.GetMerchantID(c),
|
||||||
Page: page,
|
Page: page,
|
||||||
Size: size,
|
Size: size,
|
||||||
Keyword: c.Query("keyword"),
|
Keyword: c.Query("keyword"),
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +22,7 @@ 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{
|
||||||
|
MerchantID: middleware.GetMerchantID(c),
|
||||||
Page: page,
|
Page: page,
|
||||||
Size: size,
|
Size: size,
|
||||||
Keyword: c.Query("keyword"),
|
Keyword: c.Query("keyword"),
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -13,6 +14,7 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -35,8 +35,9 @@ 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:"-"`
|
||||||
|
|
||||||
|
MerchantID uint `gorm:"index;uniqueIndex:idx_skins_merchant_sku;not null;default:0" json:"merchant_id"`
|
||||||
Name string `gorm:"size:128;not null" json:"name"` // 中文名(展示用,保持原名)
|
Name string `gorm:"size:128;not null" json:"name"` // 中文名(展示用,保持原名)
|
||||||
SKU string `gorm:"uniqueIndex;size:64;not null" json:"sku"` // 英文固定标识
|
SKU string `gorm:"uniqueIndex:idx_skins_merchant_sku;size:64;not null" json:"sku"` // 英文固定标识
|
||||||
Game string `gorm:"size:64;index" json:"game"` // 所属游戏
|
Game string `gorm:"size:64;index" json:"game"` // 所属游戏
|
||||||
Category string `gorm:"size:64;index" json:"category"` // 品类:套装/背包/...
|
Category string `gorm:"size:64;index" json:"category"` // 品类:套装/背包/...
|
||||||
CoverURL string `gorm:"size:512" json:"cover_url"`
|
CoverURL string `gorm:"size:512" json:"cover_url"`
|
||||||
@@ -55,6 +56,7 @@ 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:"-"`
|
||||||
|
|
||||||
|
MerchantID uint `gorm:"index;not null;default:0" json:"merchant_id"`
|
||||||
OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"`
|
OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"`
|
||||||
SkinID uint `gorm:"index;not null" json:"skin_id"`
|
SkinID uint `gorm:"index;not null" json:"skin_id"`
|
||||||
Skin *Skin `gorm:"foreignKey:SkinID" json:"skin,omitempty"`
|
Skin *Skin `gorm:"foreignKey:SkinID" json:"skin,omitempty"`
|
||||||
@@ -99,6 +101,7 @@ 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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -16,10 +16,11 @@ 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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ func NewSkinService(db *gorm.DB) *SkinService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SkinListQuery struct {
|
type SkinListQuery struct {
|
||||||
|
MerchantID uint
|
||||||
Page int
|
Page int
|
||||||
Size int
|
Size int
|
||||||
Keyword string
|
Keyword string
|
||||||
@@ -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: "和平精英",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -11,13 +11,15 @@ 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 {
|
||||||
|
MerchantID uint
|
||||||
Page int
|
Page int
|
||||||
Size int
|
Size int
|
||||||
Keyword string
|
Keyword string
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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={
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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: '开放接口' },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+109
-224
@@ -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=v…(value <Text strong>不</Text> URL encode)
|
timestamp、nonce、METHOD、path、sha256(body)
|
||||||
</Paragraph>
|
|
||||||
<pre style={preStyle}>{`api_key=sk_xxx&body=&method=GET&nonce=a1b2c3d4e5f67890&path=/api/open/v1/orders/O123×tamp=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×tamp=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: (
|
children: (
|
||||||
<Card size="small">
|
<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/{order_no}</Text>
|
|
||||||
</Paragraph>
|
|
||||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
|
||||||
Header:X-Api-Key / X-Timestamp / X-Nonce / X-Sign(见「签名规则」)
|
|
||||||
</Paragraph>
|
|
||||||
</Card>
|
|
||||||
<Card size="small" title="响应字段">
|
|
||||||
<Table
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey="field"
|
rowKey="path"
|
||||||
dataSource={[
|
dataSource={[
|
||||||
{ field: 'order_no', desc: '店铺订单号' },
|
{ method: 'GET', path: '/api/client/v1/products', scope: 'products:read', desc: '查询已授权商品' },
|
||||||
{ field: 'status', desc: '订单状态' },
|
{ method: 'POST', path: '/api/client/v1/orders', scope: 'orders:write', desc: '幂等创建订单并扣款' },
|
||||||
{ field: 'can_ship', desc: '是否可发货(发货前置请以此为准)' },
|
{ method: 'GET', path: '/api/client/v1/orders/{order_no}', scope: 'orders:read', desc: '查询订单状态' },
|
||||||
{ field: 'cannot_ship_reason', desc: '不可发货原因' },
|
{ method: 'POST', path: '/api/client/v1/orders/{order_no}/cancel', scope: 'orders:write', desc: '取消未履约订单并退款' },
|
||||||
{ field: 'product.sku', desc: '商品英文固定标识(发货用)' },
|
{ method: 'POST', path: '/api/client/v1/orders/{order_no}/ship-notify', scope: 'fulfillment:write', desc: '履约器回传 processing/success/failed' },
|
||||||
{ field: 'product.name', desc: '商品中文名' },
|
{ method: 'GET', path: '/api/client/v1/wallet', scope: 'wallet:read', desc: '查询商户钱包' },
|
||||||
{ field: 'product.game', desc: '游戏,如和平精英' },
|
|
||||||
{ field: 'buyer_name', desc: '买家名' },
|
|
||||||
{ field: 'amount', desc: '金额' },
|
|
||||||
{ field: 'shipped_at', desc: '发货成功时间' },
|
|
||||||
]}
|
]}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{ title: '方法', dataIndex: 'method', width: 90, render: (v) => <Tag color={v === 'GET' ? 'blue' : 'green'}>{v}</Tag> },
|
||||||
title: '字段',
|
{ title: '路径', dataIndex: 'path', render: (v) => <Text code>{v}</Text> },
|
||||||
dataIndex: 'field',
|
{ title: '权限', dataIndex: 'scope', width: 150, render: (v) => <Text code>{v}</Text> },
|
||||||
width: 200,
|
|
||||||
render: (v) => <Text code>{v}</Text>,
|
|
||||||
},
|
|
||||||
{ title: '说明', dataIndex: 'desc' },
|
{ title: '说明', dataIndex: 'desc' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</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',
|
key: 'order',
|
||||||
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>
|
<Paragraph>
|
||||||
<Tag color="green">POST</Tag>
|
<Tag color="green">POST</Tag>
|
||||||
<Text code>/api/open/v1/orders/ship-notify</Text>
|
<Text code>/api/client/v1/orders</Text>
|
||||||
</Paragraph>
|
|
||||||
<Paragraph type="secondary">
|
|
||||||
除签名头外,Body 参与签名。Body 示例:
|
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<pre style={preStyle}>{`{
|
<pre style={preStyle}>{`{
|
||||||
"order_no": "O202607201550038000",
|
"client_order_no": "shop-10001",
|
||||||
"ship_status": "success",
|
"sku": "sku-basic",
|
||||||
"provider_order_no": "SRC20260720001",
|
"quantity": 1,
|
||||||
"shipped_at": "2026-07-20T16:00:00+08:00",
|
"buyer_reference": "buyer-or-account",
|
||||||
"fail_reason": ""
|
"data": {
|
||||||
|
"server": "ios-wechat",
|
||||||
|
"uid": "player-id"
|
||||||
|
}
|
||||||
}`}</pre>
|
}`}</pre>
|
||||||
</Card>
|
</Card>
|
||||||
<Card size="small" title="请求参数">
|
<Card size="small" title="响应">
|
||||||
<Descriptions size="small" column={1} bordered>
|
<pre style={preStyle}>{`{
|
||||||
<Descriptions.Item label="order_no">必填,店铺订单号</Descriptions.Item>
|
"code": 0,
|
||||||
<Descriptions.Item label="ship_status">
|
"message": "ok",
|
||||||
必填:success / failed / processing
|
"data": {
|
||||||
</Descriptions.Item>
|
"idempotent": false,
|
||||||
<Descriptions.Item label="provider_order_no">可选,上游单号</Descriptions.Item>
|
"order": {
|
||||||
<Descriptions.Item label="shipped_at">
|
"order_no": "FO202607300001...",
|
||||||
可选,RFC3339;success 缺省用服务端时间
|
"client_order_no": "shop-10001",
|
||||||
</Descriptions.Item>
|
"payment_status": "paid",
|
||||||
<Descriptions.Item label="fail_reason">可选,失败原因</Descriptions.Item>
|
"fulfillment_status": "pending",
|
||||||
</Descriptions>
|
"can_fulfill": true
|
||||||
</Card>
|
}
|
||||||
<Card size="small" title="状态映射与幂等">
|
}
|
||||||
<Table
|
}`}</pre>
|
||||||
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>
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'errors',
|
key: 'status',
|
||||||
label: '错误码',
|
label: '状态',
|
||||||
children: (
|
children: (
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Table
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
rowKey="code"
|
rowKey="status"
|
||||||
dataSource={[
|
dataSource={[
|
||||||
{ code: 0, http: 200, msg: '成功' },
|
{ status: 'pending', can: 'true', desc: '待履约,可被履约器接单' },
|
||||||
{ code: 401, http: 401, msg: '鉴权失败:Key/签名/时间/Nonce' },
|
{ status: 'processing', can: 'false', desc: '履约中' },
|
||||||
{ code: 404, http: 404, msg: '订单不存在' },
|
{ status: 'succeeded', can: 'false', desc: '履约成功' },
|
||||||
{ code: 400, http: 400, msg: '参数错误 / 状态不允许' },
|
{ status: 'failed', can: 'true', desc: '履约失败,可重试' },
|
||||||
|
{ status: 'cancelled', can: 'false', desc: '已取消' },
|
||||||
]}
|
]}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: 'code', dataIndex: 'code', width: 80 },
|
{ title: 'fulfillment_status', dataIndex: 'status', render: (v) => <Text code>{v}</Text> },
|
||||||
{ title: 'HTTP', dataIndex: 'http', width: 80 },
|
{ title: 'can_fulfill', dataIndex: 'can', width: 120, render: (v) => v === 'true' ? <Tag color="green">true</Tag> : <Tag>false</Tag> },
|
||||||
{ title: '说明', dataIndex: 'msg' },
|
{ title: '说明', dataIndex: 'desc' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
|
</Card>
|
||||||
商品 sku 对照:
|
),
|
||||||
<Link href="/skins"> 皮肤商品列表</Link>
|
},
|
||||||
(字段「英文名」)或仓库 docs/商品英文名对照.md
|
{
|
||||||
</Paragraph>
|
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-ID、X-Timestamp、X-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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user