优化商户管理, 增加商户名重置密码等等

This commit is contained in:
yml2213
2026-08-14 13:09:56 +08:00
parent ee3e427ab7
commit 99a33675b0
7 changed files with 488 additions and 2 deletions
+50
View File
@@ -741,6 +741,56 @@ func (h *MerchantHandler) AddPlatformMerchantMember(c *gin.Context) {
response.OK(c, member)
}
// AdminGetMerchantAccount 平台管理员查看指定商户的负责人登录账号。
func (h *MerchantHandler) AdminGetMerchantAccount(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if id == 0 {
response.BadRequest(c, "商户 ID 无效")
return
}
accounts, err := h.merchantSvc.ListOwnerAccounts(uint(id))
if err != nil {
response.ServerError(c, err.Error())
return
}
response.OK(c, accounts)
}
type adminUpdateMerchantAccountReq struct {
UserID uint `json:"user_id" binding:"required"`
Username string `json:"username" binding:"required,min=3,max=64"`
Nickname string `json:"nickname" binding:"max=64"`
Password string `json:"password" binding:"omitempty,min=6,max=128"`
Status *int `json:"status"`
}
// AdminUpdateMerchantAccount 平台管理员维护指定商户的负责人登录账号:
// 修改用户名/昵称、重置密码、启用停用;密码留空表示不重置。
func (h *MerchantHandler) AdminUpdateMerchantAccount(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req adminUpdateMerchantAccountReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:用户名必填(3-64 位),密码至少 6 位")
return
}
if id == 0 {
response.BadRequest(c, "商户 ID 无效")
return
}
account, err := h.merchantSvc.UpdateMerchantOwnerAccount(uint(id), service.UpdateMerchantOwnerAccountInput{
UserID: req.UserID,
Username: req.Username,
Nickname: req.Nickname,
Password: req.Password,
Status: req.Status,
}, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, account)
}
func pageParams(c *gin.Context) (int, int) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
+2
View File
@@ -209,6 +209,8 @@ func Setup(h *Handlers) *gin.Engine {
admin.PATCH("/platform/merchants/:id/products/:pid", h.Merchant.AdminUpdateMerchantProduct)
admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts)
admin.GET("/platform/merchants/:id/wallet", h.Merchant.AdminGetWallet)
admin.GET("/platform/merchants/:id/account", h.Merchant.AdminGetMerchantAccount)
admin.PATCH("/platform/merchants/:id/account", h.Merchant.AdminUpdateMerchantAccount)
admin.GET("/platform/merchants/:id/wallet/ledger", h.Merchant.AdminListWalletLedger)
admin.POST("/platform/merchants/:id/wallet/adjust", h.Merchant.AdminAdjustWallet)
admin.GET("/platform/recharge/applications", h.Recharge.AdminListApplications)
+151
View File
@@ -294,3 +294,154 @@ func (s *MerchantService) RemoveMember(merchantID, memberID, actorUserID uint) e
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.remove", "merchant_member", fmt.Sprint(memberID), nil)
})
}
// MerchantOwnerAccount 商户负责人登录账号(平台管理员视角)。
type MerchantOwnerAccount struct {
UserID uint `json:"user_id"`
MemberID uint `json:"member_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status int `json:"status"`
IsDefault bool `json:"is_default"`
}
// ListOwnerAccounts 返回指定商户的全部负责人登录账号(通常一个)。
func (s *MerchantService) ListOwnerAccounts(merchantID uint) ([]MerchantOwnerAccount, error) {
if merchantID == 0 {
return nil, errors.New("无效的商户")
}
var members []model.MerchantMember
if err := s.db.Preload("User").
Where("merchant_id = ? AND role = ?", merchantID, model.MemberRoleOwner).
Order("id ASC").
Find(&members).Error; err != nil {
return nil, err
}
out := make([]MerchantOwnerAccount, 0, len(members))
for _, member := range members {
item := MerchantOwnerAccount{
UserID: member.UserID,
MemberID: member.ID,
Status: member.Status,
IsDefault: member.IsDefault,
}
if member.User != nil {
item.Username = member.User.Username
item.Nickname = member.User.Nickname
item.Status = member.User.Status
}
out = append(out, item)
}
return out, nil
}
// UpdateMerchantOwnerAccountInput 平台管理员维护商户负责人登录账号的输入。
type UpdateMerchantOwnerAccountInput struct {
UserID uint
Username string
Nickname string
Password string
Status *int
}
// UpdateMerchantOwnerAccount 平台管理员修改商户负责人登录账号:
// 改用户名 / 昵称 / 重置密码 / 启用停用。负责人账号受保护:
// 用户名全局唯一,至少保留一个启用的负责人账号,防止商户被锁死。
func (s *MerchantService) UpdateMerchantOwnerAccount(merchantID uint, in UpdateMerchantOwnerAccountInput, actorUserID uint) (*MerchantOwnerAccount, error) {
if merchantID == 0 || in.UserID == 0 {
return nil, errors.New("无效的商户或账号")
}
in.Username = strings.TrimSpace(in.Username)
in.Nickname = strings.TrimSpace(in.Nickname)
if len(in.Username) < 3 || len(in.Username) > 64 {
return nil, errors.New("登录用户名需为 3-64 位")
}
if in.Password != "" && len(in.Password) < 6 {
return nil, errors.New("密码至少 6 位")
}
if in.Status != nil && *in.Status != 0 && *in.Status != 1 {
return nil, errors.New("账号状态无效")
}
var out MerchantOwnerAccount
err := s.db.Transaction(func(tx *gorm.DB) error {
var member model.MerchantMember
if err := tx.Preload("User").
Where("merchant_id = ? AND user_id = ? AND role = ?", merchantID, in.UserID, model.MemberRoleOwner).
First(&member).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("该账号不是该商户的负责人账号")
}
return err
}
if member.User == nil {
return errors.New("负责人账号不存在")
}
user := member.User
if in.Username != user.Username {
var count int64
if err := tx.Model(&model.User{}).Where("username = ? AND id <> ?", in.Username, user.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("用户名已存在")
}
}
// 防锁死:停用最后一个启用的负责人账号会被拒绝。
if in.Status != nil && *in.Status == 0 && user.Status == 1 {
var enabledOwners int64
if err := tx.Model(&model.MerchantMember{}).
Joins("JOIN users ON users.id = merchant_members.user_id AND users.deleted_at IS NULL").
Where("merchant_members.merchant_id = ? AND merchant_members.role = ? AND users.status = 1",
merchantID, model.MemberRoleOwner).
Count(&enabledOwners).Error; err != nil {
return err
}
if enabledOwners <= 1 {
return errors.New("至少保留一个启用的商户负责人账号")
}
}
updates := map[string]interface{}{"username": in.Username, "nickname": in.Nickname}
if in.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
updates["password_hash"] = string(hash)
}
if in.Status != nil {
updates["status"] = *in.Status
}
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
return err
}
metadata := map[string]interface{}{
"username": in.Username,
"nickname": in.Nickname,
}
if in.Password != "" {
metadata["password_reset"] = true
}
if in.Status != nil {
metadata["status"] = *in.Status
}
if err := writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.account.update", "user", fmt.Sprint(user.ID), metadata); err != nil {
return err
}
out = MerchantOwnerAccount{
UserID: user.ID,
MemberID: member.ID,
Username: in.Username,
Nickname: in.Nickname,
Status: user.Status,
IsDefault: member.IsDefault,
}
if in.Status != nil {
out.Status = *in.Status
}
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
+135
View File
@@ -8,8 +8,143 @@ import (
"affiliate_dash/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func intPtr(v int) *int { return &v }
// seedMerchantWithOwner 直接构造商户 + 负责人登录账号 + owner 成员关系。
func seedMerchantWithOwner(t *testing.T, db *gorm.DB, merchantCode, username string, ownerStatus int) (uint, uint) {
t.Helper()
merchant := model.Merchant{Code: merchantCode, Name: merchantCode, Status: model.MerchantStatusActive}
if err := db.Create(&merchant).Error; err != nil {
t.Fatalf("create merchant: %v", err)
}
owner := model.User{Username: username, PasswordHash: "old-hash", Nickname: "负责人", Role: model.RoleMerchant, Status: ownerStatus}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("create owner: %v", err)
}
if err := db.Create(&model.MerchantMember{
MerchantID: merchant.ID, UserID: owner.ID, Role: model.MemberRoleOwner, Status: 1, IsDefault: true,
}).Error; err != nil {
t.Fatalf("create owner member: %v", err)
}
return merchant.ID, owner.ID
}
func TestUpdateMerchantOwnerAccountResetsPasswordAndUsername(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantID, ownerID := seedMerchantWithOwner(t, db, "owner-account-a", "owner-a", 1)
updated, err := svc.UpdateMerchantOwnerAccount(merchantID, UpdateMerchantOwnerAccountInput{
UserID: ownerID,
Username: "owner-a-new",
Nickname: "新负责人",
Password: "newpass123",
Status: intPtr(1),
}, 9)
if err != nil {
t.Fatalf("update owner account: %v", err)
}
if updated.Username != "owner-a-new" || updated.Nickname != "新负责人" || updated.Status != 1 {
t.Fatalf("unexpected updated account: %+v", updated)
}
var user model.User
if err := db.First(&user, ownerID).Error; err != nil {
t.Fatalf("reload user: %v", err)
}
if user.Username != "owner-a-new" || user.Nickname != "新负责人" {
t.Fatalf("user fields not persisted: %+v", user)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("newpass123")); err != nil {
t.Fatalf("password should be reset: %v", err)
}
var audit model.AuditLog
if err := db.Where("action = ? AND entity_id = ?", "merchant.account.update", fmt.Sprint(ownerID)).First(&audit).Error; err != nil {
t.Fatalf("account update audit missing: %v", err)
}
if audit.ActorUserID == nil || *audit.ActorUserID != 9 {
t.Fatalf("audit should record actor user 9, got %v", audit.ActorUserID)
}
accounts, err := svc.ListOwnerAccounts(merchantID)
if err != nil {
t.Fatalf("list owner accounts: %v", err)
}
if len(accounts) != 1 || accounts[0].Username != "owner-a-new" || !accounts[0].IsDefault {
t.Fatalf("unexpected owner accounts: %+v", accounts)
}
}
func TestUpdateMerchantOwnerAccountGuards(t *testing.T) {
db := newServiceTestDB(t)
svc := NewMerchantService(db, nil, NewTenantService(db))
merchantA, ownerA := seedMerchantWithOwner(t, db, "owner-acc-a", "owner-a1", 1)
merchantB, ownerB := seedMerchantWithOwner(t, db, "owner-acc-b", "owner-b1", 1)
// 用户名全局唯一:改成其他商户已占用的用户名必须被拒绝。
if _, err := svc.UpdateMerchantOwnerAccount(merchantA, UpdateMerchantOwnerAccountInput{
UserID: ownerA, Username: "owner-b1",
}, 9); err == nil || !strings.Contains(err.Error(), "用户名已存在") {
t.Fatalf("expected username conflict, got %v", err)
}
// 密码过短必须被拒绝。
if _, err := svc.UpdateMerchantOwnerAccount(merchantA, UpdateMerchantOwnerAccountInput{
UserID: ownerA, Username: "owner-a1", Password: "123",
}, 9); err == nil || !strings.Contains(err.Error(), "至少 6 位") {
t.Fatalf("expected short password rejection, got %v", err)
}
// 非负责人账号不能被当作负责人修改。
staff := model.User{Username: "owner-staff", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&staff).Error; err != nil {
t.Fatalf("create staff: %v", err)
}
if err := db.Create(&model.MerchantMember{
MerchantID: merchantA, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1, IsDefault: false,
}).Error; err != nil {
t.Fatalf("create staff member: %v", err)
}
if _, err := svc.UpdateMerchantOwnerAccount(merchantA, UpdateMerchantOwnerAccountInput{
UserID: staff.ID, Username: "owner-staff",
}, 9); err == nil || !strings.Contains(err.Error(), "不是该商户的负责人") {
t.Fatalf("expected non-owner rejection, got %v", err)
}
// 停用最后一个启用的负责人必须被拒绝(防锁死)。
if _, err := svc.UpdateMerchantOwnerAccount(merchantA, UpdateMerchantOwnerAccountInput{
UserID: ownerA, Username: "owner-a1", Status: intPtr(0),
}, 9); err == nil || !strings.Contains(err.Error(), "至少保留一个启用的商户负责人") {
t.Fatalf("expected last-owner protection, got %v", err)
}
// 商户 B 有第二个启用负责人时,可以停用其中一个。
second := model.User{Username: "owner-b2", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("create second owner: %v", err)
}
if err := db.Create(&model.MerchantMember{
MerchantID: merchantB, UserID: second.ID, Role: model.MemberRoleOwner, Status: 1, IsDefault: false,
}).Error; err != nil {
t.Fatalf("create second owner member: %v", err)
}
if _, err := svc.UpdateMerchantOwnerAccount(merchantB, UpdateMerchantOwnerAccountInput{
UserID: ownerB, Username: "owner-b1", Status: intPtr(0),
}, 9); err != nil {
t.Fatalf("should allow disabling one of two owners: %v", err)
}
// 再停用剩下的启用负责人则被拒绝。
if _, err := svc.UpdateMerchantOwnerAccount(merchantB, UpdateMerchantOwnerAccountInput{
UserID: second.ID, Username: "owner-b2", Status: intPtr(0),
}, 9); err == nil || !strings.Contains(err.Error(), "至少保留一个启用的商户负责人") {
t.Fatalf("expected last-owner protection after disabling first, got %v", err)
}
}
func TestCreateAPIClientEnforcesPerMerchantLimit(t *testing.T) {
db := newServiceTestDB(t)
codec, err := NewSecretCodec("test-master-key")