实现多商户履约平台基础
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const maxCallbackAttempts = 8
|
||||
|
||||
// CallbackService 以数据库 outbox 方式管理回调,进程重启不会丢失待发送事件。
|
||||
type CallbackService struct {
|
||||
db *gorm.DB
|
||||
codec *SecretCodec
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewCallbackService(db *gorm.DB, codec *SecretCodec) *CallbackService {
|
||||
return &CallbackService{
|
||||
db: db,
|
||||
codec: codec,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CallbackService) SetHTTPClient(client *http.Client) {
|
||||
if client != nil {
|
||||
s.httpClient = client
|
||||
}
|
||||
}
|
||||
|
||||
type CreateCallbackInput struct {
|
||||
Name string
|
||||
URL string
|
||||
Events string
|
||||
}
|
||||
|
||||
type CallbackCredential struct {
|
||||
Subscription *model.CallbackSubscription `json:"subscription"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
func (s *CallbackService) CreateSubscription(merchantID uint, in CreateCallbackInput, actorUserID uint) (*CallbackCredential, error) {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.URL = strings.TrimSpace(in.URL)
|
||||
if in.Name == "" {
|
||||
return nil, errors.New("回调名称不能为空")
|
||||
}
|
||||
if err := validateCallbackURL(in.URL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events := scopeList(in.Events)
|
||||
if len(events) == 0 {
|
||||
return nil, errors.New("至少订阅一个事件")
|
||||
}
|
||||
secret, err := randomToken("cb_", 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ciphertext, err := s.codec.Encrypt(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subscription := &model.CallbackSubscription{
|
||||
MerchantID: merchantID,
|
||||
Name: in.Name,
|
||||
URL: in.URL,
|
||||
Events: strings.Join(events, ","),
|
||||
SecretCiphertext: ciphertext,
|
||||
Status: model.CallbackStatusActive,
|
||||
}
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var merchant model.Merchant
|
||||
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
|
||||
return errors.New("商户不存在或已禁用")
|
||||
}
|
||||
if err := tx.Create(subscription).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "callback_subscription.create", "callback_subscription", fmt.Sprint(subscription.ID), map[string]string{"url": subscription.URL})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CallbackCredential{Subscription: subscription, Secret: secret}, nil
|
||||
}
|
||||
|
||||
func (s *CallbackService) ListSubscriptions(merchantID uint) ([]model.CallbackSubscription, error) {
|
||||
var subscriptions []model.CallbackSubscription
|
||||
err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&subscriptions).Error
|
||||
return subscriptions, err
|
||||
}
|
||||
|
||||
func (s *CallbackService) UpdateSubscriptionStatus(merchantID, id uint, status string, actorUserID uint) error {
|
||||
if status != model.CallbackStatusActive && status != model.CallbackStatusDisabled {
|
||||
return errors.New("无效的回调状态")
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.CallbackSubscription{}).
|
||||
Where("id = ? AND merchant_id = ?", id, merchantID).
|
||||
Update("status", status)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("回调订阅不存在")
|
||||
}
|
||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "callback_subscription.status.update", "callback_subscription", fmt.Sprint(id), map[string]string{"status": status})
|
||||
})
|
||||
}
|
||||
|
||||
// Enqueue 在调用方事务中写入回调 outbox,只有订单事务成功才会发送事件。
|
||||
func (s *CallbackService) Enqueue(tx *gorm.DB, merchantID uint, event string, data interface{}) error {
|
||||
var subscriptions []model.CallbackSubscription
|
||||
if err := tx.Where("merchant_id = ? AND status = ?", merchantID, model.CallbackStatusActive).Find(&subscriptions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
for _, subscription := range subscriptions {
|
||||
if !subscribesTo(subscription.Events, event) {
|
||||
continue
|
||||
}
|
||||
eventID := uuid.NewString()
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"event_id": eventID,
|
||||
"event": event,
|
||||
"occurred_at": now.UTC().Format(time.RFC3339Nano),
|
||||
"data": data,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delivery := model.CallbackDelivery{
|
||||
MerchantID: merchantID,
|
||||
CallbackSubscriptionID: subscription.ID,
|
||||
EventID: eventID,
|
||||
Event: event,
|
||||
Payload: string(payload),
|
||||
Status: model.CallbackDeliveryPending,
|
||||
NextAttemptAt: now,
|
||||
}
|
||||
if err := tx.Create(&delivery).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DispatchDue 执行一批可发送的 outbox 记录。返回成功/失败尝试数,便于日志监控。
|
||||
func (s *CallbackService) DispatchDue(ctx context.Context, limit int) (int, int, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
now := time.Now()
|
||||
var deliveries []model.CallbackDelivery
|
||||
err := s.db.Preload("CallbackSubscription").
|
||||
Where("(status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at <= ?)",
|
||||
model.CallbackDeliveryPending, now,
|
||||
model.CallbackDeliverySending, now.Add(-5*time.Minute),
|
||||
).
|
||||
Order("next_attempt_at ASC, id ASC").
|
||||
Limit(limit).
|
||||
Find(&deliveries).Error
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
successes, failures := 0, 0
|
||||
for _, delivery := range deliveries {
|
||||
ok, attempted, err := s.dispatchOne(ctx, delivery.ID)
|
||||
if err != nil {
|
||||
return successes, failures, err
|
||||
}
|
||||
if !attempted {
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
successes++
|
||||
} else {
|
||||
failures++
|
||||
}
|
||||
}
|
||||
return successes, failures, nil
|
||||
}
|
||||
|
||||
func (s *CallbackService) dispatchOne(ctx context.Context, id uint) (bool, bool, error) {
|
||||
now := time.Now()
|
||||
claimed := s.db.Model(&model.CallbackDelivery{}).
|
||||
Where("id = ? AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at <= ?))",
|
||||
id, model.CallbackDeliveryPending, now,
|
||||
model.CallbackDeliverySending, now.Add(-5*time.Minute),
|
||||
).
|
||||
Update("status", model.CallbackDeliverySending)
|
||||
if claimed.Error != nil {
|
||||
return false, false, claimed.Error
|
||||
}
|
||||
if claimed.RowsAffected == 0 {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
var delivery model.CallbackDelivery
|
||||
if err := s.db.Preload("CallbackSubscription").First(&delivery, id).Error; err != nil {
|
||||
return false, true, err
|
||||
}
|
||||
if delivery.CallbackSubscription == nil || delivery.CallbackSubscription.Status != model.CallbackStatusActive {
|
||||
return false, true, s.recordCallbackFailure(delivery, 0, "回调订阅已禁用")
|
||||
}
|
||||
secret, err := s.codec.Decrypt(delivery.CallbackSubscription.SecretCiphertext)
|
||||
if err != nil {
|
||||
return false, true, s.recordCallbackFailure(delivery, 0, "回调密钥不可用")
|
||||
}
|
||||
|
||||
timestamp := fmt.Sprint(time.Now().Unix())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, delivery.CallbackSubscription.URL, bytes.NewBufferString(delivery.Payload))
|
||||
if err != nil {
|
||||
return false, true, s.recordCallbackFailure(delivery, 0, "创建回调请求失败")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Event-ID", delivery.EventID)
|
||||
req.Header.Set("X-Timestamp", timestamp)
|
||||
req.Header.Set("X-Sign", BuildCallbackSign(secret, timestamp, delivery.Payload))
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return false, true, s.recordCallbackFailure(delivery, 0, truncateCallbackError(err.Error()))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
||||
return true, true, s.db.Model(&model.CallbackDelivery{}).Where("id = ?", delivery.ID).Updates(map[string]interface{}{
|
||||
"status": model.CallbackDeliveryDelivered,
|
||||
"attempts": delivery.Attempts + 1,
|
||||
"last_status_code": resp.StatusCode,
|
||||
"last_response": string(body),
|
||||
"delivered_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
return false, true, s.recordCallbackFailure(delivery, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
func (s *CallbackService) recordCallbackFailure(delivery model.CallbackDelivery, statusCode int, response string) error {
|
||||
attempts := delivery.Attempts + 1
|
||||
status := model.CallbackDeliveryPending
|
||||
nextAttempt := time.Now().Add(callbackRetryDelay(attempts))
|
||||
if attempts >= maxCallbackAttempts {
|
||||
status = model.CallbackDeliveryFailed
|
||||
nextAttempt = time.Now()
|
||||
}
|
||||
return s.db.Model(&model.CallbackDelivery{}).Where("id = ?", delivery.ID).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"attempts": attempts,
|
||||
"next_attempt_at": nextAttempt,
|
||||
"last_status_code": statusCode,
|
||||
"last_response": truncateCallbackError(response),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *CallbackService) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
_, _, _ = s.DispatchDue(ctx, 50)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BuildCallbackSign 供接收方校验:HMAC-SHA256(secret, timestamp + "\n" + sha256(body))。
|
||||
func BuildCallbackSign(secret, timestamp, body string) string {
|
||||
bodyHash := sha256.Sum256([]byte(body))
|
||||
content := timestamp + "\n" + hex.EncodeToString(bodyHash[:])
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(content))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func subscribesTo(events, event string) bool {
|
||||
for subscribed := range ParseScopes(events) {
|
||||
if subscribed == "*" || subscribed == event {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateCallbackURL(rawURL string) error {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return errors.New("回调地址格式错误")
|
||||
}
|
||||
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
||||
return errors.New("回调地址仅支持 http 或 https")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackRetryDelay(attempts int) time.Duration {
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
delay := time.Second * time.Duration(1<<(attempts-1))
|
||||
if delay > 15*time.Minute {
|
||||
return 15 * time.Minute
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func truncateCallbackError(value string) string {
|
||||
if len(value) <= 4096 {
|
||||
return value
|
||||
}
|
||||
return value[:4096]
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type FulfillmentService struct {
|
||||
db *gorm.DB
|
||||
callbacks *CallbackService
|
||||
}
|
||||
|
||||
func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *FulfillmentService {
|
||||
return &FulfillmentService{db: db, callbacks: callbacks}
|
||||
}
|
||||
|
||||
type CreateFulfillmentOrderInput struct {
|
||||
MerchantID uint
|
||||
APIClientID uint
|
||||
ClientOrderNo string
|
||||
SKU string
|
||||
Quantity int64
|
||||
BuyerReference string
|
||||
RequestData interface{}
|
||||
}
|
||||
|
||||
type CreateFulfillmentOrderResult struct {
|
||||
Order *model.FulfillmentOrder `json:"order"`
|
||||
Idempotent bool `json:"idempotent"`
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*CreateFulfillmentOrderResult, error) {
|
||||
in.ClientOrderNo = strings.TrimSpace(in.ClientOrderNo)
|
||||
in.SKU = strings.TrimSpace(in.SKU)
|
||||
if in.MerchantID == 0 || in.APIClientID == 0 {
|
||||
return nil, errors.New("无效的商户或 API 客户端")
|
||||
}
|
||||
if in.ClientOrderNo == "" || len(in.ClientOrderNo) > 96 {
|
||||
return nil, errors.New("client_order_no 不能为空且最长 96 位")
|
||||
}
|
||||
if in.SKU == "" {
|
||||
return nil, errors.New("sku 不能为空")
|
||||
}
|
||||
if in.Quantity == 0 {
|
||||
in.Quantity = 1
|
||||
}
|
||||
if in.Quantity < 1 {
|
||||
return nil, errors.New("quantity 必须大于零")
|
||||
}
|
||||
requestData := ""
|
||||
if in.RequestData != nil {
|
||||
raw, err := json.Marshal(in.RequestData)
|
||||
if err != nil {
|
||||
return nil, errors.New("订单请求数据无法序列化")
|
||||
}
|
||||
requestData = string(raw)
|
||||
}
|
||||
|
||||
result := &CreateFulfillmentOrderResult{}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.FulfillmentOrder
|
||||
err := tx.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error
|
||||
if err == nil {
|
||||
result.Order = &existing
|
||||
result.Idempotent = true
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
var product model.MerchantProduct
|
||||
if err := tx.Preload("Product").Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("merchant_id = ? AND sku = ? AND status = ?", in.MerchantID, in.SKU, model.ProductStatusActive).
|
||||
First(&product).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("商品不存在或已下架")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if product.Product == nil || product.Product.Status != model.ProductStatusActive {
|
||||
return errors.New("商品目录已下架")
|
||||
}
|
||||
if product.Stock >= 0 && product.Stock < in.Quantity {
|
||||
return errors.New("商品库存不足")
|
||||
}
|
||||
if product.PriceAmount > 0 && in.Quantity > math.MaxInt64/product.PriceAmount {
|
||||
return errors.New("订单金额超出范围")
|
||||
}
|
||||
totalAmount := product.PriceAmount * in.Quantity
|
||||
|
||||
var wallet model.WalletAccount
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if wallet.AvailableBalance < totalAmount {
|
||||
return errors.New("商户钱包余额不足")
|
||||
}
|
||||
newBalance := wallet.AvailableBalance - totalAmount
|
||||
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
order := &model.FulfillmentOrder{
|
||||
MerchantID: in.MerchantID,
|
||||
OrderNo: newFulfillmentOrderNo(),
|
||||
ClientOrderNo: in.ClientOrderNo,
|
||||
MerchantProductID: product.ID,
|
||||
ProductSKU: product.SKU,
|
||||
ProductName: fallbackName(product.DisplayName, product.Product.Name),
|
||||
Quantity: in.Quantity,
|
||||
Amount: totalAmount,
|
||||
Currency: product.Currency,
|
||||
PaymentStatus: model.PaymentStatusPaid,
|
||||
FulfillmentStatus: model.FulfillmentStatusPending,
|
||||
BuyerReference: in.BuyerReference,
|
||||
RequestData: requestData,
|
||||
}
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
idempotencyKey := in.ClientOrderNo
|
||||
if err := tx.Create(&model.WalletLedgerEntry{
|
||||
MerchantID: in.MerchantID,
|
||||
WalletAccountID: wallet.ID,
|
||||
EntryNo: "WL" + uuid.NewString(),
|
||||
Type: model.WalletLedgerDebit,
|
||||
Amount: -totalAmount,
|
||||
BalanceAfter: newBalance,
|
||||
ReferenceType: "fulfillment_order",
|
||||
ReferenceNo: order.OrderNo,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Note: "开放接口下单扣款",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if product.Stock >= 0 {
|
||||
if err := tx.Model(&model.MerchantProduct{}).Where("id = ? AND stock >= ?", product.ID, in.Quantity).
|
||||
Update("stock", gorm.Expr("stock - ?", in.Quantity)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&model.FulfillmentJob{
|
||||
MerchantID: in.MerchantID,
|
||||
OrderID: order.ID,
|
||||
Status: model.FulfillmentJobStatusPending,
|
||||
NextRunAt: time.Now(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "open_order.create", "fulfillment_order", order.OrderNo, map[string]interface{}{"client_order_no": in.ClientOrderNo, "sku": in.SKU}); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.callbacks != nil {
|
||||
if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.created", orderCallbackData(order)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result.Order = order
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// 并发请求恰好同时通过首次查询时,唯一约束冲突后返回既有订单。
|
||||
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "UNIQUE") {
|
||||
var existing model.FulfillmentOrder
|
||||
if queryErr := s.db.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error; queryErr == nil {
|
||||
return &CreateFulfillmentOrderResult{Order: &existing, Idempotent: true}, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) {
|
||||
var order model.FulfillmentOrder
|
||||
err := s.db.Preload("MerchantProduct.Product").
|
||||
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
|
||||
First(&order).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, fulfillmentStatus string) ([]model.FulfillmentOrder, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID)
|
||||
if fulfillmentStatus != "" {
|
||||
tx = tx.Where("fulfillment_status = ?", fulfillmentStatus)
|
||||
}
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var orders []model.FulfillmentOrder
|
||||
err := tx.Preload("MerchantProduct.Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error
|
||||
return orders, total, err
|
||||
}
|
||||
|
||||
type FulfillmentUpdateInput struct {
|
||||
MerchantID uint
|
||||
APIClientID uint
|
||||
OrderNo string
|
||||
Status string
|
||||
ProviderOrderNo string
|
||||
FailureReason string
|
||||
ResultData interface{}
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*model.FulfillmentOrder, error) {
|
||||
switch in.Status {
|
||||
case model.FulfillmentStatusProcessing, model.FulfillmentStatusSucceeded, model.FulfillmentStatusFailed:
|
||||
default:
|
||||
return nil, errors.New("无效的履约状态")
|
||||
}
|
||||
resultData := ""
|
||||
if in.ResultData != nil {
|
||||
raw, err := json.Marshal(in.ResultData)
|
||||
if err != nil {
|
||||
return nil, errors.New("履约结果无法序列化")
|
||||
}
|
||||
resultData = string(raw)
|
||||
}
|
||||
var out model.FulfillmentOrder
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.FulfillmentOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("merchant_id = ? AND order_no = ?", in.MerchantID, in.OrderNo).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("订单不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if order.FulfillmentStatus == model.FulfillmentStatusCancelled {
|
||||
return errors.New("订单已取消,不能更新履约状态")
|
||||
}
|
||||
if order.PaymentStatus != model.PaymentStatusPaid {
|
||||
return errors.New("订单未支付,不能履约")
|
||||
}
|
||||
if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.Status == model.FulfillmentStatusSucceeded {
|
||||
out = order
|
||||
return nil
|
||||
}
|
||||
if order.FulfillmentStatus == model.FulfillmentStatusSucceeded {
|
||||
return errors.New("订单已履约成功,不能回退状态")
|
||||
}
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"fulfillment_status": in.Status,
|
||||
"result_data": resultData,
|
||||
}
|
||||
if in.ProviderOrderNo != "" {
|
||||
updates["provider_order_no"] = in.ProviderOrderNo
|
||||
}
|
||||
switch in.Status {
|
||||
case model.FulfillmentStatusSucceeded:
|
||||
updates["delivered_at"] = now
|
||||
updates["failure_reason"] = ""
|
||||
case model.FulfillmentStatusFailed:
|
||||
updates["failure_reason"] = in.FailureReason
|
||||
}
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
jobStatus := model.FulfillmentJobStatusProcessing
|
||||
if in.Status == model.FulfillmentStatusSucceeded {
|
||||
jobStatus = model.FulfillmentJobStatusSucceeded
|
||||
} else if in.Status == model.FulfillmentStatusFailed {
|
||||
jobStatus = model.FulfillmentJobStatusFailed
|
||||
}
|
||||
if err := tx.Model(&model.FulfillmentJob{}).Where("order_id = ?", order.ID).Updates(map[string]interface{}{
|
||||
"status": jobStatus,
|
||||
"provider_order_no": in.ProviderOrderNo,
|
||||
"result_payload": resultData,
|
||||
"last_error": in.FailureReason,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.First(&out, order.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAudit(tx, &in.MerchantID, nil, &in.APIClientID, "fulfillment.update", "fulfillment_order", order.OrderNo, map[string]string{"status": in.Status}); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.callbacks != nil {
|
||||
if err := s.callbacks.Enqueue(tx, in.MerchantID, "order.fulfillment.updated", orderCallbackData(&out)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) {
|
||||
var out model.FulfillmentOrder
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.FulfillmentOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("订单不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if order.FulfillmentStatus == model.FulfillmentStatusCancelled {
|
||||
out = order
|
||||
return nil
|
||||
}
|
||||
if order.FulfillmentStatus == model.FulfillmentStatusProcessing || order.FulfillmentStatus == model.FulfillmentStatusSucceeded {
|
||||
return errors.New("订单已进入履约流程,不能取消")
|
||||
}
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"payment_status": model.PaymentStatusRefunded,
|
||||
"fulfillment_status": model.FulfillmentStatusCancelled,
|
||||
"failure_reason": reason,
|
||||
"cancelled_at": now,
|
||||
}
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var wallet model.WalletAccount
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newBalance := wallet.AvailableBalance + order.Amount
|
||||
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
idempotencyKey := "cancel:" + order.OrderNo
|
||||
if err := tx.Create(&model.WalletLedgerEntry{
|
||||
MerchantID: merchantID,
|
||||
WalletAccountID: wallet.ID,
|
||||
EntryNo: "WL" + uuid.NewString(),
|
||||
Type: model.WalletLedgerRefund,
|
||||
Amount: order.Amount,
|
||||
BalanceAfter: newBalance,
|
||||
ReferenceType: "fulfillment_order",
|
||||
ReferenceNo: order.OrderNo,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Note: "订单取消退款",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var product model.MerchantProduct
|
||||
if err := tx.Where("id = ?", order.MerchantProductID).First(&product).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if product.Stock >= 0 {
|
||||
if err := tx.Model(&product).Update("stock", gorm.Expr("stock + ?", order.Quantity)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&model.FulfillmentJob{}).Where("order_id = ?", order.ID).Updates(map[string]interface{}{
|
||||
"status": model.FulfillmentJobStatusFailed,
|
||||
"last_error": "订单已取消",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.First(&out, order.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAudit(tx, &merchantID, nil, &apiClientID, "open_order.cancel", "fulfillment_order", order.OrderNo, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.callbacks != nil {
|
||||
if err := s.callbacks.Enqueue(tx, merchantID, "order.cancelled", orderCallbackData(&out)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
type WalletAdjustInput struct {
|
||||
MerchantID uint
|
||||
ActorUserID uint
|
||||
Amount int64
|
||||
IdempotencyKey string
|
||||
Note string
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) AdjustWallet(in WalletAdjustInput) (*model.WalletAccount, error) {
|
||||
if in.Amount == 0 {
|
||||
return nil, errors.New("调整金额不能为零")
|
||||
}
|
||||
if in.IdempotencyKey == "" {
|
||||
return nil, errors.New("账务调整必须提供幂等键")
|
||||
}
|
||||
var out model.WalletAccount
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.WalletLedgerEntry
|
||||
if err := tx.Where("merchant_id = ? AND idempotency_key = ?", in.MerchantID, in.IdempotencyKey).First(&existing).Error; err == nil {
|
||||
if err := tx.First(&out, existing.WalletAccountID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
var wallet model.WalletAccount
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newBalance := wallet.AvailableBalance + in.Amount
|
||||
if newBalance < 0 {
|
||||
return errors.New("调整后余额不能小于零")
|
||||
}
|
||||
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
entryType := model.WalletLedgerAdjust
|
||||
if in.Amount > 0 {
|
||||
entryType = model.WalletLedgerCredit
|
||||
} else {
|
||||
entryType = model.WalletLedgerDebit
|
||||
}
|
||||
idempotencyKey := in.IdempotencyKey
|
||||
if err := tx.Create(&model.WalletLedgerEntry{
|
||||
MerchantID: in.MerchantID,
|
||||
WalletAccountID: wallet.ID,
|
||||
EntryNo: "WL" + uuid.NewString(),
|
||||
Type: entryType,
|
||||
Amount: in.Amount,
|
||||
BalanceAfter: newBalance,
|
||||
ReferenceType: "manual_adjustment",
|
||||
ReferenceNo: in.IdempotencyKey,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Note: in.Note,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
out = wallet
|
||||
out.AvailableBalance = newBalance
|
||||
return writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "wallet.adjust", "wallet_account", fmt.Sprint(wallet.ID), map[string]int64{"amount": in.Amount})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) GetWallet(merchantID uint) (*model.WalletAccount, error) {
|
||||
var wallet model.WalletAccount
|
||||
if err := s.db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("商户钱包不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &wallet, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int) ([]model.WalletLedgerEntry, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx := s.db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID)
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var entries []model.WalletLedgerEntry
|
||||
err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&entries).Error
|
||||
return entries, total, err
|
||||
}
|
||||
|
||||
func newFulfillmentOrderNo() string {
|
||||
return "FO" + time.Now().UTC().Format("20060102150405") + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
|
||||
}
|
||||
|
||||
func CanFulfill(order *model.FulfillmentOrder) (bool, string) {
|
||||
if order.PaymentStatus != model.PaymentStatusPaid {
|
||||
return false, "订单未支付或已退款"
|
||||
}
|
||||
switch order.FulfillmentStatus {
|
||||
case model.FulfillmentStatusPending, model.FulfillmentStatusFailed:
|
||||
return true, ""
|
||||
case model.FulfillmentStatusProcessing:
|
||||
return false, "订单履约中"
|
||||
case model.FulfillmentStatusSucceeded:
|
||||
return false, "订单已履约成功"
|
||||
case model.FulfillmentStatusCancelled:
|
||||
return false, "订单已取消"
|
||||
default:
|
||||
return false, "订单状态不可履约"
|
||||
}
|
||||
}
|
||||
|
||||
func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
||||
canFulfill, cannotFulfillReason := CanFulfill(order)
|
||||
return map[string]interface{}{
|
||||
"order_no": order.OrderNo,
|
||||
"client_order_no": order.ClientOrderNo,
|
||||
"product_sku": order.ProductSKU,
|
||||
"quantity": order.Quantity,
|
||||
"amount": order.Amount,
|
||||
"currency": order.Currency,
|
||||
"payment_status": order.PaymentStatus,
|
||||
"fulfillment_status": order.FulfillmentStatus,
|
||||
"can_fulfill": canFulfill,
|
||||
"cannot_fulfill_reason": cannotFulfillReason,
|
||||
"provider_order_no": order.ProviderOrderNo,
|
||||
"failure_reason": order.FailureReason,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/testdb"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newServiceTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
return testdb.New(t,
|
||||
&model.Merchant{},
|
||||
&model.Product{},
|
||||
&model.MerchantProduct{},
|
||||
&model.WalletAccount{},
|
||||
&model.WalletLedgerEntry{},
|
||||
&model.FulfillmentOrder{},
|
||||
&model.FulfillmentJob{},
|
||||
&model.AuditLog{},
|
||||
)
|
||||
}
|
||||
|
||||
func seedFulfillmentMerchant(t *testing.T, db *gorm.DB, code string, balance, stock, price int64) (uint, model.MerchantProduct) {
|
||||
t.Helper()
|
||||
merchant := model.Merchant{Code: code, Name: code, Status: model.MerchantStatusActive}
|
||||
if err := db.Create(&merchant).Error; err != nil {
|
||||
t.Fatalf("create merchant: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.WalletAccount{
|
||||
MerchantID: merchant.ID,
|
||||
Currency: "CNY",
|
||||
AvailableBalance: balance,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create wallet: %v", err)
|
||||
}
|
||||
product := model.Product{Code: code + "-product", Name: "测试商品", Status: model.ProductStatusActive}
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
t.Fatalf("create product: %v", err)
|
||||
}
|
||||
merchantProduct := model.MerchantProduct{
|
||||
MerchantID: merchant.ID,
|
||||
ProductID: product.ID,
|
||||
SKU: "sku-basic",
|
||||
DisplayName: "测试商品",
|
||||
PriceAmount: price,
|
||||
Currency: "CNY",
|
||||
Stock: stock,
|
||||
Status: model.ProductStatusActive,
|
||||
}
|
||||
if err := db.Create(&merchantProduct).Error; err != nil {
|
||||
t.Fatalf("create merchant product: %v", err)
|
||||
}
|
||||
return merchant.ID, merchantProduct
|
||||
}
|
||||
|
||||
func TestFulfillmentCreateOrderDebitsWalletAndIsIdempotent(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-a", 1000, 5, 200)
|
||||
svc := NewFulfillmentService(db, nil)
|
||||
|
||||
first, err := svc.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 11,
|
||||
ClientOrderNo: "client-001",
|
||||
SKU: product.SKU,
|
||||
Quantity: 2,
|
||||
RequestData: map[string]string{
|
||||
"account": "player-1",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
if first.Idempotent {
|
||||
t.Fatalf("first create should not be idempotent")
|
||||
}
|
||||
if first.Order.Amount != 400 || first.Order.PaymentStatus != model.PaymentStatusPaid || first.Order.FulfillmentStatus != model.FulfillmentStatusPending {
|
||||
t.Fatalf("unexpected order: %+v", first.Order)
|
||||
}
|
||||
|
||||
second, err := svc.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 11,
|
||||
ClientOrderNo: "client-001",
|
||||
SKU: product.SKU,
|
||||
Quantity: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent create: %v", err)
|
||||
}
|
||||
if !second.Idempotent || second.Order.OrderNo != first.Order.OrderNo {
|
||||
t.Fatalf("expected existing order, got %+v", second)
|
||||
}
|
||||
|
||||
var wallet model.WalletAccount
|
||||
if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
|
||||
t.Fatalf("query wallet: %v", err)
|
||||
}
|
||||
if wallet.AvailableBalance != 600 {
|
||||
t.Fatalf("wallet should debit once, got %d", wallet.AvailableBalance)
|
||||
}
|
||||
var refreshed model.MerchantProduct
|
||||
if err := db.First(&refreshed, product.ID).Error; err != nil {
|
||||
t.Fatalf("query product: %v", err)
|
||||
}
|
||||
if refreshed.Stock != 3 {
|
||||
t.Fatalf("stock should decrease once, got %d", refreshed.Stock)
|
||||
}
|
||||
var ledgerCount int64
|
||||
db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount)
|
||||
if ledgerCount != 1 {
|
||||
t.Fatalf("ledger should have one debit entry, got %d", ledgerCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFulfillmentCancelRefundsOnceAndRestoresStock(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-b", 1000, 2, 300)
|
||||
svc := NewFulfillmentService(db, nil)
|
||||
created, err := svc.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 12,
|
||||
ClientOrderNo: "client-cancel",
|
||||
SKU: product.SKU,
|
||||
Quantity: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
cancelled, err := svc.CancelOrder(merchantID, 12, created.Order.OrderNo, "用户取消")
|
||||
if err != nil {
|
||||
t.Fatalf("cancel order: %v", err)
|
||||
}
|
||||
if cancelled.PaymentStatus != model.PaymentStatusRefunded || cancelled.FulfillmentStatus != model.FulfillmentStatusCancelled {
|
||||
t.Fatalf("unexpected cancelled order: %+v", cancelled)
|
||||
}
|
||||
if _, err := svc.CancelOrder(merchantID, 12, created.Order.OrderNo, "重复取消"); err != nil {
|
||||
t.Fatalf("repeat cancel should be idempotent: %v", err)
|
||||
}
|
||||
|
||||
var wallet model.WalletAccount
|
||||
_ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error
|
||||
if wallet.AvailableBalance != 1000 {
|
||||
t.Fatalf("wallet should refund once, got %d", wallet.AvailableBalance)
|
||||
}
|
||||
var refreshed model.MerchantProduct
|
||||
_ = db.First(&refreshed, product.ID).Error
|
||||
if refreshed.Stock != 2 {
|
||||
t.Fatalf("stock should restore once, got %d", refreshed.Stock)
|
||||
}
|
||||
var ledgerCount int64
|
||||
db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID).Count(&ledgerCount)
|
||||
if ledgerCount != 2 {
|
||||
t.Fatalf("ledger should have debit and refund, got %d", ledgerCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFulfillmentStatusTransitions(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-c", 1000, -1, 100)
|
||||
svc := NewFulfillmentService(db, nil)
|
||||
created, err := svc.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 13,
|
||||
ClientOrderNo: "client-status",
|
||||
SKU: product.SKU,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
processing, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 13,
|
||||
OrderNo: created.Order.OrderNo,
|
||||
Status: model.FulfillmentStatusProcessing,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mark processing: %v", err)
|
||||
}
|
||||
if processing.FulfillmentStatus != model.FulfillmentStatusProcessing {
|
||||
t.Fatalf("expected processing, got %s", processing.FulfillmentStatus)
|
||||
}
|
||||
succeeded, err := svc.UpdateFulfillment(FulfillmentUpdateInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 13,
|
||||
OrderNo: created.Order.OrderNo,
|
||||
Status: model.FulfillmentStatusSucceeded,
|
||||
ProviderOrderNo: "provider-1",
|
||||
ResultData: map[string]string{"ok": "true"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mark succeeded: %v", err)
|
||||
}
|
||||
if succeeded.FulfillmentStatus != model.FulfillmentStatusSucceeded || succeeded.DeliveredAt == nil {
|
||||
t.Fatalf("unexpected succeeded order: %+v", succeeded)
|
||||
}
|
||||
_, err = svc.UpdateFulfillment(FulfillmentUpdateInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 13,
|
||||
OrderNo: created.Order.OrderNo,
|
||||
Status: model.FulfillmentStatusFailed,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("should reject rollback after success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFulfillmentMerchantIsolation(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantA, productA := seedFulfillmentMerchant(t, db, "merchant-d", 1000, 1, 100)
|
||||
merchantB, _ := seedFulfillmentMerchant(t, db, "merchant-e", 1000, 1, 100)
|
||||
svc := NewFulfillmentService(db, nil)
|
||||
created, err := svc.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: merchantA,
|
||||
APIClientID: 14,
|
||||
ClientOrderNo: "client-isolation",
|
||||
SKU: productA.SKU,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
if _, err := svc.GetOrder(merchantB, created.Order.OrderNo); err == nil {
|
||||
t.Fatalf("other merchant should not read the order")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletAdjustIsIdempotentPerMerchant(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantA, _ := seedFulfillmentMerchant(t, db, "merchant-f", 0, -1, 100)
|
||||
merchantB, _ := seedFulfillmentMerchant(t, db, "merchant-g", 0, -1, 100)
|
||||
svc := NewFulfillmentService(db, nil)
|
||||
for _, merchantID := range []uint{merchantA, merchantB} {
|
||||
wallet, err := svc.AdjustWallet(WalletAdjustInput{
|
||||
MerchantID: merchantID,
|
||||
ActorUserID: 1,
|
||||
Amount: 500,
|
||||
IdempotencyKey: "same-key",
|
||||
Note: "充值",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("adjust wallet merchant %d: %v", merchantID, err)
|
||||
}
|
||||
if wallet.AvailableBalance != 500 {
|
||||
t.Fatalf("unexpected balance for merchant %d: %d", merchantID, wallet.AvailableBalance)
|
||||
}
|
||||
}
|
||||
wallet, err := svc.AdjustWallet(WalletAdjustInput{
|
||||
MerchantID: merchantA,
|
||||
ActorUserID: 1,
|
||||
Amount: 500,
|
||||
IdempotencyKey: "same-key",
|
||||
Note: "重复充值",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("repeat adjust: %v", err)
|
||||
}
|
||||
if wallet.AvailableBalance != 500 {
|
||||
t.Fatalf("repeat adjust should not change balance, got %d", wallet.AvailableBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanFulfill(t *testing.T) {
|
||||
ok, reason := CanFulfill(&model.FulfillmentOrder{
|
||||
PaymentStatus: model.PaymentStatusPaid,
|
||||
FulfillmentStatus: model.FulfillmentStatusFailed,
|
||||
})
|
||||
if !ok || reason != "" {
|
||||
t.Fatalf("failed paid order should be fulfillable")
|
||||
}
|
||||
ok, _ = CanFulfill(&model.FulfillmentOrder{
|
||||
PaymentStatus: model.PaymentStatusRefunded,
|
||||
FulfillmentStatus: model.FulfillmentStatusPending,
|
||||
})
|
||||
if ok {
|
||||
t.Fatalf("refunded order should not be fulfillable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateOrderRejectsInsufficientBalance(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantID, product := seedFulfillmentMerchant(t, db, "merchant-h", 50, 1, 100)
|
||||
svc := NewFulfillmentService(db, nil)
|
||||
_, err := svc.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 15,
|
||||
ClientOrderNo: "client-low-balance",
|
||||
SKU: product.SKU,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "余额不足") {
|
||||
t.Fatalf("expected insufficient balance error, got %v", err)
|
||||
}
|
||||
var wallet model.WalletAccount
|
||||
_ = db.Where("merchant_id = ?", merchantID).First(&wallet).Error
|
||||
if wallet.AvailableBalance != 50 {
|
||||
t.Fatalf("balance should remain unchanged, got %d", wallet.AvailableBalance)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
func TestLegacyOrderCreateRejectsDistributorOutsideMerchant(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchant := model.Merchant{Code: "legacy-merchant", Name: "旧后台商户", Status: model.MerchantStatusActive}
|
||||
if err := db.Create(&merchant).Error; err != nil {
|
||||
t.Fatalf("create merchant: %v", err)
|
||||
}
|
||||
inside := model.User{Username: "inside", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "INSIDE"}
|
||||
outside := model.User{Username: "outside", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "OUTSIDE"}
|
||||
if err := db.Create(&inside).Error; err != nil {
|
||||
t.Fatalf("create inside user: %v", err)
|
||||
}
|
||||
if err := db.Create(&outside).Error; err != nil {
|
||||
t.Fatalf("create outside user: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.MerchantMember{
|
||||
MerchantID: merchant.ID,
|
||||
UserID: inside.ID,
|
||||
Role: model.MemberRoleOperator,
|
||||
Status: 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create member: %v", err)
|
||||
}
|
||||
skin := model.Skin{
|
||||
MerchantID: merchant.ID,
|
||||
Name: "旧皮肤",
|
||||
SKU: "legacy-skin",
|
||||
Price: 10,
|
||||
Stock: -1,
|
||||
Status: 1,
|
||||
}
|
||||
if err := db.Create(&skin).Error; err != nil {
|
||||
t.Fatalf("create skin: %v", err)
|
||||
}
|
||||
|
||||
_, err := NewOrderService(db).Create(CreateOrderInput{
|
||||
MerchantID: merchant.ID,
|
||||
SkinID: skin.ID,
|
||||
DistributorID: outside.ID,
|
||||
BuyerName: "买家",
|
||||
Status: model.OrderStatusPaid,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "不属于当前商户") {
|
||||
t.Fatalf("expected tenant boundary error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserListFiltersByMerchantMembership(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantA := model.Merchant{Code: "user-merchant-a", Name: "商户 A", Status: model.MerchantStatusActive}
|
||||
merchantB := model.Merchant{Code: "user-merchant-b", Name: "商户 B", Status: model.MerchantStatusActive}
|
||||
if err := db.Create(&merchantA).Error; err != nil {
|
||||
t.Fatalf("create merchant a: %v", err)
|
||||
}
|
||||
if err := db.Create(&merchantB).Error; err != nil {
|
||||
t.Fatalf("create merchant b: %v", err)
|
||||
}
|
||||
userA := model.User{Username: "user-a", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "USERA"}
|
||||
userB := model.User{Username: "user-b", PasswordHash: "hash", Role: model.RoleDistributor, Status: 1, InviteCode: "USERB"}
|
||||
if err := db.Create(&userA).Error; err != nil {
|
||||
t.Fatalf("create user a: %v", err)
|
||||
}
|
||||
if err := db.Create(&userB).Error; err != nil {
|
||||
t.Fatalf("create user b: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.MerchantMember{MerchantID: merchantA.ID, UserID: userA.ID, Role: model.MemberRoleOperator, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("create member a: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.MerchantMember{MerchantID: merchantB.ID, UserID: userB.ID, Role: model.MemberRoleOperator, Status: 1}).Error; err != nil {
|
||||
t.Fatalf("create member b: %v", err)
|
||||
}
|
||||
|
||||
active := 1
|
||||
list, total, err := NewUserService(db, nil).List(UserListQuery{
|
||||
MerchantID: merchantA.ID,
|
||||
Page: 1,
|
||||
Size: 20,
|
||||
Role: model.RoleDistributor,
|
||||
Status: &active,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list users: %v", err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 || list[0].Username != "user-a" {
|
||||
t.Fatalf("expected only merchant A user, total=%d list=%+v", total, list)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var merchantCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,63}$`)
|
||||
|
||||
type MerchantService struct {
|
||||
db *gorm.DB
|
||||
codec *SecretCodec
|
||||
tenant *TenantService
|
||||
}
|
||||
|
||||
func NewMerchantService(db *gorm.DB, codec *SecretCodec, tenant *TenantService) *MerchantService {
|
||||
return &MerchantService{db: db, codec: codec, tenant: tenant}
|
||||
}
|
||||
|
||||
type CreateMerchantInput struct {
|
||||
Code string
|
||||
Name string
|
||||
ContactName string
|
||||
ContactInfo string
|
||||
OwnerUserID uint
|
||||
}
|
||||
|
||||
func (s *MerchantService) CreateMerchant(in CreateMerchantInput, actorUserID uint) (*model.Merchant, error) {
|
||||
in.Code = strings.ToLower(strings.TrimSpace(in.Code))
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if !merchantCodePattern.MatchString(in.Code) {
|
||||
return nil, errors.New("商户编码需为 3-64 位小写字母、数字或连字符")
|
||||
}
|
||||
if in.Name == "" {
|
||||
return nil, errors.New("商户名称不能为空")
|
||||
}
|
||||
if in.OwnerUserID == 0 {
|
||||
return nil, errors.New("商户负责人不能为空")
|
||||
}
|
||||
merchant := &model.Merchant{
|
||||
Code: in.Code,
|
||||
Name: in.Name,
|
||||
Status: model.MerchantStatusActive,
|
||||
ContactName: in.ContactName,
|
||||
ContactInfo: in.ContactInfo,
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var owner model.User
|
||||
if err := tx.First(&owner, in.OwnerUserID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("商户负责人不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if owner.Status != 1 {
|
||||
return errors.New("商户负责人已禁用")
|
||||
}
|
||||
if err := tx.Create(merchant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&model.WalletAccount{MerchantID: merchant.ID, Currency: "CNY"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&model.MerchantMember{
|
||||
MerchantID: merchant.ID,
|
||||
UserID: owner.ID,
|
||||
Role: model.MemberRoleOwner,
|
||||
Status: 1,
|
||||
IsDefault: true,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAudit(tx, &merchant.ID, &actorUserID, nil, "merchant.create", "merchant", fmt.Sprint(merchant.ID), map[string]string{"code": merchant.Code})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return merchant, nil
|
||||
}
|
||||
|
||||
func (s *MerchantService) ListMerchants(page, size int) ([]model.Merchant, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx := s.db.Model(&model.Merchant{})
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var merchants []model.Merchant
|
||||
err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&merchants).Error
|
||||
return merchants, total, err
|
||||
}
|
||||
|
||||
func (s *MerchantService) GetMerchant(merchantID uint) (*model.Merchant, error) {
|
||||
var merchant model.Merchant
|
||||
if err := s.db.First(&merchant, merchantID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("商户不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &merchant, nil
|
||||
}
|
||||
|
||||
type AddMemberInput struct {
|
||||
UserID uint
|
||||
Role string
|
||||
IsDefault bool
|
||||
}
|
||||
|
||||
func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) {
|
||||
if !isValidMemberRole(in.Role) {
|
||||
return nil, errors.New("无效的商户成员角色")
|
||||
}
|
||||
member := &model.MerchantMember{
|
||||
MerchantID: merchantID,
|
||||
UserID: in.UserID,
|
||||
Role: in.Role,
|
||||
Status: 1,
|
||||
IsDefault: in.IsDefault,
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var merchant model.Merchant
|
||||
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
|
||||
return errors.New("商户不存在或已禁用")
|
||||
}
|
||||
var user model.User
|
||||
if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil {
|
||||
return errors.New("用户不存在或已禁用")
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"role": in.Role,
|
||||
"status": 1,
|
||||
"is_default": in.IsDefault,
|
||||
}),
|
||||
}).Create(member).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, in.UserID), map[string]string{"role": in.Role})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Where("merchant_id = ? AND user_id = ?", merchantID, in.UserID).First(member).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) {
|
||||
var members []model.MerchantMember
|
||||
err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error
|
||||
return members, err
|
||||
}
|
||||
|
||||
type CreateMerchantProductInput struct {
|
||||
ProductCode string
|
||||
ProductName string
|
||||
Category string
|
||||
Description string
|
||||
Attributes string
|
||||
SKU string
|
||||
DisplayName string
|
||||
PriceAmount int64
|
||||
CostAmount int64
|
||||
Currency string
|
||||
Stock int64
|
||||
Status string
|
||||
FulfillmentConfig string
|
||||
}
|
||||
|
||||
func (s *MerchantService) CreateMerchantProduct(merchantID uint, in CreateMerchantProductInput, actorUserID uint) (*model.MerchantProduct, error) {
|
||||
in.SKU = strings.TrimSpace(in.SKU)
|
||||
in.ProductCode = strings.TrimSpace(in.ProductCode)
|
||||
in.ProductName = strings.TrimSpace(in.ProductName)
|
||||
if in.SKU == "" {
|
||||
return nil, errors.New("商户商品 SKU 不能为空")
|
||||
}
|
||||
if in.PriceAmount < 0 || in.CostAmount < 0 {
|
||||
return nil, errors.New("商品金额不能小于零")
|
||||
}
|
||||
if in.Stock < -1 {
|
||||
return nil, errors.New("库存只能为 -1 或非负整数")
|
||||
}
|
||||
if in.Currency == "" {
|
||||
in.Currency = "CNY"
|
||||
}
|
||||
in.Currency = strings.ToUpper(in.Currency)
|
||||
if in.Status == "" {
|
||||
in.Status = model.ProductStatusActive
|
||||
}
|
||||
if in.Status != model.ProductStatusActive && in.Status != model.ProductStatusInactive {
|
||||
return nil, errors.New("无效的商品状态")
|
||||
}
|
||||
|
||||
merchantProduct := &model.MerchantProduct{}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var merchant model.Merchant
|
||||
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
|
||||
return errors.New("商户不存在或已禁用")
|
||||
}
|
||||
product, err := ensureProduct(tx, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merchantProduct = &model.MerchantProduct{
|
||||
MerchantID: merchantID,
|
||||
ProductID: product.ID,
|
||||
SKU: in.SKU,
|
||||
DisplayName: fallbackName(in.DisplayName, product.Name),
|
||||
PriceAmount: in.PriceAmount,
|
||||
CostAmount: in.CostAmount,
|
||||
Currency: in.Currency,
|
||||
Stock: in.Stock,
|
||||
Status: in.Status,
|
||||
FulfillmentConfig: in.FulfillmentConfig,
|
||||
}
|
||||
if err := tx.Create(merchantProduct).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.create", "merchant_product", fmt.Sprint(merchantProduct.ID), map[string]string{"sku": in.SKU})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return merchantProduct, nil
|
||||
}
|
||||
|
||||
func (s *MerchantService) ListMerchantProducts(merchantID uint, page, size int, activeOnly bool) ([]model.MerchantProduct, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx := s.db.Model(&model.MerchantProduct{}).Where("merchant_id = ?", merchantID)
|
||||
if activeOnly {
|
||||
tx = tx.Where("status = ?", model.ProductStatusActive)
|
||||
}
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var products []model.MerchantProduct
|
||||
err := tx.Preload("Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&products).Error
|
||||
return products, total, err
|
||||
}
|
||||
|
||||
type UpdateMerchantProductInput struct {
|
||||
DisplayName *string
|
||||
PriceAmount *int64
|
||||
CostAmount *int64
|
||||
Stock *int64
|
||||
Status *string
|
||||
FulfillmentConfig *string
|
||||
}
|
||||
|
||||
func (s *MerchantService) UpdateMerchantProduct(merchantID, id uint, in UpdateMerchantProductInput, actorUserID uint) error {
|
||||
updates := make(map[string]interface{})
|
||||
if in.DisplayName != nil {
|
||||
updates["display_name"] = *in.DisplayName
|
||||
}
|
||||
if in.PriceAmount != nil {
|
||||
if *in.PriceAmount < 0 {
|
||||
return errors.New("商品售价不能小于零")
|
||||
}
|
||||
updates["price_amount"] = *in.PriceAmount
|
||||
}
|
||||
if in.CostAmount != nil {
|
||||
if *in.CostAmount < 0 {
|
||||
return errors.New("商品成本不能小于零")
|
||||
}
|
||||
updates["cost_amount"] = *in.CostAmount
|
||||
}
|
||||
if in.Stock != nil {
|
||||
if *in.Stock < -1 {
|
||||
return errors.New("库存只能为 -1 或非负整数")
|
||||
}
|
||||
updates["stock"] = *in.Stock
|
||||
}
|
||||
if in.Status != nil {
|
||||
if *in.Status != model.ProductStatusActive && *in.Status != model.ProductStatusInactive {
|
||||
return errors.New("无效的商品状态")
|
||||
}
|
||||
updates["status"] = *in.Status
|
||||
}
|
||||
if in.FulfillmentConfig != nil {
|
||||
updates["fulfillment_config"] = *in.FulfillmentConfig
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return errors.New("没有可更新字段")
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.MerchantProduct{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("商户商品不存在")
|
||||
}
|
||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.update", "merchant_product", fmt.Sprint(id), nil)
|
||||
})
|
||||
}
|
||||
|
||||
type APICredential struct {
|
||||
Client *model.APIClient `json:"client"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
type CreateAPIClientInput struct {
|
||||
Name string
|
||||
Scopes string
|
||||
SignatureVersion string
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInput, actorUserID uint) (*APICredential, error) {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" {
|
||||
return nil, errors.New("API 客户端名称不能为空")
|
||||
}
|
||||
if len(ParseScopes(in.Scopes)) == 0 {
|
||||
return nil, errors.New("至少配置一个 API 权限")
|
||||
}
|
||||
if in.SignatureVersion == "" {
|
||||
in.SignatureVersion = "v1"
|
||||
}
|
||||
if in.SignatureVersion != "v1" {
|
||||
return nil, errors.New("无效的签名版本")
|
||||
}
|
||||
appKey, err := randomToken("ak_", 24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := randomToken("sk_", 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ciphertext, err := s.codec.Encrypt(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &model.APIClient{
|
||||
MerchantID: merchantID,
|
||||
Name: in.Name,
|
||||
AppKey: appKey,
|
||||
SecretCiphertext: ciphertext,
|
||||
SignatureVersion: in.SignatureVersion,
|
||||
Scopes: strings.Join(scopeList(in.Scopes), ","),
|
||||
Status: model.APIClientStatusActive,
|
||||
ExpiresAt: in.ExpiresAt,
|
||||
}
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var merchant model.Merchant
|
||||
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
|
||||
return errors.New("商户不存在或已禁用")
|
||||
}
|
||||
if err := tx.Create(client).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.create", "api_client", fmt.Sprint(client.ID), map[string]string{"name": in.Name})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &APICredential{Client: client, Secret: secret}, nil
|
||||
}
|
||||
|
||||
func (s *MerchantService) ListAPIClients(merchantID uint) ([]model.APIClient, error) {
|
||||
var clients []model.APIClient
|
||||
err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&clients).Error
|
||||
return clients, err
|
||||
}
|
||||
|
||||
func (s *MerchantService) UpdateAPIClientStatus(merchantID, id uint, status string, actorUserID uint) error {
|
||||
if status != model.APIClientStatusActive && status != model.APIClientStatusDisabled {
|
||||
return errors.New("无效的 API 客户端状态")
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.APIClient{}).Where("id = ? AND merchant_id = ?", id, merchantID).Update("status", status)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("API 客户端不存在")
|
||||
}
|
||||
return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.status.update", "api_client", fmt.Sprint(id), map[string]string{"status": status})
|
||||
})
|
||||
}
|
||||
|
||||
func ensureProduct(tx *gorm.DB, in CreateMerchantProductInput) (*model.Product, error) {
|
||||
if in.ProductCode != "" {
|
||||
var product model.Product
|
||||
err := tx.Where("code = ?", in.ProductCode).First(&product).Error
|
||||
if err == nil {
|
||||
return &product, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if in.ProductName == "" {
|
||||
return nil, errors.New("新建平台商品时商品名称不能为空")
|
||||
}
|
||||
code := in.ProductCode
|
||||
if code == "" {
|
||||
token, err := randomToken("prd_", 12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code = token
|
||||
}
|
||||
product := &model.Product{
|
||||
Code: code,
|
||||
Name: in.ProductName,
|
||||
Category: in.Category,
|
||||
Description: in.Description,
|
||||
Attributes: in.Attributes,
|
||||
Status: model.ProductStatusActive,
|
||||
}
|
||||
if err := tx.Create(product).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return product, nil
|
||||
}
|
||||
|
||||
func normalizePage(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
|
||||
func randomToken(prefix string, byteCount int) (string, error) {
|
||||
raw := make([]byte, byteCount)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func scopeList(scopes string) []string {
|
||||
set := ParseScopes(scopes)
|
||||
items := make([]string, 0, len(set))
|
||||
for scope := range set {
|
||||
items = append(items, scope)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func fallbackName(value, fallback string) string {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func NewOrderService(db *gorm.DB) *OrderService {
|
||||
}
|
||||
|
||||
type OrderListQuery struct {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// SecretCodec 使用应用主密钥加密落库的第三方密钥,避免明文存储。
|
||||
type SecretCodec struct {
|
||||
gcm cipher.AEAD
|
||||
}
|
||||
|
||||
func NewSecretCodec(masterKey string) (*SecretCodec, error) {
|
||||
if masterKey == "" {
|
||||
return nil, errors.New("数据加密主密钥不能为空")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(masterKey))
|
||||
block, err := aes.NewCipher(sum[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SecretCodec{gcm: gcm}, nil
|
||||
}
|
||||
|
||||
func (c *SecretCodec) Encrypt(plain string) (string, error) {
|
||||
nonce := make([]byte, c.gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := c.gcm.Seal(nil, nonce, []byte(plain), nil)
|
||||
return base64.RawURLEncoding.EncodeToString(append(nonce, sealed...)), nil
|
||||
}
|
||||
|
||||
func (c *SecretCodec) Decrypt(ciphertext string) (string, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", errors.New("密钥密文格式错误")
|
||||
}
|
||||
if len(raw) < c.gcm.NonceSize() {
|
||||
return "", errors.New("密钥密文长度错误")
|
||||
}
|
||||
plain, err := c.gcm.Open(nil, raw[:c.gcm.NonceSize()], raw[c.gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", errors.New("密钥解密失败")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSecretCodecRoundTrip(t *testing.T) {
|
||||
codec, err := NewSecretCodec("test-master-key")
|
||||
if err != nil {
|
||||
t.Fatalf("new codec: %v", err)
|
||||
}
|
||||
ciphertext, err := codec.Encrypt("plain-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
if ciphertext == "plain-secret" {
|
||||
t.Fatalf("secret should not be stored as plaintext")
|
||||
}
|
||||
plain, err := codec.Decrypt(ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if plain != "plain-secret" {
|
||||
t.Fatalf("unexpected plaintext: %s", plain)
|
||||
}
|
||||
}
|
||||
@@ -17,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: "和平精英",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user