Compare commits
3
Commits
9a52dbdb7b
...
efa0aa4ffd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa0aa4ffd | ||
|
|
2e13443805 | ||
|
|
f33dc97f6f |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user