优化商户管理, 增加商户名重置密码等等
This commit is contained in:
@@ -741,6 +741,56 @@ func (h *MerchantHandler) AddPlatformMerchantMember(c *gin.Context) {
|
|||||||
response.OK(c, member)
|
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) {
|
func pageParams(c *gin.Context) (int, int) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
admin.PATCH("/platform/merchants/:id/products/:pid", h.Merchant.AdminUpdateMerchantProduct)
|
admin.PATCH("/platform/merchants/:id/products/:pid", h.Merchant.AdminUpdateMerchantProduct)
|
||||||
admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts)
|
admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts)
|
||||||
admin.GET("/platform/merchants/:id/wallet", h.Merchant.AdminGetWallet)
|
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.GET("/platform/merchants/:id/wallet/ledger", h.Merchant.AdminListWalletLedger)
|
||||||
admin.POST("/platform/merchants/:id/wallet/adjust", h.Merchant.AdminAdjustWallet)
|
admin.POST("/platform/merchants/:id/wallet/adjust", h.Merchant.AdminAdjustWallet)
|
||||||
admin.GET("/platform/recharge/applications", h.Recharge.AdminListApplications)
|
admin.GET("/platform/recharge/applications", h.Recharge.AdminListApplications)
|
||||||
|
|||||||
@@ -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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,143 @@ import (
|
|||||||
"affiliate_dash/internal/model"
|
"affiliate_dash/internal/model"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"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) {
|
func TestCreateAPIClientEnforcesPerMerchantLimit(t *testing.T) {
|
||||||
db := newServiceTestDB(t)
|
db := newServiceTestDB(t)
|
||||||
codec, err := NewSecretCodec("test-master-key")
|
codec, err := NewSecretCodec("test-master-key")
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
Merchant,
|
Merchant,
|
||||||
MerchantMember,
|
MerchantMember,
|
||||||
MerchantMemberGroup,
|
MerchantMemberGroup,
|
||||||
|
MerchantOwnerAccount,
|
||||||
MerchantRole,
|
MerchantRole,
|
||||||
MerchantProduct,
|
MerchantProduct,
|
||||||
PageResult,
|
PageResult,
|
||||||
@@ -217,6 +218,10 @@ export const platformApi = {
|
|||||||
request.post(`/platform/merchants/${merchantId}/products/assign`, { catalog_ids: catalogIds }).then((r) => r.data.data as { assigned: number }),
|
request.post(`/platform/merchants/${merchantId}/products/assign`, { catalog_ids: catalogIds }).then((r) => r.data.data as { assigned: number }),
|
||||||
wallet: (merchantId: number) =>
|
wallet: (merchantId: number) =>
|
||||||
request.get(`/platform/merchants/${merchantId}/wallet`).then((r) => r.data.data as WalletAccount),
|
request.get(`/platform/merchants/${merchantId}/wallet`).then((r) => r.data.data as WalletAccount),
|
||||||
|
merchantAccount: (merchantId: number) =>
|
||||||
|
request.get(`/platform/merchants/${merchantId}/account`).then((r) => r.data.data as MerchantOwnerAccount[]),
|
||||||
|
updateMerchantAccount: (merchantId: number, data: { user_id: number; username: string; nickname?: string; password?: string; status?: number }) =>
|
||||||
|
request.patch(`/platform/merchants/${merchantId}/account`, data).then((r) => r.data.data as MerchantOwnerAccount),
|
||||||
walletLedger: (merchantId: number, params?: Record<string, unknown>) =>
|
walletLedger: (merchantId: number, params?: Record<string, unknown>) =>
|
||||||
request.get(`/platform/merchants/${merchantId}/wallet/ledger`, { params }).then((r) => r.data.data as PageResult<WalletLedgerEntry>),
|
request.get(`/platform/merchants/${merchantId}/wallet/ledger`, { params }).then((r) => r.data.data as PageResult<WalletLedgerEntry>),
|
||||||
adjustWallet: (merchantId: number, data: { amount: number; idempotency_key: string; note?: string }) =>
|
adjustWallet: (merchantId: number, data: { amount: number; idempotency_key: string; note?: string }) =>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Divider,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
@@ -10,15 +11,16 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { GiftOutlined, PlusOutlined, ReloadOutlined, WalletOutlined } from '@ant-design/icons'
|
import { GiftOutlined, PlusOutlined, ReloadOutlined, UserOutlined, WalletOutlined } from '@ant-design/icons'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import { platformApi } from '../api'
|
import { platformApi } from '../api'
|
||||||
import type { Merchant, PageResult, ProductCatalogItem, WalletAccount } from '../types'
|
import type { Merchant, MerchantOwnerAccount, PageResult, ProductCatalogItem, WalletAccount } from '../types'
|
||||||
import { formatDateTime } from '../utils/time'
|
import { formatDateTime } from '../utils/time'
|
||||||
import { PageHeader } from '../components/PageHeader'
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
|
||||||
@@ -50,10 +52,16 @@ export default function PlatformMerchants() {
|
|||||||
const [editProduct, setEditProduct] = useState<ProductCatalogItem | null>(null)
|
const [editProduct, setEditProduct] = useState<ProductCatalogItem | null>(null)
|
||||||
const [editProductOpen, setEditProductOpen] = useState(false)
|
const [editProductOpen, setEditProductOpen] = useState(false)
|
||||||
const [editProductSubmitting, setEditProductSubmitting] = useState(false)
|
const [editProductSubmitting, setEditProductSubmitting] = useState(false)
|
||||||
|
const [accounts, setAccounts] = useState<MerchantOwnerAccount[]>([])
|
||||||
|
const [accountsLoading, setAccountsLoading] = useState(false)
|
||||||
|
const [accountModalOpen, setAccountModalOpen] = useState(false)
|
||||||
|
const [editingAccount, setEditingAccount] = useState<MerchantOwnerAccount | null>(null)
|
||||||
|
const [accountSubmitting, setAccountSubmitting] = useState(false)
|
||||||
const [createForm] = Form.useForm()
|
const [createForm] = Form.useForm()
|
||||||
const [settingsForm] = Form.useForm()
|
const [settingsForm] = Form.useForm()
|
||||||
const [adjustForm] = Form.useForm()
|
const [adjustForm] = Form.useForm()
|
||||||
const [editProductForm] = Form.useForm()
|
const [editProductForm] = Form.useForm()
|
||||||
|
const [accountForm] = Form.useForm()
|
||||||
|
|
||||||
const load = useCallback(async (page = data.page, size = data.size) => {
|
const load = useCallback(async (page = data.page, size = data.size) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -112,6 +120,52 @@ export default function PlatformMerchants() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadAccounts = useCallback(async (merchantId: number) => {
|
||||||
|
setAccountsLoading(true)
|
||||||
|
try {
|
||||||
|
setAccounts(await platformApi.merchantAccount(merchantId))
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载商户账号失败')
|
||||||
|
setAccounts([])
|
||||||
|
} finally {
|
||||||
|
setAccountsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openAccountEdit = (account: MerchantOwnerAccount) => {
|
||||||
|
setEditingAccount(account)
|
||||||
|
accountForm.resetFields()
|
||||||
|
accountForm.setFieldsValue({
|
||||||
|
username: account.username,
|
||||||
|
nickname: account.nickname,
|
||||||
|
password: '',
|
||||||
|
status: account.status === 1,
|
||||||
|
})
|
||||||
|
setAccountModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitAccount = async () => {
|
||||||
|
if (!selectedMerchant || !editingAccount) return
|
||||||
|
const values = await accountForm.validateFields()
|
||||||
|
setAccountSubmitting(true)
|
||||||
|
try {
|
||||||
|
await platformApi.updateMerchantAccount(selectedMerchant.id, {
|
||||||
|
user_id: editingAccount.user_id,
|
||||||
|
username: values.username,
|
||||||
|
nickname: values.nickname || '',
|
||||||
|
password: values.password || undefined,
|
||||||
|
status: values.status ? 1 : 0,
|
||||||
|
})
|
||||||
|
message.success(values.password ? '商户账号已更新,密码已重置' : '商户账号已更新')
|
||||||
|
setAccountModalOpen(false)
|
||||||
|
void loadAccounts(selectedMerchant.id)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败')
|
||||||
|
} finally {
|
||||||
|
setAccountSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openAdjust = async (record: Merchant) => {
|
const openAdjust = async (record: Merchant) => {
|
||||||
setSelectedMerchant(record)
|
setSelectedMerchant(record)
|
||||||
setAdjustOpen(true)
|
setAdjustOpen(true)
|
||||||
@@ -308,6 +362,7 @@ export default function PlatformMerchants() {
|
|||||||
fee_fixed_amount: record.fee_fixed_amount,
|
fee_fixed_amount: record.fee_fixed_amount,
|
||||||
})
|
})
|
||||||
setSettingsOpen(true)
|
setSettingsOpen(true)
|
||||||
|
void loadAccounts(record.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
设置
|
设置
|
||||||
@@ -467,6 +522,85 @@ export default function PlatformMerchants() {
|
|||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
<Divider plain style={{ margin: '8px 0 16px' }}>
|
||||||
|
<Space size={6}>
|
||||||
|
<UserOutlined style={{ color: '#64748b' }} />
|
||||||
|
商户账号
|
||||||
|
</Space>
|
||||||
|
</Divider>
|
||||||
|
<Spin spinning={accountsLoading}>
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<Typography.Text type="secondary">该商户暂无负责人登录账号</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
rowKey="user_id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={accounts}
|
||||||
|
columns={[
|
||||||
|
{ title: '登录用户名', dataIndex: 'username', render: (v) => <Typography.Text strong>{v}</Typography.Text> },
|
||||||
|
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '-' },
|
||||||
|
{
|
||||||
|
title: '账号状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag>停用</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 70,
|
||||||
|
render: (_, record: MerchantOwnerAccount) => (
|
||||||
|
<Button type="link" size="small" onClick={() => openAccountEdit(record)}>编辑</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12.5 }}>
|
||||||
|
商户账号是负责人登录商户后台的凭证:可修改登录用户名与昵称、重置密码、启用或停用账号(至少保留一个启用的负责人账号)。停用账号仅影响登录,不等于停用商户;重置密码后旧密码立即失效。
|
||||||
|
</Typography.Text>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingAccount ? `编辑商户账号:${editingAccount.username}` : '编辑商户账号'}
|
||||||
|
open={accountModalOpen}
|
||||||
|
onOk={submitAccount}
|
||||||
|
onCancel={() => setAccountModalOpen(false)}
|
||||||
|
destroyOnClose
|
||||||
|
width={520}
|
||||||
|
confirmLoading={accountSubmitting}
|
||||||
|
okText="保存"
|
||||||
|
>
|
||||||
|
<Form form={accountForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
label="登录用户名"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入用户名' },
|
||||||
|
{ min: 3, message: '用户名至少 3 位' },
|
||||||
|
{ max: 64, message: '用户名不能超过 64 个字符' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input autoComplete="off" placeholder="修改后负责人需使用新用户名登录" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="nickname" label="昵称" rules={[{ max: 64, message: '昵称不能超过 64 个字符' }]}>
|
||||||
|
<Input autoComplete="off" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label="重置密码"
|
||||||
|
rules={[{ min: 6, message: '密码至少 6 位' }]}
|
||||||
|
extra="留空表示不修改密码"
|
||||||
|
>
|
||||||
|
<Input.Password autoComplete="new-password" placeholder="留空不修改" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="账号状态" valuePropName="checked" extra="停用后该负责人将无法登录商户后台">
|
||||||
|
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export interface MerchantMemberGroup {
|
|||||||
members: MerchantMember[]
|
members: MerchantMember[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MerchantOwnerAccount {
|
||||||
|
user_id: number
|
||||||
|
member_id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
status: number
|
||||||
|
is_default: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface MerchantRole {
|
export interface MerchantRole {
|
||||||
id: number
|
id: number
|
||||||
merchant_id: number
|
merchant_id: number
|
||||||
|
|||||||
Reference in New Issue
Block a user