From 99a33675b0b990ccda25bbb2d3c0c7b63f752349 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 14 Aug 2026 13:09:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=95=86=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86,=20=E5=A2=9E=E5=8A=A0=E5=95=86=E6=88=B7=E5=90=8D?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=AF=86=E7=A0=81=E7=AD=89=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/merchant.go | 50 +++++++ backend/internal/router/router.go | 2 + backend/internal/service/merchant_member.go | 151 ++++++++++++++++++++ backend/internal/service/merchant_test.go | 135 +++++++++++++++++ frontend/src/api/index.ts | 5 + frontend/src/pages/PlatformMerchants.tsx | 138 +++++++++++++++++- frontend/src/types/index.ts | 9 ++ 7 files changed, 488 insertions(+), 2 deletions(-) diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index faa8bff..452af2b 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -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")) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 0dff39c..2c155da 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/internal/service/merchant_member.go b/backend/internal/service/merchant_member.go index a164d49..ac2eed0 100644 --- a/backend/internal/service/merchant_member.go +++ b/backend/internal/service/merchant_member.go @@ -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 +} diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go index 139aa1d..d2822b2 100644 --- a/backend/internal/service/merchant_test.go +++ b/backend/internal/service/merchant_test.go @@ -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") diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 68869ce..bbf3874 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -19,6 +19,7 @@ import type { Merchant, MerchantMember, MerchantMemberGroup, + MerchantOwnerAccount, MerchantRole, MerchantProduct, 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 }), wallet: (merchantId: number) => 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) => request.get(`/platform/merchants/${merchantId}/wallet/ledger`, { params }).then((r) => r.data.data as PageResult), adjustWallet: (merchantId: number, data: { amount: number; idempotency_key: string; note?: string }) => diff --git a/frontend/src/pages/PlatformMerchants.tsx b/frontend/src/pages/PlatformMerchants.tsx index 7dc96a4..4488a54 100644 --- a/frontend/src/pages/PlatformMerchants.tsx +++ b/frontend/src/pages/PlatformMerchants.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom' import { Button, Checkbox, + Divider, Form, Input, InputNumber, @@ -10,15 +11,16 @@ import { Select, Space, Spin, + Switch, Table, Tag, Typography, message, } 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 { 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 { PageHeader } from '../components/PageHeader' @@ -50,10 +52,16 @@ export default function PlatformMerchants() { const [editProduct, setEditProduct] = useState(null) const [editProductOpen, setEditProductOpen] = useState(false) const [editProductSubmitting, setEditProductSubmitting] = useState(false) + const [accounts, setAccounts] = useState([]) + const [accountsLoading, setAccountsLoading] = useState(false) + const [accountModalOpen, setAccountModalOpen] = useState(false) + const [editingAccount, setEditingAccount] = useState(null) + const [accountSubmitting, setAccountSubmitting] = useState(false) const [createForm] = Form.useForm() const [settingsForm] = Form.useForm() const [adjustForm] = Form.useForm() const [editProductForm] = Form.useForm() + const [accountForm] = Form.useForm() const load = useCallback(async (page = data.page, size = data.size) => { 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) => { setSelectedMerchant(record) setAdjustOpen(true) @@ -308,6 +362,7 @@ export default function PlatformMerchants() { fee_fixed_amount: record.fee_fixed_amount, }) setSettingsOpen(true) + void loadAccounts(record.id) }} > 设置 @@ -467,6 +522,85 @@ export default function PlatformMerchants() { } + + + + + 商户账号 + + + + {accounts.length === 0 ? ( + 该商户暂无负责人登录账号 + ) : ( + {v} }, + { title: '昵称', dataIndex: 'nickname', render: (v) => v || '-' }, + { + title: '账号状态', + dataIndex: 'status', + width: 90, + render: (v) => (v === 1 ? 启用 : 停用), + }, + { + title: '操作', + key: 'action', + width: 70, + render: (_, record: MerchantOwnerAccount) => ( + + ), + }, + ]} + /> + )} + + + 商户账号是负责人登录商户后台的凭证:可修改登录用户名与昵称、重置密码、启用或停用账号(至少保留一个启用的负责人账号)。停用账号仅影响登录,不等于停用商户;重置密码后旧密码立即失效。 + + + + setAccountModalOpen(false)} + destroyOnClose + width={520} + confirmLoading={accountSubmitting} + okText="保存" + > +
+ + + + + + + + + + + + +