Files
affiliate_dash/backend/internal/service/merchant.go
T
yml2213 663d7e1e9f fix(admin): 商户筛选下拉改用不分页的精简接口
自查新做的下游订单页时发现的问题:商户筛选下拉调用
/api/platform/merchants?size=200,而分页查询会把 >100 的 size 静默降级为 20
(service.normalizePage),因此商户超过 20 家时下拉里会少一批商户,界面不报任何错。

- 新增 service.ListMerchantOptions:不分页返回全部启用商户的精简信息
  (id/code/name),独立于分页的 ListMerchants
- 新增 GET /api/platform/merchant-options
- 前端改用该接口;types 增加 MerchantOption,api 增加 merchantOptions()

测试:
- TestListMerchantOptionsReturnsAllActiveMerchants:造 25 家启用 + 1 家禁用,
  断言返回数量与库中启用数一致、必须超过分页上限、且不含已禁用商户
- TestListMerchantsClampsOversizedPageSize:钉住「size=200 实际只返回 20 条,
  而 total 反映真实总数」这一事实,即上面接口存在的理由
2026-10-06 20:44:48 +08:00

274 lines
7.9 KiB
Go

package service
import (
"errors"
"fmt"
"regexp"
"strings"
"affiliate_dash/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
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
OwnerUsername string
OwnerPassword string
OwnerNickname string
Features string
FeeType string
FeeRateBP int64
FeeFixedAmount int64
}
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)
in.Features = NormalizeMerchantFeatures(in.Features)
if !merchantCodePattern.MatchString(in.Code) {
return nil, errors.New("商户编码需为 3-64 位小写字母、数字或连字符")
}
if in.Name == "" {
return nil, errors.New("商户名称不能为空")
}
if in.OwnerUserID == 0 && strings.TrimSpace(in.OwnerUsername) == "" {
return nil, errors.New("商户负责人不能为空")
}
if in.FeeRateBP < 0 || in.FeeRateBP > 10000 {
return nil, errors.New("手续费比例需在 0-10000 BP 之间")
}
if in.FeeFixedAmount < 0 {
return nil, errors.New("固定手续费不能小于零")
}
feeType := in.FeeType
if feeType == "" {
feeType = model.FeeTypeRate
}
if feeType != model.FeeTypeRate && feeType != model.FeeTypeFixed {
return nil, errors.New("手续费类型仅支持 rate 或 fixed")
}
merchant := &model.Merchant{
Code: in.Code,
Name: in.Name,
Status: model.MerchantStatusActive,
ContactName: in.ContactName,
ContactInfo: in.ContactInfo,
Features: in.Features,
FeeType: feeType,
FeeRateBP: in.FeeRateBP,
FeeFixedAmount: in.FeeFixedAmount,
}
err := s.db.Transaction(func(tx *gorm.DB) error {
owner, err := s.resolveOrCreateOwner(tx, in)
if err != nil {
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: "POINT"}).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
}
if err := createDefaultMerchantRoles(tx, merchant.ID); err != nil {
return err
}
if err := copyDefaultProducts(tx, merchant.ID); 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) resolveOrCreateOwner(tx *gorm.DB, in CreateMerchantInput) (*model.User, error) {
if in.OwnerUserID != 0 {
var owner model.User
if err := tx.First(&owner, in.OwnerUserID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("商户负责人不存在")
}
return nil, err
}
return &owner, nil
}
username := strings.TrimSpace(in.OwnerUsername)
password := strings.TrimSpace(in.OwnerPassword)
nickname := strings.TrimSpace(in.OwnerNickname)
if username == "" || len(username) < 3 {
return nil, errors.New("负责人用户名至少 3 位")
}
if len(password) < 6 {
return nil, errors.New("负责人密码至少 6 位")
}
if nickname == "" {
nickname = username
}
var count int64
if err := tx.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, errors.New("负责人用户名已存在")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
owner := &model.User{
Username: username,
PasswordHash: string(hash),
Nickname: nickname,
Role: model.RoleMerchant,
Status: 1,
}
if err := tx.Create(owner).Error; err != nil {
return nil, err
}
return owner, nil
}
type UpdateMerchantSettingsInput struct {
Name *string
Status *string
ContactName *string
ContactInfo *string
Features *string
FeeType *string
FeeRateBP *int64
FeeFixedAmount *int64
}
func (s *MerchantService) UpdateMerchantSettings(merchantID uint, in UpdateMerchantSettingsInput, actorUserID uint) error {
updates := map[string]interface{}{}
if in.Name != nil {
name := strings.TrimSpace(*in.Name)
if name == "" {
return errors.New("商户名称不能为空")
}
updates["name"] = name
}
if in.Status != nil {
if *in.Status != model.MerchantStatusActive && *in.Status != model.MerchantStatusDisabled {
return errors.New("无效的商户状态")
}
updates["status"] = *in.Status
}
if in.ContactName != nil {
updates["contact_name"] = strings.TrimSpace(*in.ContactName)
}
if in.ContactInfo != nil {
updates["contact_info"] = strings.TrimSpace(*in.ContactInfo)
}
if in.Features != nil {
updates["features"] = NormalizeMerchantFeatures(*in.Features)
}
if in.FeeRateBP != nil {
if *in.FeeRateBP < 0 || *in.FeeRateBP > 10000 {
return errors.New("手续费比例需在 0-10000 BP 之间")
}
updates["fee_rate_bp"] = *in.FeeRateBP
}
if in.FeeFixedAmount != nil {
if *in.FeeFixedAmount < 0 {
return errors.New("固定手续费不能小于零")
}
updates["fee_fixed_amount"] = *in.FeeFixedAmount
}
if in.FeeType != nil {
if *in.FeeType != model.FeeTypeRate && *in.FeeType != model.FeeTypeFixed {
return errors.New("手续费类型仅支持 rate 或 fixed")
}
updates["fee_type"] = *in.FeeType
}
if len(updates) == 0 {
return errors.New("没有可更新字段")
}
return s.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Merchant{}).Where("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.settings.update", "merchant", fmt.Sprint(merchantID), updates)
})
}
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
}
// MerchantOption 是筛选下拉使用的精简商户信息。
type MerchantOption struct {
ID uint `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
}
// ListMerchantOptions 返回全部启用商户的精简列表。
//
// 刻意独立于分页的 ListMerchants:下拉需要「全部」,而分页接口的 size 上限是 100,
// 直接调大 size 只会被静默降级成 20,表现为下拉里少了一批商户。
func (s *MerchantService) ListMerchantOptions() ([]MerchantOption, error) {
options := make([]MerchantOption, 0)
err := s.db.Model(&model.Merchant{}).
Select("id", "code", "name").
Where("status = ?", model.MerchantStatusActive).
Order("id ASC").
Find(&options).Error
return options, 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
}