feat: 新增平台客服角色与客服查询菜单
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
MemberRoleOperator = "operator"
|
||||
MemberRoleFinance = "finance"
|
||||
MemberRoleViewer = "viewer"
|
||||
MemberRoleSupport = "support"
|
||||
|
||||
PermissionMembersManage = "members:manage"
|
||||
PermissionProductsManage = "products:manage"
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
|
||||
// 用户角色
|
||||
const (
|
||||
RoleAdmin = "admin" // 平台管理员
|
||||
RoleMerchant = "merchant" // 商户账号
|
||||
RoleAdmin = "admin" // 平台管理员
|
||||
RoleMerchant = "merchant" // 商户账号
|
||||
RolePlatformSupport = "platform_support" // 平台客服
|
||||
)
|
||||
|
||||
// User 系统用户
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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("最后一个启用的平台管理员不能被降级")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user