3 Commits
Author SHA1 Message Date
yml2213 efa0aa4ffd fix(invitation): 返利归因统一为「订单所属站点」,买卖两端一致
- 买家端不再强制记主站(原 buyerSiteID=0),卖家端不再记拆分方站点,两端统一取 orderSiteID
- orderSiteID = 订单所属站点(>0 时);订单未记站点但存在分销拆分时回退到拆分方站点,避免主站替分站订单买单
- 该站点同时用于取返利配置(GetRebateConfig)、返利流水与钱包流水的 distribution_site_id,分站后台由此能看到本站返利支出
- 基数口径未改动,非分销订单行为与改前一致;历史 9 笔数据不订正,规则自此生效
- 测试:更新跨站用例断言;新增订单站点归因用例(校验站点专属比例 0.5 与钱包流水站点维度)
2026-10-13 19:05:54 +08:00
yml2213 2e13443805 feat(distribution): 站点归属兜底——未带站点编码时按访问域名识别分站
- 新增 middleware.SiteDomainFallback:显式头/query 优先,两者都为空时用 Request.Host / X-Forwarded-Host 反查 distribution_sites.domain,并回填 X-Distribution-Site-Code 供后续 sitecode.FromRequest 使用
- 新增 distribution.Repository.ResolveSiteCodeByDomain(仅取 status=active 的站点)
- 路由层在所有业务路由之前注册;域名到站点映射走 1 分钟进程内缓存(含负缓存);查库异常不阻断请求、退化为按主站处理;回环/本地地址跳过查询
- 背景:站点归属此前完全依赖前端注入头,首屏探测失败或漏带就会把分站写入记成主站(2026-10-10 前已因此丢失一批分站注册归属)
- 新增单测:中间件(显式优先、域名解析、本地地址跳过、转发域兜底、缓存与容错)与仓储域名查询边界(大小写/空白/主站域名/停用站点)
2026-10-13 17:33:16 +08:00
yml2213 f33dc97f6f feat(admin): 客服手动录单支持平台与操作人,并可后台配置可选项
- 录单列表新增「平台」「操作人」两列,录单表单与编辑弹窗新增同名字段下拉
- 新增 cs_manual_entry_options 表与 4 个接口(读取/新增/重命名/删除),页面右上角弹窗维护平台与操作人
- 迁移 000087:cs_manual_entries 增加 platform/operator 列与索引,注册 cs_manual_entry:option_manage 权限并授予 ops/super_admin
- 记录保存名称快照,删除选项不影响历史记录;同类型重名返回 409,跨类型同名允许
- 顶部关键词查询支持命中平台/操作人
- 新增单测:选项 CRUD 边界与录单平台/操作人读写
2026-10-13 16:22:57 +08:00
18 changed files with 1500 additions and 51 deletions
@@ -0,0 +1,132 @@
package middleware
import (
"context"
"strings"
"sync"
"time"
"hfb_sys/backend/internal/sitecode"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// SiteDomainResolver 按访问域名解析启用中的分销站点编码;无匹配返回空串。
type SiteDomainResolver interface {
ResolveSiteCodeByDomain(ctx context.Context, domain string) (string, error)
}
// siteDomainCacheTTL 域名→站点编码的进程内缓存时长。
// 站点域名极少变更,1 分钟足以吸收首屏探测与脚本产生的无站点头请求,
// 又不会让改绑后的域名长时间解析到旧站点。
const siteDomainCacheTTL = time.Minute
type siteDomainCacheEntry struct {
code string
expiresAt time.Time
}
type siteDomainCache struct {
mu sync.RWMutex
items map[string]siteDomainCacheEntry
}
func newSiteDomainCache() *siteDomainCache {
return &siteDomainCache{items: make(map[string]siteDomainCacheEntry)}
}
func (c *siteDomainCache) get(host string) (string, bool) {
c.mu.RLock()
entry, ok := c.items[host]
c.mu.RUnlock()
if !ok || time.Now().After(entry.expiresAt) {
return "", false
}
return entry.code, true
}
func (c *siteDomainCache) set(host, code string) {
c.mu.Lock()
c.items[host] = siteDomainCacheEntry{code: code, expiresAt: time.Now().Add(siteDomainCacheTTL)}
c.mu.Unlock()
}
// SiteDomainFallback 在请求未显式携带站点编码时,按访问域名兜底识别分销站点,
// 并把结果回填到权威请求头,使后续所有 sitecode.FromRequest 调用自动生效。
//
// 背景:站点归属此前完全依赖前端注入的 X-Distribution-Site-Code 头,
// 首屏探测失败或漏带就会把分站写入当成主站(2026-10-10 之前已因此丢过一批注册归属)。
// 显式头/query 始终优先,本中间件只在两者都为空时兜底;数据库异常时不阻断请求,
// 退化为原有行为(视为主站)。
func SiteDomainFallback(resolver SiteDomainResolver, logger *zap.Logger) gin.HandlerFunc {
if resolver == nil {
return func(c *gin.Context) { c.Next() }
}
cache := newSiteDomainCache()
return func(c *gin.Context) {
if sitecode.FromRequest(c) != "" {
c.Next()
return
}
if code := resolveSiteCodeByHost(c, resolver, cache, logger); code != "" {
c.Request.Header.Set(sitecode.HeaderName, code)
}
c.Next()
}
}
func resolveSiteCodeByHost(c *gin.Context, resolver SiteDomainResolver, cache *siteDomainCache, logger *zap.Logger) string {
for _, host := range candidateHosts(c) {
if code, ok := cache.get(host); ok {
if code != "" {
return code
}
continue
}
code, err := resolver.ResolveSiteCodeByDomain(c.Request.Context(), host)
if err != nil {
// 记入缓存,避免数据库故障时每个请求都打一次日志与查询。
cache.set(host, "")
if logger != nil {
logger.Warn("按域名兜底解析分销站点失败,已按未指定站点处理",
zap.String("host", host),
zap.Error(err),
)
}
continue
}
cache.set(host, code)
if code != "" {
return code
}
}
return ""
}
// candidateHosts 返回可能承载站点域名的候选主机名(去空、去本地地址、去重)。
// 依次尝试请求 Host 与反代透传的 X-Forwarded-Host。
func candidateHosts(c *gin.Context) []string {
raw := []string{c.Request.Host}
for _, value := range strings.Split(c.GetHeader("X-Forwarded-Host"), ",") {
raw = append(raw, value)
}
hosts := make([]string, 0, len(raw))
for _, item := range raw {
host := sitecode.ExtractHost(item)
if host == "" || sitecode.IsLocalHost(host) {
continue
}
duplicated := false
for _, existing := range hosts {
if existing == host {
duplicated = true
break
}
}
if !duplicated {
hosts = append(hosts, host)
}
}
return hosts
}
@@ -0,0 +1,153 @@
package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"hfb_sys/backend/internal/sitecode"
"github.com/gin-gonic/gin"
)
type fakeSiteDomainResolver struct {
byDomain map[string]string
err error
calls int
hosts []string
}
func (f *fakeSiteDomainResolver) ResolveSiteCodeByDomain(_ context.Context, domain string) (string, error) {
f.calls++
f.hosts = append(f.hosts, domain)
if f.err != nil {
return "", f.err
}
return f.byDomain[domain], nil
}
// newSiteFallbackRouter 构建一个把 sitecode.FromRequest 结果回显的路由,便于断言兜底结果。
func newSiteFallbackRouter(resolver SiteDomainResolver) *gin.Engine {
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.Use(SiteDomainFallback(resolver, nil))
engine.GET("/probe", func(c *gin.Context) {
c.String(http.StatusOK, sitecode.FromRequest(c))
})
return engine
}
func doSiteFallbackRequest(t *testing.T, engine *gin.Engine, host, target string, headers map[string]string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, target, nil)
req.Host = host
for key, value := range headers {
req.Header.Set(key, value)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", recorder.Code)
}
return recorder.Body.String()
}
func TestSiteDomainFallbackResolvesByHost(t *testing.T) {
resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}}
engine := newSiteFallbackRouter(resolver)
if got := doSiteFallbackRequest(t, engine, "tysh.hao858.com", "/probe", nil); got != "gz_dist" {
t.Fatalf("站点域名应解析为 gz_dist, got %q", got)
}
// 带端口的 Host 也要能解析
if got := doSiteFallbackRequest(t, engine, "tysh.hao858.com:443", "/probe", nil); got != "gz_dist" {
t.Fatalf("带端口站点域名应解析为 gz_dist, got %q", got)
}
// 主站域名无匹配:保持为空
if got := doSiteFallbackRequest(t, engine, "hao858.com", "/probe", nil); got != "" {
t.Fatalf("主站域名不应被识别为分站, got %q", got)
}
}
func TestSiteDomainFallbackPrefersExplicitSiteCode(t *testing.T) {
resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}}
engine := newSiteFallbackRouter(resolver)
// 显式头优先:即使域名能解析出 gz_dist,也必须尊重前端给的站点
cases := []struct {
name string
target string
headers map[string]string
}{
{name: "权威头", target: "/probe", headers: map[string]string{sitecode.HeaderName: "other_site"}},
{name: "旧头", target: "/probe", headers: map[string]string{sitecode.LegacyHeaderName: "legacy_site"}},
{name: "query", target: "/probe?site_code=query_site", headers: nil},
{name: "旧query", target: "/probe?distribution_site_code=legacy_query", headers: nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := doSiteFallbackRequest(t, engine, "tysh.hao858.com", tc.target, tc.headers)
if got == "gz_dist" || got == "" {
t.Fatalf("显式站点应优先于域名兜底, got %q", got)
}
})
}
if resolver.calls != 0 {
t.Fatalf("显式站点不应触发域名解析, calls = %d", resolver.calls)
}
}
func TestSiteDomainFallbackSkipsLocalAndIPHosts(t *testing.T) {
resolver := &fakeSiteDomainResolver{byDomain: map[string]string{}}
engine := newSiteFallbackRouter(resolver)
for _, host := range []string{"localhost:8080", "127.0.0.1:18088", "10.0.0.5:8080"} {
if got := doSiteFallbackRequest(t, engine, host, "/probe", nil); got != "" {
t.Fatalf("本地/内网地址 %s 不应解析站点, got %q", host, got)
}
}
// 本地地址直接跳过;10.0.0.5 不是 IsLocalHost 白名单,会查一次但无匹配
if resolver.calls > 1 {
t.Fatalf("本地地址不应反复查询, calls = %d", resolver.calls)
}
}
func TestSiteDomainFallbackFallsBackToForwardedHost(t *testing.T) {
resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}}
engine := newSiteFallbackRouter(resolver)
got := doSiteFallbackRequest(t, engine, "hao858.com", "/probe", map[string]string{
"X-Forwarded-Host": "tysh.hao858.com",
})
if got != "gz_dist" {
t.Fatalf("应回退到 X-Forwarded-Host 解析站点, got %q", got)
}
}
func TestSiteDomainFallbackCachesAndFailsOpen(t *testing.T) {
resolver := &fakeSiteDomainResolver{byDomain: map[string]string{"tysh.hao858.com": "gz_dist"}}
engine := newSiteFallbackRouter(resolver)
for i := 0; i < 3; i++ {
if got := doSiteFallbackRequest(t, engine, "tysh.hao858.com", "/probe", nil); got != "gz_dist" {
t.Fatalf("第 %d 次解析 = %q, want gz_dist", i+1, got)
}
}
if resolver.calls != 1 {
t.Fatalf("同域名应命中缓存只查一次, calls = %d", resolver.calls)
}
// 解析失败时不阻断请求,退化为未指定站点
failing := &fakeSiteDomainResolver{err: errors.New("db down")}
failEngine := newSiteFallbackRouter(failing)
for i := 0; i < 3; i++ {
if got := doSiteFallbackRequest(t, failEngine, "tysh.hao858.com", "/probe", nil); got != "" {
t.Fatalf("解析失败应退化为空站点, got %q", got)
}
}
if failing.calls != 1 {
t.Fatalf("解析失败也应缓存避免刷日志, calls = %d", failing.calls)
}
}
+19 -1
View File
@@ -4,10 +4,13 @@ import "time"
// CSManualEntry 客服手动录单记录。
// 客服在后台手动登记一个编号(如 202610120013),该编号与其登录账号绑定,
// 记录登记时间等,供后续查询与按客服统计今日录单量。
// 记录平台、操作人、登记时间等,供后续查询与按客服统计今日录单量。
// platform / operator 存名称快照:后台删掉选项也不影响历史记录展示。
type CSManualEntry struct {
ID uint64 `gorm:"primaryKey" json:"id"`
EntryNo string `gorm:"size:64;not null;uniqueIndex" json:"entry_no"`
Platform string `gorm:"size:64;not null;default:'';index" json:"platform"`
Operator string `gorm:"size:64;not null;default:'';index" json:"operator"`
AdminID uint64 `gorm:"not null;index" json:"admin_id"`
AdminName string `gorm:"size:64;not null;default:''" json:"admin_name"`
AdminUsername string `gorm:"size:64;not null;default:''" json:"admin_username"`
@@ -19,3 +22,18 @@ type CSManualEntry struct {
func (CSManualEntry) TableName() string {
return "cs_manual_entries"
}
// CSManualEntryOption 客服手动录单的可选项配置(平台 / 操作人)。
// 仅作为录单表单的下拉数据源,记录里保存的是名称快照。
type CSManualEntryOption struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:16;not null;uniqueIndex:uk_cs_manual_entry_options_type_name,priority:1" json:"type"`
Name string `gorm:"size:64;not null;uniqueIndex:uk_cs_manual_entry_options_type_name,priority:2" json:"name"`
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (CSManualEntryOption) TableName() string {
return "cs_manual_entry_options"
}
+42 -4
View File
@@ -2,22 +2,34 @@ package csmanualentry
import "time"
// 录单选项类型。
const (
OptionTypePlatform = "platform" // 平台
OptionTypeOperator = "operator" // 操作人
)
// CreateRequest 客服手动录单请求。
type CreateRequest struct {
EntryNo string `json:"entry_no" binding:"required"`
Remark string `json:"remark"`
EntryNo string `json:"entry_no" binding:"required"`
Platform string `json:"platform"`
Operator string `json:"operator"`
Remark string `json:"remark"`
}
// UpdateRequest 管理员修正录单请求。
type UpdateRequest struct {
EntryNo string `json:"entry_no" binding:"required"`
Remark string `json:"remark"`
EntryNo string `json:"entry_no" binding:"required"`
Platform string `json:"platform"`
Operator string `json:"operator"`
Remark string `json:"remark"`
}
// EntryDTO 录单记录。
type EntryDTO struct {
ID uint64 `json:"id"`
EntryNo string `json:"entry_no"`
Platform string `json:"platform"`
Operator string `json:"operator"`
AdminID uint64 `json:"admin_id"`
AdminName string `json:"admin_name"`
AdminUsername string `json:"admin_username"`
@@ -56,3 +68,29 @@ type AgentDayStatsDTO struct {
type CreateResult struct {
Entry EntryDTO `json:"entry"`
}
// OptionDTO 录单可选项(平台 / 操作人)。
type OptionDTO struct {
ID uint64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
}
// OptionListDTO 平台与操作人选项集合,供录单表单下拉直接使用。
type OptionListDTO struct {
Platforms []OptionDTO `json:"platforms"`
Operators []OptionDTO `json:"operators"`
}
// CreateOptionRequest 新增平台 / 操作人选项。
type CreateOptionRequest struct {
Type string `json:"type" binding:"required"`
Name string `json:"name" binding:"required"`
}
// UpdateOptionRequest 重命名平台 / 操作人选项。
type UpdateOptionRequest struct {
Name string `json:"name" binding:"required"`
}
@@ -8,4 +8,8 @@ var (
ErrEntryNoInvalid = errors.New("entry number is invalid")
ErrEntryNotFound = errors.New("manual entry not found")
ErrAdminNotFound = errors.New("admin not found")
ErrOptionTypeInvalid = errors.New("manual entry option type is invalid")
ErrOptionNameInvalid = errors.New("manual entry option name is invalid")
ErrOptionExists = errors.New("manual entry option already exists")
ErrOptionNotFound = errors.New("manual entry option not found")
)
@@ -122,6 +122,81 @@ func (h *Handler) Delete(c *gin.Context) {
response.OK(c, gin.H{"deleted": true})
}
// ListOptions 返回平台与操作人选项,供录单表单下拉使用。
func (h *Handler) ListOptions(c *gin.Context) {
if _, ok := currentAdminID(c); !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
result, err := h.service.ListOptions(c.Request.Context())
if err != nil {
writeManualEntryError(c, err)
return
}
response.OK(c, result)
}
// CreateOption 新增平台 / 操作人选项。
func (h *Handler) CreateOption(c *gin.Context) {
if _, ok := currentAdminID(c); !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateOptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "选项参数不正确")
return
}
option, err := h.service.CreateOption(c.Request.Context(), req)
if err != nil {
writeManualEntryError(c, err)
return
}
response.Created(c, option)
}
// UpdateOption 重命名平台 / 操作人选项。
func (h *Handler) UpdateOption(c *gin.Context) {
if _, ok := currentAdminID(c); !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID 不正确")
return
}
var req UpdateOptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "选项参数不正确")
return
}
option, err := h.service.UpdateOption(c.Request.Context(), id, req)
if err != nil {
writeManualEntryError(c, err)
return
}
response.OK(c, option)
}
// DeleteOption 删除平台 / 操作人选项。
func (h *Handler) DeleteOption(c *gin.Context) {
if _, ok := currentAdminID(c); !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID 不正确")
return
}
if err := h.service.DeleteOption(c.Request.Context(), id); err != nil {
writeManualEntryError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
@@ -159,6 +234,14 @@ func writeManualEntryError(c *gin.Context, err error) {
response.NotFound(c, "录单记录不存在")
case errors.Is(err, ErrEntryNoExists):
response.Error(c, http.StatusConflict, "entry_no_exists", "该编号已被登记")
case errors.Is(err, ErrOptionTypeInvalid):
response.BadRequest(c, "选项类型不正确")
case errors.Is(err, ErrOptionNameInvalid):
response.BadRequest(c, "选项名称不正确")
case errors.Is(err, ErrOptionNotFound):
response.NotFound(c, "选项不存在")
case errors.Is(err, ErrOptionExists):
response.Error(c, http.StatusConflict, "option_exists", "该选项已存在")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "录单服务暂时不可用")
}
@@ -34,9 +34,11 @@ func (r *Repository) FindAdminSnapshot(ctx context.Context, adminID uint64) (nam
}
// Create 写入一条录单记录。
func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, remark string) (*EntryDTO, error) {
func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, platform, operator, remark string) (*EntryDTO, error) {
row := model.CSManualEntry{
EntryNo: entryNo,
Platform: platform,
Operator: operator,
AdminID: adminID,
AdminName: adminName,
AdminUsername: adminUsername,
@@ -65,12 +67,14 @@ func (r *Repository) FindByID(ctx context.Context, id uint64) (*EntryDTO, error)
return &dto, nil
}
// Update 修正录单的编号与备注。编号唯一冲突返回 ErrEntryNoExists。
func (r *Repository) Update(ctx context.Context, id uint64, entryNo, remark string) (*EntryDTO, error) {
// Update 修正录单的编号、平台、操作人与备注。编号唯一冲突返回 ErrEntryNoExists。
func (r *Repository) Update(ctx context.Context, id uint64, entryNo, platform, operator, remark string) (*EntryDTO, error) {
result := r.db.WithContext(ctx).Model(&model.CSManualEntry{}).
Where("id = ?", id).
Updates(map[string]any{
"entry_no": entryNo,
"platform": platform,
"operator": operator,
"remark": remark,
})
if result.Error != nil {
@@ -118,7 +122,10 @@ func (r *Repository) List(ctx context.Context, query ListQuery) (*PaginatedResul
}
if kw := strings.TrimSpace(query.Keyword); kw != "" {
like := "%" + kw + "%"
cond := r.db.Where("entry_no LIKE ? OR admin_name LIKE ? OR admin_username LIKE ?", like, like, like)
cond := r.db.Where(
"entry_no LIKE ? OR admin_name LIKE ? OR admin_username LIKE ? OR platform LIKE ? OR operator LIKE ?",
like, like, like, like, like,
)
db = db.Where(cond)
countDB = countDB.Where(cond)
}
@@ -192,11 +199,99 @@ func (r *Repository) TodayAgentStats(ctx context.Context, day time.Time) ([]Agen
return items, nil
}
// ListOptions 返回某类型(平台 / 操作人)的全部可选项。
func (r *Repository) ListOptions(ctx context.Context, optionType string) ([]OptionDTO, error) {
if !IsValidOptionType(optionType) {
return nil, ErrOptionTypeInvalid
}
rows := make([]model.CSManualEntryOption, 0)
if err := r.db.WithContext(ctx).
Where("type = ?", optionType).
Order("sort_order ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
items := make([]OptionDTO, 0, len(rows))
for _, row := range rows {
items = append(items, toOptionDTO(row))
}
return items, nil
}
// ListAllOptions 返回平台与操作人两组可选项,供录单表单直接使用。
func (r *Repository) ListAllOptions(ctx context.Context) (*OptionListDTO, error) {
platforms, err := r.ListOptions(ctx, OptionTypePlatform)
if err != nil {
return nil, err
}
operators, err := r.ListOptions(ctx, OptionTypeOperator)
if err != nil {
return nil, err
}
return &OptionListDTO{Platforms: platforms, Operators: operators}, nil
}
// CreateOption 新增一个平台 / 操作人选项,重名返回 ErrOptionExists。
func (r *Repository) CreateOption(ctx context.Context, optionType, name string) (*OptionDTO, error) {
if !IsValidOptionType(optionType) {
return nil, ErrOptionTypeInvalid
}
row := model.CSManualEntryOption{Type: optionType, Name: name}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
if isDuplicateEntry(err) {
return nil, ErrOptionExists
}
return nil, err
}
dto := toOptionDTO(row)
return &dto, nil
}
// UpdateOption 重命名一个选项,重名返回 ErrOptionExists。
func (r *Repository) UpdateOption(ctx context.Context, id uint64, name string) (*OptionDTO, error) {
var row model.CSManualEntryOption
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrOptionNotFound
}
return nil, err
}
result := r.db.WithContext(ctx).Model(&model.CSManualEntryOption{}).
Where("id = ?", id).
Update("name", name)
if result.Error != nil {
if isDuplicateEntry(result.Error) {
return nil, ErrOptionExists
}
return nil, result.Error
}
row.Name = name
dto := toOptionDTO(row)
return &dto, nil
}
// DeleteOption 删除一个选项(历史录单里的名称快照不受影响)。
func (r *Repository) DeleteOption(ctx context.Context, id uint64) error {
result := r.db.WithContext(ctx).Delete(&model.CSManualEntryOption{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrOptionNotFound
}
return nil
}
// IsValidOptionType 判断选项类型是否合法。
func IsValidOptionType(optionType string) bool {
return optionType == OptionTypePlatform || optionType == OptionTypeOperator
}
func parseDay(value string, loc *time.Location) (time.Time, error) {
return time.ParseInLocation("2006-01-02", value, loc)
}
// isDuplicateEntry 判断错误是否为 entry_no 唯一键冲突。
// isDuplicateEntry 判断错误是否为唯一键冲突。
func isDuplicateEntry(err error) bool {
if err == nil {
return false
@@ -211,6 +306,8 @@ func toDTO(row model.CSManualEntry) EntryDTO {
return EntryDTO{
ID: row.ID,
EntryNo: row.EntryNo,
Platform: row.Platform,
Operator: row.Operator,
AdminID: row.AdminID,
AdminName: row.AdminName,
AdminUsername: row.AdminUsername,
@@ -218,3 +315,13 @@ func toDTO(row model.CSManualEntry) EntryDTO {
CreatedAt: row.CreatedAt,
}
}
func toOptionDTO(row model.CSManualEntryOption) OptionDTO {
return OptionDTO{
ID: row.ID,
Type: row.Type,
Name: row.Name,
SortOrder: row.SortOrder,
CreatedAt: row.CreatedAt,
}
}
@@ -0,0 +1,156 @@
package csmanualentry
import (
"errors"
"strings"
"testing"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
func newManualEntryTestRepo(t *testing.T) (*Repository, *gorm.DB) {
t.Helper()
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db,
&model.AdminUser{},
&model.CSManualEntry{},
&model.CSManualEntryOption{},
); err != nil {
t.Fatalf("迁移测试表失败: %v", err)
}
return NewRepository(db), db
}
func TestNormalizeOptionName(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr error
}{
{name: "trim", input: " 网易大神 ", want: "网易大神"},
{name: "empty allowed", input: " ", want: ""},
{name: "inner space kept", input: "网易 大神", want: "网易 大神"},
{name: "64 runes", input: strings.Repeat("游", 64), want: strings.Repeat("游", 64)},
{name: "65 runes", input: strings.Repeat("游", 65), wantErr: ErrOptionNameInvalid},
{name: "newline", input: "网易\n大神", wantErr: ErrOptionNameInvalid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeOptionName(tt.input)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("normalizeOptionName(%q) error = %v, want %v", tt.input, err, tt.wantErr)
}
if tt.wantErr == nil && got != tt.want {
t.Fatalf("normalizeOptionName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestRepositoryOptionCRUDAndEntryPlatformOperator(t *testing.T) {
repo, db := newManualEntryTestRepo(t)
ctx := t.Context()
admin := model.AdminUser{Username: "cs001", Nickname: "客服小张", PasswordHash: "x"}
if err := db.Create(&admin).Error; err != nil {
t.Fatalf("创建管理员失败: %v", err)
}
// 1. 选项 CRUD
platform, err := repo.CreateOption(ctx, OptionTypePlatform, "网易大神")
if err != nil {
t.Fatalf("创建平台选项失败: %v", err)
}
// 同类型第二个平台,用于验证重命名撞名(唯一键是 type+name,跨类型不算冲突)
if _, err := repo.CreateOption(ctx, OptionTypePlatform, "交易猫"); err != nil {
t.Fatalf("创建第二个平台选项失败: %v", err)
}
operator, err := repo.CreateOption(ctx, OptionTypeOperator, "技术测试")
if err != nil {
t.Fatalf("创建操作人选项失败: %v", err)
}
if _, err := repo.CreateOption(ctx, OptionTypePlatform, "网易大神"); !errors.Is(err, ErrOptionExists) {
t.Fatalf("重复平台应返回 ErrOptionExists, got %v", err)
}
if _, err := repo.CreateOption(ctx, "unknown", "x"); !errors.Is(err, ErrOptionTypeInvalid) {
t.Fatalf("非法类型应返回 ErrOptionTypeInvalid, got %v", err)
}
// 与平台同名的操作人是允许的(不同 type)
if _, err := repo.CreateOption(ctx, OptionTypeOperator, "网易大神"); err != nil {
t.Fatalf("跨类型同名应允许: %v", err)
}
if _, err := repo.UpdateOption(ctx, platform.ID, "网易"); err != nil {
t.Fatalf("重命名选项失败: %v", err)
}
if _, err := repo.UpdateOption(ctx, platform.ID, "交易猫"); !errors.Is(err, ErrOptionExists) {
t.Fatalf("重命名撞同类型已有名应返回 ErrOptionExists, got %v", err)
}
if _, err := repo.UpdateOption(ctx, 999999, "任何"); !errors.Is(err, ErrOptionNotFound) {
t.Fatalf("重命名不存在选项应返回 ErrOptionNotFound, got %v", err)
}
options, err := repo.ListAllOptions(ctx)
if err != nil {
t.Fatalf("读取选项列表失败: %v", err)
}
if len(options.Platforms) != 2 || options.Platforms[0].Name != "网易" {
t.Fatalf("平台选项 = %+v, want 2 条且首条为网易", options.Platforms)
}
if len(options.Operators) != 2 {
t.Fatalf("操作人选项 = %+v, want 2 条", options.Operators)
}
// 2. 录单写入平台 / 操作人
entry, err := repo.Create(ctx, admin.ID, "客服小张", "cs001", "202610130001", "网易", "技术测试", "备注1")
if err != nil {
t.Fatalf("录单失败: %v", err)
}
if entry.Platform != "网易" || entry.Operator != "技术测试" {
t.Fatalf("录单平台/操作人 = %q/%q", entry.Platform, entry.Operator)
}
// 修正录单的平台与操作人
updated, err := repo.Update(ctx, entry.ID, "202610130001", "网易", "客服小李", "备注2")
if err != nil {
t.Fatalf("修正录单失败: %v", err)
}
if updated.Operator != "客服小李" || updated.Remark != "备注2" {
t.Fatalf("修正后 = %+v", updated)
}
// 3. 关键字命中平台 / 操作人
list, err := repo.List(ctx, ListQuery{Keyword: "客服小李", Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("按操作人查询失败: %v", err)
}
if list.Total != 1 {
t.Fatalf("按操作人查询命中 %d 条, want 1", list.Total)
}
list, err = repo.List(ctx, ListQuery{Keyword: "网易", Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("按平台查询失败: %v", err)
}
if list.Total != 1 {
t.Fatalf("按平台查询命中 %d 条, want 1", list.Total)
}
// 4. 删除选项不影响历史录单(记录里是名称快照)
if err := repo.DeleteOption(ctx, operator.ID); err != nil {
t.Fatalf("删除操作人选项失败: %v", err)
}
if err := repo.DeleteOption(ctx, operator.ID); !errors.Is(err, ErrOptionNotFound) {
t.Fatalf("重复删除应返回 ErrOptionNotFound, got %v", err)
}
after, err := repo.FindByID(ctx, entry.ID)
if err != nil {
t.Fatalf("删除选项后读取录单失败: %v", err)
}
if after.Operator != "客服小李" {
t.Fatalf("删除选项后历史录单操作人 = %q, want 客服小李", after.Operator)
}
}
@@ -3,11 +3,15 @@ package csmanualentry
import (
"context"
"strings"
"unicode/utf8"
"hfb_sys/backend/internal/timeutil"
)
const maxEntryNoLength = 64
const (
maxEntryNoLength = 64
maxOptionNameLength = 64
)
type Service struct {
repo *Repository
@@ -26,6 +30,14 @@ func (s *Service) Create(ctx context.Context, adminID uint64, req CreateRequest)
if err != nil {
return nil, err
}
platform, err := normalizeOptionName(req.Platform)
if err != nil {
return nil, err
}
operator, err := normalizeOptionName(req.Operator)
if err != nil {
return nil, err
}
adminName, adminUsername, err := s.repo.FindAdminSnapshot(ctx, adminID)
if err != nil {
return nil, err
@@ -33,7 +45,7 @@ func (s *Service) Create(ctx context.Context, adminID uint64, req CreateRequest)
if adminName == "" {
adminName = adminUsername
}
entry, err := s.repo.Create(ctx, adminID, adminName, adminUsername, entryNo, strings.TrimSpace(req.Remark))
entry, err := s.repo.Create(ctx, adminID, adminName, adminUsername, entryNo, platform, operator, strings.TrimSpace(req.Remark))
if err != nil {
return nil, err
}
@@ -49,7 +61,15 @@ func (s *Service) Update(ctx context.Context, id uint64, req UpdateRequest) (*En
if err != nil {
return nil, err
}
return s.repo.Update(ctx, id, entryNo, strings.TrimSpace(req.Remark))
platform, err := normalizeOptionName(req.Platform)
if err != nil {
return nil, err
}
operator, err := normalizeOptionName(req.Operator)
if err != nil {
return nil, err
}
return s.repo.Update(ctx, id, entryNo, platform, operator, strings.TrimSpace(req.Remark))
}
// Delete 管理员删除录单记录。
@@ -76,6 +96,56 @@ func (s *Service) TodayAgentStats(ctx context.Context) ([]AgentDayStatsDTO, erro
return s.repo.TodayAgentStats(ctx, timeutil.ShanghaiNow())
}
// ListOptions 返回平台与操作人两组可选项。
func (s *Service) ListOptions(ctx context.Context) (*OptionListDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListAllOptions(ctx)
}
// CreateOption 新增平台 / 操作人选项。
func (s *Service) CreateOption(ctx context.Context, req CreateOptionRequest) (*OptionDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
optionType := strings.TrimSpace(req.Type)
if !IsValidOptionType(optionType) {
return nil, ErrOptionTypeInvalid
}
name, err := normalizeOptionName(req.Name)
if err != nil {
return nil, err
}
if name == "" {
return nil, ErrOptionNameInvalid
}
return s.repo.CreateOption(ctx, optionType, name)
}
// UpdateOption 重命名平台 / 操作人选项。
func (s *Service) UpdateOption(ctx context.Context, id uint64, req UpdateOptionRequest) (*OptionDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
name, err := normalizeOptionName(req.Name)
if err != nil {
return nil, err
}
if name == "" {
return nil, ErrOptionNameInvalid
}
return s.repo.UpdateOption(ctx, id, name)
}
// DeleteOption 删除平台 / 操作人选项。
func (s *Service) DeleteOption(ctx context.Context, id uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.DeleteOption(ctx, id)
}
// normalizeEntryNo 去除首尾空白并做基础校验。
func normalizeEntryNo(raw string) (string, error) {
value := strings.TrimSpace(raw)
@@ -90,3 +160,16 @@ func normalizeEntryNo(raw string) (string, error) {
}
return value, nil
}
// normalizeOptionName 规整平台 / 操作人名称:去首尾空白、按字符数限长。
// 允许为空(录单时平台与操作人选填),但内部不允许换行等控制字符。
func normalizeOptionName(raw string) (string, error) {
value := strings.TrimSpace(raw)
if utf8.RuneCountInString(value) > maxOptionNameLength {
return "", ErrOptionNameInvalid
}
if strings.ContainsAny(value, "\t\r\n") {
return "", ErrOptionNameInvalid
}
return value, nil
}
@@ -2679,6 +2679,34 @@ func (r *Repository) evictSiteInfoCache(ctx context.Context, site *model.Distrib
}
}
// ResolveSiteCodeByDomain 按访问域名解析启用中的分销站点编码,找不到返回空串。
//
// 用途:站点归属兜底。前端(SPA)会显式带 X-Distribution-Site-Code 头,
// 但首屏探测、脚本调用、分享链接直达等场景可能没带;此时按请求域名判定站点,
// 避免注册等写入把分站用户记成主站(历史上就丢过一批)。
func (r *Repository) ResolveSiteCodeByDomain(ctx context.Context, domain string) (string, error) {
if r == nil || r.db == nil {
return "", ErrDependencyUnavailable
}
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return "", nil
}
var site model.DistributionSite
err := r.db.WithContext(ctx).
Select("site_code").
Where("status = 'active' AND domain = ?", domain).
Order("id ASC").
First(&site).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
return site.SiteCode, nil
}
func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) (*PublicSiteInfoDTO, error) {
if r.db == nil {
return nil, ErrDependencyUnavailable
@@ -42,6 +42,45 @@ func distributionTestDB(t *testing.T) *gorm.DB {
return db
}
// 站点归属兜底依赖「按访问域名反查站点编码」,这里覆盖大小写/空白、主站域名、停用站点等边界。
func TestRepositoryResolveSiteCodeByDomain(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
if err := db.Create(&model.DistributionSite{
SiteCode: "gz_dist", SiteName: "天域商行", Domain: "tysh.hao858.com", Status: "active", TaxRateBPS: 100,
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.DistributionSite{
SiteCode: "down_site", SiteName: "停用站", Domain: "down.hao858.com", Status: "disabled", TaxRateBPS: 100,
}).Error; err != nil {
t.Fatal(err)
}
cases := []struct {
name string
domain string
want string
}{
{name: "命中并统一小写", domain: "TYSH.HAO858.COM", want: "gz_dist"},
{name: "去除首尾空白", domain: " tysh.hao858.com ", want: "gz_dist"},
{name: "主站域名无匹配", domain: "hao858.com", want: ""},
{name: "停用站点不解析", domain: "down.hao858.com", want: ""},
{name: "空域名", domain: " ", want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := repo.ResolveSiteCodeByDomain(t.Context(), tc.domain)
if err != nil {
t.Fatalf("ResolveSiteCodeByDomain(%q) error = %v", tc.domain, err)
}
if got != tc.want {
t.Fatalf("ResolveSiteCodeByDomain(%q) = %q, want %q", tc.domain, got, tc.want)
}
})
}
}
func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
@@ -386,27 +386,30 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
now := time.Now()
// 返利归因站点(统一规则):成本一律由「订单所属站点」承担,并使用该站点自己的返利配置。
// 订单未记录站点但存在分销拆分时,回退到拆分方站点(钱实际归它),
// 避免再次出现「主站替分站订单买单」的倒贴。
orderSiteID := distributorSiteID
if order.DistributionSiteID != nil && *order.DistributionSiteID > 0 {
orderSiteID = *order.DistributionSiteID
}
// 1. 买家端处理(租客 RenterID)
var buyerInvitation model.UserInvitation
if err := tx.Where("invitee_id = ? AND status = ?", order.RenterID, StatusActive).First(&buyerInvitation).Error; err == nil {
if buyerInvitation.VerifiedAt != nil {
var inviter model.User
if err := tx.First(&inviter, buyerInvitation.InviterID).Error; err == nil && inviter.RealnameStatus == "verified" && inviter.ID != order.RenterID {
// 买家端基数:若有分销售卖拆分按 platformSellCent,否则按整单毛利对半拆
// 买家端基数:有分销售卖拆分按 platformSellCent,否则按整单毛利对半拆;归因站点统一取 orderSiteID
var buyerBasisCent int64
var buyerSiteID uint64
if platformSellCent > 0 {
buyerBasisCent = int64(math.Floor(float64(platformSellCent) / 2.0))
buyerSiteID = 0
} else {
buyerBasisCent = int64(math.Floor(float64(orderGrossProfitCent) / 2.0))
if order.DistributionSiteID != nil {
buyerSiteID = *order.DistributionSiteID
}
}
if buyerBasisCent > 0 {
cfg, _ := r.GetRebateConfig(ctx, buyerSiteID)
cfg, _ := r.GetRebateConfig(ctx, orderSiteID)
if cfg == nil || cfg.Enabled {
rate, cycle, days := CalculateRebateRate(*buyerInvitation.VerifiedAt, now, cfg)
rebateCent := int64(math.Round(float64(buyerBasisCent) * rate))
@@ -415,7 +418,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
ledger := model.InvitationRebateLedger{
OrderID: order.ID,
OrderNo: order.OrderNo,
DistributionSiteID: buyerSiteID,
DistributionSiteID: orderSiteID,
InviterID: buyerInvitation.InviterID,
InviteeID: order.RenterID,
TradeRole: "buyer",
@@ -432,7 +435,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 {
_ = wallet.AppendEntries(tx, wallet.Entry{
UserID: buyerInvitation.InviterID,
DistributionSiteID: buyerSiteID,
DistributionSiteID: orderSiteID,
Direction: "in",
AmountCent: rebateCent,
BalanceType: "available",
@@ -454,21 +457,16 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
if sellerInvitation.VerifiedAt != nil {
var inviter model.User
if err := tx.First(&inviter, sellerInvitation.InviterID).Error; err == nil && inviter.RealnameStatus == "verified" && inviter.ID != order.OwnerID {
// 卖家端基数:若有分站收号拆分按 distributorReceiveCent(从分站出),否则按整单毛利对半拆
// 卖家端基数:有分站收号拆分按 distributorReceiveCent,否则按整单毛利对半拆;归因站点统一取 orderSiteID
var sellerBasisCent int64
var sellerSiteID uint64
if distributorReceiveCent > 0 {
sellerBasisCent = int64(math.Floor(float64(distributorReceiveCent) / 2.0))
sellerSiteID = distributorSiteID
} else {
sellerBasisCent = int64(math.Floor(float64(orderGrossProfitCent) / 2.0))
if order.DistributionSiteID != nil {
sellerSiteID = *order.DistributionSiteID
}
}
if sellerBasisCent > 0 {
cfg, _ := r.GetRebateConfig(ctx, sellerSiteID)
cfg, _ := r.GetRebateConfig(ctx, orderSiteID)
if cfg == nil || cfg.Enabled {
rate, cycle, days := CalculateRebateRate(*sellerInvitation.VerifiedAt, now, cfg)
rebateCent := int64(math.Round(float64(sellerBasisCent) * rate))
@@ -477,7 +475,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
ledger := model.InvitationRebateLedger{
OrderID: order.ID,
OrderNo: order.OrderNo,
DistributionSiteID: sellerSiteID,
DistributionSiteID: orderSiteID,
InviterID: sellerInvitation.InviterID,
InviteeID: order.OwnerID,
TradeRole: "seller",
@@ -494,7 +492,7 @@ func (r *Repository) ProcessOrderInvitationRebate(ctx context.Context, tx *gorm.
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ledger).Error; err == nil && ledger.ID > 0 {
_ = wallet.AppendEntries(tx, wallet.Entry{
UserID: sellerInvitation.InviterID,
DistributionSiteID: sellerSiteID,
DistributionSiteID: orderSiteID,
Direction: "in",
AmountCent: rebateCent,
BalanceType: "available",
@@ -271,16 +271,17 @@ func TestCrossSiteDistributionRebate(t *testing.T) {
t.Fatalf("跨站分销返利结算失败: %v", err)
}
// 验证买家端流水:基数 4000 / 2 = 2000,首月 60% = 1200,站点为 0 (主站)
// 验证买家端流水:基数 4000 / 2 = 2000,首月 60% = 1200;
// 本单未记录归属站点,归因回退到拆分方站点(买卖两端统一,不再记主站)
var buyerLedger model.InvitationRebateLedger
if err := db.Where("order_id = ? AND trade_role = 'buyer'", order.ID).First(&buyerLedger).Error; err != nil {
t.Fatalf("未找到买家端流水: %v", err)
}
if buyerLedger.DistributionSiteID != 0 || buyerLedger.RebateAmountCent != 1200 {
if buyerLedger.DistributionSiteID != distributorSiteID || buyerLedger.RebateAmountCent != 1200 {
t.Fatalf("买家端跨站返利错误: siteID=%d, amount=%d", buyerLedger.DistributionSiteID, buyerLedger.RebateAmountCent)
}
// 验证卖家端流水:基数 6000 / 2 = 3000,首月 60% = 1800,站点为 88 (分站)
// 验证卖家端流水:基数 6000 / 2 = 3000,首月 60% = 1800,站点同为拆分方站点
var sellerLedger model.InvitationRebateLedger
if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&sellerLedger).Error; err != nil {
t.Fatalf("未找到卖家端流水: %v", err)
@@ -290,6 +291,112 @@ func TestCrossSiteDistributionRebate(t *testing.T) {
}
}
// 分销订单的返利归因必须统一到「订单所属站点」并使用该站自己的比例。
// 修复前买家端被强制记主站(用主站比例),基数却取分站售卖额,导致主站替分站订单倒贴。
func TestOrderRebateAttrToOrderSiteForDistributionOrder(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
ctx := context.Background()
now := time.Now()
verifiedAt := now.AddDate(0, 0, -1).Truncate(time.Second)
site := model.DistributionSite{
SiteCode: "site_attr", SiteName: "归因测试站", Domain: "attr.example.com", Status: "active",
}
if err := db.Create(&site).Error; err != nil {
t.Fatalf("创建站点失败: %v", err)
}
// 站点专属比例 0.5(主站默认 0.6),用于证明取的是站点配置而非主站配置
if err := db.Create(&model.InvitationRebateConfig{
DistributionSiteID: site.ID,
FirstMonthRate: 0.5,
SecondMonthRate: 0.3,
SubsequentRate: 0.15,
Enabled: true,
}).Error; err != nil {
t.Fatalf("创建站点返利配置失败: %v", err)
}
buyerInviter := model.User{Phone: "13800000401", RealnameStatus: "verified", Status: "active"}
buyer := model.User{Phone: "13800000402", RealnameStatus: "verified", Status: "active"}
sellerInviter := model.User{Phone: "13800000403", RealnameStatus: "verified", Status: "active"}
seller := model.User{Phone: "13800000404", RealnameStatus: "verified", Status: "active"}
for _, user := range []*model.User{&buyerInviter, &buyer, &sellerInviter, &seller} {
if err := db.Create(user).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
}
if err := db.Create(&model.UserInvitation{
InviterID: buyerInviter.ID, InviteeID: buyer.ID, DistributionSiteID: site.ID,
Status: StatusActive, VerifiedAt: &verifiedAt,
}).Error; err != nil {
t.Fatalf("创建买家邀请关系失败: %v", err)
}
if err := db.Create(&model.UserInvitation{
InviterID: sellerInviter.ID, InviteeID: seller.ID, DistributionSiteID: site.ID,
Status: StatusActive, VerifiedAt: &verifiedAt,
}).Error; err != nil {
t.Fatalf("创建卖家邀请关系失败: %v", err)
}
order := model.RentalOrder{
OrderNo: fmt.Sprintf("ORD-SITE-%d", now.UnixNano()),
RenterID: buyer.ID,
OwnerID: seller.ID,
Status: "completed",
SettlementStatus: "settled",
RentAmountCent: 30000,
OwnerRentAmountCent: 20000, // 整单毛利 10000
DistributionSiteID: &site.ID,
}
if err := db.Create(&order).Error; err != nil {
t.Fatalf("创建订单失败: %v", err)
}
// 分站自售拆分:distributor_sell 6000(进入 platformSellCent)+ distributor_receive 4000
parties := []model.DistributionOrderParty{
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_sell", PartyType: "site", PartyID: site.ID, ProfitAmountCent: 6000, IncomeAmountCent: 6000},
{OrderID: order.ID, SiteID: site.ID, PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, ProfitAmountCent: 4000, IncomeAmountCent: 4000},
}
if err := db.Create(&parties).Error; err != nil {
t.Fatalf("创建分销拆分行失败: %v", err)
}
if err := repo.ProcessOrderInvitationRebate(ctx, db, order.ID); err != nil {
t.Fatalf("分销订单返利结算失败: %v", err)
}
// 买家端:基数 6000/2 = 3000,站点比例 0.5 => 1500,归因必须为订单站点
var buyerLedger model.InvitationRebateLedger
if err := db.Where("order_id = ? AND trade_role = 'buyer'", order.ID).First(&buyerLedger).Error; err != nil {
t.Fatalf("未找到买家端流水: %v", err)
}
if buyerLedger.DistributionSiteID != site.ID || buyerLedger.RateApplied != 0.5 || buyerLedger.RebateAmountCent != 1500 {
t.Fatalf("买家端归因/比例错误: site=%d rate=%.2f amount=%d",
buyerLedger.DistributionSiteID, buyerLedger.RateApplied, buyerLedger.RebateAmountCent)
}
// 卖家端:基数 4000/2 = 2000,站点比例 0.5 => 1000,归因同为订单站点
var sellerLedger model.InvitationRebateLedger
if err := db.Where("order_id = ? AND trade_role = 'seller'", order.ID).First(&sellerLedger).Error; err != nil {
t.Fatalf("未找到卖家端流水: %v", err)
}
if sellerLedger.DistributionSiteID != site.ID || sellerLedger.RateApplied != 0.5 || sellerLedger.RebateAmountCent != 1000 {
t.Fatalf("卖家端归因/比例错误: site=%d rate=%.2f amount=%d",
sellerLedger.DistributionSiteID, sellerLedger.RateApplied, sellerLedger.RebateAmountCent)
}
// 钱包流水同样记在站点维度,分站后台才能看到这笔返利支出
var walletLedger model.WalletLedger
if err := db.Where("biz_type = ? AND biz_no = ?", "invite_rebate", order.OrderNo).
Order("id ASC").First(&walletLedger).Error; err != nil {
t.Fatalf("未找到返利钱包流水: %v", err)
}
if walletLedger.DistributionSiteID != site.ID {
t.Fatalf("钱包流水站点维度错误: site=%d, want %d", walletLedger.DistributionSiteID, site.ID)
}
}
// cycleLabel 的比例必须来自实际生效配置,改配置后文案要跟着变,
// 否则钱包流水备注会与后台比例不一致(历史缺陷)。
func TestCycleLabelFollowsConfiguredRate(t *testing.T) {
+9
View File
@@ -358,6 +358,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
authService.SetSiteSMSResolver(distributionService)
}
}
// 站点归属兜底:请求未携带站点编码时按访问域名判定站点(前端首屏探测/漏带场景),
// 避免注册等写入把分站用户记成主站。必须注册在任何业务路由之前。
if distributionRepo != nil {
engine.Use(middleware.SiteDomainFallback(distributionRepo, logger))
}
distributionHandler := distribution.NewHandler(distributionService)
if invitationHandler != nil && distributionService != nil {
invitationHandler.SetSiteScoper(distributionService)
@@ -721,6 +726,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/files/upload", fileHandler.Upload)
adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary)
adminRoutes.GET("/manual-entries/today-stats", requirePerm("cs_manual_entry:view"), manualEntryHandler.TodayAgentStats)
adminRoutes.GET("/manual-entries/options", requirePerm("cs_manual_entry:view"), manualEntryHandler.ListOptions)
adminRoutes.POST("/manual-entries/options", requirePerm("cs_manual_entry:option_manage"), manualEntryHandler.CreateOption)
adminRoutes.PUT("/manual-entries/options/:id", requirePerm("cs_manual_entry:option_manage"), manualEntryHandler.UpdateOption)
adminRoutes.DELETE("/manual-entries/options/:id", requirePerm("cs_manual_entry:option_manage"), manualEntryHandler.DeleteOption)
adminRoutes.GET("/manual-entries", requirePerm("cs_manual_entry:view"), manualEntryHandler.List)
adminRoutes.POST("/manual-entries", requirePerm("cs_manual_entry:create"), manualEntryHandler.Create)
adminRoutes.PUT("/manual-entries/:id", requirePerm("cs_manual_entry:update"), manualEntryHandler.Update)
@@ -0,0 +1,52 @@
-- +goose Up
-- 客服手动录单:新增「平台」与「操作人」两列,并支持后台维护可选项。
-- platform/operator 存名称快照(与 admin_name 同样的快照思路),
-- 选项只作为下拉数据源,删除选项不影响历史记录。
ALTER TABLE cs_manual_entries
ADD COLUMN platform VARCHAR(64) NOT NULL DEFAULT '' COMMENT '平台名称快照' AFTER entry_no,
ADD COLUMN operator VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称快照' AFTER platform,
ADD KEY idx_cs_manual_entries_platform (platform),
ADD KEY idx_cs_manual_entries_operator (operator);
CREATE TABLE IF NOT EXISTS cs_manual_entry_options (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
type VARCHAR(16) NOT NULL COMMENT '选项类型: platform=平台, operator=操作人',
name VARCHAR(64) NOT NULL COMMENT '选项名称',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_cs_manual_entry_options_type_name (type, name),
KEY idx_cs_manual_entry_options_type_sort (type, sort_order, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客服手动录单平台/操作人选项配置表';
INSERT INTO permissions (code, name, resource, action) VALUES
('cs_manual_entry:option_manage', '配置客服手动录单平台与操作人', 'cs_manual_entry', 'option_manage')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
resource = VALUES(resource),
action = VALUES(action);
-- 运营/超管:可维护平台与操作人选项
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.code = 'cs_manual_entry:option_manage'
WHERE r.code IN ('ops', 'super_admin');
-- +goose Down
DELETE rp
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE p.code = 'cs_manual_entry:option_manage';
DELETE FROM permissions WHERE code = 'cs_manual_entry:option_manage';
DROP TABLE IF EXISTS cs_manual_entry_options;
ALTER TABLE cs_manual_entries
DROP KEY idx_cs_manual_entries_platform,
DROP KEY idx_cs_manual_entries_operator,
DROP COLUMN platform,
DROP COLUMN operator;
@@ -2,9 +2,14 @@ import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
/** 录单可选项类型:平台 / 操作人 */
export type ManualEntryOptionType = 'platform' | 'operator'
export interface ManualEntryItem {
id: number
entry_no: string
platform: string
operator: string
admin_id: number
admin_name: string
admin_username: string
@@ -12,6 +17,19 @@ export interface ManualEntryItem {
created_at: string
}
export interface ManualEntryOption {
id: number
type: ManualEntryOptionType
name: string
sort_order: number
created_at: string
}
export interface ManualEntryOptions {
platforms: ManualEntryOption[]
operators: ManualEntryOption[]
}
export interface ManualEntryAgentStats {
admin_id: number
admin_name: string
@@ -20,11 +38,15 @@ export interface ManualEntryAgentStats {
export interface CreateManualEntryRequest {
entry_no: string
platform?: string
operator?: string
remark?: string
}
export interface UpdateManualEntryRequest {
entry_no: string
platform?: string
operator?: string
remark?: string
}
@@ -79,3 +101,37 @@ export async function fetchManualEntryTodayStats() {
)
return data.data
}
/** 获取平台 / 操作人可选项(录单下拉数据源) */
export async function fetchManualEntryOptions() {
const { data } = await apiClient.get<ApiResponse<ManualEntryOptions>>(
'/admin/manual-entries/options'
)
return data.data
}
export async function createManualEntryOption(payload: {
type: ManualEntryOptionType
name: string
}) {
const { data } = await apiClient.post<ApiResponse<ManualEntryOption>>(
'/admin/manual-entries/options',
payload
)
return data.data
}
export async function updateManualEntryOption(id: number, name: string) {
const { data } = await apiClient.put<ApiResponse<ManualEntryOption>>(
`/admin/manual-entries/options/${id}`,
{ name }
)
return data.data
}
export async function deleteManualEntryOption(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(
`/admin/manual-entries/options/${id}`
)
return data.data
}
@@ -13,6 +13,12 @@
margin-top: 20px;
}
.page-header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.manual-entry-form-head {
display: flex;
align-items: baseline;
@@ -34,19 +40,22 @@
.manual-entry-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
align-items: end;
}
.manual-entry-form-grid :deep(.el-form-item) {
margin-bottom: 0;
}
.manual-entry-submit {
.full-control {
width: 100%;
}
.manual-entry-submit-row {
display: flex;
align-items: center;
height: 32px;
justify-content: flex-end;
margin-top: 18px;
}
.manual-entry-filter-row {
@@ -62,7 +71,7 @@
}
.filter-keyword {
width: 260px;
width: 320px;
}
.filter-date {
@@ -76,8 +85,87 @@
color: #1e3a8a;
}
/* 平台 / 操作人配置弹窗 */
.option-config-tip {
margin: 0 0 16px;
font-size: 12px;
color: #94a3b8;
}
.option-config-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.option-config-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.option-config-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
}
.option-config-add {
display: flex;
gap: 8px;
}
.option-config-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
max-height: 320px;
overflow-y: auto;
}
.option-config-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
background: #ffffff;
border: 1px solid #e2e8f0;
}
.option-config-name {
font-size: 13px;
color: #0f172a;
word-break: break-all;
}
.option-config-actions {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.option-config-actions :deep(.el-button) {
margin: 0;
}
.option-config-empty {
margin: 0;
font-size: 12px;
color: #94a3b8;
}
@media (max-width: 1100px) {
.manual-entry-form-grid {
.manual-entry-form-grid,
.option-config-grid {
grid-template-columns: 1fr;
}
}
@@ -1,13 +1,20 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createManualEntry,
createManualEntryOption,
deleteManualEntry,
deleteManualEntryOption,
fetchManualEntries,
fetchManualEntryOptions,
updateManualEntry,
updateManualEntryOption,
type ManualEntryItem,
type ManualEntryOption,
type ManualEntryOptionType,
type ManualEntryOptions,
} from '@/features/admin/api/adminManualEntry'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { useAdminSessionStore } from '@/stores/adminSession'
@@ -21,6 +28,7 @@ const currentAdminName = computed(() => adminSession.nickname || adminSession.us
const canCreate = computed(() => adminSession.hasPermission('cs_manual_entry:create'))
const canUpdate = computed(() => adminSession.hasPermission('cs_manual_entry:update'))
const canDelete = computed(() => adminSession.hasPermission('cs_manual_entry:delete'))
const canManageOptions = computed(() => adminSession.hasPermission('cs_manual_entry:option_manage'))
const filters = reactive({
keyword: '',
@@ -28,8 +36,13 @@ const filters = reactive({
dateRange: null as string[] | null,
})
const options = ref<ManualEntryOptions>({ platforms: [], operators: [] })
const optionsLoading = ref(false)
const entryForm = reactive({
entry_no: '',
platform: '',
operator: '',
remark: '',
})
@@ -54,6 +67,21 @@ const {
},
})
onMounted(() => {
void loadOptions()
})
async function loadOptions() {
optionsLoading.value = true
try {
options.value = await fetchManualEntryOptions()
} catch (error) {
ElMessage.error(readError(error, '获取平台与操作人选项失败'))
} finally {
optionsLoading.value = false
}
}
function resetFilters() {
filters.keyword = ''
filters.entry_no = ''
@@ -86,10 +114,14 @@ async function handleCreate() {
try {
const result = await createManualEntry({
entry_no: entryNo,
platform: entryForm.platform,
operator: entryForm.operator,
remark: entryForm.remark,
})
ElMessage.success(`编号 ${result.entry.entry_no} 已登记到【${result.entry.admin_name}】`)
entryForm.entry_no = ''
entryForm.platform = ''
entryForm.operator = ''
entryForm.remark = ''
currentPage.value = 1
await loadEntries()
@@ -104,11 +136,35 @@ async function handleCreate() {
const editVisible = ref(false)
const editSubmitting = ref(false)
const editingEntry = ref<ManualEntryItem | null>(null)
const editForm = reactive({ entry_no: '', remark: '' })
const editForm = reactive({ entry_no: '', platform: '', operator: '', remark: '' })
/**
* 历史记录里的平台/操作人可能已被后台删除选项,
* 这里把当前值补进下拉,保证编辑时能正常显示与保留。
*/
function withCurrentValue(list: ManualEntryOption[], current: string) {
const items: { id: number | string; name: string }[] = list.map(item => ({
id: item.id,
name: item.name,
}))
if (current && !items.some(item => item.name === current)) {
items.unshift({ id: `current-${current}`, name: current })
}
return items
}
const editPlatformOptions = computed(() =>
withCurrentValue(options.value.platforms, editForm.platform)
)
const editOperatorOptions = computed(() =>
withCurrentValue(options.value.operators, editForm.operator)
)
function openEdit(row: ManualEntryItem) {
editingEntry.value = row
editForm.entry_no = row.entry_no
editForm.platform = row.platform || ''
editForm.operator = row.operator || ''
editForm.remark = row.remark
editVisible.value = true
}
@@ -124,6 +180,8 @@ async function handleUpdate() {
try {
await updateManualEntry(editingEntry.value.id, {
entry_no: entryNo,
platform: editForm.platform,
operator: editForm.operator,
remark: editForm.remark,
})
ElMessage.success('录单已更新')
@@ -154,6 +212,86 @@ async function handleDelete(row: ManualEntryItem) {
ElMessage.error(readError(error, '删除失败'))
}
}
// ---------- 平台 / 操作人配置 ----------
const configVisible = ref(false)
const optionSubmitting = ref(false)
const newPlatformName = ref('')
const newOperatorName = ref('')
function openConfig() {
configVisible.value = true
if (!options.value.platforms.length && !options.value.operators.length) {
void loadOptions()
}
}
async function handleAddOption(type: ManualEntryOptionType) {
const name = (type === 'platform' ? newPlatformName.value : newOperatorName.value).trim()
if (!name) {
ElMessage.warning('请输入名称')
return
}
optionSubmitting.value = true
try {
await createManualEntryOption({ type, name })
if (type === 'platform') newPlatformName.value = ''
else newOperatorName.value = ''
ElMessage.success('已添加')
await loadOptions()
} catch (error) {
ElMessage.error(readError(error, '添加失败'))
} finally {
optionSubmitting.value = false
}
}
async function handleRenameOption(item: ManualEntryOption) {
let input: string
try {
const result = await ElMessageBox.prompt('请输入新的名称', '重命名', {
inputValue: item.name,
inputPattern: /\S/,
inputErrorMessage: '名称不能为空',
confirmButtonText: '保存',
cancelButtonText: '取消',
})
input = result.value
} catch {
return // 用户取消
}
const name = input.trim()
if (!name || name === item.name) return
optionSubmitting.value = true
try {
await updateManualEntryOption(item.id, name)
ElMessage.success('已重命名')
await loadOptions()
} catch (error) {
ElMessage.error(readError(error, '重命名失败'))
} finally {
optionSubmitting.value = false
}
}
async function handleRemoveOption(item: ManualEntryOption) {
try {
await ElMessageBox.confirm(
`确定删除${item.type === 'platform' ? '平台' : '操作人'}【${item.name}】吗?历史录单记录不受影响。`,
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return // 用户取消
}
try {
await deleteManualEntryOption(item.id)
ElMessage.success('已删除')
await loadOptions()
} catch (error) {
ElMessage.error(readError(error, '删除失败'))
}
}
</script>
<template>
@@ -165,9 +303,14 @@ async function handleDelete(row: ManualEntryItem) {
<p>
当前登录客服:
<strong>{{ currentAdminName }}</strong>
· 输入编号登记到本人名下,记录登记时间,支持查询与修正。
· 输入编号登记到本人名下,可选平台与操作人,支持查询与修正。
</p>
</div>
<div class="page-header-actions">
<el-button v-if="canManageOptions" :loading="optionsLoading" @click="openConfig">
配置平台/操作人
</el-button>
</div>
</div>
<!-- 录单表单 -->
@@ -186,6 +329,40 @@ async function handleDelete(row: ManualEntryItem) {
@keyup.enter="handleCreate"
/>
</el-form-item>
<el-form-item label="平台(可选)">
<el-select
v-model="entryForm.platform"
placeholder="请选择平台"
clearable
filterable
class="full-control"
:loading="optionsLoading"
>
<el-option
v-for="item in options.platforms"
:key="item.id"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="操作人(可选)">
<el-select
v-model="entryForm.operator"
placeholder="请选择操作人"
clearable
filterable
class="full-control"
:loading="optionsLoading"
>
<el-option
v-for="item in options.operators"
:key="item.id"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="备注(可选)">
<el-input
v-model="entryForm.remark"
@@ -195,11 +372,9 @@ async function handleDelete(row: ManualEntryItem) {
@keyup.enter="handleCreate"
/>
</el-form-item>
<div class="manual-entry-submit">
<el-button type="primary" :loading="submitting" @click="handleCreate">
提交登记
</el-button>
</div>
</div>
<div class="manual-entry-submit-row">
<el-button type="primary" :loading="submitting" @click="handleCreate"> 提交登记 </el-button>
</div>
</div>
@@ -215,7 +390,7 @@ async function handleDelete(row: ManualEntryItem) {
/>
<el-input
v-model="filters.keyword"
placeholder="按编号 / 客服名 / 登录名模糊查询"
placeholder="按编号 / 客服名 / 登录名 / 平台 / 操作人模糊查询"
clearable
class="filter-keyword"
@keyup.enter="applySearch"
@@ -235,11 +410,17 @@ async function handleDelete(row: ManualEntryItem) {
<el-table v-loading="loading" class="table-panel" :data="entries">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="entry_no" label="编号" min-width="160">
<el-table-column prop="entry_no" label="编号" width="160">
<template #default="{ row }">
<span class="mono-entry-no">{{ row.entry_no }}</span>
</template>
</el-table-column>
<el-table-column prop="platform" label="平台" min-width="120">
<template #default="{ row }">{{ row.platform || '-' }}</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" min-width="120">
<template #default="{ row }">{{ row.operator || '-' }}</template>
</el-table-column>
<el-table-column prop="admin_name" label="客服" min-width="120">
<template #default="{ row }">{{ row.admin_name || row.admin_username || '-' }}</template>
</el-table-column>
@@ -285,6 +466,38 @@ async function handleDelete(row: ManualEntryItem) {
clearable
/>
</el-form-item>
<el-form-item label="平台">
<el-select
v-model="editForm.platform"
placeholder="请选择平台"
clearable
filterable
class="full-control"
>
<el-option
v-for="item in editPlatformOptions"
:key="item.id"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="操作人">
<el-select
v-model="editForm.operator"
placeholder="请选择操作人"
clearable
filterable
class="full-control"
>
<el-option
v-for="item in editOperatorOptions"
:key="item.id"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="editForm.remark"
@@ -299,6 +512,91 @@ async function handleDelete(row: ManualEntryItem) {
<el-button type="primary" :loading="editSubmitting" @click="handleUpdate">保存</el-button>
</template>
</el-dialog>
<!-- 平台 / 操作人配置弹窗 -->
<el-dialog
v-model="configVisible"
title="配置平台与操作人"
width="720px"
destroy-on-close
class="manual-entry-config-dialog"
>
<p class="option-config-tip">
这里维护的可选项会出现在录单表单的下拉中;删除选项不会影响已登记的历史记录。
</p>
<div class="option-config-grid">
<section class="option-config-panel">
<header class="option-config-title">平台</header>
<div class="option-config-add">
<el-input
v-model="newPlatformName"
placeholder="新增平台名称"
maxlength="64"
clearable
@keyup.enter="handleAddOption('platform')"
/>
<el-button
type="primary"
:loading="optionSubmitting"
@click="handleAddOption('platform')"
>
添加
</el-button>
</div>
<ul class="option-config-list">
<li v-for="item in options.platforms" :key="item.id">
<span class="option-config-name">{{ item.name }}</span>
<span class="option-config-actions">
<el-button link type="primary" size="small" @click="handleRenameOption(item)">
重命名
</el-button>
<el-button link type="danger" size="small" @click="handleRemoveOption(item)">
删除
</el-button>
</span>
</li>
</ul>
<p v-if="!options.platforms.length" class="option-config-empty">暂无平台,请先添加</p>
</section>
<section class="option-config-panel">
<header class="option-config-title">操作人</header>
<div class="option-config-add">
<el-input
v-model="newOperatorName"
placeholder="新增操作人名称"
maxlength="64"
clearable
@keyup.enter="handleAddOption('operator')"
/>
<el-button
type="primary"
:loading="optionSubmitting"
@click="handleAddOption('operator')"
>
添加
</el-button>
</div>
<ul class="option-config-list">
<li v-for="item in options.operators" :key="item.id">
<span class="option-config-name">{{ item.name }}</span>
<span class="option-config-actions">
<el-button link type="primary" size="small" @click="handleRenameOption(item)">
重命名
</el-button>
<el-button link type="danger" size="small" @click="handleRemoveOption(item)">
删除
</el-button>
</span>
</li>
</ul>
<p v-if="!options.operators.length" class="option-config-empty">暂无操作人,请先添加</p>
</section>
</div>
<template #footer>
<el-button type="primary" @click="configVisible = false">关闭</el-button>
</template>
</el-dialog>
</section>
</template>