实现多商户履约平台基础

This commit is contained in:
yml2213
2026-07-30 12:02:32 +08:00
parent dde6f2c269
commit 83429456a6
41 changed files with 5487 additions and 504 deletions
+12 -9
View File
@@ -14,6 +14,8 @@ type Config struct {
JWTSecret string
DatabaseURL string
Mode string // debug / release
// DataEncryptionKey 用于加密保存 API/回调密钥;为空时由 JWT_SECRET 派生。
DataEncryptionKey string
// OpenAPIKey 皮肤源头开放接口标识(Header: X-Api-Key,可公开给对接方)
OpenAPIKey string
// OpenAPISecret 签名密钥(仅用于 HMAC,不走 Header
@@ -33,15 +35,16 @@ func Load() *Config {
// OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启
debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "")
return &Config{
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"),
Mode: mode,
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
OpenAPIDebug: debugOpen,
LogFile: getEnv("LOG_FILE", "logs/app.log"),
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"),
Mode: mode,
DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")),
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
OpenAPIDebug: debugOpen,
LogFile: getEnv("LOG_FILE", "logs/app.log"),
}
}
+168
View File
@@ -0,0 +1,168 @@
package database
import (
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"path/filepath"
"sort"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
type schemaMigration struct {
Version string `gorm:"primaryKey;size:64"`
Name string `gorm:"size:255;not null"`
Checksum string `gorm:"size:64;not null"`
AppliedAt time.Time `gorm:"not null"`
}
func (schemaMigration) TableName() string {
return "schema_migrations"
}
// Migrate 使用显式 SQL 迁移管理表结构,避免大型项目依赖隐式结构同步。
func Migrate(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", "affiliate_dash_schema_migrations").Error; err != nil {
return fmt.Errorf("lock schema migrations: %w", err)
}
if err := ensureMigrationTable(tx); err != nil {
return err
}
if err := applySQLMigrations(tx); err != nil {
return err
}
merchant, err := ensureSelfMerchant(tx)
if err != nil {
return err
}
return ensureWallet(tx, merchant.ID)
})
}
func ensureMigrationTable(tx *gorm.DB) error {
statements := []string{
`
CREATE TABLE IF NOT EXISTS schema_migrations (
version varchar(64) PRIMARY KEY,
name varchar(255) NOT NULL,
checksum varchar(64) NOT NULL,
applied_at timestamptz NOT NULL
)`,
`ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS name varchar(255) NOT NULL DEFAULT ''`,
`ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS checksum varchar(64) NOT NULL DEFAULT ''`,
`ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS applied_at timestamptz NOT NULL DEFAULT now()`,
}
for _, statement := range statements {
if err := tx.Exec(statement).Error; err != nil {
return fmt.Errorf("ensure schema_migrations: %w", err)
}
}
return nil
}
func applySQLMigrations(tx *gorm.DB) error {
paths, err := fs.Glob(migrationFiles, "migrations/*.sql")
if err != nil {
return fmt.Errorf("list migrations: %w", err)
}
sort.Strings(paths)
for _, path := range paths {
content, err := migrationFiles.ReadFile(path)
if err != nil {
return fmt.Errorf("read migration %s: %w", path, err)
}
version, name := parseMigrationName(path)
checksum := migrationChecksum(content)
var existing schemaMigration
err = tx.Where("version = ?", version).First(&existing).Error
if err == nil {
if existing.Checksum == "" {
if err := tx.Model(&schemaMigration{}).Where("version = ?", version).Updates(map[string]interface{}{
"name": name,
"checksum": checksum,
}).Error; err != nil {
return fmt.Errorf("adopt migration %s: %w", version, err)
}
continue
}
if existing.Checksum != checksum {
return fmt.Errorf("migration %s checksum mismatch: 已执行版本不能被修改", version)
}
continue
}
if err != gorm.ErrRecordNotFound {
return fmt.Errorf("query migration %s: %w", version, err)
}
sql := strings.TrimSpace(string(content))
if sql == "" {
return fmt.Errorf("migration %s is empty", path)
}
if err := tx.Exec(sql).Error; err != nil {
return fmt.Errorf("apply migration %s: %w", path, err)
}
if err := tx.Create(&schemaMigration{
Version: version,
Name: name,
Checksum: checksum,
AppliedAt: time.Now(),
}).Error; err != nil {
return fmt.Errorf("record migration %s: %w", version, err)
}
}
return nil
}
func parseMigrationName(path string) (string, string) {
base := filepath.Base(path)
name := strings.TrimSuffix(base, filepath.Ext(base))
parts := strings.SplitN(name, "_", 2)
if len(parts) == 1 {
return parts[0], name
}
return parts[0], parts[1]
}
func migrationChecksum(content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:])
}
func ensureSelfMerchant(tx *gorm.DB) (*model.Merchant, error) {
var merchant model.Merchant
err := tx.Where("code = ?", "self-operated").First(&merchant).Error
if err == nil {
return &merchant, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
merchant = model.Merchant{
Code: "self-operated",
Name: "自营商户",
Status: model.MerchantStatusActive,
}
if err := tx.Create(&merchant).Error; err != nil {
return nil, err
}
return &merchant, nil
}
func ensureWallet(tx *gorm.DB, merchantID uint) error {
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.WalletAccount{
MerchantID: merchantID,
Currency: "CNY",
}).Error
}
@@ -0,0 +1,349 @@
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
nickname VARCHAR(64),
role VARCHAR(32) NOT NULL DEFAULT 'distributor',
status BIGINT DEFAULT 1,
invite_code VARCHAR(32),
parent_id BIGINT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_invite_code ON users (invite_code);
CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users (deleted_at);
CREATE INDEX IF NOT EXISTS idx_users_parent_id ON users (parent_id);
CREATE TABLE IF NOT EXISTS merchants (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
contact_name VARCHAR(64),
contact_info VARCHAR(128)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_merchants_code ON merchants (code);
CREATE INDEX IF NOT EXISTS idx_merchants_deleted_at ON merchants (deleted_at);
CREATE INDEX IF NOT EXISTS idx_merchants_status ON merchants (status);
CREATE TABLE IF NOT EXISTS merchant_members (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'operator',
status BIGINT NOT NULL DEFAULT 1,
is_default BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_merchant_member ON merchant_members (merchant_id, user_id);
CREATE INDEX IF NOT EXISTS idx_merchant_members_merchant_id ON merchant_members (merchant_id);
CREATE INDEX IF NOT EXISTS idx_merchant_members_user_id ON merchant_members (user_id);
CREATE TABLE IF NOT EXISTS skins (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL DEFAULT 0,
name VARCHAR(128) NOT NULL,
sku VARCHAR(64) NOT NULL,
game VARCHAR(64),
category VARCHAR(64),
cover_url VARCHAR(512),
price DOUBLE PRECISION NOT NULL DEFAULT 0,
cost_price DOUBLE PRECISION DEFAULT 0,
commission DOUBLE PRECISION DEFAULT 0,
stock BIGINT DEFAULT 0,
status BIGINT DEFAULT 1,
description TEXT
);
DROP INDEX IF EXISTS idx_skins_sku;
CREATE UNIQUE INDEX IF NOT EXISTS idx_skins_merchant_sku ON skins (merchant_id, sku);
CREATE INDEX IF NOT EXISTS idx_skins_deleted_at ON skins (deleted_at);
CREATE INDEX IF NOT EXISTS idx_skins_merchant_id ON skins (merchant_id);
CREATE INDEX IF NOT EXISTS idx_skins_game ON skins (game);
CREATE INDEX IF NOT EXISTS idx_skins_category ON skins (category);
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL DEFAULT 0,
order_no VARCHAR(64) NOT NULL,
skin_id BIGINT NOT NULL,
distributor_id BIGINT NOT NULL,
buyer_name VARCHAR(64),
amount DOUBLE PRECISION NOT NULL,
commission_amt DOUBLE PRECISION DEFAULT 0,
status VARCHAR(32) DEFAULT 'pending',
remark VARCHAR(255),
provider_order_no VARCHAR(64),
shipped_at TIMESTAMPTZ,
ship_fail_reason VARCHAR(512),
game_channel VARCHAR(64),
game_uid VARCHAR(128),
role_name VARCHAR(64),
pay_score BIGINT DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_order_no ON orders (order_no);
CREATE INDEX IF NOT EXISTS idx_orders_deleted_at ON orders (deleted_at);
CREATE INDEX IF NOT EXISTS idx_orders_merchant_id ON orders (merchant_id);
CREATE INDEX IF NOT EXISTS idx_orders_skin_id ON orders (skin_id);
CREATE INDEX IF NOT EXISTS idx_orders_distributor_id ON orders (distributor_id);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders (status);
CREATE INDEX IF NOT EXISTS idx_orders_provider_order_no ON orders (provider_order_no);
CREATE TABLE IF NOT EXISTS ship_logs (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL DEFAULT 0,
order_no VARCHAR(64) NOT NULL,
order_id BIGINT,
ship_status VARCHAR(32) NOT NULL,
provider_order_no VARCHAR(64),
fail_reason VARCHAR(512),
payload TEXT,
result_status VARCHAR(32),
message VARCHAR(255)
);
CREATE INDEX IF NOT EXISTS idx_ship_logs_merchant_id ON ship_logs (merchant_id);
CREATE INDEX IF NOT EXISTS idx_ship_logs_order_no ON ship_logs (order_no);
CREATE INDEX IF NOT EXISTS idx_ship_logs_order_id ON ship_logs (order_id);
CREATE TABLE IF NOT EXISTS api_clients (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL,
app_key VARCHAR(96) NOT NULL,
secret_ciphertext TEXT NOT NULL,
signature_version VARCHAR(16) NOT NULL DEFAULT 'v1',
scopes TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_clients_app_key ON api_clients (app_key);
CREATE INDEX IF NOT EXISTS idx_api_clients_deleted_at ON api_clients (deleted_at);
CREATE INDEX IF NOT EXISTS idx_api_clients_merchant_id ON api_clients (merchant_id);
CREATE INDEX IF NOT EXISTS idx_api_clients_status ON api_clients (status);
CREATE TABLE IF NOT EXISTS products (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
code VARCHAR(96) NOT NULL,
name VARCHAR(160) NOT NULL,
category VARCHAR(64),
description TEXT,
attributes TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'active'
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_products_code ON products (code);
CREATE INDEX IF NOT EXISTS idx_products_deleted_at ON products (deleted_at);
CREATE INDEX IF NOT EXISTS idx_products_category ON products (category);
CREATE INDEX IF NOT EXISTS idx_products_status ON products (status);
CREATE TABLE IF NOT EXISTS merchant_products (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
sku VARCHAR(96) NOT NULL,
display_name VARCHAR(160),
price_amount BIGINT NOT NULL DEFAULT 0,
cost_amount BIGINT NOT NULL DEFAULT 0,
currency VARCHAR(12) NOT NULL DEFAULT 'CNY',
stock BIGINT NOT NULL DEFAULT -1,
status VARCHAR(16) NOT NULL DEFAULT 'active',
fulfillment_config TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_merchant_product_sku ON merchant_products (merchant_id, sku);
CREATE INDEX IF NOT EXISTS idx_merchant_products_deleted_at ON merchant_products (deleted_at);
CREATE INDEX IF NOT EXISTS idx_merchant_products_merchant_id ON merchant_products (merchant_id);
CREATE INDEX IF NOT EXISTS idx_merchant_products_product_id ON merchant_products (product_id);
CREATE INDEX IF NOT EXISTS idx_merchant_products_status ON merchant_products (status);
CREATE TABLE IF NOT EXISTS wallet_accounts (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL,
currency VARCHAR(12) NOT NULL DEFAULT 'CNY',
available_balance BIGINT NOT NULL DEFAULT 0,
frozen_balance BIGINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_wallet_accounts_merchant_id ON wallet_accounts (merchant_id);
CREATE TABLE IF NOT EXISTS wallet_ledger_entries (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL,
wallet_account_id BIGINT NOT NULL,
entry_no VARCHAR(64) NOT NULL,
type VARCHAR(24) NOT NULL,
amount BIGINT NOT NULL,
balance_after BIGINT NOT NULL,
reference_type VARCHAR(32) NOT NULL,
reference_no VARCHAR(96) NOT NULL,
idempotency_key VARCHAR(128),
note VARCHAR(255)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_wallet_ledger_entries_entry_no ON wallet_ledger_entries (entry_no);
CREATE UNIQUE INDEX IF NOT EXISTS idx_wallet_ledger_idempotency ON wallet_ledger_entries (merchant_id, idempotency_key);
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_merchant_id ON wallet_ledger_entries (merchant_id);
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_wallet_account_id ON wallet_ledger_entries (wallet_account_id);
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_type ON wallet_ledger_entries (type);
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_entries_reference_no ON wallet_ledger_entries (reference_no);
CREATE TABLE IF NOT EXISTS fulfillment_orders (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
client_order_no VARCHAR(96) NOT NULL,
merchant_product_id BIGINT NOT NULL,
product_sku VARCHAR(96) NOT NULL,
product_name VARCHAR(160) NOT NULL,
quantity BIGINT NOT NULL DEFAULT 1,
amount BIGINT NOT NULL,
currency VARCHAR(12) NOT NULL DEFAULT 'CNY',
payment_status VARCHAR(16) NOT NULL DEFAULT 'pending',
fulfillment_status VARCHAR(16) NOT NULL DEFAULT 'pending',
buyer_reference VARCHAR(128),
request_data TEXT,
result_data TEXT,
provider_order_no VARCHAR(96),
failure_reason VARCHAR(512),
cancelled_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_fulfillment_orders_order_no ON fulfillment_orders (order_no);
CREATE UNIQUE INDEX IF NOT EXISTS idx_order_client_no ON fulfillment_orders (merchant_id, client_order_no);
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_deleted_at ON fulfillment_orders (deleted_at);
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_merchant_id ON fulfillment_orders (merchant_id);
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_merchant_product_id ON fulfillment_orders (merchant_product_id);
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_payment_status ON fulfillment_orders (payment_status);
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_fulfillment_status ON fulfillment_orders (fulfillment_status);
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_provider_order_no ON fulfillment_orders (provider_order_no);
CREATE TABLE IF NOT EXISTS fulfillment_jobs (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts BIGINT NOT NULL DEFAULT 0,
next_run_at TIMESTAMPTZ NOT NULL,
provider_order_no VARCHAR(96),
request_payload TEXT,
result_payload TEXT,
last_error VARCHAR(512)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_fulfillment_jobs_order_id ON fulfillment_jobs (order_id);
CREATE INDEX IF NOT EXISTS idx_fulfillment_jobs_merchant_id ON fulfillment_jobs (merchant_id);
CREATE INDEX IF NOT EXISTS idx_fulfillment_jobs_status ON fulfillment_jobs (status);
CREATE INDEX IF NOT EXISTS idx_fulfillment_jobs_next_run_at ON fulfillment_jobs (next_run_at);
CREATE INDEX IF NOT EXISTS idx_fulfillment_jobs_provider_order_no ON fulfillment_jobs (provider_order_no);
CREATE TABLE IF NOT EXISTS callback_subscriptions (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL,
url VARCHAR(1024) NOT NULL,
events TEXT NOT NULL,
secret_ciphertext TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active'
);
CREATE INDEX IF NOT EXISTS idx_callback_subscriptions_deleted_at ON callback_subscriptions (deleted_at);
CREATE INDEX IF NOT EXISTS idx_callback_subscriptions_merchant_id ON callback_subscriptions (merchant_id);
CREATE INDEX IF NOT EXISTS idx_callback_subscriptions_status ON callback_subscriptions (status);
CREATE TABLE IF NOT EXISTS callback_deliveries (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
merchant_id BIGINT NOT NULL,
callback_subscription_id BIGINT NOT NULL,
event_id VARCHAR(64) NOT NULL,
event VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts BIGINT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL,
last_status_code BIGINT NOT NULL DEFAULT 0,
last_response TEXT,
delivered_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_callback_deliveries_event_id ON callback_deliveries (event_id);
CREATE INDEX IF NOT EXISTS idx_callback_deliveries_merchant_id ON callback_deliveries (merchant_id);
CREATE INDEX IF NOT EXISTS idx_callback_deliveries_callback_subscription_id ON callback_deliveries (callback_subscription_id);
CREATE INDEX IF NOT EXISTS idx_callback_deliveries_event ON callback_deliveries (event);
CREATE INDEX IF NOT EXISTS idx_callback_deliveries_status ON callback_deliveries (status);
CREATE INDEX IF NOT EXISTS idx_callback_deliveries_next_attempt_at ON callback_deliveries (next_attempt_at);
CREATE TABLE IF NOT EXISTS api_request_nonces (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
api_client_id BIGINT NOT NULL,
nonce VARCHAR(96) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_nonce ON api_request_nonces (api_client_id, nonce);
CREATE INDEX IF NOT EXISTS idx_api_request_nonces_api_client_id ON api_request_nonces (api_client_id);
CREATE INDEX IF NOT EXISTS idx_api_request_nonces_expires_at ON api_request_nonces (expires_at);
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ,
merchant_id BIGINT,
actor_user_id BIGINT,
api_client_id BIGINT,
request_id VARCHAR(64),
action VARCHAR(96) NOT NULL,
entity_type VARCHAR(64) NOT NULL,
entity_id VARCHAR(96) NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_merchant_id ON audit_logs (merchant_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_user_id ON audit_logs (actor_user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_api_client_id ON audit_logs (api_client_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_request_id ON audit_logs (request_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs (action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity_type ON audit_logs (entity_type);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity_id ON audit_logs (entity_id);
+383
View File
@@ -0,0 +1,383 @@
package handler
import (
"strconv"
"time"
"affiliate_dash/internal/middleware"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
)
// MerchantHandler 提供商户后台与平台管理员的多租户管理能力。
type MerchantHandler struct {
merchantSvc *service.MerchantService
fulfillmentSvc *service.FulfillmentService
callbackSvc *service.CallbackService
}
func NewMerchantHandler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService, callbackSvc *service.CallbackService) *MerchantHandler {
return &MerchantHandler{
merchantSvc: merchantSvc,
fulfillmentSvc: fulfillmentSvc,
callbackSvc: callbackSvc,
}
}
func (h *MerchantHandler) Current(c *gin.Context) {
merchant, err := h.merchantSvc.GetMerchant(middleware.GetMerchantID(c))
if err != nil {
response.NotFound(c, err.Error())
return
}
response.OK(c, gin.H{
"merchant": merchant,
"role": middleware.GetMerchantRole(c),
})
}
func (h *MerchantHandler) ListProducts(c *gin.Context) {
page, size := pageParams(c)
list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, false)
if err != nil {
response.ServerError(c, err.Error())
return
}
response.Page(c, list, total, page, size)
}
type merchantProductReq struct {
ProductCode string `json:"product_code"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Description string `json:"description"`
Attributes string `json:"attributes"`
SKU string `json:"sku" binding:"required"`
DisplayName string `json:"display_name"`
PriceAmount int64 `json:"price_amount"`
CostAmount int64 `json:"cost_amount"`
Currency string `json:"currency"`
Stock int64 `json:"stock"`
Status string `json:"status"`
FulfillmentConfig string `json:"fulfillment_config"`
}
func (h *MerchantHandler) CreateProduct(c *gin.Context) {
var req merchantProductReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:sku 必填")
return
}
product, err := h.merchantSvc.CreateMerchantProduct(middleware.GetMerchantID(c), service.CreateMerchantProductInput{
ProductCode: req.ProductCode,
ProductName: req.ProductName,
Category: req.Category,
Description: req.Description,
Attributes: req.Attributes,
SKU: req.SKU,
DisplayName: req.DisplayName,
PriceAmount: req.PriceAmount,
CostAmount: req.CostAmount,
Currency: req.Currency,
Stock: req.Stock,
Status: req.Status,
FulfillmentConfig: req.FulfillmentConfig,
}, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, product)
}
type merchantProductUpdateReq struct {
DisplayName *string `json:"display_name"`
PriceAmount *int64 `json:"price_amount"`
CostAmount *int64 `json:"cost_amount"`
Stock *int64 `json:"stock"`
Status *string `json:"status"`
FulfillmentConfig *string `json:"fulfillment_config"`
}
func (h *MerchantHandler) UpdateProduct(c *gin.Context) {
var req merchantProductUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.merchantSvc.UpdateMerchantProduct(middleware.GetMerchantID(c), uint(id), service.UpdateMerchantProductInput{
DisplayName: req.DisplayName,
PriceAmount: req.PriceAmount,
CostAmount: req.CostAmount,
Stock: req.Stock,
Status: req.Status,
FulfillmentConfig: req.FulfillmentConfig,
}, middleware.GetUserID(c)); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, nil)
}
func (h *MerchantHandler) ListOrders(c *gin.Context) {
page, size := pageParams(c)
list, total, err := h.fulfillmentSvc.ListOrders(middleware.GetMerchantID(c), page, size, c.Query("fulfillment_status"))
if err != nil {
response.ServerError(c, err.Error())
return
}
response.Page(c, list, total, page, size)
}
func (h *MerchantHandler) GetWallet(c *gin.Context) {
wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c))
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, wallet)
}
func (h *MerchantHandler) ListWalletLedger(c *gin.Context) {
page, size := pageParams(c)
list, total, err := h.fulfillmentSvc.ListWalletLedger(middleware.GetMerchantID(c), page, size)
if err != nil {
response.ServerError(c, err.Error())
return
}
response.Page(c, list, total, page, size)
}
type walletAdjustReq struct {
Amount int64 `json:"amount" binding:"required"`
IdempotencyKey string `json:"idempotency_key" binding:"required"`
Note string `json:"note"`
}
func (h *MerchantHandler) AdjustWallet(c *gin.Context) {
var req walletAdjustReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:amount 与 idempotency_key 必填")
return
}
wallet, err := h.fulfillmentSvc.AdjustWallet(service.WalletAdjustInput{
MerchantID: middleware.GetMerchantID(c),
ActorUserID: middleware.GetUserID(c),
Amount: req.Amount,
IdempotencyKey: req.IdempotencyKey,
Note: req.Note,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, wallet)
}
func (h *MerchantHandler) ListAPIClients(c *gin.Context) {
clients, err := h.merchantSvc.ListAPIClients(middleware.GetMerchantID(c))
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, clients)
}
type apiClientReq struct {
Name string `json:"name" binding:"required"`
Scopes string `json:"scopes" binding:"required"`
SignatureVersion string `json:"signature_version"`
ExpiresAt string `json:"expires_at"`
}
func (h *MerchantHandler) CreateAPIClient(c *gin.Context) {
var req apiClientReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:name 与 scopes 必填")
return
}
var expiresAt *time.Time
if req.ExpiresAt != "" {
value, err := time.Parse(time.RFC3339, req.ExpiresAt)
if err != nil {
response.BadRequest(c, "expires_at 必须是 RFC3339 时间")
return
}
expiresAt = &value
}
credential, err := h.merchantSvc.CreateAPIClient(middleware.GetMerchantID(c), service.CreateAPIClientInput{
Name: req.Name,
Scopes: req.Scopes,
SignatureVersion: req.SignatureVersion,
ExpiresAt: expiresAt,
}, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, credential)
}
type statusReq struct {
Status string `json:"status" binding:"required"`
}
func (h *MerchantHandler) UpdateAPIClientStatus(c *gin.Context) {
var req statusReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.merchantSvc.UpdateAPIClientStatus(middleware.GetMerchantID(c), uint(id), req.Status, middleware.GetUserID(c)); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, nil)
}
func (h *MerchantHandler) ListCallbacks(c *gin.Context) {
list, err := h.callbackSvc.ListSubscriptions(middleware.GetMerchantID(c))
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, list)
}
type callbackReq struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
Events string `json:"events" binding:"required"`
}
func (h *MerchantHandler) CreateCallback(c *gin.Context) {
var req callbackReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:name、url、events 必填")
return
}
credential, err := h.callbackSvc.CreateSubscription(middleware.GetMerchantID(c), service.CreateCallbackInput{
Name: req.Name,
URL: req.URL,
Events: req.Events,
}, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, credential)
}
func (h *MerchantHandler) UpdateCallbackStatus(c *gin.Context) {
var req statusReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.callbackSvc.UpdateSubscriptionStatus(middleware.GetMerchantID(c), uint(id), req.Status, middleware.GetUserID(c)); err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, nil)
}
func (h *MerchantHandler) ListMembers(c *gin.Context) {
members, err := h.merchantSvc.ListMembers(middleware.GetMerchantID(c))
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, members)
}
type addMemberReq struct {
UserID uint `json:"user_id" binding:"required"`
Role string `json:"role" binding:"required"`
IsDefault bool `json:"is_default"`
}
func (h *MerchantHandler) AddCurrentMerchantMember(c *gin.Context) {
var req addMemberReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:user_id 与 role 必填")
return
}
member, err := h.merchantSvc.AddMember(middleware.GetMerchantID(c), service.AddMemberInput{
UserID: req.UserID,
Role: req.Role,
IsDefault: req.IsDefault,
}, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, member)
}
func (h *MerchantHandler) ListPlatformMerchants(c *gin.Context) {
page, size := pageParams(c)
list, total, err := h.merchantSvc.ListMerchants(page, size)
if err != nil {
response.ServerError(c, err.Error())
return
}
response.Page(c, list, total, page, size)
}
type createMerchantReq struct {
Code string `json:"code" binding:"required"`
Name string `json:"name" binding:"required"`
ContactName string `json:"contact_name"`
ContactInfo string `json:"contact_info"`
OwnerUserID uint `json:"owner_user_id" binding:"required"`
}
func (h *MerchantHandler) CreateMerchant(c *gin.Context) {
var req createMerchantReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:code、name、owner_user_id 必填")
return
}
merchant, err := h.merchantSvc.CreateMerchant(service.CreateMerchantInput{
Code: req.Code,
Name: req.Name,
ContactName: req.ContactName,
ContactInfo: req.ContactInfo,
OwnerUserID: req.OwnerUserID,
}, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, merchant)
}
func (h *MerchantHandler) AddPlatformMerchantMember(c *gin.Context) {
var req addMemberReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:user_id 与 role 必填")
return
}
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
member, err := h.merchantSvc.AddMember(uint(id), service.AddMemberInput{
UserID: req.UserID,
Role: req.Role,
IsDefault: req.IsDefault,
}, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, member)
}
func pageParams(c *gin.Context) (int, int) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
return page, size
}
+221
View File
@@ -0,0 +1,221 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"affiliate_dash/internal/middleware"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
)
// OpenV1Handler 提供面向商户系统和履约器的通用开放接口。
type OpenV1Handler struct {
merchantSvc *service.MerchantService
fulfillmentSvc *service.FulfillmentService
}
func NewOpenV1Handler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService) *OpenV1Handler {
return &OpenV1Handler{merchantSvc: merchantSvc, fulfillmentSvc: fulfillmentSvc}
}
func (h *OpenV1Handler) ListProducts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, true)
if err != nil {
response.ServerError(c, err.Error())
return
}
response.Page(c, list, total, page, size)
}
type openCreateOrderReq struct {
ClientOrderNo string `json:"client_order_no" binding:"required"`
SKU string `json:"sku" binding:"required"`
Quantity int64 `json:"quantity"`
BuyerReference string `json:"buyer_reference"`
Data json.RawMessage `json:"data"`
}
func (h *OpenV1Handler) CreateOrder(c *gin.Context) {
var req openCreateOrderReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:client_order_no 与 sku 必填")
return
}
if key := c.GetHeader("Idempotency-Key"); key != "" && key != req.ClientOrderNo {
response.BadRequest(c, "Idempotency-Key 必须与 client_order_no 一致")
return
}
var data interface{}
if len(req.Data) > 0 {
var decoded interface{}
if err := json.Unmarshal(req.Data, &decoded); err != nil {
response.BadRequest(c, "data 必须是有效 JSON")
return
}
data = decoded
}
client := middleware.GetAPIClient(c)
result, err := h.fulfillmentSvc.CreateOrder(service.CreateFulfillmentOrderInput{
MerchantID: middleware.GetMerchantID(c),
APIClientID: client.ID,
ClientOrderNo: req.ClientOrderNo,
SKU: req.SKU,
Quantity: req.Quantity,
BuyerReference: req.BuyerReference,
RequestData: data,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
status := http.StatusOK
if !result.Idempotent {
status = http.StatusCreated
}
c.JSON(status, response.Body{
Code: 0,
Message: "ok",
Data: gin.H{
"order": buildOpenOrderResponse(result.Order),
"idempotent": result.Idempotent,
},
})
}
func (h *OpenV1Handler) QueryOrder(c *gin.Context) {
order, err := h.fulfillmentSvc.GetOrder(middleware.GetMerchantID(c), c.Param("order_no"))
if err != nil {
if err.Error() == "订单不存在" {
response.NotFound(c, err.Error())
return
}
response.ServerError(c, err.Error())
return
}
response.OK(c, buildOpenOrderResponse(order))
}
type openCancelOrderReq struct {
Reason string `json:"reason"`
}
func (h *OpenV1Handler) CancelOrder(c *gin.Context) {
var req openCancelOrderReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
client := middleware.GetAPIClient(c)
order, err := h.fulfillmentSvc.CancelOrder(middleware.GetMerchantID(c), client.ID, c.Param("order_no"), req.Reason)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, buildOpenOrderResponse(order))
}
type openShipNotifyReq struct {
OrderNo string `json:"order_no"`
ShipStatus string `json:"ship_status" binding:"required"`
ProviderOrderNo string `json:"provider_order_no"`
FailReason string `json:"fail_reason"`
Result json.RawMessage `json:"result"`
}
func (h *OpenV1Handler) ShipNotify(c *gin.Context) {
var req openShipNotifyReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:ship_status 必填")
return
}
status := ""
switch req.ShipStatus {
case "processing":
status = model.FulfillmentStatusProcessing
case "success":
status = model.FulfillmentStatusSucceeded
case "failed":
status = model.FulfillmentStatusFailed
default:
response.BadRequest(c, "ship_status 仅支持 processing、success、failed")
return
}
var result interface{}
if len(req.Result) > 0 {
if err := json.Unmarshal(req.Result, &result); err != nil {
response.BadRequest(c, "result 必须是有效 JSON")
return
}
}
client := middleware.GetAPIClient(c)
orderNo := c.Param("order_no")
if orderNo == "" {
orderNo = req.OrderNo
}
if orderNo == "" {
response.BadRequest(c, "order_no 必填")
return
}
order, err := h.fulfillmentSvc.UpdateFulfillment(service.FulfillmentUpdateInput{
MerchantID: middleware.GetMerchantID(c),
APIClientID: client.ID,
OrderNo: orderNo,
Status: status,
ProviderOrderNo: req.ProviderOrderNo,
FailureReason: req.FailReason,
ResultData: result,
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, buildOpenOrderResponse(order))
}
func (h *OpenV1Handler) GetWallet(c *gin.Context) {
wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c))
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, wallet)
}
func buildOpenOrderResponse(order *model.FulfillmentOrder) gin.H {
canFulfill, reason := service.CanFulfill(order)
data := gin.H{
"order_no": order.OrderNo,
"client_order_no": order.ClientOrderNo,
"payment_status": order.PaymentStatus,
"fulfillment_status": order.FulfillmentStatus,
"can_fulfill": canFulfill,
"cannot_fulfill_reason": reason,
"product": gin.H{
"sku": order.ProductSKU,
"name": order.ProductName,
},
"quantity": order.Quantity,
"amount": order.Amount,
"currency": order.Currency,
"buyer_reference": order.BuyerReference,
"provider_order_no": order.ProviderOrderNo,
"failure_reason": order.FailureReason,
"created_at": order.CreatedAt,
"delivered_at": order.DeliveredAt,
"cancelled_at": order.CancelledAt,
}
if json.Valid([]byte(order.RequestData)) {
data["data"] = json.RawMessage(order.RequestData)
}
if json.Valid([]byte(order.ResultData)) {
data["result"] = json.RawMessage(order.ResultData)
}
return data
}
+13 -10
View File
@@ -23,9 +23,10 @@ func (h *OrderHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
q := service.OrderListQuery{
Page: page,
Size: size,
Status: c.Query("status"),
MerchantID: middleware.GetMerchantID(c),
Page: page,
Size: size,
Status: c.Query("status"),
}
// 分销商只能看自己的订单
if middleware.GetRole(c) == model.RoleDistributor {
@@ -45,11 +46,11 @@ func (h *OrderHandler) List(c *gin.Context) {
}
type createOrderReq struct {
SkinID uint `json:"skin_id" binding:"required"`
BuyerName string `json:"buyer_name"`
Remark string `json:"remark"`
Status string `json:"status"` // 管理员可指定初始状态(联调造异常单)
DistributorID *uint `json:"distributor_id"` // 管理员可指定分销商
SkinID uint `json:"skin_id" binding:"required"`
BuyerName string `json:"buyer_name"`
Remark string `json:"remark"`
Status string `json:"status"` // 管理员可指定初始状态(联调造异常单)
DistributorID *uint `json:"distributor_id"` // 管理员可指定分销商
}
func (h *OrderHandler) Create(c *gin.Context) {
@@ -75,6 +76,7 @@ func (h *OrderHandler) Create(c *gin.Context) {
req.BuyerName = "测试买家"
}
order, err := h.svc.Create(service.CreateOrderInput{
MerchantID: middleware.GetMerchantID(c),
SkinID: req.SkinID,
DistributorID: distributorID,
BuyerName: req.BuyerName,
@@ -99,7 +101,7 @@ func (h *OrderHandler) UpdateStatus(c *gin.Context) {
response.BadRequest(c, "参数错误")
return
}
if err := h.svc.UpdateStatus(uint(id), req.Status); err != nil {
if err := h.svc.UpdateStatus(middleware.GetMerchantID(c), uint(id), req.Status); err != nil {
response.BadRequest(c, err.Error())
return
}
@@ -107,7 +109,7 @@ func (h *OrderHandler) UpdateStatus(c *gin.Context) {
}
func (h *OrderHandler) Dashboard(c *gin.Context) {
stats, err := h.svc.Dashboard()
stats, err := h.svc.Dashboard(middleware.GetMerchantID(c))
if err != nil {
response.ServerError(c, err.Error())
return
@@ -120,6 +122,7 @@ func (h *OrderHandler) ListShipLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
list, total, err := h.svc.ListShipLogs(service.ShipLogListQuery{
MerchantID: middleware.GetMerchantID(c),
Page: page,
Size: size,
OrderNo: c.Query("order_no"),
+11 -8
View File
@@ -3,6 +3,7 @@ package handler
import (
"strconv"
"affiliate_dash/internal/middleware"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
@@ -22,11 +23,12 @@ func (h *SkinHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
q := service.SkinListQuery{
Page: page,
Size: size,
Keyword: c.Query("keyword"),
Game: c.Query("game"),
Category: c.Query("category"),
MerchantID: middleware.GetMerchantID(c),
Page: page,
Size: size,
Keyword: c.Query("keyword"),
Game: c.Query("game"),
Category: c.Query("category"),
}
if s := c.Query("status"); s != "" {
v, _ := strconv.Atoi(s)
@@ -42,7 +44,7 @@ func (h *SkinHandler) List(c *gin.Context) {
func (h *SkinHandler) Get(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
skin, err := h.svc.Get(uint(id))
skin, err := h.svc.Get(middleware.GetMerchantID(c), uint(id))
if err != nil {
response.NotFound(c, err.Error())
return
@@ -79,6 +81,7 @@ func (h *SkinHandler) Create(c *gin.Context) {
req.Game = "和平精英"
}
skin := &model.Skin{
MerchantID: middleware.GetMerchantID(c),
Name: req.Name,
SKU: req.SKU,
Game: req.Game,
@@ -108,7 +111,7 @@ func (h *SkinHandler) Update(c *gin.Context) {
delete(updates, "id")
delete(updates, "created_at")
delete(updates, "updated_at")
if err := h.svc.Update(uint(id), updates); err != nil {
if err := h.svc.Update(middleware.GetMerchantID(c), uint(id), updates); err != nil {
response.BadRequest(c, err.Error())
return
}
@@ -117,7 +120,7 @@ func (h *SkinHandler) Update(c *gin.Context) {
func (h *SkinHandler) Delete(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if err := h.svc.Delete(uint(id)); err != nil {
if err := h.svc.Delete(middleware.GetMerchantID(c), uint(id)); err != nil {
response.BadRequest(c, err.Error())
return
}
+7 -5
View File
@@ -3,6 +3,7 @@ package handler
import (
"strconv"
"affiliate_dash/internal/middleware"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
@@ -21,10 +22,11 @@ func (h *UserHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
q := service.UserListQuery{
Page: page,
Size: size,
Keyword: c.Query("keyword"),
Role: c.Query("role"),
MerchantID: middleware.GetMerchantID(c),
Page: page,
Size: size,
Keyword: c.Query("keyword"),
Role: c.Query("role"),
}
if s := c.Query("status"); s != "" {
v, _ := strconv.Atoi(s)
@@ -52,7 +54,7 @@ func (h *UserHandler) Create(c *gin.Context) {
response.BadRequest(c, "参数错误")
return
}
user, err := h.svc.Create(req.Username, req.Password, req.Nickname, req.Role, req.ParentID)
user, err := h.svc.Create(req.Username, req.Password, req.Nickname, req.Role, req.ParentID, middleware.GetMerchantID(c))
if err != nil {
response.BadRequest(c, err.Error())
return
+44 -3
View File
@@ -5,14 +5,16 @@ import (
"affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
)
const (
CtxUserID = "user_id"
CtxUsername = "username"
CtxRole = "role"
CtxUserID = "user_id"
CtxUsername = "username"
CtxRole = "role"
CtxMerchantRole = "merchant_role"
)
func Auth(jm *jwt.Manager) gin.HandlerFunc {
@@ -70,3 +72,42 @@ func GetRole(c *gin.Context) string {
role, _ := v.(string)
return role
}
// Tenant 根据 X-Merchant-ID(商户 ID 或编码)解析当前后台请求所属商户。
// 未指定时选择该账号的默认商户,确保旧后台继续落到“自营商户”。
func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc {
return func(c *gin.Context) {
member, err := tenantSvc.ResolveMember(GetUserID(c), c.GetHeader("X-Merchant-ID"))
if err != nil {
response.Forbidden(c, err.Error())
c.Abort()
return
}
c.Set(CtxMerchantID, member.MerchantID)
c.Set(CtxMerchantRole, member.Role)
c.Next()
}
}
func RequireMerchantRole(roles ...string) gin.HandlerFunc {
allowed := make(map[string]struct{}, len(roles))
for _, role := range roles {
allowed[role] = struct{}{}
}
return func(c *gin.Context) {
value, _ := c.Get(CtxMerchantRole)
role, _ := value.(string)
if _, ok := allowed[role]; !ok {
response.Forbidden(c, "商户权限不足")
c.Abort()
return
}
c.Next()
}
}
func GetMerchantRole(c *gin.Context) string {
value, _ := c.Get(CtxMerchantRole)
role, _ := value.(string)
return role
}
+131 -135
View File
@@ -6,191 +6,192 @@ import (
"crypto/sha256"
"encoding/hex"
"io"
"sort"
"strconv"
"strings"
"sync"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// OpenAuthConfig 开放接口鉴权配置
const (
CtxAPIClient = "api_client"
CtxAPIClientID = "api_client_id"
CtxMerchantID = "merchant_id"
)
// OpenAuthConfig 配置数据库化的开放接口认证。
type OpenAuthConfig struct {
APIKey string
APISecret string
// 允许的时间偏差(秒),默认 300
DB *gorm.DB
Codec *service.SecretCodec
SkewSeconds int64
// Debug 详细日志
Debug bool
Debug bool
}
// nonce 防重放(进程内,重启清空;生产可换 Redis)
type nonceStore struct {
mu sync.Mutex
data map[string]int64 // nonce -> expire unix
}
func newNonceStore() *nonceStore {
return &nonceStore{data: make(map[string]int64)}
}
func (s *nonceStore) seen(nonce string, now, ttl int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
for k, exp := range s.data {
if exp < now {
delete(s.data, k)
}
}
if exp, ok := s.data[nonce]; ok && exp >= now {
return true
}
s.data[nonce] = now + ttl
return false
}
// OpenAuth 校验 X-Api-Key + 时间戳 + nonce + HMAC-SHA256 签名
//
// 待签名参数(value 原样,不做 URL encode):
//
// api_key, body, method, nonce, path, timestamp
//
// 按 key 字典序排序后拼接 k1=v1&k2=v2&...
// method 大写;path 为 URL.Path(不含 query);GET 时 body 为空串。
// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。
// OpenAuth 校验独立 API 客户端、时间戳、持久化 nonce 与 HMAC 签名。
// 客户侧新接口只使用 X-App-Key 和 body SHA256;上游发货接口独立使用 SourceOpenAuth。
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
if cfg.SkewSeconds <= 0 {
cfg.SkewSeconds = 300
}
store := newNonceStore()
return func(c *gin.Context) {
reqID := openlog.EnsureReqID(c)
c.Set(openlog.CtxDebug, cfg.Debug)
c.Set(openlog.CtxStart, time.Now())
c.Header("X-Request-Id", reqID)
if cfg.APIKey == "" || cfg.APISecret == "" {
openlog.Warn(c, "auth_fail reason=server_not_configured")
response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET")
if cfg.DB == nil || cfg.Codec == nil {
response.ServerError(c, "开放接口认证服务未初始化")
c.Abort()
return
}
appKey := c.GetHeader("X-App-Key")
timestamp := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
sign := c.GetHeader("X-Sign")
if appKey == "" || timestamp == "" || nonce == "" || sign == "" {
response.Unauthorized(c, "缺少鉴权头:X-App-Key、X-Timestamp、X-Nonce、X-Sign")
c.Abort()
return
}
if len(nonce) < 8 || len(nonce) > 96 {
response.Unauthorized(c, "X-Nonce 长度需在 8~96 之间")
c.Abort()
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || abs64(time.Now().Unix()-ts) > cfg.SkewSeconds {
response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误")
c.Abort()
return
}
apiKey := c.GetHeader("X-Api-Key")
timestamp := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
sign := c.GetHeader("X-Sign")
clientIP := c.ClientIP()
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
openlog.Warn(c, "auth_fail reason=read_body_error err=%v ip=%s", err, clientIP)
response.BadRequest(c, "读取请求体失败")
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
body := string(bodyBytes)
c.Set(openlog.CtxBody, body)
method := strings.ToUpper(c.Request.Method)
path := c.Request.URL.Path
openlog.Info(c, "request in method=%s path=%s ip=%s key=%s ts=%s nonce=%s sign=%s body=%q body_len=%d",
method, path, clientIP,
openlog.MaskKey(apiKey), timestamp, nonce, openlog.MaskSign(sign),
openlog.Truncate(body, 500), len(bodyBytes),
)
if apiKey == "" || timestamp == "" || nonce == "" || sign == "" {
openlog.Warn(c, "auth_fail reason=missing_headers key=%s ts=%s nonce=%s has_sign=%v",
openlog.MaskKey(apiKey), timestamp, nonce, sign != "")
response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign")
c.Abort()
return
}
if apiKey != cfg.APIKey {
openlog.Warn(c, "auth_fail reason=invalid_api_key key=%s", openlog.MaskKey(apiKey))
var client model.APIClient
if err := cfg.DB.Where("app_key = ? AND status = ?", appKey, model.APIClientStatusActive).First(&client).Error; err != nil {
response.Unauthorized(c, "无效的 API Key")
c.Abort()
return
}
if len(nonce) < 8 || len(nonce) > 64 {
openlog.Warn(c, "auth_fail reason=bad_nonce_len len=%d", len(nonce))
response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间")
if client.ExpiresAt != nil && client.ExpiresAt.Before(time.Now()) {
response.Unauthorized(c, "API Key 已过期")
c.Abort()
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
secret, err := cfg.Codec.Decrypt(client.SecretCiphertext)
if err != nil {
openlog.Warn(c, "auth_fail reason=bad_timestamp raw=%s", timestamp)
response.Unauthorized(c, "X-Timestamp 格式错误,需为 Unix 秒级时间戳")
c.Abort()
return
}
now := time.Now().Unix()
skew := now - ts
if abs64(skew) > cfg.SkewSeconds {
openlog.Warn(c, "auth_fail reason=timestamp_skew server_now=%d ts=%d skew=%ds limit=%ds",
now, ts, skew, cfg.SkewSeconds)
response.Unauthorized(c, "请求已过期或时间偏差过大")
response.ServerError(c, "API 客户端密钥不可用")
c.Abort()
return
}
if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) {
openlog.Warn(c, "auth_fail reason=replay_nonce nonce=%s", nonce)
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
c.Abort()
return
}
stringToSign := BuildSignString(apiKey, timestamp, nonce, method, path, body)
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
method := strings.ToUpper(c.Request.Method)
path := c.Request.URL.Path
expected := BuildOpenV1Sign(secret, timestamp, nonce, method, path, bodyBytes)
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
// 调试时输出待签名串(不含 secret),便于源头对齐
openlog.Warn(c, "auth_fail reason=sign_mismatch sign=%s expected_prefix=%s string_to_sign=%q",
openlog.MaskSign(sign), openlog.MaskSign(expected), openlog.Truncate(stringToSign, 800))
response.Unauthorized(c, "签名校验失败")
c.Abort()
return
}
c.Set(openlog.CtxAPIKey, apiKey)
openlog.Info(c, "auth_ok skew=%ds string_to_sign_len=%d", skew, len(stringToSign))
c.Next()
now := time.Now()
nonceTTL := time.Duration(cfg.SkewSeconds) * time.Second
_ = cfg.DB.Where("expires_at < ?", now).Delete(&model.APIRequestNonce{}).Error
nonceRow := model.APIRequestNonce{
APIClientID: client.ID,
Nonce: nonce,
ExpiresAt: now.Add(nonceTTL),
}
created := cfg.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&nonceRow)
if created.Error != nil {
response.ServerError(c, "记录请求 nonce 失败")
c.Abort()
return
}
if created.RowsAffected == 0 {
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
c.Abort()
return
}
// 请求结束后补一条总耗时(handler 里会打业务结果)
openlog.Info(c, "request done status=%d cost=%s", c.Writer.Status(), openlog.Elapsed(c).Round(time.Microsecond))
c.Set(CtxAPIClient, &client)
c.Set(CtxAPIClientID, client.ID)
c.Set(CtxMerchantID, client.MerchantID)
c.Set(openlog.CtxAPIKey, appKey)
_ = cfg.DB.Model(&model.APIClient{}).Where("id = ?", client.ID).Update("last_used_at", now).Error
c.Next()
}
}
// BuildSignString 生成待签名字符串:参数字典序 + & 拼接
// RequireAPIScope 要求客户端至少拥有一个指定权限。
func RequireAPIScope(scopes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
client := GetAPIClient(c)
if client == nil || !service.HasAnyScope(client.Scopes, scopes...) {
response.Forbidden(c, "API 客户端权限不足")
c.Abort()
return
}
c.Next()
}
}
func GetAPIClient(c *gin.Context) *model.APIClient {
value, ok := c.Get(CtxAPIClient)
if !ok {
return nil
}
client, _ := value.(*model.APIClient)
return client
}
func GetMerchantID(c *gin.Context) uint {
value, _ := c.Get(CtxMerchantID)
merchantID, _ := value.(uint)
return merchantID
}
// BuildOpenV1Sign 生成新开放接口签名:timestamp、nonce、method、path 与 body SHA256。
func BuildOpenV1Sign(secret, timestamp, nonce, method, path string, body []byte) string {
bodyHash := sha256.Sum256(body)
content := strings.Join([]string{
timestamp,
nonce,
strings.ToUpper(method),
path,
hex.EncodeToString(bodyHash[:]),
}, "\n")
return hmacSHA256Hex(secret, content)
}
// BuildSignString 保留旧接口的字典序签名算法,供兼容客户端和测试使用。
func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string {
params := map[string]string{
"api_key": apiKey,
"body": body,
"method": strings.ToUpper(method),
"nonce": nonce,
"path": path,
"timestamp": timestamp,
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+params[k])
}
return strings.Join(parts, "&")
return strings.Join([]string{
"api_key=" + apiKey,
"body=" + body,
"method=" + strings.ToUpper(method),
"nonce=" + nonce,
"path=" + path,
"timestamp=" + timestamp,
}, "&")
}
// BuildOpenSign 供旧发货系统兼容使用。
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
}
func hmacSHA256Hex(secret, content string) string {
@@ -199,14 +200,9 @@ func hmacSHA256Hex(secret, content string) string {
return hex.EncodeToString(mac.Sum(nil))
}
func abs64(v int64) int64 {
if v < 0 {
return -v
func abs64(value int64) int64 {
if value < 0 {
return -value
}
return v
}
// BuildOpenSign 供测试或内部生成签名(与 OpenAuth 规则一致)
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
return value
}
@@ -0,0 +1,162 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/service"
"affiliate_dash/internal/testdb"
"github.com/gin-gonic/gin"
)
func newOpenAuthTestServer(t *testing.T, signatureVersion string) (*gin.Engine, string, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db := testdb.New(t, &model.Merchant{}, &model.APIClient{}, &model.APIRequestNonce{})
codec, err := service.NewSecretCodec("test-master-key")
if err != nil {
t.Fatalf("codec: %v", err)
}
merchant := model.Merchant{Code: "merchant-open", Name: "开放测试商户", Status: model.MerchantStatusActive}
if err := db.Create(&merchant).Error; err != nil {
t.Fatalf("create merchant: %v", err)
}
secret := "client-secret"
ciphertext, err := codec.Encrypt(secret)
if err != nil {
t.Fatalf("encrypt secret: %v", err)
}
client := model.APIClient{
MerchantID: merchant.ID,
Name: "测试客户端",
AppKey: "ak_test",
SecretCiphertext: ciphertext,
SignatureVersion: signatureVersion,
Scopes: "*",
Status: model.APIClientStatusActive,
}
if err := db.Create(&client).Error; err != nil {
t.Fatalf("create client: %v", err)
}
r := gin.New()
r.Use(OpenAuth(OpenAuthConfig{
DB: db,
Codec: codec,
SkewSeconds: 300,
}))
r.POST("/api/client/v1/orders", RequireAPIScope("orders:write"), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"merchant_id": GetMerchantID(c),
"client_id": GetAPIClient(c).ID,
})
})
return r, client.AppKey, secret
}
func TestOpenAuthV1AcceptsSignedRequestAndRejectsReplay(t *testing.T) {
r, appKey, secret := newOpenAuthTestServer(t, "v1")
body := `{"client_order_no":"client-001","sku":"sku-basic"}`
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := "nonce-123456"
path := "/api/client/v1/orders"
sign := BuildOpenV1Sign(secret, ts, nonce, http.MethodPost, path, []byte(body))
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("X-App-Key", appKey)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Sign", sign)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("signed request should pass, code=%d body=%s", w.Code, w.Body.String())
}
replay := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
replay.Header = req.Header.Clone()
w = httptest.NewRecorder()
r.ServeHTTP(w, replay)
if w.Code != http.StatusUnauthorized {
t.Fatalf("replay should be rejected, code=%d body=%s", w.Code, w.Body.String())
}
}
func TestOpenAuthRejectsSignatureMismatch(t *testing.T) {
r, appKey, _ := newOpenAuthTestServer(t, "v1")
req := httptest.NewRequest(http.MethodPost, "/api/client/v1/orders", strings.NewReader(`{"a":1}`))
req.Header.Set("X-App-Key", appKey)
req.Header.Set("X-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
req.Header.Set("X-Nonce", "nonce-bad-sign")
req.Header.Set("X-Sign", "bad-sign")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("bad signature should be rejected, code=%d body=%s", w.Code, w.Body.String())
}
}
func TestSourceOpenAuthKeepsLegacyUpstreamSignature(t *testing.T) {
gin.SetMode(gin.TestMode)
appKey := "source-key"
secret := "source-secret"
r := gin.New()
r.Use(SourceOpenAuth(SourceOpenAuthConfig{
APIKey: appKey,
APISecret: secret,
SkewSeconds: 300,
}))
r.POST("/api/open/v1/orders/ship-notify", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
body := `{"client_order_no":"client-legacy","sku":"sku-basic"}`
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := "legacy-nonce-123"
path := "/api/open/v1/orders/ship-notify"
sign := BuildOpenSign(appKey, secret, ts, nonce, http.MethodPost, path, body)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("X-Api-Key", appKey)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Sign", sign)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("legacy signed request should pass, code=%d body=%s", w.Code, w.Body.String())
}
}
func TestSourceOpenAuthAcceptsUppercaseLegacySignature(t *testing.T) {
gin.SetMode(gin.TestMode)
appKey := "source-key"
secret := "source-secret"
r := gin.New()
r.Use(SourceOpenAuth(SourceOpenAuthConfig{
APIKey: appKey,
APISecret: secret,
SkewSeconds: 300,
}))
r.GET("/api/open/v1/orders/O123", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := "legacy-nonce-upper"
path := "/api/open/v1/orders/O123"
sign := strings.ToUpper(BuildOpenSign(appKey, secret, ts, nonce, http.MethodGet, path, ""))
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Api-Key", appKey)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Sign", sign)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("uppercase legacy signature should pass, code=%d body=%s", w.Code, w.Body.String())
}
}
@@ -0,0 +1,114 @@
package middleware
import (
"bytes"
"crypto/hmac"
"io"
"strconv"
"strings"
"sync"
"time"
"affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/response"
"github.com/gin-gonic/gin"
)
// SourceOpenAuthConfig 是原上游发货接口鉴权配置,保持 X-Api-Key 兼容。
type SourceOpenAuthConfig struct {
APIKey string
APISecret string
SkewSeconds int64
Debug bool
}
type sourceNonceStore struct {
mu sync.Mutex
data map[string]int64
}
func newSourceNonceStore() *sourceNonceStore {
return &sourceNonceStore{data: make(map[string]int64)}
}
func (s *sourceNonceStore) seen(nonce string, now, ttl int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, expiresAt := range s.data {
if expiresAt < now {
delete(s.data, key)
}
}
if expiresAt, ok := s.data[nonce]; ok && expiresAt >= now {
return true
}
s.data[nonce] = now + ttl
return false
}
// SourceOpenAuth 保持现有上游对接签名算法不变:X-Api-Key + 字典序 HMAC。
func SourceOpenAuth(cfg SourceOpenAuthConfig) gin.HandlerFunc {
if cfg.SkewSeconds <= 0 {
cfg.SkewSeconds = 300
}
store := newSourceNonceStore()
return func(c *gin.Context) {
reqID := openlog.EnsureReqID(c)
c.Set(openlog.CtxDebug, cfg.Debug)
c.Set(openlog.CtxStart, time.Now())
c.Header("X-Request-Id", reqID)
if cfg.APIKey == "" || cfg.APISecret == "" {
response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET")
c.Abort()
return
}
apiKey := c.GetHeader("X-Api-Key")
timestamp := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
sign := c.GetHeader("X-Sign")
if apiKey == "" || timestamp == "" || nonce == "" || sign == "" {
response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign")
c.Abort()
return
}
if apiKey != cfg.APIKey {
response.Unauthorized(c, "无效的 API Key")
c.Abort()
return
}
if len(nonce) < 8 || len(nonce) > 64 {
response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间")
c.Abort()
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || abs64(time.Now().Unix()-ts) > cfg.SkewSeconds {
response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误")
c.Abort()
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
response.BadRequest(c, "读取请求体失败")
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
if store.seen(apiKey+":"+nonce, time.Now().Unix(), cfg.SkewSeconds) {
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
c.Abort()
return
}
expected := BuildOpenSign(apiKey, cfg.APISecret, timestamp, nonce, c.Request.Method, c.Request.URL.Path, string(bodyBytes))
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
response.Unauthorized(c, "签名校验失败")
c.Abort()
return
}
c.Next()
}
}
+270
View File
@@ -0,0 +1,270 @@
package model
import (
"time"
"gorm.io/gorm"
)
const (
MerchantStatusActive = "active"
MerchantStatusDisabled = "disabled"
MemberRoleOwner = "owner"
MemberRoleOperator = "operator"
MemberRoleFinance = "finance"
MemberRoleViewer = "viewer"
APIClientStatusActive = "active"
APIClientStatusDisabled = "disabled"
ProductStatusActive = "active"
ProductStatusInactive = "inactive"
PaymentStatusPending = "pending"
PaymentStatusPaid = "paid"
PaymentStatusRefunded = "refunded"
PaymentStatusCancelled = "cancelled"
FulfillmentStatusPending = "pending"
FulfillmentStatusProcessing = "processing"
FulfillmentStatusSucceeded = "succeeded"
FulfillmentStatusFailed = "failed"
FulfillmentStatusCancelled = "cancelled"
FulfillmentJobStatusPending = "pending"
FulfillmentJobStatusProcessing = "processing"
FulfillmentJobStatusSucceeded = "succeeded"
FulfillmentJobStatusFailed = "failed"
WalletLedgerCredit = "credit"
WalletLedgerDebit = "debit"
WalletLedgerRefund = "refund"
WalletLedgerAdjust = "adjust"
CallbackStatusActive = "active"
CallbackStatusDisabled = "disabled"
CallbackDeliveryPending = "pending"
CallbackDeliverySending = "sending"
CallbackDeliveryDelivered = "delivered"
CallbackDeliveryFailed = "failed"
)
// Merchant 商户租户,是所有新业务数据的隔离边界。
type Merchant struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Code string `gorm:"uniqueIndex;size:64;not null" json:"code"`
Name string `gorm:"size:128;not null" json:"name"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
ContactName string `gorm:"size:64" json:"contact_name"`
ContactInfo string `gorm:"size:128" json:"contact_info"`
}
// MerchantMember 将系统账号和商户权限分离,账号可以属于多个商户。
type MerchantMember struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MerchantID uint `gorm:"not null;uniqueIndex:idx_merchant_member;index" json:"merchant_id"`
UserID uint `gorm:"not null;uniqueIndex:idx_merchant_member;index" json:"user_id"`
Role string `gorm:"size:32;not null;default:operator" json:"role"`
Status int `gorm:"not null;default:1" json:"status"`
IsDefault bool `gorm:"not null;default:false" json:"is_default"`
Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
// APIClient 为一个商户的外部系统集成凭证。SecretCiphertext 仅保存加密后的密钥。
type APIClient struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
MerchantID uint `gorm:"not null;index" json:"merchant_id"`
Name string `gorm:"size:128;not null" json:"name"`
AppKey string `gorm:"uniqueIndex;size:96;not null" json:"app_key"`
SecretCiphertext string `gorm:"type:text;not null" json:"-"`
SignatureVersion string `gorm:"size:16;not null;default:v1" json:"signature_version"`
Scopes string `gorm:"type:text;not null" json:"scopes"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
ExpiresAt *time.Time `json:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at"`
Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"`
}
// Product 是平台通用商品目录;商品特性通过 Attributes 保存,避免把游戏、角色等字段写死。
type Product struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Code string `gorm:"uniqueIndex;size:96;not null" json:"code"`
Name string `gorm:"size:160;not null" json:"name"`
Category string `gorm:"size:64;index" json:"category"`
Description string `gorm:"type:text" json:"description"`
Attributes string `gorm:"type:text" json:"attributes"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
}
// MerchantProduct 是商户可售商品及其价格、库存和履约配置。
type MerchantProduct struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
MerchantID uint `gorm:"not null;uniqueIndex:idx_merchant_product_sku;index" json:"merchant_id"`
ProductID uint `gorm:"not null;index" json:"product_id"`
SKU string `gorm:"size:96;not null;uniqueIndex:idx_merchant_product_sku" json:"sku"`
DisplayName string `gorm:"size:160" json:"display_name"`
PriceAmount int64 `gorm:"not null;default:0" json:"price_amount"`
CostAmount int64 `gorm:"not null;default:0" json:"cost_amount"`
Currency string `gorm:"size:12;not null;default:CNY" json:"currency"`
Stock int64 `gorm:"not null;default:-1" json:"stock"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
FulfillmentConfig string `gorm:"type:text" json:"fulfillment_config"`
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
Merchant *Merchant `gorm:"foreignKey:MerchantID" json:"merchant,omitempty"`
}
// WalletAccount 以最小货币单位记录商户余额,绝不使用 float64 做账。
type WalletAccount struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MerchantID uint `gorm:"not null;uniqueIndex" json:"merchant_id"`
Currency string `gorm:"size:12;not null;default:CNY" json:"currency"`
AvailableBalance int64 `gorm:"not null;default:0" json:"available_balance"`
FrozenBalance int64 `gorm:"not null;default:0" json:"frozen_balance"`
}
// WalletLedgerEntry 记录每一笔余额变化,BalanceAfter 方便审计与对账。
type WalletLedgerEntry struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
MerchantID uint `gorm:"not null;index;uniqueIndex:idx_wallet_ledger_idempotency" json:"merchant_id"`
WalletAccountID uint `gorm:"not null;index" json:"wallet_account_id"`
EntryNo string `gorm:"uniqueIndex;size:64;not null" json:"entry_no"`
Type string `gorm:"size:24;not null;index" json:"type"`
Amount int64 `gorm:"not null" json:"amount"`
BalanceAfter int64 `gorm:"not null" json:"balance_after"`
ReferenceType string `gorm:"size:32;not null" json:"reference_type"`
ReferenceNo string `gorm:"size:96;not null;index" json:"reference_no"`
IdempotencyKey *string `gorm:"uniqueIndex:idx_wallet_ledger_idempotency;size:128" json:"-"`
Note string `gorm:"size:255" json:"note"`
}
// FulfillmentOrder 将支付和履约状态拆分,并保留商品及请求快照。
type FulfillmentOrder struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
MerchantID uint `gorm:"not null;index;uniqueIndex:idx_order_client_no" json:"merchant_id"`
OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"`
ClientOrderNo string `gorm:"size:96;not null;uniqueIndex:idx_order_client_no" json:"client_order_no"`
MerchantProductID uint `gorm:"not null;index" json:"merchant_product_id"`
ProductSKU string `gorm:"size:96;not null" json:"product_sku"`
ProductName string `gorm:"size:160;not null" json:"product_name"`
Quantity int64 `gorm:"not null;default:1" json:"quantity"`
Amount int64 `gorm:"not null" json:"amount"`
Currency string `gorm:"size:12;not null;default:CNY" json:"currency"`
PaymentStatus string `gorm:"size:16;not null;default:pending;index" json:"payment_status"`
FulfillmentStatus string `gorm:"size:16;not null;default:pending;index" json:"fulfillment_status"`
BuyerReference string `gorm:"size:128" json:"buyer_reference"`
RequestData string `gorm:"type:text" json:"request_data"`
ResultData string `gorm:"type:text" json:"result_data"`
ProviderOrderNo string `gorm:"size:96;index" json:"provider_order_no"`
FailureReason string `gorm:"size:512" json:"failure_reason"`
CancelledAt *time.Time `json:"cancelled_at"`
DeliveredAt *time.Time `json:"delivered_at"`
MerchantProduct *MerchantProduct `gorm:"foreignKey:MerchantProductID" json:"merchant_product,omitempty"`
}
// FulfillmentJob 为后续履约器保留可重试的持久化任务边界。
type FulfillmentJob struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MerchantID uint `gorm:"not null;index" json:"merchant_id"`
OrderID uint `gorm:"not null;uniqueIndex" json:"order_id"`
Status string `gorm:"size:16;not null;default:pending;index" json:"status"`
Attempts int `gorm:"not null;default:0" json:"attempts"`
NextRunAt time.Time `gorm:"not null;index" json:"next_run_at"`
ProviderOrderNo string `gorm:"size:96;index" json:"provider_order_no"`
RequestPayload string `gorm:"type:text" json:"request_payload"`
ResultPayload string `gorm:"type:text" json:"result_payload"`
LastError string `gorm:"size:512" json:"last_error"`
}
// CallbackSubscription 为商户提供独立、可禁用的事件订阅。
type CallbackSubscription struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
MerchantID uint `gorm:"not null;index" json:"merchant_id"`
Name string `gorm:"size:128;not null" json:"name"`
URL string `gorm:"size:1024;not null" json:"url"`
Events string `gorm:"type:text;not null" json:"events"`
SecretCiphertext string `gorm:"type:text;not null" json:"-"`
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
}
// CallbackDelivery 是事务性 outbox 记录;事件 ID 可供接收方做幂等。
type CallbackDelivery struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MerchantID uint `gorm:"not null;index" json:"merchant_id"`
CallbackSubscriptionID uint `gorm:"not null;index" json:"callback_subscription_id"`
EventID string `gorm:"uniqueIndex;size:64;not null" json:"event_id"`
Event string `gorm:"size:64;not null;index" json:"event"`
Payload string `gorm:"type:text;not null" json:"payload"`
Status string `gorm:"size:16;not null;default:pending;index" json:"status"`
Attempts int `gorm:"not null;default:0" json:"attempts"`
NextAttemptAt time.Time `gorm:"not null;index" json:"next_attempt_at"`
LastStatusCode int `gorm:"not null;default:0" json:"last_status_code"`
LastResponse string `gorm:"type:text" json:"last_response"`
DeliveredAt *time.Time `json:"delivered_at"`
CallbackSubscription *CallbackSubscription `gorm:"foreignKey:CallbackSubscriptionID" json:"callback_subscription,omitempty"`
}
// APIRequestNonce 使 API 重放保护跨进程、跨重启生效。
type APIRequestNonce struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
APIClientID uint `gorm:"not null;uniqueIndex:idx_api_nonce;index" json:"api_client_id"`
Nonce string `gorm:"size:96;not null;uniqueIndex:idx_api_nonce" json:"nonce"`
ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"`
}
// AuditLog 留存后台和开放接口的重要业务操作。
type AuditLog struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
MerchantID *uint `gorm:"index" json:"merchant_id"`
ActorUserID *uint `gorm:"index" json:"actor_user_id"`
APIClientID *uint `gorm:"index" json:"api_client_id"`
RequestID string `gorm:"size:64;index" json:"request_id"`
Action string `gorm:"size:96;not null;index" json:"action"`
EntityType string `gorm:"size:64;not null;index" json:"entity_type"`
EntityID string `gorm:"size:96;not null;index" json:"entity_id"`
Metadata string `gorm:"type:text" json:"metadata"`
}
+25 -22
View File
@@ -8,7 +8,7 @@ import (
// 用户角色
const (
RoleAdmin = "admin" // 管理员
RoleAdmin = "admin" // 管理员
RoleDistributor = "distributor" // 分销商
)
@@ -35,16 +35,17 @@ type Skin struct {
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Name string `gorm:"size:128;not null" json:"name"` // 中文名(展示用,保持原名)
SKU string `gorm:"uniqueIndex;size:64;not null" json:"sku"` // 英文固定标识
Game string `gorm:"size:64;index" json:"game"` // 所属游戏
Category string `gorm:"size:64;index" json:"category"` // 品类:套装/背包/...
MerchantID uint `gorm:"index;uniqueIndex:idx_skins_merchant_sku;not null;default:0" json:"merchant_id"`
Name string `gorm:"size:128;not null" json:"name"` // 中文名(展示用,保持原名)
SKU string `gorm:"uniqueIndex:idx_skins_merchant_sku;size:64;not null" json:"sku"` // 英文固定标识
Game string `gorm:"size:64;index" json:"game"` // 所属游戏
Category string `gorm:"size:64;index" json:"category"` // 品类:套装/背包/...
CoverURL string `gorm:"size:512" json:"cover_url"`
Price float64 `gorm:"not null;default:0" json:"price"` // 售价
CostPrice float64 `gorm:"default:0" json:"cost_price"` // 成本价
Commission float64 `gorm:"default:0" json:"commission"` // 佣金比例 0-1
Stock int `gorm:"default:0" json:"stock"` // -1 无限
Status int `gorm:"default:1" json:"status"` // 1上架 0下架
Price float64 `gorm:"not null;default:0" json:"price"` // 售价
CostPrice float64 `gorm:"default:0" json:"cost_price"` // 成本价
Commission float64 `gorm:"default:0" json:"commission"` // 佣金比例 0-1
Stock int `gorm:"default:0" json:"stock"` // -1 无限
Status int `gorm:"default:1" json:"status"` // 1上架 0下架
Description string `gorm:"type:text" json:"description"`
}
@@ -55,16 +56,17 @@ type Order struct {
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"`
SkinID uint `gorm:"index;not null" json:"skin_id"`
Skin *Skin `gorm:"foreignKey:SkinID" json:"skin,omitempty"`
DistributorID uint `gorm:"index;not null" json:"distributor_id"`
Distributor *User `gorm:"foreignKey:DistributorID" json:"distributor,omitempty"`
BuyerName string `gorm:"size:64" json:"buyer_name"`
Amount float64 `gorm:"not null" json:"amount"`
CommissionAmt float64 `gorm:"default:0" json:"commission_amt"`
Status string `gorm:"size:32;default:pending;index" json:"status"`
Remark string `gorm:"size:255" json:"remark"`
MerchantID uint `gorm:"index;not null;default:0" json:"merchant_id"`
OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"`
SkinID uint `gorm:"index;not null" json:"skin_id"`
Skin *Skin `gorm:"foreignKey:SkinID" json:"skin,omitempty"`
DistributorID uint `gorm:"index;not null" json:"distributor_id"`
Distributor *User `gorm:"foreignKey:DistributorID" json:"distributor,omitempty"`
BuyerName string `gorm:"size:64" json:"buyer_name"`
Amount float64 `gorm:"not null" json:"amount"`
CommissionAmt float64 `gorm:"default:0" json:"commission_amt"`
Status string `gorm:"size:32;default:pending;index" json:"status"`
Remark string `gorm:"size:255" json:"remark"`
// 发货相关(上游皮肤源头对接)
ProviderOrderNo string `gorm:"size:64;index" json:"provider_order_no"` // 上游单号
@@ -74,7 +76,7 @@ type Order struct {
GameChannel string `gorm:"size:64" json:"game_channel"` // 账号区服(安卓/IOS-微信/QQ)
GameUID string `gorm:"size:128" json:"game_uid"` // 游戏角色UUID
RoleName string `gorm:"size:64" json:"role_name"` // 角色名
PayScore int `gorm:"default:0" json:"pay_score"` // 消耗积分
PayScore int `gorm:"default:0" json:"pay_score"` // 消耗积分
}
// 订单状态
@@ -99,12 +101,13 @@ type ShipLog struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
MerchantID uint `gorm:"index;not null;default:0" json:"merchant_id"`
OrderNo string `gorm:"size:64;index;not null" json:"order_no"`
OrderID uint `gorm:"index" json:"order_id"`
ShipStatus string `gorm:"size:32;not null" json:"ship_status"` // success/failed/processing
ProviderOrderNo string `gorm:"size:64" json:"provider_order_no"`
FailReason string `gorm:"size:512" json:"fail_reason"`
Payload string `gorm:"type:text" json:"payload"` // 原始请求 JSON
Payload string `gorm:"type:text" json:"payload"` // 原始请求 JSON
ResultStatus string `gorm:"size:32" json:"result_status"` // 处理后订单状态
Message string `gorm:"size:255" json:"message"`
}
+57 -8
View File
@@ -5,9 +5,11 @@ import (
"affiliate_dash/internal/middleware"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/service"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Handlers struct {
@@ -15,8 +17,13 @@ type Handlers struct {
Skin *handler.SkinHandler
Order *handler.OrderHandler
User *handler.UserHandler
Open *handler.OpenHandler
Open *handler.OpenV1Handler
SourceOpen *handler.OpenHandler
Merchant *handler.MerchantHandler
JWT *jwt.Manager
Tenant *service.TenantService
OpenDB *gorm.DB
SecretCodec *service.SecretCodec
OpenAPIKey string
OpenAPISecret string
OpenSignSkew int64
@@ -29,8 +36,8 @@ func Setup(h *Handlers) *gin.Engine {
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign"},
ExposeHeaders: []string{"Content-Length"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Merchant-ID", "X-App-Key", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign", "Idempotency-Key", "X-Request-ID"},
ExposeHeaders: []string{"Content-Length", "X-Request-ID"},
AllowCredentials: true,
}))
@@ -43,21 +50,39 @@ func Setup(h *Handlers) *gin.Engine {
api.POST("/auth/login", h.Auth.Login)
api.POST("/auth/register", h.Auth.Register)
// 皮肤源头开放接口(ApiKey + HMAC 签名)
open := api.Group("/open/v1")
open.Use(middleware.OpenAuth(middleware.OpenAuthConfig{
// 原上游发货接口:路径、鉴权和字段保持不变。
sourceOpen := api.Group("/open/v1")
sourceOpen.Use(middleware.SourceOpenAuth(middleware.SourceOpenAuthConfig{
APIKey: h.OpenAPIKey,
APISecret: h.OpenAPISecret,
SkewSeconds: h.OpenSignSkew,
Debug: h.OpenAPIDebug,
}))
{
open.GET("/orders/:order_no", h.Open.QueryOrder)
open.POST("/orders/ship-notify", h.Open.ShipNotify)
sourceOpen.GET("/orders/:order_no", h.SourceOpen.QueryOrder)
sourceOpen.POST("/orders/ship-notify", h.SourceOpen.ShipNotify)
}
// 客户侧通用开放接口:独立 API 客户端 + HMAC 签名。
clientOpen := api.Group("/client/v1")
clientOpen.Use(middleware.OpenAuth(middleware.OpenAuthConfig{
DB: h.OpenDB,
Codec: h.SecretCodec,
SkewSeconds: h.OpenSignSkew,
Debug: h.OpenAPIDebug,
}))
{
clientOpen.GET("/products", middleware.RequireAPIScope("products:read"), h.Open.ListProducts)
clientOpen.POST("/orders", middleware.RequireAPIScope("orders:write"), h.Open.CreateOrder)
clientOpen.GET("/orders/:order_no", middleware.RequireAPIScope("orders:read", "fulfillment:read"), h.Open.QueryOrder)
clientOpen.POST("/orders/:order_no/cancel", middleware.RequireAPIScope("orders:write"), h.Open.CancelOrder)
clientOpen.POST("/orders/:order_no/ship-notify", middleware.RequireAPIScope("fulfillment:write"), h.Open.ShipNotify)
clientOpen.GET("/wallet", middleware.RequireAPIScope("wallet:read"), h.Open.GetWallet)
}
auth := api.Group("")
auth.Use(middleware.Auth(h.JWT))
auth.Use(middleware.Tenant(h.Tenant))
{
auth.GET("/auth/profile", h.Auth.Profile)
auth.GET("/dashboard", h.Order.Dashboard)
@@ -74,6 +99,27 @@ func Setup(h *Handlers) *gin.Engine {
auth.POST("/orders", h.Order.Create)
auth.PATCH("/orders/:id/status", middleware.RequireRole(model.RoleAdmin), h.Order.UpdateStatus)
// 新商户后台:不依赖旧皮肤/分销商模型。
merchant := auth.Group("/merchant")
{
merchant.GET("", h.Merchant.Current)
merchant.GET("/products", h.Merchant.ListProducts)
merchant.POST("/products", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateProduct)
merchant.PATCH("/products/:id", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProduct)
merchant.GET("/orders", h.Merchant.ListOrders)
merchant.GET("/wallet", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet)
merchant.GET("/wallet/ledger", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger)
merchant.POST("/wallet/adjust", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.AdjustWallet)
merchant.GET("/api-clients", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListAPIClients)
merchant.POST("/api-clients", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateAPIClient)
merchant.PATCH("/api-clients/:id/status", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateAPIClientStatus)
merchant.GET("/callbacks", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListCallbacks)
merchant.POST("/callbacks", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateCallback)
merchant.PATCH("/callbacks/:id/status", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateCallbackStatus)
merchant.GET("/members", middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.ListMembers)
merchant.POST("/members", middleware.RequireMerchantRole(model.MemberRoleOwner), h.Merchant.AddCurrentMerchantMember)
}
// 用户 / 分销商 / 发货记录(仅管理员)
admin := auth.Group("")
admin.Use(middleware.RequireRole(model.RoleAdmin))
@@ -82,6 +128,9 @@ func Setup(h *Handlers) *gin.Engine {
admin.POST("/users", h.User.Create)
admin.PATCH("/users/:id/status", h.User.UpdateStatus)
admin.GET("/ship-logs", h.Order.ListShipLogs)
admin.GET("/platform/merchants", h.Merchant.ListPlatformMerchants)
admin.POST("/platform/merchants", h.Merchant.CreateMerchant)
admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember)
}
}
}
+51
View File
@@ -0,0 +1,51 @@
package router
import (
"testing"
"affiliate_dash/internal/handler"
"affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/service"
"affiliate_dash/internal/testdb"
"github.com/gin-gonic/gin"
)
func TestSetupDoesNotPanic(t *testing.T) {
gin.SetMode(gin.TestMode)
db := testdb.New(t)
codec, err := service.NewSecretCodec("test-master-key")
if err != nil {
t.Fatalf("codec: %v", err)
}
tenantSvc := service.NewTenantService(db)
authSvc := service.NewAuthService(db, jwt.NewManager("test-jwt"), tenantSvc)
skinSvc := service.NewSkinService(db)
orderSvc := service.NewOrderService(db)
userSvc := service.NewUserService(db, tenantSvc)
callbackSvc := service.NewCallbackService(db, codec)
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("setup should not panic: %v", recovered)
}
}()
_ = Setup(&Handlers{
Auth: handler.NewAuthHandler(authSvc),
Skin: handler.NewSkinHandler(skinSvc),
Order: handler.NewOrderHandler(orderSvc),
User: handler.NewUserHandler(userSvc),
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc),
SourceOpen: handler.NewOpenHandler(orderSvc),
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc),
JWT: jwt.NewManager("test-jwt"),
Tenant: tenantSvc,
OpenDB: db,
SecretCodec: codec,
OpenAPIKey: "source-key",
OpenAPISecret: "source-secret",
OpenSignSkew: 300,
})
}
+27
View File
@@ -0,0 +1,27 @@
package service
import (
"encoding/json"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
func writeAudit(tx *gorm.DB, merchantID, actorUserID, apiClientID *uint, action, entityType, entityID string, metadata interface{}) error {
raw := ""
if metadata != nil {
if bytes, err := json.Marshal(metadata); err == nil {
raw = string(bytes)
}
}
return tx.Create(&model.AuditLog{
MerchantID: merchantID,
ActorUserID: actorUserID,
APIClientID: apiClientID,
Action: action,
EntityType: entityType,
EntityID: entityID,
Metadata: raw,
}).Error
}
+28 -5
View File
@@ -14,12 +14,13 @@ import (
)
type AuthService struct {
db *gorm.DB
jwt *jwt.Manager
db *gorm.DB
jwt *jwt.Manager
tenant *TenantService
}
func NewAuthService(db *gorm.DB, jm *jwt.Manager) *AuthService {
return &AuthService{db: db, jwt: jm}
func NewAuthService(db *gorm.DB, jm *jwt.Manager, tenant *TenantService) *AuthService {
return &AuthService{db: db, jwt: jm, tenant: tenant}
}
type LoginResult struct {
@@ -72,6 +73,11 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User
if err := s.db.Create(user).Error; err != nil {
return nil, err
}
if s.tenant != nil {
if err := s.tenant.EnsureSelfMember(user.ID, model.MemberRoleOperator, user.Status); err != nil {
return nil, err
}
}
return user, nil
}
@@ -87,6 +93,17 @@ func (s *AuthService) EnsureAdmin() error {
var count int64
s.db.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&count)
if count > 0 {
if s.tenant != nil {
var admins []model.User
if err := s.db.Where("role = ?", model.RoleAdmin).Find(&admins).Error; err != nil {
return err
}
for _, admin := range admins {
if err := s.tenant.EnsureSelfMember(admin.ID, model.MemberRoleOwner, admin.Status); err != nil {
return err
}
}
}
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
@@ -101,7 +118,13 @@ func (s *AuthService) EnsureAdmin() error {
Status: 1,
InviteCode: "ADMIN001",
}
return s.db.Create(admin).Error
if err := s.db.Create(admin).Error; err != nil {
return err
}
if s.tenant != nil {
return s.tenant.EnsureSelfMember(admin.ID, model.MemberRoleOwner, admin.Status)
}
return nil
}
func generateInviteCode() string {
+330
View File
@@ -0,0 +1,330 @@
package service
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"affiliate_dash/internal/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
const maxCallbackAttempts = 8
// CallbackService 以数据库 outbox 方式管理回调,进程重启不会丢失待发送事件。
type CallbackService struct {
db *gorm.DB
codec *SecretCodec
httpClient *http.Client
}
func NewCallbackService(db *gorm.DB, codec *SecretCodec) *CallbackService {
return &CallbackService{
db: db,
codec: codec,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *CallbackService) SetHTTPClient(client *http.Client) {
if client != nil {
s.httpClient = client
}
}
type CreateCallbackInput struct {
Name string
URL string
Events string
}
type CallbackCredential struct {
Subscription *model.CallbackSubscription `json:"subscription"`
Secret string `json:"secret"`
}
func (s *CallbackService) CreateSubscription(merchantID uint, in CreateCallbackInput, actorUserID uint) (*CallbackCredential, error) {
in.Name = strings.TrimSpace(in.Name)
in.URL = strings.TrimSpace(in.URL)
if in.Name == "" {
return nil, errors.New("回调名称不能为空")
}
if err := validateCallbackURL(in.URL); err != nil {
return nil, err
}
events := scopeList(in.Events)
if len(events) == 0 {
return nil, errors.New("至少订阅一个事件")
}
secret, err := randomToken("cb_", 32)
if err != nil {
return nil, err
}
ciphertext, err := s.codec.Encrypt(secret)
if err != nil {
return nil, err
}
subscription := &model.CallbackSubscription{
MerchantID: merchantID,
Name: in.Name,
URL: in.URL,
Events: strings.Join(events, ","),
SecretCiphertext: ciphertext,
Status: model.CallbackStatusActive,
}
err = s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
if err := tx.Create(subscription).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "callback_subscription.create", "callback_subscription", fmt.Sprint(subscription.ID), map[string]string{"url": subscription.URL})
})
if err != nil {
return nil, err
}
return &CallbackCredential{Subscription: subscription, Secret: secret}, nil
}
func (s *CallbackService) ListSubscriptions(merchantID uint) ([]model.CallbackSubscription, error) {
var subscriptions []model.CallbackSubscription
err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&subscriptions).Error
return subscriptions, err
}
func (s *CallbackService) UpdateSubscriptionStatus(merchantID, id uint, status string, actorUserID uint) error {
if status != model.CallbackStatusActive && status != model.CallbackStatusDisabled {
return errors.New("无效的回调状态")
}
return s.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.CallbackSubscription{}).
Where("id = ? AND merchant_id = ?", id, merchantID).
Update("status", status)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("回调订阅不存在")
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "callback_subscription.status.update", "callback_subscription", fmt.Sprint(id), map[string]string{"status": status})
})
}
// Enqueue 在调用方事务中写入回调 outbox,只有订单事务成功才会发送事件。
func (s *CallbackService) Enqueue(tx *gorm.DB, merchantID uint, event string, data interface{}) error {
var subscriptions []model.CallbackSubscription
if err := tx.Where("merchant_id = ? AND status = ?", merchantID, model.CallbackStatusActive).Find(&subscriptions).Error; err != nil {
return err
}
now := time.Now()
for _, subscription := range subscriptions {
if !subscribesTo(subscription.Events, event) {
continue
}
eventID := uuid.NewString()
payload, err := json.Marshal(map[string]interface{}{
"event_id": eventID,
"event": event,
"occurred_at": now.UTC().Format(time.RFC3339Nano),
"data": data,
})
if err != nil {
return err
}
delivery := model.CallbackDelivery{
MerchantID: merchantID,
CallbackSubscriptionID: subscription.ID,
EventID: eventID,
Event: event,
Payload: string(payload),
Status: model.CallbackDeliveryPending,
NextAttemptAt: now,
}
if err := tx.Create(&delivery).Error; err != nil {
return err
}
}
return nil
}
// DispatchDue 执行一批可发送的 outbox 记录。返回成功/失败尝试数,便于日志监控。
func (s *CallbackService) DispatchDue(ctx context.Context, limit int) (int, int, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
now := time.Now()
var deliveries []model.CallbackDelivery
err := s.db.Preload("CallbackSubscription").
Where("(status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at <= ?)",
model.CallbackDeliveryPending, now,
model.CallbackDeliverySending, now.Add(-5*time.Minute),
).
Order("next_attempt_at ASC, id ASC").
Limit(limit).
Find(&deliveries).Error
if err != nil {
return 0, 0, err
}
successes, failures := 0, 0
for _, delivery := range deliveries {
ok, attempted, err := s.dispatchOne(ctx, delivery.ID)
if err != nil {
return successes, failures, err
}
if !attempted {
continue
}
if ok {
successes++
} else {
failures++
}
}
return successes, failures, nil
}
func (s *CallbackService) dispatchOne(ctx context.Context, id uint) (bool, bool, error) {
now := time.Now()
claimed := s.db.Model(&model.CallbackDelivery{}).
Where("id = ? AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at <= ?))",
id, model.CallbackDeliveryPending, now,
model.CallbackDeliverySending, now.Add(-5*time.Minute),
).
Update("status", model.CallbackDeliverySending)
if claimed.Error != nil {
return false, false, claimed.Error
}
if claimed.RowsAffected == 0 {
return false, false, nil
}
var delivery model.CallbackDelivery
if err := s.db.Preload("CallbackSubscription").First(&delivery, id).Error; err != nil {
return false, true, err
}
if delivery.CallbackSubscription == nil || delivery.CallbackSubscription.Status != model.CallbackStatusActive {
return false, true, s.recordCallbackFailure(delivery, 0, "回调订阅已禁用")
}
secret, err := s.codec.Decrypt(delivery.CallbackSubscription.SecretCiphertext)
if err != nil {
return false, true, s.recordCallbackFailure(delivery, 0, "回调密钥不可用")
}
timestamp := fmt.Sprint(time.Now().Unix())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, delivery.CallbackSubscription.URL, bytes.NewBufferString(delivery.Payload))
if err != nil {
return false, true, s.recordCallbackFailure(delivery, 0, "创建回调请求失败")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Event-ID", delivery.EventID)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Sign", BuildCallbackSign(secret, timestamp, delivery.Payload))
resp, err := s.httpClient.Do(req)
if err != nil {
return false, true, s.recordCallbackFailure(delivery, 0, truncateCallbackError(err.Error()))
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return true, true, s.db.Model(&model.CallbackDelivery{}).Where("id = ?", delivery.ID).Updates(map[string]interface{}{
"status": model.CallbackDeliveryDelivered,
"attempts": delivery.Attempts + 1,
"last_status_code": resp.StatusCode,
"last_response": string(body),
"delivered_at": time.Now(),
}).Error
}
return false, true, s.recordCallbackFailure(delivery, resp.StatusCode, string(body))
}
func (s *CallbackService) recordCallbackFailure(delivery model.CallbackDelivery, statusCode int, response string) error {
attempts := delivery.Attempts + 1
status := model.CallbackDeliveryPending
nextAttempt := time.Now().Add(callbackRetryDelay(attempts))
if attempts >= maxCallbackAttempts {
status = model.CallbackDeliveryFailed
nextAttempt = time.Now()
}
return s.db.Model(&model.CallbackDelivery{}).Where("id = ?", delivery.ID).Updates(map[string]interface{}{
"status": status,
"attempts": attempts,
"next_attempt_at": nextAttempt,
"last_status_code": statusCode,
"last_response": truncateCallbackError(response),
}).Error
}
func (s *CallbackService) Run(ctx context.Context) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
_, _, _ = s.DispatchDue(ctx, 50)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// BuildCallbackSign 供接收方校验:HMAC-SHA256(secret, timestamp + "\n" + sha256(body))。
func BuildCallbackSign(secret, timestamp, body string) string {
bodyHash := sha256.Sum256([]byte(body))
content := timestamp + "\n" + hex.EncodeToString(bodyHash[:])
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(content))
return hex.EncodeToString(mac.Sum(nil))
}
func subscribesTo(events, event string) bool {
for subscribed := range ParseScopes(events) {
if subscribed == "*" || subscribed == event {
return true
}
}
return false
}
func validateCallbackURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return errors.New("回调地址格式错误")
}
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return errors.New("回调地址仅支持 http 或 https")
}
return nil
}
func callbackRetryDelay(attempts int) time.Duration {
if attempts < 1 {
attempts = 1
}
delay := time.Second * time.Duration(1<<(attempts-1))
if delay > 15*time.Minute {
return 15 * time.Minute
}
return delay
}
func truncateCallbackError(value string) string {
if len(value) <= 4096 {
return value
}
return value[:4096]
}
+530
View File
@@ -0,0 +1,530 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"affiliate_dash/internal/model"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type FulfillmentService struct {
db *gorm.DB
callbacks *CallbackService
}
func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *FulfillmentService {
return &FulfillmentService{db: db, callbacks: callbacks}
}
type CreateFulfillmentOrderInput struct {
MerchantID uint
APIClientID uint
ClientOrderNo string
SKU string
Quantity int64
BuyerReference string
RequestData interface{}
}
type CreateFulfillmentOrderResult struct {
Order *model.FulfillmentOrder `json:"order"`
Idempotent bool `json:"idempotent"`
}
func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*CreateFulfillmentOrderResult, error) {
in.ClientOrderNo = strings.TrimSpace(in.ClientOrderNo)
in.SKU = strings.TrimSpace(in.SKU)
if in.MerchantID == 0 || in.APIClientID == 0 {
return nil, errors.New("无效的商户或 API 客户端")
}
if in.ClientOrderNo == "" || len(in.ClientOrderNo) > 96 {
return nil, errors.New("client_order_no 不能为空且最长 96 位")
}
if in.SKU == "" {
return nil, errors.New("sku 不能为空")
}
if in.Quantity == 0 {
in.Quantity = 1
}
if in.Quantity < 1 {
return nil, errors.New("quantity 必须大于零")
}
requestData := ""
if in.RequestData != nil {
raw, err := json.Marshal(in.RequestData)
if err != nil {
return nil, errors.New("订单请求数据无法序列化")
}
requestData = string(raw)
}
result := &CreateFulfillmentOrderResult{}
err := s.db.Transaction(func(tx *gorm.DB) error {
var existing model.FulfillmentOrder
err := tx.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error
if err == nil {
result.Order = &existing
result.Idempotent = true
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var product model.MerchantProduct
if err := tx.Preload("Product").Clauses(clause.Locking{Strength: "UPDATE"}).
Where("merchant_id = ? AND sku = ? AND status = ?", in.MerchantID, in.SKU, model.ProductStatusActive).
First(&product).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("商品不存在或已下架")
}
return err
}
if product.Product == nil || product.Product.Status != model.ProductStatusActive {
return errors.New("商品目录已下架")
}
if product.Stock >= 0 && product.Stock < in.Quantity {
return errors.New("商品库存不足")
}
if product.PriceAmount > 0 && in.Quantity > math.MaxInt64/product.PriceAmount {
return errors.New("订单金额超出范围")
}
totalAmount := product.PriceAmount * in.Quantity
var wallet model.WalletAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil {
return err
}
if wallet.AvailableBalance < totalAmount {
return errors.New("商户钱包余额不足")
}
newBalance := wallet.AvailableBalance - totalAmount
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
return err
}
order := &model.FulfillmentOrder{
MerchantID: in.MerchantID,
OrderNo: newFulfillmentOrderNo(),
ClientOrderNo: in.ClientOrderNo,
MerchantProductID: product.ID,
ProductSKU: product.SKU,
ProductName: fallbackName(product.DisplayName, product.Product.Name),
Quantity: in.Quantity,
Amount: totalAmount,
Currency: product.Currency,
PaymentStatus: model.PaymentStatusPaid,
FulfillmentStatus: model.FulfillmentStatusPending,
BuyerReference: in.BuyerReference,
RequestData: requestData,
}
if err := tx.Create(order).Error; err != nil {
return err
}
idempotencyKey := in.ClientOrderNo
if err := tx.Create(&model.WalletLedgerEntry{
MerchantID: in.MerchantID,
WalletAccountID: wallet.ID,
EntryNo: "WL" + uuid.NewString(),
Type: model.WalletLedgerDebit,
Amount: -totalAmount,
BalanceAfter: newBalance,
ReferenceType: "fulfillment_order",
ReferenceNo: order.OrderNo,
IdempotencyKey: &idempotencyKey,
Note: "开放接口下单扣款",
}).Error; err != nil {
return err
}
if product.Stock >= 0 {
if err := tx.Model(&model.MerchantProduct{}).Where("id = ? AND stock >= ?", product.ID, in.Quantity).
Update("stock", gorm.Expr("stock - ?", in.Quantity)).Error; err != nil {
return err
}
}
if err := tx.Create(&model.FulfillmentJob{
MerchantID: in.MerchantID,
OrderID: order.ID,
Status: model.FulfillmentJobStatusPending,
NextRunAt: time.Now(),
}).Error; err != nil {
return err
}
if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "open_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{"client_order_no": in.ClientOrderNo, "sku": in.SKU}); err != nil {
return err
}
if s.callbacks != nil {
if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.created", orderCallbackData(order)); err != nil {
return err
}
}
result.Order = order
return nil
})
if err != nil {
// 并发请求恰好同时通过首次查询时,唯一约束冲突后返回既有订单。
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "UNIQUE") {
var existing model.FulfillmentOrder
if queryErr := s.db.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error; queryErr == nil {
return &CreateFulfillmentOrderResult{Order: &existing, Idempotent: true}, nil
}
}
return nil, err
}
return result, nil
}
func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) {
var order model.FulfillmentOrder
err := s.db.Preload("MerchantProduct.Product").
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("订单不存在")
}
if err != nil {
return nil, err
}
return &order, nil
}
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, fulfillmentStatus string) ([]model.FulfillmentOrder, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID)
if fulfillmentStatus != "" {
tx = tx.Where("fulfillment_status = ?", fulfillmentStatus)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var orders []model.FulfillmentOrder
err := tx.Preload("MerchantProduct.Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error
return orders, total, err
}
type FulfillmentUpdateInput struct {
MerchantID uint
APIClientID uint
OrderNo string
Status string
ProviderOrderNo string
FailureReason string
ResultData interface{}
}
func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*model.FulfillmentOrder, error) {
switch in.Status {
case model.FulfillmentStatusProcessing, model.FulfillmentStatusSucceeded, model.FulfillmentStatusFailed:
default:
return nil, errors.New("无效的履约状态")
}
resultData := ""
if in.ResultData != nil {
raw, err := json.Marshal(in.ResultData)
if err != nil {
return nil, errors.New("履约结果无法序列化")
}
resultData = string(raw)
}
var out model.FulfillmentOrder
err := s.db.Transaction(func(tx *gorm.DB) error {
var order model.FulfillmentOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("merchant_id = ? AND order_no = ?", in.MerchantID, in.OrderNo).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("订单不存在")
}
return err
}
if order.FulfillmentStatus == model.FulfillmentStatusCancelled {
return errors.New("订单已取消,不能更新履约状态")
}
if order.PaymentStatus != model.PaymentStatusPaid {
return errors.New("订单未支付,不能履约")
}
if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.Status == model.FulfillmentStatusSucceeded {
out = order
return nil
}
if order.FulfillmentStatus == model.FulfillmentStatusSucceeded {
return errors.New("订单已履约成功,不能回退状态")
}
now := time.Now()
updates := map[string]interface{}{
"fulfillment_status": in.Status,
"result_data": resultData,
}
if in.ProviderOrderNo != "" {
updates["provider_order_no"] = in.ProviderOrderNo
}
switch in.Status {
case model.FulfillmentStatusSucceeded:
updates["delivered_at"] = now
updates["failure_reason"] = ""
case model.FulfillmentStatusFailed:
updates["failure_reason"] = in.FailureReason
}
if err := tx.Model(&order).Updates(updates).Error; err != nil {
return err
}
jobStatus := model.FulfillmentJobStatusProcessing
if in.Status == model.FulfillmentStatusSucceeded {
jobStatus = model.FulfillmentJobStatusSucceeded
} else if in.Status == model.FulfillmentStatusFailed {
jobStatus = model.FulfillmentJobStatusFailed
}
if err := tx.Model(&model.FulfillmentJob{}).Where("order_id = ?", order.ID).Updates(map[string]interface{}{
"status": jobStatus,
"provider_order_no": in.ProviderOrderNo,
"result_payload": resultData,
"last_error": in.FailureReason,
}).Error; err != nil {
return err
}
if err := tx.First(&out, order.ID).Error; err != nil {
return err
}
if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "fulfillment.update", "fulfillment_order", order.OrderNo, map[string]string{"status": in.Status}); err != nil {
return err
}
if s.callbacks != nil {
if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.fulfillment.updated", orderCallbackData(&out)); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) {
var out model.FulfillmentOrder
err := s.db.Transaction(func(tx *gorm.DB) error {
var order model.FulfillmentOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("订单不存在")
}
return err
}
if order.FulfillmentStatus == model.FulfillmentStatusCancelled {
out = order
return nil
}
if order.FulfillmentStatus == model.FulfillmentStatusProcessing || order.FulfillmentStatus == model.FulfillmentStatusSucceeded {
return errors.New("订单已进入履约流程,不能取消")
}
now := time.Now()
updates := map[string]interface{}{
"payment_status": model.PaymentStatusRefunded,
"fulfillment_status": model.FulfillmentStatusCancelled,
"failure_reason": reason,
"cancelled_at": now,
}
if err := tx.Model(&order).Updates(updates).Error; err != nil {
return err
}
var wallet model.WalletAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
return err
}
newBalance := wallet.AvailableBalance + order.Amount
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
return err
}
idempotencyKey := "cancel:" + order.OrderNo
if err := tx.Create(&model.WalletLedgerEntry{
MerchantID: merchantID,
WalletAccountID: wallet.ID,
EntryNo: "WL" + uuid.NewString(),
Type: model.WalletLedgerRefund,
Amount: order.Amount,
BalanceAfter: newBalance,
ReferenceType: "fulfillment_order",
ReferenceNo: order.OrderNo,
IdempotencyKey: &idempotencyKey,
Note: "订单取消退款",
}).Error; err != nil {
return err
}
var product model.MerchantProduct
if err := tx.Where("id = ?", order.MerchantProductID).First(&product).Error; err != nil {
return err
}
if product.Stock >= 0 {
if err := tx.Model(&product).Update("stock", gorm.Expr("stock + ?", order.Quantity)).Error; err != nil {
return err
}
}
if err := tx.Model(&model.FulfillmentJob{}).Where("order_id = ?", order.ID).Updates(map[string]interface{}{
"status": model.FulfillmentJobStatusFailed,
"last_error": "订单已取消",
}).Error; err != nil {
return err
}
if err := tx.First(&out, order.ID).Error; err != nil {
return err
}
if err := writeAudit(tx, &merchantID, nil, &apiClientID, "open_order.cancel", "fulfillment_order", order.OrderNo, nil); err != nil {
return err
}
if s.callbacks != nil {
if err := s.callbacks.Enqueue(tx, merchantID, "order.cancelled", orderCallbackData(&out)); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
type WalletAdjustInput struct {
MerchantID uint
ActorUserID uint
Amount int64
IdempotencyKey string
Note string
}
func (s *FulfillmentService) AdjustWallet(in WalletAdjustInput) (*model.WalletAccount, error) {
if in.Amount == 0 {
return nil, errors.New("调整金额不能为零")
}
if in.IdempotencyKey == "" {
return nil, errors.New("账务调整必须提供幂等键")
}
var out model.WalletAccount
err := s.db.Transaction(func(tx *gorm.DB) error {
var existing model.WalletLedgerEntry
if err := tx.Where("merchant_id = ? AND idempotency_key = ?", in.MerchantID, in.IdempotencyKey).First(&existing).Error; err == nil {
if err := tx.First(&out, existing.WalletAccountID).Error; err != nil {
return err
}
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var wallet model.WalletAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil {
return err
}
newBalance := wallet.AvailableBalance + in.Amount
if newBalance < 0 {
return errors.New("调整后余额不能小于零")
}
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
return err
}
entryType := model.WalletLedgerAdjust
if in.Amount > 0 {
entryType = model.WalletLedgerCredit
} else {
entryType = model.WalletLedgerDebit
}
idempotencyKey := in.IdempotencyKey
if err := tx.Create(&model.WalletLedgerEntry{
MerchantID: in.MerchantID,
WalletAccountID: wallet.ID,
EntryNo: "WL" + uuid.NewString(),
Type: entryType,
Amount: in.Amount,
BalanceAfter: newBalance,
ReferenceType: "manual_adjustment",
ReferenceNo: in.IdempotencyKey,
IdempotencyKey: &idempotencyKey,
Note: in.Note,
}).Error; err != nil {
return err
}
out = wallet
out.AvailableBalance = newBalance
return writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "wallet.adjust", "wallet_account", fmt.Sprint(wallet.ID), map[string]int64{"amount": in.Amount})
})
if err != nil {
return nil, err
}
return &out, nil
}
func (s *FulfillmentService) GetWallet(merchantID uint) (*model.WalletAccount, error) {
var wallet model.WalletAccount
if err := s.db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("商户钱包不存在")
}
return nil, err
}
return &wallet, nil
}
func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int) ([]model.WalletLedgerEntry, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID)
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var entries []model.WalletLedgerEntry
err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&entries).Error
return entries, total, err
}
func newFulfillmentOrderNo() string {
return "FO" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
}
func CanFulfill(order *model.FulfillmentOrder) (bool, string) {
if order.PaymentStatus != model.PaymentStatusPaid {
return false, "订单未支付或已退款"
}
switch order.FulfillmentStatus {
case model.FulfillmentStatusPending, model.FulfillmentStatusFailed:
return true, ""
case model.FulfillmentStatusProcessing:
return false, "订单履约中"
case model.FulfillmentStatusSucceeded:
return false, "订单已履约成功"
case model.FulfillmentStatusCancelled:
return false, "订单已取消"
default:
return false, "订单状态不可履约"
}
}
func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
canFulfill, cannotFulfillReason := CanFulfill(order)
return map[string]interface{}{
"order_no": order.OrderNo,
"client_order_no": order.ClientOrderNo,
"product_sku": order.ProductSKU,
"quantity": order.Quantity,
"amount": order.Amount,
"currency": order.Currency,
"payment_status": order.PaymentStatus,
"fulfillment_status": order.FulfillmentStatus,
"can_fulfill": canFulfill,
"cannot_fulfill_reason": cannotFulfillReason,
"provider_order_no": order.ProviderOrderNo,
"failure_reason": order.FailureReason,
}
}
@@ -0,0 +1,301 @@
package service
import (
"strings"
"testing"
"affiliate_dash/internal/model"
"affiliate_dash/internal/testdb"
"gorm.io/gorm"
)
func newServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
return testdb.New(t,
&model.Merchant{},
&model.Product{},
&model.MerchantProduct{},
&model.WalletAccount{},
&model.WalletLedgerEntry{},
&model.FulfillmentOrder{},
&model.FulfillmentJob{},
&model.AuditLog{},
)
}
func seedFulfillmentMerchant(t *testing.T, db *gorm.DB, code string, balance, stock, price int64) (uint, model.MerchantProduct) {
t.Helper()
merchant := model.Merchant{Code: code, Name: code, Status: model.MerchantStatusActive}
if err := db.Create(&merchant).Error; err != nil {
t.Fatalf("create merchant: %v", err)
}
if err := db.Create(&model.WalletAccount{
MerchantID: merchant.ID,
Currency: "CNY",
AvailableBalance: balance,
}).Error; err != nil {
t.Fatalf("create wallet: %v", err)
}
product := model.Product{Code: code + "-product", Name: "测试商品", Status: model.ProductStatusActive}
if err := db.Create(&product).Error; err != nil {
t.Fatalf("create product: %v", err)
}
merchantProduct := model.MerchantProduct{
MerchantID: merchant.ID,
ProductID: product.ID,
SKU: "sku-basic",
DisplayName: "测试商品",
PriceAmount: price,
Currency: "CNY",
Stock: stock,
Status: model.ProductStatusActive,
}
if err := db.Create(&merchantProduct).Error; err != nil {
t.Fatalf("create merchant product: %v", err)
}
return merchant.ID, merchantProduct
}
func TestFulfillmentCreateOrderDebitsWalletAndIsIdempotent(t *testing.T) {
db := newServiceTestDB(t)
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-a", 1000, 5, 200)
svc := NewFulfillmentService(db, nil)
first, err := svc.CreateOrder(CreateFulfillmentOrderInput{
MerchantID: merchantID,
APIClientID: 11,
ClientOrderNo: "client-001",
SKU: product.SKU,
Quantity: 2,
RequestData: map[string]string{
"account": "player-1",
},
})
if err != nil {
t.Fatalf("create order: %v", err)
}
if first.Idempotent {
t.Fatalf("first create should not be idempotent")
}
if first.Order.Amount != 400 || first.Order.PaymentStatus != model.PaymentStatusPaid || first.Order.FulfillmentStatus != model.FulfillmentStatusPending {
t.Fatalf("unexpected order: %+v", first.Order)
}
second, err := svc.CreateOrder(CreateFulfillmentOrderInput{
MerchantID: merchantID,
APIClientID: 11,
ClientOrderNo: "client-001",
SKU: product.SKU,
Quantity: 2,
})
if err != nil {
t.Fatalf("idempotent create: %v", err)
}
if !second.Idempotent || second.Order.OrderNo != first.Order.OrderNo {
t.Fatalf("expected existing order, got %+v", second)
}
var wallet model.WalletAccount
if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
t.Fatalf("query wallet: %v", err)
}
if wallet.AvailableBalance != 600 {
t.Fatalf("wallet should debit once, got %d", wallet.AvailableBalance)
}
var refreshed model.MerchantProduct
if err := db.First(&refreshed, product.ID).Error; err != nil {
t.Fatalf("query product: %v", err)
}
if refreshed.Stock != 3 {
t.Fatalf("stock should decrease once, got %d", refreshed.Stock)
}
var ledgerCount int64
db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount)
if ledgerCount != 1 {
t.Fatalf("ledger should have one debit entry, got %d", ledgerCount)
}
}
func TestFulfillmentCancelRefundsOnceAndRestoresStock(t *testing.T) {
db := newServiceTestDB(t)
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-b", 1000, 2, 300)
svc := NewFulfillmentService(db, nil)
created, err := svc.CreateOrder(CreateFulfillmentOrderInput{
MerchantID: merchantID,
APIClientID: 12,
ClientOrderNo: "client-cancel",
SKU: product.SKU,
Quantity: 1,
})
if err != nil {
t.Fatalf("create order: %v", err)
}
cancelled, err := svc.CancelOrder(merchantID, 12, created.Order.OrderNo, "用户取消")
if err != nil {
t.Fatalf("cancel order: %v", err)
}
if cancelled.PaymentStatus != model.PaymentStatusRefunded || cancelled.FulfillmentStatus != model.FulfillmentStatusCancelled {
t.Fatalf("unexpected cancelled order: %+v", cancelled)
}
if _, err := svc.CancelOrder(merchantID, 12, created.Order.OrderNo, "重复取消"); err != nil {
t.Fatalf("repeat cancel should be idempotent: %v", err)
}
var wallet model.WalletAccount
_ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error
if wallet.AvailableBalance != 1000 {
t.Fatalf("wallet should refund once, got %d", wallet.AvailableBalance)
}
var refreshed model.MerchantProduct
_ = db.First(&refreshed, product.ID).Error
if refreshed.Stock != 2 {
t.Fatalf("stock should restore once, got %d", refreshed.Stock)
}
var ledgerCount int64
db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount)
if ledgerCount != 2 {
t.Fatalf("ledger should have debit and refund, got %d", ledgerCount)
}
}
func TestFulfillmentStatusTransitions(t *testing.T) {
db := newServiceTestDB(t)
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-c", 1000, -1, 100)
svc := NewFulfillmentService(db, nil)
created, err := svc.CreateOrder(CreateFulfillmentOrderInput{
MerchantID: merchantID,
APIClientID: 13,
ClientOrderNo: "client-status",
SKU: product.SKU,
})
if err != nil {
t.Fatalf("create order: %v", err)
}
processing, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: merchantID,
APIClientID: 13,
OrderNo: created.Order.OrderNo,
Status: model.FulfillmentStatusProcessing,
})
if err != nil {
t.Fatalf("mark processing: %v", err)
}
if processing.FulfillmentStatus != model.FulfillmentStatusProcessing {
t.Fatalf("expected processing, got %s", processing.FulfillmentStatus)
}
succeeded, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: merchantID,
APIClientID: 13,
OrderNo: created.Order.OrderNo,
Status: model.FulfillmentStatusSucceeded,
ProviderOrderNo: "provider-1",
ResultData: map[string]string{"ok": "true"},
})
if err != nil {
t.Fatalf("mark succeeded: %v", err)
}
if succeeded.FulfillmentStatus != model.FulfillmentStatusSucceeded || succeeded.DeliveredAt == nil {
t.Fatalf("unexpected succeeded order: %+v", succeeded)
}
_, err = svc.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: merchantID,
APIClientID: 13,
OrderNo: created.Order.OrderNo,
Status: model.FulfillmentStatusFailed,
})
if err == nil {
t.Fatalf("should reject rollback after success")
}
}
func TestFulfillmentMerchantIsolation(t *testing.T) {
db := newServiceTestDB(t)
merchantA, productA := seedFulfillmentMerchant(t, db, "merchant-d", 1000, 1, 100)
merchantB, _ := seedFulfillmentMerchant(t, db, "merchant-e", 1000, 1, 100)
svc := NewFulfillmentService(db, nil)
created, err := svc.CreateOrder(CreateFulfillmentOrderInput{
MerchantID: merchantA,
APIClientID: 14,
ClientOrderNo: "client-isolation",
SKU: productA.SKU,
})
if err != nil {
t.Fatalf("create order: %v", err)
}
if _, err := svc.GetOrder(merchantB, created.Order.OrderNo); err == nil {
t.Fatalf("other merchant should not read the order")
}
}
func TestWalletAdjustIsIdempotentPerMerchant(t *testing.T) {
db := newServiceTestDB(t)
merchantA, _ := seedFulfillmentMerchant(t, db, "merchant-f", 0, -1, 100)
merchantB, _ := seedFulfillmentMerchant(t, db, "merchant-g", 0, -1, 100)
svc := NewFulfillmentService(db, nil)
for _, merchantID := range []uint{merchantA, merchantB} {
wallet, err := svc.AdjustWallet(WalletAdjustInput{
MerchantID: merchantID,
ActorUserID: 1,
Amount: 500,
IdempotencyKey: "same-key",
Note: "充值",
})
if err != nil {
t.Fatalf("adjust wallet merchant %d: %v", merchantID, err)
}
if wallet.AvailableBalance != 500 {
t.Fatalf("unexpected balance for merchant %d: %d", merchantID, wallet.AvailableBalance)
}
}
wallet, err := svc.AdjustWallet(WalletAdjustInput{
MerchantID: merchantA,
ActorUserID: 1,
Amount: 500,
IdempotencyKey: "same-key",
Note: "重复充值",
})
if err != nil {
t.Fatalf("repeat adjust: %v", err)
}
if wallet.AvailableBalance != 500 {
t.Fatalf("repeat adjust should not change balance, got %d", wallet.AvailableBalance)
}
}
func TestCanFulfill(t *testing.T) {
ok, reason := CanFulfill(&model.FulfillmentOrder{
PaymentStatus: model.PaymentStatusPaid,
FulfillmentStatus: model.FulfillmentStatusFailed,
})
if !ok || reason != "" {
t.Fatalf("failed paid order should be fulfillable")
}
ok, _ = CanFulfill(&model.FulfillmentOrder{
PaymentStatus: model.PaymentStatusRefunded,
FulfillmentStatus: model.FulfillmentStatusPending,
})
if ok {
t.Fatalf("refunded order should not be fulfillable")
}
}
func TestCreateOrderRejectsInsufficientBalance(t *testing.T) {
db := newServiceTestDB(t)
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-h", 50, 1, 100)
svc := NewFulfillmentService(db, nil)
_, err := svc.CreateOrder(CreateFulfillmentOrderInput{
MerchantID: merchantID,
APIClientID: 15,
ClientOrderNo: "client-low-balance",
SKU: product.SKU,
})
if err == nil || !strings.Contains(err.Error(), "余额不足") {
t.Fatalf("expected insufficient balance error, got %v", err)
}
var wallet model.WalletAccount
_ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error
if wallet.AvailableBalance != 50 {
t.Fatalf("balance should remain unchanged, got %d", wallet.AvailableBalance)
}
}
@@ -0,0 +1,95 @@
package service
import (
"strings"
"testing"
"affiliate_dash/internal/model"
)
func TestLegacyOrderCreateRejectsDistributorOutsideMerchant(t *testing.T) {
db := newServiceTestDB(t)
merchant := model.Merchant{Code: "legacy-merchant", Name: "旧后台商户", Status: model.MerchantStatusActive}
if err := db.Create(&merchant).Error; err != nil {
t.Fatalf("create merchant: %v", err)
}
inside := model.User{Username: "inside", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "INSIDE"}
outside := model.User{Username: "outside", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "OUTSIDE"}
if err := db.Create(&inside).Error; err != nil {
t.Fatalf("create inside user: %v", err)
}
if err := db.Create(&outside).Error; err != nil {
t.Fatalf("create outside user: %v", err)
}
if err := db.Create(&model.MerchantMember{
MerchantID: merchant.ID,
UserID: inside.ID,
Role: model.MemberRoleOperator,
Status: 1,
}).Error; err != nil {
t.Fatalf("create member: %v", err)
}
skin := model.Skin{
MerchantID: merchant.ID,
Name: "旧皮肤",
SKU: "legacy-skin",
Price: 10,
Stock: -1,
Status: 1,
}
if err := db.Create(&skin).Error; err != nil {
t.Fatalf("create skin: %v", err)
}
_, err := NewOrderService(db).Create(CreateOrderInput{
MerchantID: merchant.ID,
SkinID: skin.ID,
DistributorID: outside.ID,
BuyerName: "买家",
Status: model.OrderStatusPaid,
})
if err == nil || !strings.Contains(err.Error(), "不属于当前商户") {
t.Fatalf("expected tenant boundary error, got %v", err)
}
}
func TestUserListFiltersByMerchantMembership(t *testing.T) {
db := newServiceTestDB(t)
merchantA := model.Merchant{Code: "user-merchant-a", Name: "商户 A", Status: model.MerchantStatusActive}
merchantB := model.Merchant{Code: "user-merchant-b", Name: "商户 B", Status: model.MerchantStatusActive}
if err := db.Create(&merchantA).Error; err != nil {
t.Fatalf("create merchant a: %v", err)
}
if err := db.Create(&merchantB).Error; err != nil {
t.Fatalf("create merchant b: %v", err)
}
userA := model.User{Username: "user-a", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "USERA"}
userB := model.User{Username: "user-b", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "USERB"}
if err := db.Create(&userA).Error; err != nil {
t.Fatalf("create user a: %v", err)
}
if err := db.Create(&userB).Error; err != nil {
t.Fatalf("create user b: %v", err)
}
if err := db.Create(&model.MerchantMember{MerchantID: merchantA.ID, UserID: userA.ID, Role: model.MemberRoleOperator, Status: 1}).Error; err != nil {
t.Fatalf("create member a: %v", err)
}
if err := db.Create(&model.MerchantMember{MerchantID: merchantB.ID, UserID: userB.ID, Role: model.MemberRoleOperator, Status: 1}).Error; err != nil {
t.Fatalf("create member b: %v", err)
}
active := 1
list, total, err := NewUserService(db, nil).List(UserListQuery{
MerchantID: merchantA.ID,
Page: 1,
Size: 20,
Role: model.RoleDistributor,
Status: &active,
})
if err != nil {
t.Fatalf("list users: %v", err)
}
if total != 1 || len(list) != 1 || list[0].Username != "user-a" {
t.Fatalf("expected only merchant A user, total=%d list=%+v", total, list)
}
}
+465
View File
@@ -0,0 +1,465 @@
package service
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"regexp"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var merchantCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,63}$`)
type MerchantService struct {
db *gorm.DB
codec *SecretCodec
tenant *TenantService
}
func NewMerchantService(db *gorm.DB, codec *SecretCodec, tenant *TenantService) *MerchantService {
return &MerchantService{db: db, codec: codec, tenant: tenant}
}
type CreateMerchantInput struct {
Code string
Name string
ContactName string
ContactInfo string
OwnerUserID uint
}
func (s *MerchantService) CreateMerchant(in CreateMerchantInput, actorUserID uint) (*model.Merchant, error) {
in.Code = strings.ToLower(strings.TrimSpace(in.Code))
in.Name = strings.TrimSpace(in.Name)
if !merchantCodePattern.MatchString(in.Code) {
return nil, errors.New("商户编码需为 3-64 位小写字母、数字或连字符")
}
if in.Name == "" {
return nil, errors.New("商户名称不能为空")
}
if in.OwnerUserID == 0 {
return nil, errors.New("商户负责人不能为空")
}
merchant := &model.Merchant{
Code: in.Code,
Name: in.Name,
Status: model.MerchantStatusActive,
ContactName: in.ContactName,
ContactInfo: in.ContactInfo,
}
err := s.db.Transaction(func(tx *gorm.DB) error {
var owner model.User
if err := tx.First(&owner, in.OwnerUserID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("商户负责人不存在")
}
return err
}
if owner.Status != 1 {
return errors.New("商户负责人已禁用")
}
if err := tx.Create(merchant).Error; err != nil {
return err
}
if err := tx.Create(&model.WalletAccount{MerchantID: merchant.ID, Currency: "CNY"}).Error; err != nil {
return err
}
if err := tx.Create(&model.MerchantMember{
MerchantID: merchant.ID,
UserID: owner.ID,
Role: model.MemberRoleOwner,
Status: 1,
IsDefault: true,
}).Error; err != nil {
return err
}
return writeAudit(tx, &merchant.ID, &actorUserID, nil, "merchant.create", "merchant", fmt.Sprint(merchant.ID), map[string]string{"code": merchant.Code})
})
if err != nil {
return nil, err
}
return merchant, nil
}
func (s *MerchantService) ListMerchants(page, size int) ([]model.Merchant, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.Merchant{})
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var merchants []model.Merchant
err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&merchants).Error
return merchants, total, err
}
func (s *MerchantService) GetMerchant(merchantID uint) (*model.Merchant, error) {
var merchant model.Merchant
if err := s.db.First(&merchant, merchantID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("商户不存在")
}
return nil, err
}
return &merchant, nil
}
type AddMemberInput struct {
UserID uint
Role string
IsDefault bool
}
func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) {
if !isValidMemberRole(in.Role) {
return nil, errors.New("无效的商户成员角色")
}
member := &model.MerchantMember{
MerchantID: merchantID,
UserID: in.UserID,
Role: in.Role,
Status: 1,
IsDefault: in.IsDefault,
}
err := s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
var user model.User
if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil {
return errors.New("用户不存在或已禁用")
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"role": in.Role,
"status": 1,
"is_default": in.IsDefault,
}),
}).Create(member).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, in.UserID), map[string]string{"role": in.Role})
})
if err != nil {
return nil, err
}
if err := s.db.Where("merchant_id = ? AND user_id = ?", merchantID, in.UserID).First(member).Error; err != nil {
return nil, err
}
return member, nil
}
func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) {
var members []model.MerchantMember
err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error
return members, err
}
type CreateMerchantProductInput struct {
ProductCode string
ProductName string
Category string
Description string
Attributes string
SKU string
DisplayName string
PriceAmount int64
CostAmount int64
Currency string
Stock int64
Status string
FulfillmentConfig string
}
func (s *MerchantService) CreateMerchantProduct(merchantID uint, in CreateMerchantProductInput, actorUserID uint) (*model.MerchantProduct, error) {
in.SKU = strings.TrimSpace(in.SKU)
in.ProductCode = strings.TrimSpace(in.ProductCode)
in.ProductName = strings.TrimSpace(in.ProductName)
if in.SKU == "" {
return nil, errors.New("商户商品 SKU 不能为空")
}
if in.PriceAmount < 0 || in.CostAmount < 0 {
return nil, errors.New("商品金额不能小于零")
}
if in.Stock < -1 {
return nil, errors.New("库存只能为 -1 或非负整数")
}
if in.Currency == "" {
in.Currency = "CNY"
}
in.Currency = strings.ToUpper(in.Currency)
if in.Status == "" {
in.Status = model.ProductStatusActive
}
if in.Status != model.ProductStatusActive && in.Status != model.ProductStatusInactive {
return nil, errors.New("无效的商品状态")
}
merchantProduct := &model.MerchantProduct{}
err := s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
product, err := ensureProduct(tx, in)
if err != nil {
return err
}
merchantProduct = &model.MerchantProduct{
MerchantID: merchantID,
ProductID: product.ID,
SKU: in.SKU,
DisplayName: fallbackName(in.DisplayName, product.Name),
PriceAmount: in.PriceAmount,
CostAmount: in.CostAmount,
Currency: in.Currency,
Stock: in.Stock,
Status: in.Status,
FulfillmentConfig: in.FulfillmentConfig,
}
if err := tx.Create(merchantProduct).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.create", "merchant_product", fmt.Sprint(merchantProduct.ID), map[string]string{"sku": in.SKU})
})
if err != nil {
return nil, err
}
return merchantProduct, nil
}
func (s *MerchantService) ListMerchantProducts(merchantID uint, page, size int, activeOnly bool) ([]model.MerchantProduct, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.MerchantProduct{}).Where("merchant_id = ?", merchantID)
if activeOnly {
tx = tx.Where("status = ?", model.ProductStatusActive)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var products []model.MerchantProduct
err := tx.Preload("Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&products).Error
return products, total, err
}
type UpdateMerchantProductInput struct {
DisplayName *string
PriceAmount *int64
CostAmount *int64
Stock *int64
Status *string
FulfillmentConfig *string
}
func (s *MerchantService) UpdateMerchantProduct(merchantID, id uint, in UpdateMerchantProductInput, actorUserID uint) error {
updates := make(map[string]interface{})
if in.DisplayName != nil {
updates["display_name"] = *in.DisplayName
}
if in.PriceAmount != nil {
if *in.PriceAmount < 0 {
return errors.New("商品售价不能小于零")
}
updates["price_amount"] = *in.PriceAmount
}
if in.CostAmount != nil {
if *in.CostAmount < 0 {
return errors.New("商品成本不能小于零")
}
updates["cost_amount"] = *in.CostAmount
}
if in.Stock != nil {
if *in.Stock < -1 {
return errors.New("库存只能为 -1 或非负整数")
}
updates["stock"] = *in.Stock
}
if in.Status != nil {
if *in.Status != model.ProductStatusActive && *in.Status != model.ProductStatusInactive {
return errors.New("无效的商品状态")
}
updates["status"] = *in.Status
}
if in.FulfillmentConfig != nil {
updates["fulfillment_config"] = *in.FulfillmentConfig
}
if len(updates) == 0 {
return errors.New("没有可更新字段")
}
return s.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.MerchantProduct{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("商户商品不存在")
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.update", "merchant_product", fmt.Sprint(id), nil)
})
}
type APICredential struct {
Client *model.APIClient `json:"client"`
Secret string `json:"secret"`
}
type CreateAPIClientInput struct {
Name string
Scopes string
SignatureVersion string
ExpiresAt *time.Time
}
func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInput, actorUserID uint) (*APICredential, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
return nil, errors.New("API 客户端名称不能为空")
}
if len(ParseScopes(in.Scopes)) == 0 {
return nil, errors.New("至少配置一个 API 权限")
}
if in.SignatureVersion == "" {
in.SignatureVersion = "v1"
}
if in.SignatureVersion != "v1" {
return nil, errors.New("无效的签名版本")
}
appKey, err := randomToken("ak_", 24)
if err != nil {
return nil, err
}
secret, err := randomToken("sk_", 32)
if err != nil {
return nil, err
}
ciphertext, err := s.codec.Encrypt(secret)
if err != nil {
return nil, err
}
client := &model.APIClient{
MerchantID: merchantID,
Name: in.Name,
AppKey: appKey,
SecretCiphertext: ciphertext,
SignatureVersion: in.SignatureVersion,
Scopes: strings.Join(scopeList(in.Scopes), ","),
Status: model.APIClientStatusActive,
ExpiresAt: in.ExpiresAt,
}
err = s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
if err := tx.Create(client).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.create", "api_client", fmt.Sprint(client.ID), map[string]string{"name": in.Name})
})
if err != nil {
return nil, err
}
return &APICredential{Client: client, Secret: secret}, nil
}
func (s *MerchantService) ListAPIClients(merchantID uint) ([]model.APIClient, error) {
var clients []model.APIClient
err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&clients).Error
return clients, err
}
func (s *MerchantService) UpdateAPIClientStatus(merchantID, id uint, status string, actorUserID uint) error {
if status != model.APIClientStatusActive && status != model.APIClientStatusDisabled {
return errors.New("无效的 API 客户端状态")
}
return s.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.APIClient{}).Where("id = ? AND merchant_id = ?", id, merchantID).Update("status", status)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("API 客户端不存在")
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.status.update", "api_client", fmt.Sprint(id), map[string]string{"status": status})
})
}
func ensureProduct(tx *gorm.DB, in CreateMerchantProductInput) (*model.Product, error) {
if in.ProductCode != "" {
var product model.Product
err := tx.Where("code = ?", in.ProductCode).First(&product).Error
if err == nil {
return &product, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
if in.ProductName == "" {
return nil, errors.New("新建平台商品时商品名称不能为空")
}
code := in.ProductCode
if code == "" {
token, err := randomToken("prd_", 12)
if err != nil {
return nil, err
}
code = token
}
product := &model.Product{
Code: code,
Name: in.ProductName,
Category: in.Category,
Description: in.Description,
Attributes: in.Attributes,
Status: model.ProductStatusActive,
}
if err := tx.Create(product).Error; err != nil {
return nil, err
}
return product, nil
}
func normalizePage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
return page, size
}
func randomToken(prefix string, byteCount int) (string, error) {
raw := make([]byte, byteCount)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return prefix + base64.RawURLEncoding.EncodeToString(raw), nil
}
func scopeList(scopes string) []string {
set := ParseScopes(scopes)
items := make([]string, 0, len(set))
for scope := range set {
items = append(items, scope)
}
return items
}
func fallbackName(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return fallback
}
+36 -8
View File
@@ -20,6 +20,7 @@ func NewOrderService(db *gorm.DB) *OrderService {
}
type OrderListQuery struct {
MerchantID uint
Page int
Size int
Status string
@@ -27,6 +28,7 @@ type OrderListQuery struct {
}
type CreateOrderInput struct {
MerchantID uint
SkinID uint
DistributorID uint
BuyerName string
@@ -43,6 +45,9 @@ func (s *OrderService) List(q OrderListQuery) ([]model.Order, int64, error) {
q.Size = 20
}
tx := s.db.Model(&model.Order{})
if q.MerchantID != 0 {
tx = tx.Where("merchant_id = ?", q.MerchantID)
}
if q.Status != "" {
tx = tx.Where("status = ?", q.Status)
}
@@ -63,7 +68,10 @@ func (s *OrderService) List(q OrderListQuery) ([]model.Order, int64, error) {
func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
var skin model.Skin
if err := s.db.First(&skin, in.SkinID).Error; err != nil {
if in.MerchantID == 0 {
return nil, errors.New("商户不能为空")
}
if err := s.db.Where("id = ? AND merchant_id = ?", in.SkinID, in.MerchantID).First(&skin).Error; err != nil {
return nil, errors.New("皮肤不存在")
}
if skin.Status != 1 {
@@ -72,6 +80,15 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
if skin.Stock == 0 {
return nil, errors.New("库存不足")
}
var memberCount int64
if err := s.db.Model(&model.MerchantMember{}).
Where("merchant_id = ? AND user_id = ? AND status = ?", in.MerchantID, in.DistributorID, 1).
Count(&memberCount).Error; err != nil {
return nil, err
}
if memberCount == 0 {
return nil, errors.New("分销商不属于当前商户")
}
status := model.OrderStatusPending
switch in.Status {
@@ -89,6 +106,7 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
}
order := &model.Order{
MerchantID: in.MerchantID,
OrderNo: generateOrderNo(),
SkinID: in.SkinID,
DistributorID: in.DistributorID,
@@ -121,7 +139,7 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
return order, nil
}
func (s *OrderService) UpdateStatus(id uint, status string) error {
func (s *OrderService) UpdateStatus(merchantID, id uint, status string) error {
allowed := map[string]bool{
model.OrderStatusPending: true,
model.OrderStatusPaid: true,
@@ -133,7 +151,7 @@ func (s *OrderService) UpdateStatus(id uint, status string) error {
if !allowed[status] {
return errors.New("无效的订单状态")
}
res := s.db.Model(&model.Order{}).Where("id = ?", id).Update("status", status)
res := s.db.Model(&model.Order{}).Where("id = ? AND merchant_id = ?", id, merchantID).Update("status", status)
if res.Error != nil {
return res.Error
}
@@ -381,6 +399,7 @@ func (s *OrderService) appendShipLog(order *model.Order, in ShipNotifyInput, res
payload = string(b)
}
log := &model.ShipLog{
MerchantID: order.MerchantID,
OrderNo: order.OrderNo,
OrderID: order.ID,
ShipStatus: in.ShipStatus,
@@ -394,6 +413,7 @@ func (s *OrderService) appendShipLog(order *model.Order, in ShipNotifyInput, res
}
type ShipLogListQuery struct {
MerchantID uint
Page int
Size int
OrderNo string
@@ -408,6 +428,9 @@ func (s *OrderService) ListShipLogs(q ShipLogListQuery) ([]model.ShipLog, int64,
q.Size = 20
}
tx := s.db.Model(&model.ShipLog{})
if q.MerchantID != 0 {
tx = tx.Where("merchant_id = ?", q.MerchantID)
}
if q.OrderNo != "" {
tx = tx.Where("order_no LIKE ?", "%"+q.OrderNo+"%")
}
@@ -432,16 +455,21 @@ type DashboardStats struct {
PendingOrderCount int64 `json:"pending_order_count"`
}
func (s *OrderService) Dashboard() (*DashboardStats, error) {
func (s *OrderService) Dashboard(merchantID uint) (*DashboardStats, error) {
stats := &DashboardStats{}
s.db.Model(&model.Skin{}).Count(&stats.SkinCount)
s.db.Model(&model.User{}).Where("role = ?", model.RoleDistributor).Count(&stats.DistributorCount)
s.db.Model(&model.Order{}).Count(&stats.OrderCount)
s.db.Model(&model.Order{}).Where("status = ?", model.OrderStatusPending).Count(&stats.PendingOrderCount)
s.db.Model(&model.Skin{}).Where("merchant_id = ?", merchantID).Count(&stats.SkinCount)
s.db.Model(&model.User{}).
Joins("JOIN merchant_members ON merchant_members.user_id = users.id").
Where("merchant_members.merchant_id = ? AND users.role = ?", merchantID, model.RoleDistributor).
Count(&stats.DistributorCount)
s.db.Model(&model.Order{}).Where("merchant_id = ?", merchantID).Count(&stats.OrderCount)
s.db.Model(&model.Order{}).Where("merchant_id = ? AND status = ?", merchantID, model.OrderStatusPending).Count(&stats.PendingOrderCount)
s.db.Model(&model.Order{}).
Where("merchant_id = ?", merchantID).
Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}).
Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales)
s.db.Model(&model.Order{}).
Where("merchant_id = ?", merchantID).
Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}).
Select("COALESCE(SUM(commission_amt),0)").Scan(&stats.TotalCommission)
return stats, nil
+56
View File
@@ -0,0 +1,56 @@
package service
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
)
// SecretCodec 使用应用主密钥加密落库的第三方密钥,避免明文存储。
type SecretCodec struct {
gcm cipher.AEAD
}
func NewSecretCodec(masterKey string) (*SecretCodec, error) {
if masterKey == "" {
return nil, errors.New("数据加密主密钥不能为空")
}
sum := sha256.Sum256([]byte(masterKey))
block, err := aes.NewCipher(sum[:])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &SecretCodec{gcm: gcm}, nil
}
func (c *SecretCodec) Encrypt(plain string) (string, error) {
nonce := make([]byte, c.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := c.gcm.Seal(nil, nonce, []byte(plain), nil)
return base64.RawURLEncoding.EncodeToString(append(nonce, sealed...)), nil
}
func (c *SecretCodec) Decrypt(ciphertext string) (string, error) {
raw, err := base64.RawURLEncoding.DecodeString(ciphertext)
if err != nil {
return "", errors.New("密钥密文格式错误")
}
if len(raw) < c.gcm.NonceSize() {
return "", errors.New("密钥密文长度错误")
}
plain, err := c.gcm.Open(nil, raw[:c.gcm.NonceSize()], raw[c.gcm.NonceSize():], nil)
if err != nil {
return "", errors.New("密钥解密失败")
}
return string(plain), nil
}
@@ -0,0 +1,24 @@
package service
import "testing"
func TestSecretCodecRoundTrip(t *testing.T) {
codec, err := NewSecretCodec("test-master-key")
if err != nil {
t.Fatalf("new codec: %v", err)
}
ciphertext, err := codec.Encrypt("plain-secret")
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if ciphertext == "plain-secret" {
t.Fatalf("secret should not be stored as plaintext")
}
plain, err := codec.Decrypt(ciphertext)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if plain != "plain-secret" {
t.Fatalf("unexpected plaintext: %s", plain)
}
}
+27 -14
View File
@@ -17,12 +17,13 @@ func NewSkinService(db *gorm.DB) *SkinService {
}
type SkinListQuery struct {
Page int
Size int
Keyword string
Game string
Category string
Status *int
MerchantID uint
Page int
Size int
Keyword string
Game string
Category string
Status *int
}
func (s *SkinService) List(q SkinListQuery) ([]model.Skin, int64, error) {
@@ -33,6 +34,9 @@ func (s *SkinService) List(q SkinListQuery) ([]model.Skin, int64, error) {
q.Size = 20
}
tx := s.db.Model(&model.Skin{})
if q.MerchantID != 0 {
tx = tx.Where("merchant_id = ?", q.MerchantID)
}
if q.Keyword != "" {
like := "%" + q.Keyword + "%"
tx = tx.Where("name LIKE ? OR sku LIKE ?", like, like)
@@ -55,9 +59,13 @@ func (s *SkinService) List(q SkinListQuery) ([]model.Skin, int64, error) {
return list, total, err
}
func (s *SkinService) Get(id uint) (*model.Skin, error) {
func (s *SkinService) Get(merchantID, id uint) (*model.Skin, error) {
var skin model.Skin
if err := s.db.First(&skin, id).Error; err != nil {
tx := s.db.Where("id = ?", id)
if merchantID != 0 {
tx = tx.Where("merchant_id = ?", merchantID)
}
if err := tx.First(&skin).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("皮肤不存在")
}
@@ -70,8 +78,8 @@ func (s *SkinService) Create(skin *model.Skin) error {
return s.db.Create(skin).Error
}
func (s *SkinService) Update(id uint, updates map[string]interface{}) error {
res := s.db.Model(&model.Skin{}).Where("id = ?", id).Updates(updates)
func (s *SkinService) Update(merchantID, id uint, updates map[string]interface{}) error {
res := s.db.Model(&model.Skin{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates)
if res.Error != nil {
return res.Error
}
@@ -81,8 +89,8 @@ func (s *SkinService) Update(id uint, updates map[string]interface{}) error {
return nil
}
func (s *SkinService) Delete(id uint) error {
res := s.db.Delete(&model.Skin{}, id)
func (s *SkinService) Delete(merchantID, id uint) error {
res := s.db.Where("merchant_id = ?", merchantID).Delete(&model.Skin{}, id)
if res.Error != nil {
return res.Error
}
@@ -94,12 +102,16 @@ func (s *SkinService) Delete(id uint) error {
// SeedCatalog 按 sku 幂等导入商品目录(已存在则跳过)
func (s *SkinService) SeedCatalog() error {
var merchant model.Merchant
if err := s.db.Where("code = ?", "self-operated").First(&merchant).Error; err != nil {
return err
}
// 清理早期无 sku 的演示数据,避免唯一索引冲突
_ = s.db.Where("sku = ? OR sku IS NULL", "").Delete(&model.Skin{}).Error
_ = s.db.Where("merchant_id = ? AND (sku = ? OR sku IS NULL)", merchant.ID, "").Delete(&model.Skin{}).Error
for _, item := range peaceEliteCatalog {
var existing model.Skin
err := s.db.Where("sku = ?", item.SKU).First(&existing).Error
err := s.db.Where("merchant_id = ? AND sku = ?", merchant.ID, item.SKU).First(&existing).Error
if err == nil {
// 已存在:仅同步默认佣金为 0(不改价格等业务字段)
if existing.Commission != 0 {
@@ -111,6 +123,7 @@ func (s *SkinService) SeedCatalog() error {
return err
}
skin := model.Skin{
MerchantID: merchant.ID,
Name: item.Name,
SKU: item.SKU,
Game: "和平精英",
+120
View File
@@ -0,0 +1,120 @@
package service
import (
"errors"
"strconv"
"strings"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// TenantService 负责商户成员关系与请求租户解析。
type TenantService struct {
db *gorm.DB
}
func NewTenantService(db *gorm.DB) *TenantService {
return &TenantService{db: db}
}
func (s *TenantService) ResolveMember(userID uint, merchantRef string) (*model.MerchantMember, error) {
if userID == 0 {
return nil, errors.New("无效的用户身份")
}
tx := s.db.Preload("Merchant").
Where("merchant_members.user_id = ? AND merchant_members.status = ?", userID, 1).
Joins("JOIN merchants ON merchants.id = merchant_members.merchant_id AND merchants.status = ?", model.MerchantStatusActive)
if merchantRef != "" {
if id, err := strconv.ParseUint(merchantRef, 10, 64); err == nil {
tx = tx.Where("merchant_members.merchant_id = ?", uint(id))
} else {
tx = tx.Where("merchants.code = ?", merchantRef)
}
}
var member model.MerchantMember
err := tx.Order("merchant_members.is_default DESC, merchant_members.id ASC").First(&member).Error
if err == nil {
return &member, nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("当前账号无权访问该商户")
}
return nil, err
}
func (s *TenantService) EnsureSelfMember(userID uint, role string, status int) error {
var merchant model.Merchant
if err := s.db.Where("code = ?", "self-operated").First(&merchant).Error; err != nil {
return err
}
return s.EnsureMember(merchant.ID, userID, role, status, role == model.MemberRoleOwner)
}
func (s *TenantService) EnsureMember(merchantID, userID uint, role string, status int, isDefault bool) error {
if merchantID == 0 || userID == 0 {
return errors.New("商户和用户不能为空")
}
if !isValidMemberRole(role) {
return errors.New("无效的商户成员角色")
}
member := model.MerchantMember{
MerchantID: merchantID,
UserID: userID,
Role: role,
Status: status,
IsDefault: isDefault,
}
return s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"role": role,
"status": status,
"is_default": isDefault,
}),
}).Create(&member).Error
}
func isValidMemberRole(role string) bool {
switch role {
case model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance, model.MemberRoleViewer:
return true
default:
return false
}
}
func MemberCanManage(memberRole string) bool {
return memberRole == model.MemberRoleOwner || memberRole == model.MemberRoleOperator
}
func MemberCanManageFinance(memberRole string) bool {
return memberRole == model.MemberRoleOwner || memberRole == model.MemberRoleFinance
}
func ParseScopes(scopes string) map[string]struct{} {
out := make(map[string]struct{})
for _, scope := range strings.FieldsFunc(scopes, func(r rune) bool {
return r == ',' || r == ' ' || r == '\n' || r == '\t'
}) {
if scope != "" {
out[scope] = struct{}{}
}
}
return out
}
func HasAnyScope(scopes string, wanted ...string) bool {
set := ParseScopes(scopes)
if _, ok := set["*"]; ok {
return true
}
for _, scope := range wanted {
if _, ok := set[scope]; ok {
return true
}
}
return false
}
+42 -14
View File
@@ -10,19 +10,21 @@ import (
)
type UserService struct {
db *gorm.DB
db *gorm.DB
tenant *TenantService
}
func NewUserService(db *gorm.DB) *UserService {
return &UserService{db: db}
func NewUserService(db *gorm.DB, tenant *TenantService) *UserService {
return &UserService{db: db, tenant: tenant}
}
type UserListQuery struct {
Page int
Size int
Keyword string
Role string
Status *int
MerchantID uint
Page int
Size int
Keyword string
Role string
Status *int
}
func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) {
@@ -33,26 +35,30 @@ func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) {
q.Size = 20
}
tx := s.db.Model(&model.User{})
if q.MerchantID != 0 {
tx = tx.Joins("JOIN merchant_members ON merchant_members.user_id = users.id").
Where("merchant_members.merchant_id = ?", q.MerchantID)
}
if q.Keyword != "" {
like := "%" + q.Keyword + "%"
tx = tx.Where("username LIKE ? OR nickname LIKE ?", like, like)
tx = tx.Where("users.username LIKE ? OR users.nickname LIKE ?", like, like)
}
if q.Role != "" {
tx = tx.Where("role = ?", q.Role)
tx = tx.Where("users.role = ?", q.Role)
}
if q.Status != nil {
tx = tx.Where("status = ?", *q.Status)
tx = tx.Where("users.status = ?", *q.Status)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.User
err := tx.Order("id DESC").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&list).Error
err := tx.Order("users.id DESC").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&list).Error
return list, total, err
}
func (s *UserService) Create(username, password, nickname, role string, parentID *uint) (*model.User, error) {
func (s *UserService) Create(username, password, nickname, role string, parentID *uint, merchantID uint) (*model.User, error) {
var count int64
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
if count > 0 {
@@ -77,7 +83,29 @@ func (s *UserService) Create(username, password, nickname, role string, parentID
if user.Nickname == "" {
user.Nickname = username
}
if err := s.db.Create(user).Error; err != nil {
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(user).Error; err != nil {
return err
}
if s.tenant != nil && merchantID != 0 {
memberRole := model.MemberRoleOperator
if role == model.RoleAdmin {
memberRole = model.MemberRoleOwner
}
member := model.MerchantMember{
MerchantID: merchantID,
UserID: user.ID,
Role: memberRole,
Status: user.Status,
IsDefault: role == model.RoleAdmin,
}
if err := tx.Create(&member).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return user, nil
+76
View File
@@ -0,0 +1,76 @@
package testdb
import (
"fmt"
"net/url"
"os"
"strings"
"testing"
"time"
"affiliate_dash/internal/database"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// New 使用真实 PostgreSQL 创建测试隔离 schema,避免 SQLite 与生产 SQL 行为不一致。
func New(t testing.TB, _ ...interface{}) *gorm.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
dsn = "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"
}
cfg := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
adminDB, err := gorm.Open(postgres.Open(dsn), cfg)
if err != nil {
t.Fatalf("连接 PostgreSQL 测试库失败: %v", err)
}
adminSQL, err := adminDB.DB()
if err != nil {
t.Fatalf("获取 PostgreSQL 连接失败: %v", err)
}
schema := fmt.Sprintf("test_%d", time.Now().UnixNano())
if err := adminDB.Exec(`CREATE SCHEMA "` + schema + `"`).Error; err != nil {
t.Fatalf("创建测试 schema 失败: %v", err)
}
t.Cleanup(func() {
_ = adminDB.Exec(`DROP SCHEMA IF EXISTS "` + schema + `" CASCADE`).Error
_ = adminSQL.Close()
})
db, err := gorm.Open(postgres.Open(withSearchPath(dsn, schema)), cfg)
if err != nil {
t.Fatalf("连接测试 schema 失败: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("获取测试 schema 连接失败: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := database.Migrate(db); err != nil {
t.Fatalf("迁移测试 schema 失败: %v", err)
}
return db
}
func withSearchPath(dsn, schema string) string {
parsed, err := url.Parse(dsn)
if err == nil && strings.HasPrefix(parsed.Scheme, "postgres") {
query := parsed.Query()
query.Set("search_path", schema)
parsed.RawQuery = query.Encode()
return parsed.String()
}
if strings.TrimSpace(dsn) == "" {
return dsn
}
return dsn + " search_path=" + schema
}