feat: 新增平台客服角色与客服查询菜单

This commit is contained in:
2026-11-09 18:18:19 +08:00
parent 38cbb37a08
commit 08884270b7
23 changed files with 1075 additions and 106 deletions
+33
View File
@@ -325,6 +325,39 @@ func (h *RockHandler) ListDeletedTokens(c *gin.Context) {
})
}
// ListSupportBoundTokens 平台客服只读查询绑定中的 Token;search 可选,为空时返回全部。
func (h *RockHandler) ListSupportBoundTokens(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
search := strings.TrimSpace(c.Query("search"))
tokens, total, err := h.rockSvc.ListSupportBoundTokens(c.Request.Context(), page, pageSize, search)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{
"list": tokens,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// ListSupportTokenStatusLogs 平台客服只读查询绑定中 Token 的状态历史。
func (h *RockHandler) ListSupportTokenStatusLogs(c *gin.Context) {
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "无效的 Token ID")
return
}
logs, err := h.rockSvc.ListSupportTokenStatusLogs(c.Request.Context(), uint(idUint))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"list": logs})
}
type restoreTokensReq struct {
IDs []uint `json:"ids" binding:"required"`
}
+4 -1
View File
@@ -44,6 +44,7 @@ type createPlatformAdminReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
Nickname string `json:"nickname"`
Role string `json:"role"`
}
func (h *UserHandler) CreatePlatformAdmin(c *gin.Context) {
@@ -52,7 +53,7 @@ func (h *UserHandler) CreatePlatformAdmin(c *gin.Context) {
response.BadRequest(c, "参数错误")
return
}
user, err := h.svc.CreatePlatformAdmin(req.Username, req.Password, req.Nickname)
user, err := h.svc.CreatePlatformUser(req.Username, req.Password, req.Nickname, req.Role)
if err != nil {
response.BadRequest(c, err.Error())
return
@@ -65,6 +66,7 @@ type updatePlatformAdminReq struct {
Nickname string `json:"nickname"`
Password string `json:"password" binding:"omitempty,min=6"`
Status int `json:"status"`
Role string `json:"role"`
}
func (h *UserHandler) UpdatePlatformAdmin(c *gin.Context) {
@@ -79,6 +81,7 @@ func (h *UserHandler) UpdatePlatformAdmin(c *gin.Context) {
Nickname: req.Nickname,
Password: req.Password,
Status: req.Status,
Role: req.Role,
}, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
+15
View File
@@ -94,6 +94,21 @@ func Tenant(tenantSvc *service.TenantService) gin.HandlerFunc {
c.Next()
return
}
if GetRole(c) == model.RolePlatformSupport {
// 平台客服只借用自营商户的订单上下文:可完整操作发货订单,
// 但不写入 merchant_members,也不会获得商品、钱包、成员等权限。
merchant, err := tenantSvc.ResolveMerchantForAdmin(model.MerchantCodeSelfOperated)
if err != nil {
response.Forbidden(c, err.Error())
c.Abort()
return
}
c.Set(CtxMerchantID, merchant.ID)
c.Set(CtxMerchantRole, model.MemberRoleSupport)
c.Set(CtxMerchantPermissions, map[string]struct{}{model.PermissionOrdersManage: {}})
c.Next()
return
}
member, err := tenantSvc.ResolveMember(GetUserID(c), merchantRef)
if err != nil {
response.Forbidden(c, err.Error())
+1
View File
@@ -24,6 +24,7 @@ const (
MemberRoleOperator = "operator"
MemberRoleFinance = "finance"
MemberRoleViewer = "viewer"
MemberRoleSupport = "support"
PermissionMembersManage = "members:manage"
PermissionProductsManage = "products:manage"
+3 -2
View File
@@ -8,8 +8,9 @@ import (
// 用户角色
const (
RoleAdmin = "admin" // 平台管理员
RoleMerchant = "merchant" // 商户账号
RoleAdmin = "admin" // 平台管理员
RoleMerchant = "merchant" // 商户账号
RolePlatformSupport = "platform_support" // 平台客服
)
// User 系统用户
+13 -1
View File
@@ -302,7 +302,6 @@ func Setup(h *Handlers) *gin.Engine {
platform.GET("/platform/rock/tokens/:id/status-logs", h.Rock.ListTokenStatusLogs)
platform.POST("/platform/rock/tokens/:id/refresh-binding", h.Rock.RefreshBinding)
platform.POST("/platform/rock/token-batches/refresh-stale", h.Rock.RefreshStaleTokens)
platform.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink)
platform.GET("/platform/rock/token-batches/preview", h.Rock.PreviewTokenDelete)
platform.POST("/platform/rock/token-batches/batch-delete", h.Rock.BatchDeleteTokens)
platform.POST("/platform/rock/token-batches/delete-all", h.Rock.DeleteAllTokens)
@@ -313,6 +312,19 @@ func Setup(h *Handlers) *gin.Engine {
}
}
// 平台客服查询:仅平台管理员与平台客服可用,跨商户只读查询绑定中 Token。
// package-link 是只读的上游定位链接,允许平台客服复用。
if h.Rock != nil {
supportQuery := api.Group("")
supportQuery.Use(middleware.Auth(h.JWT))
supportQuery.Use(middleware.RequireRole(model.RoleAdmin, model.RolePlatformSupport))
{
supportQuery.GET("/platform/rock/support/bound-tokens", h.Rock.ListSupportBoundTokens)
supportQuery.GET("/platform/rock/support/tokens/:id/status-logs", h.Rock.ListSupportTokenStatusLogs)
supportQuery.GET("/platform/rock/tokens/:id/package-link", h.Rock.TokenPackageLink)
}
}
auth := api.Group("")
auth.Use(middleware.Auth(h.JWT))
auth.Use(middleware.Tenant(h.Tenant))
+45
View File
@@ -36,6 +36,7 @@ func newTestRouter(t *testing.T) (*gin.Engine, *jwt.Manager) {
merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
rechargeSvc := service.NewRechargeService(db, fulfillmentSvc, "")
deliverySvc := service.NewDeliveryService(fulfillmentSvc, "", "", "", "", 0)
rockSvc := service.NewRockService(db, service.NewRockClient("https://example.com"))
engine := Setup(&Handlers{
Auth: handler.NewAuthHandler(authSvc),
@@ -45,6 +46,7 @@ func newTestRouter(t *testing.T) (*gin.Engine, *jwt.Manager) {
SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc, rechargeSvc),
Recharge: handler.NewRechargeHandler(rechargeSvc),
Rock: handler.NewRockHandler(rockSvc),
JWT: jm,
Tenant: tenantSvc,
OpenDB: db,
@@ -240,3 +242,46 @@ func TestPlatformMerchantMembersRoutes(t *testing.T) {
t.Fatalf("平台管理员查询商户角色应返回 200,实际 %d: %s", w.Code, w.Body.String())
}
}
// TestPlatformSupportContext 验证平台客服只获得自营商户订单上下文与客服查询权限。
func TestPlatformSupportContext(t *testing.T) {
engine, jm := newTestRouter(t)
token, err := jm.Generate(99, "platform-support", model.RolePlatformSupport)
if err != nil {
t.Fatalf("generate token: %v", err)
}
cases := []struct {
name string
path string
want int
}{
{name: "商户上下文", path: "/api/merchant", want: 200},
{name: "发货订单", path: "/api/merchant/orders", want: 200},
{name: "商品列表应被拒绝", path: "/api/merchant/products", want: 403},
{name: "钱包应被拒绝", path: "/api/merchant/wallet", want: 403},
{name: "平台管理接口应被拒绝", path: "/api/platform/merchants", want: 403},
{name: "客服查询空条件应被拦截", path: "/api/platform/rock/support/bound-tokens", want: 400},
{name: "客服查询", path: "/api/platform/rock/support/bound-tokens?search=SUP", want: 200},
{name: "上游包链接路由", path: "/api/platform/rock/tokens/1/package-link", want: 400},
{name: "商户客服旧接口应下线", path: "/api/merchant/support/rock/bound-tokens", want: 404},
}
for _, tc := range cases {
req := httptest.NewRequest("GET", tc.path, nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != tc.want {
t.Fatalf("%s: 期望 %d,实际 %d: %s", tc.name, tc.want, w.Code, w.Body.String())
}
}
req := httptest.NewRequest("GET", "/api/merchant", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
body := w.Body.String()
if !strings.Contains(body, `"role":"support"`) || !strings.Contains(body, `"orders:manage"`) {
t.Fatalf("平台客服应获得自营商户客服上下文与 orders:manage,实际: %s", body)
}
}
@@ -0,0 +1,108 @@
package service
import (
"testing"
"affiliate_dash/internal/model"
)
func TestPlatformSupportAccountRoleSwitch(t *testing.T) {
db := newServiceTestDB(t)
svc := NewUserService(db, NewTenantService(db))
actor, err := svc.CreatePlatformAdmin("platform-role-actor", "password123", "管理员")
if err != nil {
t.Fatalf("创建管理员失败: %v", err)
}
support, err := svc.CreatePlatformUser("platform-role-support", "password123", "平台客服", model.RolePlatformSupport)
if err != nil {
t.Fatalf("创建平台客服失败: %v", err)
}
if support.Role != model.RolePlatformSupport {
t.Fatalf("平台客服角色不符: %+v", support)
}
var membershipCount int64
if err := db.Model(&model.MerchantMember{}).Where("user_id = ?", support.ID).Count(&membershipCount).Error; err != nil {
t.Fatalf("统计成员关系失败: %v", err)
}
if membershipCount != 0 {
t.Fatalf("平台客服不应写入商户成员关系,实际 %d 条", membershipCount)
}
list, total, err := svc.ListPlatformAdmins(1, 100)
if err != nil {
t.Fatalf("查询平台账号失败: %v", err)
}
if total < 2 {
t.Fatalf("平台账号列表应包含管理员与平台客服,实际 total=%d", total)
}
foundSupport := false
for _, item := range list {
if item.ID == support.ID && item.Role == model.RolePlatformSupport {
foundSupport = true
}
}
if !foundSupport {
t.Fatalf("平台账号列表未包含平台客服: %+v", list)
}
// 平台客服 -> 平台管理员:补自营商户负责人上下文。
updated, err := svc.UpdatePlatformAdmin(support.ID, UpdatePlatformAdminInput{
Username: support.Username,
Nickname: support.Nickname,
Status: 1,
Role: model.RoleAdmin,
}, actor.ID)
if err != nil {
t.Fatalf("提升为管理员失败: %v", err)
}
if updated.Role != model.RoleAdmin {
t.Fatalf("角色未更新为管理员: %+v", updated)
}
var membership model.MerchantMember
if err := db.Where("user_id = ?", support.ID).First(&membership).Error; err != nil {
t.Fatalf("管理员缺少自营商户成员关系: %v", err)
}
if membership.Role != model.MemberRoleOwner || !membership.IsDefault {
t.Fatalf("管理员成员关系不符: %+v", membership)
}
// 平台管理员 -> 平台客服:移除商户成员关系,回到 Tenant 合成上下文。
updated, err = svc.UpdatePlatformAdmin(support.ID, UpdatePlatformAdminInput{
Username: support.Username,
Nickname: support.Nickname,
Status: 1,
Role: model.RolePlatformSupport,
}, actor.ID)
if err != nil {
t.Fatalf("降级为平台客服失败: %v", err)
}
if updated.Role != model.RolePlatformSupport {
t.Fatalf("角色未更新为平台客服: %+v", updated)
}
if err := db.Model(&model.MerchantMember{}).Where("user_id = ?", support.ID).Count(&membershipCount).Error; err != nil {
t.Fatalf("统计成员关系失败: %v", err)
}
if membershipCount != 0 {
t.Fatalf("降级后应移除商户成员关系,实际 %d 条", membershipCount)
}
}
func TestCannotDemoteLastPlatformAdmin(t *testing.T) {
db := newServiceTestDB(t)
svc := NewUserService(db, nil)
admin, err := svc.CreatePlatformAdmin("only-role-admin", "password123", "唯一管理员")
if err != nil {
t.Fatalf("创建管理员失败: %v", err)
}
if _, err := svc.UpdatePlatformAdmin(admin.ID, UpdatePlatformAdminInput{
Username: admin.Username,
Nickname: admin.Nickname,
Status: 1,
Role: model.RolePlatformSupport,
}, 0); err == nil {
t.Fatal("最后一个启用的平台管理员不能被降级")
}
}
+37 -31
View File
@@ -523,6 +523,42 @@ func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) {
log.Printf("[RockService] 自动补货成功,实际入库 %d 个 Token", len(tokens))
}
// prepareRockTokenList 填充列表展示字段:提取码与关联订单的商户单号。
func (s *RockService) prepareRockTokenList(ctx context.Context, tokens []model.RockToken) {
for i := range tokens {
tokens[i].TokenCode = tokens[i].Token
}
// 批量反查关联订单的商户单号(创建订单时的 ClientOrderNo)
orderNos := make([]string, 0, len(tokens))
for _, t := range tokens {
if t.AssignedOrderNo != "" {
orderNos = append(orderNos, t.AssignedOrderNo)
}
}
if len(orderNos) == 0 {
return
}
var orders []struct {
OrderNo string `gorm:"column:order_no"`
ClientOrderNo string `gorm:"column:client_order_no"`
}
if scanErr := s.db.WithContext(ctx).Table("fulfillment_orders").
Select("order_no, client_order_no").
Where("order_no IN ?", orderNos).
Scan(&orders).Error; scanErr == nil {
clientMap := make(map[string]string, len(orders))
for _, o := range orders {
clientMap[o.OrderNo] = o.ClientOrderNo
}
for i := range tokens {
if cNo, ok := clientMap[tokens[i].AssignedOrderNo]; ok && cNo != "" {
tokens[i].ClientOrderNo = cNo
}
}
}
}
// ListTokens 分页查询 Token 列表
func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status, search string) ([]model.RockToken, int64, error) {
if page <= 0 {
@@ -553,37 +589,7 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
if err != nil {
return nil, 0, err
}
for i := range tokens {
tokens[i].TokenCode = tokens[i].Token
}
// 批量反查关联订单的商户单号(创建订单时的 ClientOrderNo)
orderNos := make([]string, 0, len(tokens))
for _, t := range tokens {
if t.AssignedOrderNo != "" {
orderNos = append(orderNos, t.AssignedOrderNo)
}
}
if len(orderNos) > 0 {
var orders []struct {
OrderNo string `gorm:"column:order_no"`
ClientOrderNo string `gorm:"column:client_order_no"`
}
if scanErr := s.db.WithContext(ctx).Table("fulfillment_orders").
Select("order_no, client_order_no").
Where("order_no IN ?", orderNos).
Scan(&orders).Error; scanErr == nil {
clientMap := make(map[string]string, len(orders))
for _, o := range orders {
clientMap[o.OrderNo] = o.ClientOrderNo
}
for i := range tokens {
if cNo, ok := clientMap[tokens[i].AssignedOrderNo]; ok && cNo != "" {
tokens[i].ClientOrderNo = cNo
}
}
}
}
s.prepareRockTokenList(ctx, tokens)
return tokens, total, nil
}
+64
View File
@@ -0,0 +1,64 @@
package service
import (
"context"
"errors"
"strings"
"affiliate_dash/internal/model"
)
const rockSupportSearchRequired = "请输入包ID、上游编号、提取码、兑换码、工单号、商户单号或角色 ID 后再查询"
// ListSupportBoundTokens 平台客服只读查询绑定中的 Token。
// 固定只返回 status=bound;search 必填,避免客服页面在未输入条件时拉取全量数据。
func (s *RockService) ListSupportBoundTokens(ctx context.Context, page, pageSize int, search string) ([]model.RockToken, int64, error) {
search = strings.TrimSpace(search)
if search == "" {
return nil, 0, errors.New(rockSupportSearchRequired)
}
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
db := s.db.WithContext(ctx).Model(&model.RockToken{}).Where("status = ?", model.RockStatusBound)
term := "%" + search + "%"
db = db.Where(
"upstream_token_id = ? OR upstream_token_id LIKE ? OR redeem_code LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR remark LIKE ? OR CAST(package_id AS TEXT) LIKE ? OR token LIKE ? OR CAST(id AS TEXT) = ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)",
search, term, term, term, term, term, term, term, term, term, search, term,
)
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var tokens []model.RockToken
if err := db.Order("id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&tokens).Error; err != nil {
return nil, 0, err
}
s.prepareRockTokenList(ctx, tokens)
return tokens, total, nil
}
// ListSupportTokenStatusLogs 平台客服只读查询状态历史;只允许查看绑定中的 Token。
func (s *RockService) ListSupportTokenStatusLogs(ctx context.Context, tokenID uint) ([]model.RockTokenStatusLog, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
Where("id = ? AND status = ?", tokenID, model.RockStatusBound).
Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, errors.New("Token 不存在或无权查看")
}
return s.ListTokenStatusLogs(ctx, tokenID)
}
@@ -0,0 +1,187 @@
package service
import (
"context"
"fmt"
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
)
func createSupportTestOrder(t *testing.T, svc *RockService, merchantID uint, orderNo, clientOrderNo string) {
t.Helper()
now := time.Now()
order := model.FulfillmentOrder{
MerchantID: merchantID,
OrderNo: orderNo,
ClientOrderNo: clientOrderNo,
ProductSKU: model.RockSKUBlackTshirt,
ProductName: "黑色ROCK长袖T恤",
OrderStatus: model.OrderStatusPaid,
OrderSource: "manual",
Currency: "POINT",
CreatedAt: now,
UpdatedAt: now,
}
if err := svc.db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
}
func createSupportTestToken(t *testing.T, svc *RockService, upstreamID, redeemCode, orderNo, status, roleName, roleID string) model.RockToken {
t.Helper()
now := time.Now()
token := model.RockToken{
UpstreamTokenID: upstreamID,
Token: "jwt-" + upstreamID,
RedeemCode: redeemCode,
Color: model.RockColorBlack,
Status: status,
AssignedOrderNo: orderNo,
ExpectedUID: roleID,
ActualRoleName: roleName,
ActualRoleID: roleID,
ActualChannel: "安卓-微信",
BoundAt: &now,
}
if err := svc.db.Create(&token).Error; err != nil {
t.Fatalf("创建 Token 失败: %v", err)
}
return token
}
func TestSupportBoundTokens_RequiresSearchAndFiltersBound(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
var selfMerchant model.Merchant
if err := db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
t.Fatalf("读取自营商户失败: %v", err)
}
otherMerchant := model.Merchant{Code: "support-test-other", Name: "测试商户", Status: model.MerchantStatusActive}
if err := db.Create(&otherMerchant).Error; err != nil {
t.Fatalf("创建测试商户失败: %v", err)
}
createSupportTestOrder(t, svc, selfMerchant.ID, "SUP-ORDER-1", "SUP-CLIENT-1")
createSupportTestOrder(t, svc, otherMerchant.ID, "SUP-ORDER-2", "SUP-CLIENT-2")
selfToken1 := createSupportTestToken(t, svc, "SUP-TOKEN-1", "SUP-CODE-1", "SUP-ORDER-1", model.RockStatusBound, "客服测试角色", "10001")
selfToken2 := createSupportTestToken(t, svc, "SUP-TOKEN-2", "SUP-CODE-2", "SUP-ORDER-1", model.RockStatusBound, "客服第二个角色", "10002")
otherToken := createSupportTestToken(t, svc, "SUP-TOKEN-3", "SUP-CODE-3", "SUP-ORDER-2", model.RockStatusBound, "其他商户角色", "20001")
usingToken := createSupportTestToken(t, svc, "SUP-TOKEN-4", "SUP-CODE-4", "SUP-ORDER-1", model.RockStatusUsing, "使用中角色", "10003")
// 不传搜索条件时必须拒绝,避免一次拉取全量数据。
if _, _, err := svc.ListSupportBoundTokens(ctx, 1, 20, " "); err == nil || !strings.Contains(err.Error(), "请输入") {
t.Fatalf("空搜索条件必须拒绝,实际: %v", err)
}
// 输入搜索条件后返回全部命中,且不混入 using。
allTokens, allTotal, err := svc.ListSupportBoundTokens(ctx, 1, 20, "SUP-")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if allTotal != 3 || len(allTokens) != 3 {
t.Fatalf("应返回 3 条绑定中 Token,实际 total=%d len=%d", allTotal, len(allTokens))
}
for _, token := range allTokens {
if token.Status != model.RockStatusBound {
t.Fatalf("查询混入非 bound 状态: %+v", token)
}
if token.TokenCode == "" {
t.Fatalf("TokenCode 未填充: %+v", token)
}
if token.ID == usingToken.ID {
t.Fatalf("using Token 不应出现在查询结果中: %+v", token)
}
}
// 按商户单号查询:同一订单下的两条 Token 都应命中。
byClientOrder, clientTotal, err := svc.ListSupportBoundTokens(ctx, 1, 20, "SUP-CLIENT-1")
if err != nil {
t.Fatalf("按商户单号查询失败: %v", err)
}
if clientTotal != 2 || len(byClientOrder) != 2 {
t.Fatalf("按商户单号应查到 2 条,实际 total=%d len=%d", clientTotal, len(byClientOrder))
}
for _, token := range byClientOrder {
if token.ID != selfToken1.ID && token.ID != selfToken2.ID {
t.Fatalf("按商户单号查询结果不符: %+v", token)
}
}
// 按上游编号查询:只命中其他商户那条。
byUpstream, upstreamTotal, err := svc.ListSupportBoundTokens(ctx, 1, 20, "SUP-TOKEN-3")
if err != nil {
t.Fatalf("按上游编号查询失败: %v", err)
}
if upstreamTotal != 1 || len(byUpstream) != 1 || byUpstream[0].ID != otherToken.ID {
t.Fatalf("按上游编号查询结果不符: total=%d tokens=%+v", upstreamTotal, byUpstream)
}
// 按 Token ID 查询。
byID, idTotal, err := svc.ListSupportBoundTokens(ctx, 1, 20, fmt.Sprint(otherToken.ID))
if err != nil {
t.Fatalf("按 Token ID 查询失败: %v", err)
}
if idTotal != 1 || len(byID) != 1 || byID[0].ID != otherToken.ID {
t.Fatalf("按 Token ID 查询结果不符: total=%d tokens=%+v", idTotal, byID)
}
// 使用中的 Token 不能被客服查询到。
usingResult, usingTotal, err := svc.ListSupportBoundTokens(ctx, 1, 20, "SUP-TOKEN-4")
if err != nil {
t.Fatalf("查询 using Token 失败: %v", err)
}
if usingTotal != 0 || len(usingResult) != 0 {
t.Fatalf("using Token 不应被查询到,实际 total=%d", usingTotal)
}
}
func TestSupportTokenStatusLogs_RequiresBound(t *testing.T) {
db := newRockTestDB(t)
svc := NewRockService(db, NewRockClient("https://example.com"))
ctx := context.Background()
var selfMerchant model.Merchant
if err := db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
t.Fatalf("读取自营商户失败: %v", err)
}
createSupportTestOrder(t, svc, selfMerchant.ID, "SUP-LOG-ORDER-1", "SUP-LOG-CLIENT-1")
boundToken := createSupportTestToken(t, svc, "SUP-LOG-TOKEN-1", "SUP-LOG-CODE-1", "SUP-LOG-ORDER-1", model.RockStatusBound, "自营客服角色", "30001")
usingToken := createSupportTestToken(t, svc, "SUP-LOG-TOKEN-2", "SUP-LOG-CODE-2", "SUP-LOG-ORDER-1", model.RockStatusUsing, "使用中角色", "30002")
for _, token := range []model.RockToken{boundToken, usingToken} {
entry := model.RockTokenStatusLog{
TokenID: token.ID,
UpstreamTokenID: token.UpstreamTokenID,
EventType: rockTokenEventStatusChanged,
FromStatus: model.RockStatusUsing,
ToStatus: token.Status,
Source: rockTokenSourceBindCheck,
Reason: "测试状态变更",
}
if err := db.Create(&entry).Error; err != nil {
t.Fatalf("创建状态日志失败: %v", err)
}
}
logs, err := svc.ListSupportTokenStatusLogs(ctx, boundToken.ID)
if err != nil {
t.Fatalf("读取绑定中 Token 历史失败: %v", err)
}
if len(logs) != 1 {
t.Fatalf("绑定中 Token 应读到 1 条历史,实际 %d", len(logs))
}
if _, err := svc.ListSupportTokenStatusLogs(ctx, usingToken.ID); err == nil || !strings.Contains(err.Error(), "不存在") {
t.Fatalf("非绑定中 Token 必须拒绝,实际: %v", err)
}
if _, err := svc.ListSupportTokenStatusLogs(ctx, 999999); err == nil || !strings.Contains(err.Error(), "不存在") {
t.Fatalf("不存在的 Token 必须拒绝,实际: %v", err)
}
}
+79 -13
View File
@@ -28,6 +28,7 @@ type UserListQuery struct {
Size int
Keyword string
Role string
Roles []string
Status *int
}
@@ -55,7 +56,9 @@ func (s *UserService) List(q UserListQuery) ([]model.User, int64, error) {
like := "%" + q.Keyword + "%"
tx = tx.Where("users.username LIKE ? OR users.nickname LIKE ?", like, like)
}
if q.Role != "" {
if len(q.Roles) > 0 {
tx = tx.Where("users.role IN ?", q.Roles)
} else if q.Role != "" {
tx = tx.Where("users.role = ?", q.Role)
}
if q.Status != nil {
@@ -79,7 +82,7 @@ func (s *UserService) Create(username, password, nickname, role string, merchant
if role == "" {
role = model.RoleMerchant
}
if role != model.RoleAdmin && role != model.RoleMerchant {
if role != model.RoleAdmin && role != model.RoleMerchant && role != model.RolePlatformSupport {
return nil, errors.New("无效的账号角色")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@@ -142,10 +145,15 @@ type UpdatePlatformAdminInput struct {
Nickname string
Password string
Status int
Role string
}
func (s *UserService) ListPlatformAdmins(page, size int) ([]model.User, int64, error) {
list, total, err := s.List(UserListQuery{Page: page, Size: size, Role: model.RoleAdmin})
list, total, err := s.List(UserListQuery{
Page: page,
Size: size,
Roles: []string{model.RoleAdmin, model.RolePlatformSupport},
})
if err != nil {
return nil, 0, err
}
@@ -213,10 +221,23 @@ func (s *UserService) ListMerchantAccountGroups(page, size int) ([]MerchantMembe
return groups, total, nil
}
// CreatePlatformUser creates a platform-level account. Platform admins get the
// hidden self-operated owner membership; platform support accounts use the
// synthetic tenant context in middleware.Tenant and do not become merchant members.
func (s *UserService) CreatePlatformUser(username, password, nickname, role string) (*model.User, error) {
if role == "" {
role = model.RoleAdmin
}
if role != model.RoleAdmin && role != model.RolePlatformSupport {
return nil, errors.New("无效的平台账号角色")
}
return s.Create(username, password, nickname, role, 0)
}
// CreatePlatformAdmin creates an account with platform privileges and the
// hidden self-operated merchant context required by merchant-side routes.
func (s *UserService) CreatePlatformAdmin(username, password, nickname string) (*model.User, error) {
return s.Create(username, password, nickname, model.RoleAdmin, 0)
return s.CreatePlatformUser(username, password, nickname, model.RoleAdmin)
}
func (s *UserService) UpdatePlatformAdmin(id uint, in UpdatePlatformAdminInput, actorUserID uint) (*model.User, error) {
@@ -238,10 +259,18 @@ func (s *UserService) UpdatePlatformAdmin(id uint, in UpdatePlatformAdminInput,
}
return err
}
if user.Role != model.RoleAdmin {
return errors.New("只能管理平台管理员账号")
if user.Role != model.RoleAdmin && user.Role != model.RolePlatformSupport {
return errors.New("只能管理平台账号")
}
if user.Status == 1 && in.Status == 0 {
targetRole := user.Role
if in.Role != "" {
targetRole = in.Role
}
if targetRole != model.RoleAdmin && targetRole != model.RolePlatformSupport {
return errors.New("无效的平台账号角色")
}
// 降级或停用最后一个启用的平台管理员会导致平台无法登录。
if user.Role == model.RoleAdmin && user.Status == 1 && (targetRole != model.RoleAdmin || in.Status == 0) {
var admins int64
if err := tx.Model(&model.User{}).Where("role = ? AND status = ?", model.RoleAdmin, 1).Count(&admins).Error; err != nil {
return err
@@ -259,7 +288,40 @@ func (s *UserService) UpdatePlatformAdmin(id uint, in UpdatePlatformAdminInput,
return errors.New("用户名已存在")
}
}
updates := map[string]interface{}{"username": in.Username, "nickname": in.Nickname, "status": in.Status}
if targetRole != user.Role {
// 管理员需要自营商户负责人成员关系;平台客服使用 Tenant 合成上下文,
// 不写入 merchant_members,避免出现在商户成员列表里。
if err := tx.Where("user_id = ?", user.ID).Delete(&model.MerchantMember{}).Error; err != nil {
return err
}
if targetRole == model.RoleAdmin {
var selfMerchant model.Merchant
if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
return err
}
member := model.MerchantMember{
MerchantID: selfMerchant.ID,
UserID: user.ID,
Role: model.MemberRoleOwner,
Status: 1,
IsDefault: true,
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"role": model.MemberRoleOwner, "status": 1, "is_default": true,
}),
}).Create(&member).Error; err != nil {
return err
}
}
}
updates := map[string]interface{}{
"username": in.Username,
"nickname": in.Nickname,
"status": in.Status,
"role": targetRole,
}
if in.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
@@ -303,8 +365,9 @@ func (s *UserService) UpdateStatus(id uint, status int) error {
})
}
// DeletePlatformAdmin removes a platform-only account. It protects the current
// account and the final enabled platform administrator so the platform cannot be locked out.
// DeletePlatformAdmin removes a platform account (admin or support). It protects
// the current account and the final enabled platform administrator so the platform
// cannot be locked out.
func (s *UserService) DeletePlatformAdmin(id, actorUserID uint) error {
if id == 0 {
return errors.New("用户不存在")
@@ -320,8 +383,8 @@ func (s *UserService) DeletePlatformAdmin(id, actorUserID uint) error {
}
return err
}
if user.Role != model.RoleAdmin {
return errors.New("只能删除平台管理员账号")
if user.Role != model.RoleAdmin && user.Role != model.RolePlatformSupport {
return errors.New("只能删除平台账号")
}
if user.Role == model.RoleAdmin && user.Status == 1 {
var admins int64
@@ -332,6 +395,9 @@ func (s *UserService) DeletePlatformAdmin(id, actorUserID uint) error {
return errors.New("至少保留一个启用的平台管理员")
}
}
if err := tx.Where("user_id = ?", user.ID).Delete(&model.MerchantMember{}).Error; err != nil {
return err
}
return tx.Delete(&user).Error
})
}
@@ -371,7 +437,7 @@ func (s *UserService) DeleteMerchantAccount(id, actorUserID uint) error {
return err
}
if user.Role != model.RoleMerchant {
return errors.New("平台管理员请在平台管理员列表中管理")
return errors.New("平台账号请在平台账号列表中管理")
}
if err := tx.Where("user_id = ?", user.ID).Delete(&model.MerchantMember{}).Error; err != nil {
return err