466 lines
14 KiB
Go
466 lines
14 KiB
Go
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
|
|
}
|