From 2264851d5d3472c97c8e32be19a605074851e8ac Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 5 Aug 2026 13:32:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=20service=20=E4=B8=8E?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=A4=A7=E6=96=87=E4=BB=B6,=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20CORS=20=E9=85=8D=E7=BD=AE=E4=B8=8E=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 internal/service 按职责拆分: fulfillment.go(1397→527)拆出 wallet/timeout/data/order/query/dashboard/shipnotify delivery.go(1124→801)拆出 upstream/link/state/helpers merchant.go(855→251)拆出 member/product/api_client/catalog/helpers - 前端 MerchantCenter.tsx(1327→606)拆出 merchantCenterTabs/merchantCenterUtils - docker-compose backend 透传 CORS_ALLOWED_ORIGINS - CORS 白名单实现(config/router/README/.env.example 配套) - 修复 gofmt 与文件尾部多余空行 --- .env.example | 5 + .gitignore | 3 + README.md | 1 + backend/cmd/server/main.go | 35 +- backend/internal/config/config.go | 22 + backend/internal/router/router.go | 31 +- backend/internal/service/delivery.go | 325 ------- backend/internal/service/delivery_helpers.go | 77 ++ backend/internal/service/delivery_link.go | 74 ++ backend/internal/service/delivery_state.go | 71 ++ backend/internal/service/delivery_upstream.go | 137 +++ backend/internal/service/fulfillment.go | 870 ------------------ .../internal/service/fulfillment_dashboard.go | 195 ++++ backend/internal/service/fulfillment_data.go | 157 ++++ backend/internal/service/fulfillment_order.go | 45 + backend/internal/service/fulfillment_query.go | 116 +++ .../service/fulfillment_shipnotify.go | 190 ++++ .../internal/service/fulfillment_timeout.go | 131 +++ .../internal/service/fulfillment_wallet.go | 110 +++ backend/internal/service/merchant.go | 606 ------------ .../internal/service/merchant_api_client.go | 131 +++ backend/internal/service/merchant_catalog.go | 262 ++++++ backend/internal/service/merchant_helpers.go | 41 + backend/internal/service/merchant_member.go | 64 ++ backend/internal/service/merchant_product.go | 155 ++++ docker-compose.yml | 1 + frontend/src/pages/MerchantCenter.tsx | 853 ++--------------- frontend/src/pages/merchantCenterTabs.tsx | 769 ++++++++++++++++ frontend/src/pages/merchantCenterUtils.ts | 119 +++ 29 files changed, 2990 insertions(+), 2606 deletions(-) create mode 100644 backend/internal/service/delivery_helpers.go create mode 100644 backend/internal/service/delivery_link.go create mode 100644 backend/internal/service/delivery_state.go create mode 100644 backend/internal/service/delivery_upstream.go create mode 100644 backend/internal/service/fulfillment_dashboard.go create mode 100644 backend/internal/service/fulfillment_data.go create mode 100644 backend/internal/service/fulfillment_order.go create mode 100644 backend/internal/service/fulfillment_query.go create mode 100644 backend/internal/service/fulfillment_shipnotify.go create mode 100644 backend/internal/service/fulfillment_timeout.go create mode 100644 backend/internal/service/fulfillment_wallet.go create mode 100644 backend/internal/service/merchant_api_client.go create mode 100644 backend/internal/service/merchant_catalog.go create mode 100644 backend/internal/service/merchant_helpers.go create mode 100644 backend/internal/service/merchant_member.go create mode 100644 backend/internal/service/merchant_product.go create mode 100644 frontend/src/pages/merchantCenterTabs.tsx create mode 100644 frontend/src/pages/merchantCenterUtils.ts diff --git a/.env.example b/.env.example index 5d75820..cfefec3 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,11 @@ DELIVERY_LINK_TTL_MINUTES=120 FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=30 FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=60 +# 跨域来源白名单(逗号分隔)。生产前后端同域(Caddy 统一入口)无需配置; +# 仅前后端分离部署时设置,例如:CORS_ALLOWED_ORIGINS=https://admin.example.com +# 为空时仅放行本地开发端口 localhost:15173 / 18080;不支持 "*"(与凭证模式冲突)。 +# CORS_ALLOWED_ORIGINS= + # Docker 构建基础镜像;如镜像源不可用,可改为官方镜像或你的私有镜像源 POSTGRES_IMAGE=docker.m.daocloud.io/library/postgres:16-alpine GO_BUILDER_IMAGE=docker.m.daocloud.io/library/golang:1.26-alpine diff --git a/.gitignore b/.gitignore index 08024ef..f783724 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ frontend/dist/ .idea/ .vscode/ *.log + +# reasonix 本地会话数据 +.reasonix/ diff --git a/README.md b/README.md index 933cb35..4d91550 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ make docker-down # 停止并移除 | `OPEN_API_DEBUG` | 开放接口调试日志 | debug 模式默认开启 | | `LOG_FILE` | 日志文件路径(按天分割为 `app-YYYYMMDD.log`) | `logs/app.log` | | `LOG_RETAIN_DAYS` | 日志文件保留天数,到期自动清理 | `30` | +| `CORS_ALLOWED_ORIGINS` | 跨域来源白名单(逗号分隔);生产同域部署无需配置,为空时仅放行本地开发端口,不支持 `*` | 空 | | `FULFILLMENT_PROCESSING_TIMEOUT_MINUTES` | 发货中订单自动标记失败的超时分钟数,<=0 关闭 | `30` | | `FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS` | 发货超时巡检间隔秒数 | `60` | diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index d5d5f8a..9d606d3 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -91,23 +91,24 @@ func main() { openlog.Init(cfg.OpenAPIDebug) h := &router.Handlers{ - Auth: handler.NewAuthHandler(authSvc), - Dashboard: handler.NewDashboardHandler(fulfillmentSvc), - Delivery: handler.NewDeliveryHandler(deliverySvc), - User: handler.NewUserHandler(userSvc), - Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc), - SourceOpen: handler.NewOpenHandler(fulfillmentSvc), - Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc, rechargeSvc), - Recharge: handler.NewRechargeHandler(rechargeSvc), - JWT: jm, - Tenant: tenantSvc, - OpenDB: db, - SecretCodec: codec, - OpenAPIKey: cfg.OpenAPIKey, - OpenAPISecret: cfg.OpenAPISecret, - OpenSignSkew: cfg.OpenSignSkew, - OpenAPIDebug: cfg.OpenAPIDebug, - UploadDir: cfg.UploadDir, + Auth: handler.NewAuthHandler(authSvc), + Dashboard: handler.NewDashboardHandler(fulfillmentSvc), + Delivery: handler.NewDeliveryHandler(deliverySvc), + User: handler.NewUserHandler(userSvc), + Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc, deliverySvc), + SourceOpen: handler.NewOpenHandler(fulfillmentSvc), + Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc, rechargeSvc), + Recharge: handler.NewRechargeHandler(rechargeSvc), + JWT: jm, + Tenant: tenantSvc, + OpenDB: db, + SecretCodec: codec, + OpenAPIKey: cfg.OpenAPIKey, + OpenAPISecret: cfg.OpenAPISecret, + OpenSignSkew: cfg.OpenSignSkew, + OpenAPIDebug: cfg.OpenAPIDebug, + UploadDir: cfg.UploadDir, + CORSAllowedOrigins: cfg.CORSAllowedOrigins, } go callbackSvc.Run(context.Background()) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 15d7c06..e64ee2d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -51,6 +51,9 @@ type Config struct { CallbackRetryScheduleSeconds []int // UploadDir 凭证等上传文件的存储目录(默认 data/uploads,通过 /uploads 公开访问)。 UploadDir string + // CORSAllowedOrigins 跨域允许的来源(逗号分隔);为空时仅放行常见本地开发端口。 + // 生产前后端同域(Caddy 统一入口)时无需配置;仅在前后端分离部署时设置。 + CORSAllowedOrigins []string } // Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高) @@ -82,9 +85,28 @@ func Load() *Config { CallbackPushTimeoutSeconds: getEnvInt("CALLBACK_PUSH_TIMEOUT_SECONDS", 15), CallbackRetryScheduleSeconds: getEnvIntList("CALLBACK_RETRY_SCHEDULE", nil), UploadDir: getEnv("UPLOAD_DIR", "data/uploads"), + CORSAllowedOrigins: getEnvList("CORS_ALLOWED_ORIGINS", nil), } } +// getEnvList 解析逗号分隔的字符串列表(如 CORS 来源);空串返回默认值。 +func getEnvList(key string, def []string) []string { + v := os.Getenv(key) + if v == "" { + return def + } + var out []string + for _, part := range strings.Split(v, ",") { + if s := strings.TrimSpace(part); s != "" { + out = append(out, s) + } + } + if len(out) == 0 { + return def + } + return out +} + // getEnvIntList 解析逗号分隔的整数列表(如 "15,15,30,180");空串返回 nil。 func getEnvIntList(key string, def []int) []int { v := os.Getenv(key) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f2a005e..9b92252 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -1,6 +1,8 @@ package router import ( + "strings" + "affiliate_dash/internal/handler" "affiliate_dash/internal/middleware" "affiliate_dash/internal/model" @@ -30,6 +32,33 @@ type Handlers struct { OpenSignSkew int64 OpenAPIDebug bool UploadDir string + // CORSAllowedOrigins 跨域来源白名单;为空时使用本地开发默认来源。 + CORSAllowedOrigins []string +} + +// defaultCORSOrigins 未配置 CORS_ALLOWED_ORIGINS 时的本地开发默认来源。 +var defaultCORSOrigins = []string{ + "http://localhost:15173", + "http://127.0.0.1:15173", + "http://localhost:18080", + "http://127.0.0.1:18080", +} + +// corsOrigins 返回生效的 CORS 来源白名单,并拒绝通配符 "*" +// (与 AllowCredentials 组合会让浏览器把凭证请求放行给任意站点)。 +func corsOrigins(h *Handlers) []string { + var origins []string + for _, o := range h.CORSAllowedOrigins { + o = strings.TrimSpace(o) + if o == "" || o == "*" { + continue + } + origins = append(origins, o) + } + if len(origins) == 0 { + return defaultCORSOrigins + } + return origins } // requestLogger 访问日志中间件:跳过 /health 健康检查(每 30 秒一次,避免刷屏)。 @@ -49,7 +78,7 @@ func Setup(h *Handlers) *gin.Engine { r.Use(requestLogger(), gin.Recovery()) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, + AllowOrigins: corsOrigins(h), AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Merchant-ID", "X-App-Key", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign", "X-Request-ID"}, ExposeHeaders: []string{"Content-Length", "X-Request-ID"}, diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index 58a3690..e34b514 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -1,17 +1,9 @@ package service import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" "errors" - "fmt" - "io" "net/http" "net/url" - "strconv" "strings" "time" @@ -616,67 +608,6 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m return info, order, goodID, nil } -func buildExistingDeliverySubmitResult(order *model.FulfillmentOrder) *DeliverySubmitResult { - if order == nil { - return nil - } - switch normalizeOrderStatus(order) { - case model.OrderStatusDelivering, model.OrderStatusDelivered: - default: - return nil - } - var resultData map[string]interface{} - if json.Valid([]byte(order.ResultData)) { - _ = json.Unmarshal([]byte(order.ResultData), &resultData) - } - providerOrderNo := firstNonEmpty( - order.ProviderOrderNo, - stringFromMap(resultData, "provider_order_no"), - ) - message := "订单暂时正在发货中,请稍后查询" - status := normalizeOrderStatus(order) - if status == model.OrderStatusDelivered { - message = "订单已交付" - } - return &DeliverySubmitResult{ - OrderNo: order.OrderNo, - Status: status, - Message: message, - ProviderOrderNo: providerOrderNo, - UpstreamOrder: resultData, - } -} - -func upstreamDeliverySucceeded(order map[string]interface{}) bool { - return strings.EqualFold(stringFromMap(order, "status"), "FINISHED") || - strings.EqualFold(stringFromMap(order, "send_status"), "SUCCESS") -} - -func deliverySubmissionInProgress(stage string) bool { - return stage == deliveryStageClaimed || stage == deliveryStageQueueCreated -} - -func deliverySubmissionStale(order *model.FulfillmentOrder, now time.Time) bool { - if order == nil { - return false - } - startedAtRaw := resultDataString(order.ResultData, "submit_started_at") - if startedAtRaw != "" { - if startedAt, err := time.Parse(timeutil.APITimeLayout, startedAtRaw); err == nil { - return now.Sub(startedAt) >= deliverySubmissionStaleTimeout - } - } - return !order.UpdatedAt.IsZero() && now.Sub(order.UpdatedAt) >= deliverySubmissionStaleTimeout -} - -func optionalUint(value uint) *uint { - if value == 0 { - return nil - } - out := value - return &out -} - func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) { orderNo = strings.TrimSpace(orderNo) if orderNo == "" { @@ -826,207 +757,6 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth return info, order, goodID, nil } -func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, auth DeliveryLinkAuth) error { - if order == nil { - return newDeliveryHTTPError(http.StatusNotFound, "订单不存在") - } - if auth.Exp <= 0 || strings.TrimSpace(auth.Sign) == "" { - return newDeliveryHTTPError(http.StatusForbidden, "链接参数缺失") - } - if order.DeliveryLinkRevokedAt != nil { - return newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废") - } - if order.DeliveryLinkExpiresAt == nil { - return newDeliveryHTTPError(http.StatusForbidden, "发货链接未生成") - } - expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second) - linkExpires := time.Unix(auth.Exp, 0).UTC() - if !linkExpires.Equal(expiresAt) { - return newDeliveryHTTPError(http.StatusForbidden, "发货链接已失效") - } - if time.Now().UTC().After(linkExpires) { - return newDeliveryHTTPError(http.StatusForbidden, "发货链接已过期") - } - expected := s.signDeliveryLink(order.OrderNo, auth.Exp) - if !hmac.Equal([]byte(strings.ToLower(strings.TrimSpace(auth.Sign))), []byte(expected)) { - return newDeliveryHTTPError(http.StatusForbidden, "发货链接签名无效") - } - return nil -} - -func (s *DeliveryService) buildDeliveryLinkResult(orderNo string, expiresAt time.Time, requestBaseURL string) *DeliveryLinkResult { - expiresAt = expiresAt.UTC() - exp := expiresAt.Unix() - sign := s.signDeliveryLink(orderNo, exp) - return &DeliveryLinkResult{ - OrderNo: orderNo, - DeliveryURL: s.buildDeliveryURL(orderNo, exp, sign, requestBaseURL), - ExpiresAt: expiresAt, - Exp: exp, - Sign: sign, - } -} - -func (s *DeliveryService) buildDeliveryURL(orderNo string, exp int64, sign, requestBaseURL string) string { - path := fmt.Sprintf("/delivery/%s/%s?exp=%d&sign=%s", url.PathEscape(s.channel), url.PathEscape(orderNo), exp, url.QueryEscape(sign)) - base := strings.TrimRight(s.linkBaseURL, "/") - if base == "" { - base = strings.TrimRight(requestBaseURL, "/") - } - if base == "" { - return path - } - return base + path -} - -func (s *DeliveryService) signDeliveryLink(orderNo string, exp int64) string { - mac := hmac.New(sha256.New, []byte(s.linkSecret)) - _, _ = mac.Write([]byte(orderNo + "|" + strconv.FormatInt(exp, 10))) - return hex.EncodeToString(mac.Sum(nil)) -} - -func buildDeliveryProduct(openProduct *OpenOrderProduct, good map[string]interface{}) *DeliveryProduct { - if openProduct == nil { - return nil - } - product := &DeliveryProduct{ - Name: openProduct.Name, - SKU: openProduct.SKU, - Game: openProduct.Game, - } - if title := stringFromMap(good, "title"); title != "" { - product.Name = title - } - product.Image = stringFromMap(good, "image") - return product -} - -func (s *DeliveryService) goodsDetail(goodID string) (map[string]interface{}, error) { - var out struct { - Good map[string]interface{} `json:"good"` - } - if err := s.signProxy("/public/goods/detail", "POST", map[string]interface{}{ - "good_id": goodID, - }, &out); err != nil { - return nil, err - } - return out.Good, nil -} - -func (s *DeliveryService) accountBound(bindUUID, goodID string) (map[string]interface{}, error) { - var out struct { - GameAccount map[string]interface{} `json:"gameAccount"` - Snake map[string]interface{} `json:"game_account"` - } - if err := s.signProxy("/public/games/account-bound", "POST", map[string]interface{}{ - "bind_uuid": bindUUID, - "bindUuid": bindUUID, - "goodId": goodID, - "good_id": goodID, - }, &out); err != nil { - return nil, err - } - if out.GameAccount == nil { - out.GameAccount = out.Snake - } - if out.GameAccount == nil { - return nil, errors.New("账号尚未绑定,请扫码完成绑定后再提交") - } - return out.GameAccount, nil -} - -func (s *DeliveryService) createOrderQueue(goodID, orderNo string) (string, error) { - var out struct { - Orders []map[string]interface{} `json:"orders"` - } - if err := s.signProxy("/public/users/orders-queue", "POST", map[string]interface{}{ - "good_id": goodID, - "quantity": 1, - "order_sn": orderNo, - }, &out); err != nil { - return "", err - } - if len(out.Orders) == 0 { - return "", errors.New("发货服务未返回队列订单") - } - orderID := firstNonEmpty(stringFromMap(out.Orders[0], "_id"), stringFromMap(out.Orders[0], "id")) - if orderID == "" { - return "", errors.New("发货服务未返回队列订单 ID") - } - return orderID, nil -} - -func (s *DeliveryService) patchOrderQueue(orderID, gameAccount, bindUUID string) error { - var out map[string]interface{} - return s.signProxy("/public/users/orders-queue", "PATCH", map[string]interface{}{ - "order_id": orderID, - "game_account": gameAccount, - "bind_uuid": bindUUID, - }, &out) -} - -func (s *DeliveryService) createUpstreamOrder(orderID, gameAccount, goodID, orderNo string) (map[string]interface{}, error) { - var out struct { - Order map[string]interface{} `json:"order"` - } - if err := s.signProxy("/public/users/orders", "POST", map[string]interface{}{ - "order_id": orderID, - "game_account": gameAccount, - "good_id": goodID, - "h5_prefix": s.channel, - "order_sn": orderNo, - }, &out); err != nil { - return nil, err - } - if out.Order == nil { - out.Order = map[string]interface{}{"order_id": orderID, "order_sn": orderNo} - } - return out.Order, nil -} - -func (s *DeliveryService) signProxy(path, method string, data interface{}, out interface{}) error { - payload, err := json.Marshal(map[string]interface{}{ - "path": path, - "method": strings.ToUpper(method), - "data": data, - }) - if err != nil { - return err - } - req, err := http.NewRequest(http.MethodPost, s.bffBaseURL+"/sign-proxy", bytes.NewReader(payload)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - resp, err := s.httpClient.Do(req) - if err != nil { - return fmt.Errorf("发货服务请求失败:%w", err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - var envelope struct { - Code interface{} `json:"code"` - Message string `json:"message"` - Data json.RawMessage `json:"data"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - return fmt.Errorf("发货服务响应无法解析:%s", truncateDeliveryText(string(body))) - } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || !isZeroCode(envelope.Code) { - if envelope.Message != "" { - return errors.New(envelope.Message) - } - return fmt.Errorf("发货服务请求失败:HTTP %d", resp.StatusCode) - } - if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" { - return nil - } - if err := json.Unmarshal(envelope.Data, out); err != nil { - return fmt.Errorf("发货服务数据无法解析:%w", err) - } - return nil -} - func deliveryGoodID(channel, sku string) string { if channel != "dlc" { return "" @@ -1067,58 +797,3 @@ func deliveryGoodID(channel, sku string) string { "lucky_coin_x90": "682ef39ca8f40c4234c59f42", }[sku] } - -func isZeroCode(code interface{}) bool { - switch v := code.(type) { - case nil: - return true - case float64: - return v == 0 - case string: - return v == "0" - default: - return false - } -} - -func stringFromMap(m map[string]interface{}, key string) string { - if m == nil { - return "" - } - switch v := m[key].(type) { - case string: - return v - case float64: - return fmt.Sprintf("%.0f", v) - default: - if v != nil { - return fmt.Sprint(v) - } - return "" - } -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func gameChannelText(account map[string]interface{}) string { - area := stringFromMap(account, "game_account_area") - plat := stringFromMap(account, "game_account_plat") - if area == "" && plat == "" { - return "" - } - return strings.Trim(strings.Join([]string{area, plat}, "-"), "-") -} - -func truncateDeliveryText(value string) string { - if len(value) <= 300 { - return value - } - return value[:300] -} diff --git a/backend/internal/service/delivery_helpers.go b/backend/internal/service/delivery_helpers.go new file mode 100644 index 0000000..b6716ca --- /dev/null +++ b/backend/internal/service/delivery_helpers.go @@ -0,0 +1,77 @@ +package service + +import ( + "fmt" + "strings" +) + +func buildDeliveryProduct(openProduct *OpenOrderProduct, good map[string]interface{}) *DeliveryProduct { + if openProduct == nil { + return nil + } + product := &DeliveryProduct{ + Name: openProduct.Name, + SKU: openProduct.SKU, + Game: openProduct.Game, + } + if title := stringFromMap(good, "title"); title != "" { + product.Name = title + } + product.Image = stringFromMap(good, "image") + return product +} + +func isZeroCode(code interface{}) bool { + switch v := code.(type) { + case nil: + return true + case float64: + return v == 0 + case string: + return v == "0" + default: + return false + } +} + +func stringFromMap(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + switch v := m[key].(type) { + case string: + return v + case float64: + return fmt.Sprintf("%.0f", v) + default: + if v != nil { + return fmt.Sprint(v) + } + return "" + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func gameChannelText(account map[string]interface{}) string { + area := stringFromMap(account, "game_account_area") + plat := stringFromMap(account, "game_account_plat") + if area == "" && plat == "" { + return "" + } + return strings.Trim(strings.Join([]string{area, plat}, "-"), "-") +} + +func truncateDeliveryText(value string) string { + if len(value) <= 300 { + return value + } + return value[:300] +} diff --git a/backend/internal/service/delivery_link.go b/backend/internal/service/delivery_link.go new file mode 100644 index 0000000..7353c1e --- /dev/null +++ b/backend/internal/service/delivery_link.go @@ -0,0 +1,74 @@ +package service + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "affiliate_dash/internal/model" +) + +func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, auth DeliveryLinkAuth) error { + if order == nil { + return newDeliveryHTTPError(http.StatusNotFound, "订单不存在") + } + if auth.Exp <= 0 || strings.TrimSpace(auth.Sign) == "" { + return newDeliveryHTTPError(http.StatusForbidden, "链接参数缺失") + } + if order.DeliveryLinkRevokedAt != nil { + return newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废") + } + if order.DeliveryLinkExpiresAt == nil { + return newDeliveryHTTPError(http.StatusForbidden, "发货链接未生成") + } + expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second) + linkExpires := time.Unix(auth.Exp, 0).UTC() + if !linkExpires.Equal(expiresAt) { + return newDeliveryHTTPError(http.StatusForbidden, "发货链接已失效") + } + if time.Now().UTC().After(linkExpires) { + return newDeliveryHTTPError(http.StatusForbidden, "发货链接已过期") + } + expected := s.signDeliveryLink(order.OrderNo, auth.Exp) + if !hmac.Equal([]byte(strings.ToLower(strings.TrimSpace(auth.Sign))), []byte(expected)) { + return newDeliveryHTTPError(http.StatusForbidden, "发货链接签名无效") + } + return nil +} + +func (s *DeliveryService) buildDeliveryLinkResult(orderNo string, expiresAt time.Time, requestBaseURL string) *DeliveryLinkResult { + expiresAt = expiresAt.UTC() + exp := expiresAt.Unix() + sign := s.signDeliveryLink(orderNo, exp) + return &DeliveryLinkResult{ + OrderNo: orderNo, + DeliveryURL: s.buildDeliveryURL(orderNo, exp, sign, requestBaseURL), + ExpiresAt: expiresAt, + Exp: exp, + Sign: sign, + } +} + +func (s *DeliveryService) buildDeliveryURL(orderNo string, exp int64, sign, requestBaseURL string) string { + path := fmt.Sprintf("/delivery/%s/%s?exp=%d&sign=%s", url.PathEscape(s.channel), url.PathEscape(orderNo), exp, url.QueryEscape(sign)) + base := strings.TrimRight(s.linkBaseURL, "/") + if base == "" { + base = strings.TrimRight(requestBaseURL, "/") + } + if base == "" { + return path + } + return base + path +} + +func (s *DeliveryService) signDeliveryLink(orderNo string, exp int64) string { + mac := hmac.New(sha256.New, []byte(s.linkSecret)) + _, _ = mac.Write([]byte(orderNo + "|" + strconv.FormatInt(exp, 10))) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/backend/internal/service/delivery_state.go b/backend/internal/service/delivery_state.go new file mode 100644 index 0000000..774e377 --- /dev/null +++ b/backend/internal/service/delivery_state.go @@ -0,0 +1,71 @@ +package service + +import ( + "encoding/json" + "strings" + "time" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/timeutil" +) + +func buildExistingDeliverySubmitResult(order *model.FulfillmentOrder) *DeliverySubmitResult { + if order == nil { + return nil + } + switch normalizeOrderStatus(order) { + case model.OrderStatusDelivering, model.OrderStatusDelivered: + default: + return nil + } + var resultData map[string]interface{} + if json.Valid([]byte(order.ResultData)) { + _ = json.Unmarshal([]byte(order.ResultData), &resultData) + } + providerOrderNo := firstNonEmpty( + order.ProviderOrderNo, + stringFromMap(resultData, "provider_order_no"), + ) + message := "订单暂时正在发货中,请稍后查询" + status := normalizeOrderStatus(order) + if status == model.OrderStatusDelivered { + message = "订单已交付" + } + return &DeliverySubmitResult{ + OrderNo: order.OrderNo, + Status: status, + Message: message, + ProviderOrderNo: providerOrderNo, + UpstreamOrder: resultData, + } +} + +func upstreamDeliverySucceeded(order map[string]interface{}) bool { + return strings.EqualFold(stringFromMap(order, "status"), "FINISHED") || + strings.EqualFold(stringFromMap(order, "send_status"), "SUCCESS") +} + +func deliverySubmissionInProgress(stage string) bool { + return stage == deliveryStageClaimed || stage == deliveryStageQueueCreated +} + +func deliverySubmissionStale(order *model.FulfillmentOrder, now time.Time) bool { + if order == nil { + return false + } + startedAtRaw := resultDataString(order.ResultData, "submit_started_at") + if startedAtRaw != "" { + if startedAt, err := time.Parse(timeutil.APITimeLayout, startedAtRaw); err == nil { + return now.Sub(startedAt) >= deliverySubmissionStaleTimeout + } + } + return !order.UpdatedAt.IsZero() && now.Sub(order.UpdatedAt) >= deliverySubmissionStaleTimeout +} + +func optionalUint(value uint) *uint { + if value == 0 { + return nil + } + out := value + return &out +} diff --git a/backend/internal/service/delivery_upstream.go b/backend/internal/service/delivery_upstream.go new file mode 100644 index 0000000..d8fa482 --- /dev/null +++ b/backend/internal/service/delivery_upstream.go @@ -0,0 +1,137 @@ +package service + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +func (s *DeliveryService) goodsDetail(goodID string) (map[string]interface{}, error) { + var out struct { + Good map[string]interface{} `json:"good"` + } + if err := s.signProxy("/public/goods/detail", "POST", map[string]interface{}{ + "good_id": goodID, + }, &out); err != nil { + return nil, err + } + return out.Good, nil +} + +func (s *DeliveryService) accountBound(bindUUID, goodID string) (map[string]interface{}, error) { + var out struct { + GameAccount map[string]interface{} `json:"gameAccount"` + Snake map[string]interface{} `json:"game_account"` + } + if err := s.signProxy("/public/games/account-bound", "POST", map[string]interface{}{ + "bind_uuid": bindUUID, + "bindUuid": bindUUID, + "goodId": goodID, + "good_id": goodID, + }, &out); err != nil { + return nil, err + } + if out.GameAccount == nil { + out.GameAccount = out.Snake + } + if out.GameAccount == nil { + return nil, errors.New("账号尚未绑定,请扫码完成绑定后再提交") + } + return out.GameAccount, nil +} + +func (s *DeliveryService) createOrderQueue(goodID, orderNo string) (string, error) { + var out struct { + Orders []map[string]interface{} `json:"orders"` + } + if err := s.signProxy("/public/users/orders-queue", "POST", map[string]interface{}{ + "good_id": goodID, + "quantity": 1, + "order_sn": orderNo, + }, &out); err != nil { + return "", err + } + if len(out.Orders) == 0 { + return "", errors.New("发货服务未返回队列订单") + } + orderID := firstNonEmpty(stringFromMap(out.Orders[0], "_id"), stringFromMap(out.Orders[0], "id")) + if orderID == "" { + return "", errors.New("发货服务未返回队列订单 ID") + } + return orderID, nil +} + +func (s *DeliveryService) patchOrderQueue(orderID, gameAccount, bindUUID string) error { + var out map[string]interface{} + return s.signProxy("/public/users/orders-queue", "PATCH", map[string]interface{}{ + "order_id": orderID, + "game_account": gameAccount, + "bind_uuid": bindUUID, + }, &out) +} + +func (s *DeliveryService) createUpstreamOrder(orderID, gameAccount, goodID, orderNo string) (map[string]interface{}, error) { + var out struct { + Order map[string]interface{} `json:"order"` + } + if err := s.signProxy("/public/users/orders", "POST", map[string]interface{}{ + "order_id": orderID, + "game_account": gameAccount, + "good_id": goodID, + "h5_prefix": s.channel, + "order_sn": orderNo, + }, &out); err != nil { + return nil, err + } + if out.Order == nil { + out.Order = map[string]interface{}{"order_id": orderID, "order_sn": orderNo} + } + return out.Order, nil +} + +func (s *DeliveryService) signProxy(path, method string, data interface{}, out interface{}) error { + payload, err := json.Marshal(map[string]interface{}{ + "path": path, + "method": strings.ToUpper(method), + "data": data, + }) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, s.bffBaseURL+"/sign-proxy", bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("发货服务请求失败:%w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var envelope struct { + Code interface{} `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("发货服务响应无法解析:%s", truncateDeliveryText(string(body))) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || !isZeroCode(envelope.Code) { + if envelope.Message != "" { + return errors.New(envelope.Message) + } + return fmt.Errorf("发货服务请求失败:HTTP %d", resp.StatusCode) + } + if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" { + return nil + } + if err := json.Unmarshal(envelope.Data, out); err != nil { + return fmt.Errorf("发货服务数据无法解析:%w", err) + } + return nil +} diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 33beafe..8f9bf60 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -1,19 +1,13 @@ package service import ( - "context" "encoding/json" "errors" - "fmt" - "log" "math" - "strconv" "strings" "time" - "unicode/utf8" "affiliate_dash/internal/model" - "affiliate_dash/internal/pkg/timeutil" "github.com/google/uuid" "gorm.io/gorm" @@ -326,35 +320,6 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu return &out, nil } -func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) { - var order model.FulfillmentOrder - err := s.db.Preload("MerchantProduct.Product"). - Where("merchant_id = ? AND order_no = ?", merchantID, orderNo). - First(&order).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("订单不存在") - } - if err != nil { - return nil, err - } - return &order, nil -} - -func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus string) ([]model.FulfillmentOrder, int64, error) { - page, size = normalizePage(page, size) - tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID) - if orderStatus != "" { - tx = tx.Where("order_status = ?", orderStatus) - } - var total int64 - if err := tx.Count(&total).Error; err != nil { - return nil, 0, err - } - var orders []model.FulfillmentOrder - err := tx.Preload("MerchantProduct.Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error - return orders, total, err -} - type FulfillmentUpdateInput struct { MerchantID uint APIClientID uint @@ -436,112 +401,6 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode return &out, nil } -func (s *FulfillmentService) MarkProcessingTimeouts(timeout time.Duration, limit int) (int, error) { - if timeout <= 0 { - return 0, nil - } - if limit <= 0 || limit > 100 { - limit = 50 - } - now := time.Now() - cutoff := now.Add(-timeout) - var ids []uint - if err := s.db.Model(&model.FulfillmentOrder{}). - Where("order_status = ? AND updated_at < ?", model.OrderStatusDelivering, cutoff). - Order("updated_at ASC, id ASC"). - Limit(limit). - Pluck("id", &ids).Error; err != nil { - return 0, err - } - changed := 0 - for _, id := range ids { - updated, err := s.markProcessingTimeout(id, timeout, now) - if err != nil { - return changed, err - } - if updated { - changed++ - } - } - return changed, nil -} - -func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duration, now time.Time) (bool, error) { - returned := false - err := s.db.Transaction(func(tx *gorm.DB) error { - var order model.FulfillmentOrder - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil - } - return err - } - if !processingTimedOut(&order, timeout, now) { - return nil - } - if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil { - return nil - } - submittedUpstream := deliverySubmittedUpstream(&order) - var reason string - if submittedUpstream { - reason = fmt.Sprintf("发货超时:订单已提交上游但超过 %d 分钟未回传结果,可能仍在处理;请勿直接重试,先在上游确认订单状态", int(timeout.Minutes())) - } else { - reason = fmt.Sprintf("发货超时:发货提交中断,请重新提交(已停留 delivering 超过 %d 分钟)", int(timeout.Minutes())) - } - updates := map[string]interface{}{"order_status": model.OrderStatusShipFailed} - updates["failure_reason"] = reason - updates["result_data"] = buildProcessingTimeoutResultData(order.ResultData, timeout, now, reason) - if err := tx.Model(&order).Updates(updates).Error; err != nil { - return err - } - var out model.FulfillmentOrder - if err := tx.First(&out, order.ID).Error; err != nil { - return err - } - if err := writeAudit(tx, &order.MerchantID, nil, nil, "fulfillment.timeout", "fulfillment_order", order.OrderNo, map[string]interface{}{ - "from": normalizeOrderStatus(&order), - "to": model.OrderStatusShipFailed, - "timeout_minutes": int(timeout.Minutes()), - "reason": reason, - }); err != nil { - return err - } - if s.callbacks != nil { - if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&out)); err != nil { - return err - } - } - returned = true - return nil - }) - return returned, err -} - -func (s *FulfillmentService) RunProcessingTimeoutMonitor(ctx context.Context, timeout, interval time.Duration) { - if timeout <= 0 { - return - } - if interval <= 0 { - interval = time.Minute - } - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - changed, err := s.MarkProcessingTimeouts(timeout, 50) - if err != nil { - log.Printf("[fulfillment] timeout scan error: %v", err) - } else if changed > 0 { - log.Printf("[fulfillment] timeout scan marked failed count=%d", changed) - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} - func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) { var out model.FulfillmentOrder err := s.db.Transaction(func(tx *gorm.DB) error { @@ -619,137 +478,6 @@ func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, return &out, nil } -type WalletAdjustInput struct { - MerchantID uint - ActorUserID uint - Amount int64 - IdempotencyKey string - Note string -} - -func (s *FulfillmentService) AdjustWallet(in WalletAdjustInput) (*model.WalletAccount, error) { - if in.Amount == 0 { - return nil, errors.New("调整金额不能为零") - } - if in.IdempotencyKey == "" { - return nil, errors.New("账务调整必须提供幂等键") - } - var out model.WalletAccount - err := s.db.Transaction(func(tx *gorm.DB) error { - var existing model.WalletLedgerEntry - if err := tx.Where("merchant_id = ? AND idempotency_key = ?", in.MerchantID, in.IdempotencyKey).First(&existing).Error; err == nil { - if err := tx.First(&out, existing.WalletAccountID).Error; err != nil { - return err - } - return nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - var wallet model.WalletAccount - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil { - return err - } - newBalance := wallet.AvailableBalance + in.Amount - if newBalance < 0 { - return errors.New("调整后余额不能小于零") - } - if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil { - return err - } - entryType := model.WalletLedgerAdjust - if in.Amount > 0 { - entryType = model.WalletLedgerCredit - } else { - entryType = model.WalletLedgerDebit - } - idempotencyKey := in.IdempotencyKey - if err := tx.Create(&model.WalletLedgerEntry{ - MerchantID: in.MerchantID, - WalletAccountID: wallet.ID, - EntryNo: "WL" + uuid.NewString(), - Type: entryType, - Amount: in.Amount, - BalanceAfter: newBalance, - ReferenceType: "manual_adjustment", - ReferenceNo: in.IdempotencyKey, - IdempotencyKey: &idempotencyKey, - Note: in.Note, - }).Error; err != nil { - return err - } - out = wallet - out.AvailableBalance = newBalance - return writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "wallet.adjust", "wallet_account", fmt.Sprint(wallet.ID), map[string]int64{"amount": in.Amount}) - }) - if err != nil { - return nil, err - } - return &out, nil -} - -func (s *FulfillmentService) GetWallet(merchantID uint) (*model.WalletAccount, error) { - var wallet model.WalletAccount - if err := s.db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("商户钱包不存在") - } - return nil, err - } - return &wallet, nil -} - -func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int, referenceNo, entryType string) ([]model.WalletLedgerEntry, int64, error) { - page, size = normalizePage(page, size) - tx := s.db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID) - if referenceNo != "" { - tx = tx.Where("reference_no LIKE ?", "%"+referenceNo+"%") - } - if entryType != "" { - tx = tx.Where("type = ?", entryType) - } - var total int64 - if err := tx.Count(&total).Error; err != nil { - return nil, 0, err - } - var entries []model.WalletLedgerEntry - err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&entries).Error - return entries, total, err -} - -func newFulfillmentOrderNo() string { - return "FO" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "") -} - -func newTestFulfillmentOrderNo() string { - return "O" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "") -} - -// calculateServiceFee 按"百分比或固定"二选一计算手续费: -// - feeType=rate:按 baseAmount * feeRateBP / 10000 计算 -// - feeType=fixed:直接取 feeFixedAmount -// -// 二者互斥,不会叠加。 -func calculateServiceFee(baseAmount int64, feeType string, feeRateBP, feeFixedAmount int64) (int64, error) { - if baseAmount < 0 || feeRateBP < 0 || feeFixedAmount < 0 { - return 0, errors.New("订单金额或手续费配置无效") - } - switch feeType { - case model.FeeTypeFixed: - return feeFixedAmount, nil - case model.FeeTypeRate, "": - if feeRateBP > 10000 { - return 0, errors.New("手续费比例不能超过 10000 BP") - } - if feeRateBP > 0 && baseAmount > math.MaxInt64/feeRateBP { - return 0, errors.New("手续费金额超出范围") - } - return baseAmount * feeRateBP / 10000, nil - default: - return 0, errors.New("无效的手续费类型") - } -} - func CanFulfill(order *model.FulfillmentOrder) (bool, string) { switch normalizeOrderStatus(order) { case model.OrderStatusPaid: @@ -797,601 +525,3 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} { // ----- 仪表盘统计 ----- // DashboardStats 仪表盘聚合指标。 -type DashboardStats struct { - Scope string `json:"scope"` - CatalogProductCount int64 `json:"catalog_product_count"` - ProductCount int64 `json:"product_count"` - ActiveProductCount int64 `json:"active_product_count"` - MerchantCount int64 `json:"merchant_count"` - ActiveMerchantCount int64 `json:"active_merchant_count"` - UserCount int64 `json:"user_count"` - OrderCount int64 `json:"order_count"` - TodayOrderCount int64 `json:"today_order_count"` - TotalSales int64 `json:"total_sales"` - TodaySales int64 `json:"today_sales"` - TotalFees int64 `json:"total_fees"` - TodayFees int64 `json:"today_fees"` - PaidOrderCount int64 `json:"paid_order_count"` - DeliveringOrderCount int64 `json:"delivering_order_count"` - DeliveredOrderCount int64 `json:"delivered_order_count"` - ShipFailedOrderCount int64 `json:"ship_failed_order_count"` - CancelledOrderCount int64 `json:"cancelled_order_count"` - WalletAvailableBalance int64 `json:"wallet_available_balance"` - WalletFrozenBalance int64 `json:"wallet_frozen_balance"` - APIClientCount int64 `json:"api_client_count"` - ActiveAPIClientCount int64 `json:"active_api_client_count"` - CallbackSubscriptionCount int64 `json:"callback_subscription_count"` - PendingCallbackCount int64 `json:"pending_callback_count"` - FailedCallbackCount int64 `json:"failed_callback_count"` -} - -type dashboardStatusCount struct { - Status string - Count int64 -} - -// Dashboard 按角色汇总运营指标:平台管理员看全平台,商户账号看当前商户。 -func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*DashboardStats, error) { - stats := &DashboardStats{Scope: "merchant"} - if isPlatformAdmin { - stats.Scope = "platform" - } - todayStart := timeutil.StartOfDay(time.Now()) - - productScope := func() *gorm.DB { - tx := s.db.Model(&model.MerchantProduct{}) - if !isPlatformAdmin { - tx = tx.Where("merchant_id = ?", merchantID) - } - return tx - } - orderScope := func() *gorm.DB { - tx := s.db.Model(&model.FulfillmentOrder{}) - if !isPlatformAdmin { - tx = tx.Where("merchant_id = ?", merchantID) - } - return tx - } - walletScope := func() *gorm.DB { - tx := s.db.Model(&model.WalletAccount{}) - if !isPlatformAdmin { - tx = tx.Where("merchant_id = ?", merchantID) - } - return tx - } - apiClientScope := func() *gorm.DB { - tx := s.db.Model(&model.APIClient{}) - if !isPlatformAdmin { - tx = tx.Where("merchant_id = ?", merchantID) - } - return tx - } - callbackScope := func() *gorm.DB { - tx := s.db.Model(&model.CallbackSubscription{}) - if !isPlatformAdmin { - tx = tx.Where("merchant_id = ?", merchantID) - } - return tx - } - callbackDeliveryScope := func() *gorm.DB { - tx := s.db.Model(&model.CallbackDelivery{}) - if !isPlatformAdmin { - tx = tx.Where("merchant_id = ?", merchantID) - } - return tx - } - - if err := s.db.Model(&model.Product{}).Count(&stats.CatalogProductCount).Error; err != nil { - return nil, err - } - if err := productScope().Count(&stats.ProductCount).Error; err != nil { - return nil, err - } - if err := productScope().Where("status = ?", model.ProductStatusActive).Count(&stats.ActiveProductCount).Error; err != nil { - return nil, err - } - if isPlatformAdmin { - if err := s.db.Model(&model.Merchant{}).Count(&stats.MerchantCount).Error; err != nil { - return nil, err - } - if err := s.db.Model(&model.Merchant{}).Where("status = ?", model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil { - return nil, err - } - if err := s.db.Model(&model.User{}).Count(&stats.UserCount).Error; err != nil { - return nil, err - } - } else { - if err := s.db.Model(&model.Merchant{}).Where("id = ?", merchantID).Count(&stats.MerchantCount).Error; err != nil { - return nil, err - } - if err := s.db.Model(&model.Merchant{}).Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil { - return nil, err - } - if err := s.db.Model(&model.User{}). - Joins("JOIN merchant_members ON merchant_members.user_id = users.id"). - Where("merchant_members.merchant_id = ?", merchantID). - Count(&stats.UserCount).Error; err != nil { - return nil, err - } - stats.CatalogProductCount = stats.ProductCount - } - if err := orderScope().Count(&stats.OrderCount).Error; err != nil { - return nil, err - } - if err := orderScope().Where("created_at >= ?", todayStart).Count(&stats.TodayOrderCount).Error; err != nil { - return nil, err - } - if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled). - Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales).Error; err != nil { - return nil, err - } - if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled). - Where("created_at >= ?", todayStart). - Select("COALESCE(SUM(amount),0)").Scan(&stats.TodaySales).Error; err != nil { - return nil, err - } - if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled). - Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TotalFees).Error; err != nil { - return nil, err - } - if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled). - Where("created_at >= ?", todayStart). - Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TodayFees).Error; err != nil { - return nil, err - } - var orderStatusCounts []dashboardStatusCount - if err := orderScope().Select("order_status AS status, COUNT(*) AS count"). - Group("order_status").Scan(&orderStatusCounts).Error; err != nil { - return nil, err - } - for _, item := range orderStatusCounts { - switch item.Status { - case model.OrderStatusPaid: - stats.PaidOrderCount = item.Count - case model.OrderStatusDelivering: - stats.DeliveringOrderCount = item.Count - case model.OrderStatusDelivered: - stats.DeliveredOrderCount = item.Count - case model.OrderStatusShipFailed: - stats.ShipFailedOrderCount = item.Count - case model.OrderStatusCancelled: - stats.CancelledOrderCount = item.Count - } - } - if err := walletScope().Select("COALESCE(SUM(available_balance),0)").Scan(&stats.WalletAvailableBalance).Error; err != nil { - return nil, err - } - if err := walletScope().Select("COALESCE(SUM(frozen_balance),0)").Scan(&stats.WalletFrozenBalance).Error; err != nil { - return nil, err - } - if err := apiClientScope().Count(&stats.APIClientCount).Error; err != nil { - return nil, err - } - if err := apiClientScope().Where("status = ?", model.APIClientStatusActive).Count(&stats.ActiveAPIClientCount).Error; err != nil { - return nil, err - } - if err := callbackScope().Count(&stats.CallbackSubscriptionCount).Error; err != nil { - return nil, err - } - if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryPending).Count(&stats.PendingCallbackCount).Error; err != nil { - return nil, err - } - if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryFailed).Count(&stats.FailedCallbackCount).Error; err != nil { - return nil, err - } - return stats, nil -} - -// ----- 上游 SourceOpen 接口(基于 FulfillmentOrder)----- - -// OpenOrderQuery 开放接口订单查询结果。 -type OpenOrderQuery struct { - OrderNo string `json:"order_no"` - Status string `json:"status"` - CanShip bool `json:"can_ship"` - CannotShipReason string `json:"cannot_ship_reason,omitempty"` - Product *OpenOrderProduct `json:"product,omitempty"` - BuyerName string `json:"buyer_name"` - Amount int64 `json:"amount"` - ProviderOrderNo string `json:"provider_order_no,omitempty"` - CreatedAt time.Time `json:"created_at"` - ShippedAt *time.Time `json:"shipped_at"` - ShipFailReason string `json:"ship_fail_reason,omitempty"` - GameChannel string `json:"game_channel,omitempty"` - GameUID string `json:"game_uid,omitempty"` - RoleName string `json:"role_name,omitempty"` - PayScore int `json:"pay_score,omitempty"` -} - -// OpenOrderProduct 开放接口返回的商品快照。 -type OpenOrderProduct struct { - Name string `json:"name"` - SKU string `json:"sku"` - Game string `json:"game"` -} - -// ShipNotifyInput 上游发货结果推送。 -type ShipNotifyInput struct { - OrderNo string - ShipStatus string // success / failed - ProviderOrderNo string - ShippedAt *time.Time - FailReason string - RawPayload string - GameChannel *string - GameUID *string - RoleName *string - PayScore *int -} - -// ShipNotifyResult 上游推送处理结果。 -type ShipNotifyResult struct { - OrderNo string `json:"order_no"` - Status string `json:"status"` - Message string `json:"message"` -} - -// GetByOrderNo 按订单号查询(不限定商户,供上游 SourceOpen 使用)。 -func (s *FulfillmentService) GetByOrderNo(orderNo string) (*model.FulfillmentOrder, error) { - var order model.FulfillmentOrder - err := s.db.Preload("MerchantProduct.Product").Where("order_no = ?", orderNo).First(&order).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("订单不存在") - } - if err != nil { - return nil, err - } - return &order, nil -} - -// QueryOpenOrder 供上游查询:商品信息 + 是否可发货。 -func (s *FulfillmentService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, error) { - if orderNo == "" { - return nil, errors.New("订单号不能为空") - } - order, err := s.GetByOrderNo(orderNo) - if err != nil { - return nil, err - } - canShip, reason := CanFulfill(order) - out := &OpenOrderQuery{ - OrderNo: order.OrderNo, - Status: normalizeOrderStatus(order), - CanShip: canShip, - CannotShipReason: reason, - BuyerName: order.BuyerReference, - Amount: order.Amount, - ProviderOrderNo: order.ProviderOrderNo, - CreatedAt: order.CreatedAt, - ShippedAt: order.DeliveredAt, - ShipFailReason: order.FailureReason, - } - if order.MerchantProduct != nil { - game := "" - if order.MerchantProduct.Product != nil { - game = order.MerchantProduct.Product.Category - } - out.Product = &OpenOrderProduct{ - Name: order.ProductName, - SKU: order.ProductSKU, - Game: game, - } - } - // 新模型无独立的游戏字段列,从 RequestData / ResultData JSON 中还原。 - extractGameFields(order.RequestData, out) - extractGameFields(order.ResultData, out) - return out, nil -} - -// HandleShipNotify 处理上游发货结果推送(幂等),基于 FulfillmentOrder。 -func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) { - in.ShipStatus = strings.TrimSpace(in.ShipStatus) - in.FailReason = strings.TrimSpace(in.FailReason) - if in.OrderNo == "" { - return nil, errors.New("订单号不能为空") - } - if in.ShipStatus != "success" && in.ShipStatus != "failed" { - return nil, errors.New("无效的 ship_status,仅支持 success/failed") - } - if in.ShipStatus == "failed" && in.FailReason == "" { - return nil, errors.New("发货失败时 fail_reason 必填") - } - if utf8.RuneCountInString(in.FailReason) > 512 { - return nil, errors.New("fail_reason 最长 512 个字符") - } - - var nextStatus string - switch in.ShipStatus { - case "success": - nextStatus = model.OrderStatusDelivered - case "failed": - nextStatus = model.OrderStatusShipFailed - } - - var result ShipNotifyResult - var rejectionErr error - if err := s.db.Transaction(func(tx *gorm.DB) error { - var order model.FulfillmentOrder - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Preload("MerchantProduct.Product"). - Where("order_no = ?", in.OrderNo). - First(&order).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("订单不存在") - } - return err - } - - // 已交付:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。 - if normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" { - result = ShipNotifyResult{ - OrderNo: order.OrderNo, - Status: normalizeOrderStatus(&order), - Message: "订单已交付,幂等成功", - } - return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, - shipNotifyAuditMetadata(in, normalizeOrderStatus(&order), "订单已交付,幂等忽略")) - } - - if normalizeOrderStatus(&order) == model.OrderStatusCancelled { - if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil { - return err - } - rejectionErr = errors.New("订单已取消,无法更新发货状态") - return nil - } - - now := time.Now() - shippedAt := in.ShippedAt - if shippedAt == nil && in.ShipStatus == "success" { - shippedAt = &now - } - - updates := map[string]interface{}{ - "order_status": nextStatus, - } - var message string - switch in.ShipStatus { - case "success": - if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil { - message := "当前状态不允许标记发货成功" - if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { - return err - } - rejectionErr = err - return nil - } - updates["delivered_at"] = shippedAt - updates["failure_reason"] = "" - if in.ProviderOrderNo != "" { - updates["provider_order_no"] = in.ProviderOrderNo - } - message = "发货成功,订单已交付" - case "failed": - if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil { - message := "当前状态不允许标记发货失败" - if normalizeOrderStatus(&order) == model.OrderStatusDelivered { - message = "订单已交付,拒绝失败推送" - } - if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { - return err - } - rejectionErr = err - return nil - } - updates["failure_reason"] = in.FailReason - if in.ProviderOrderNo != "" { - updates["provider_order_no"] = in.ProviderOrderNo - } - message = "已记录发货失败" - } - - resultData := buildShipNotifyResultData(order.ResultData, in, shippedAt) - updates["result_data"] = resultData - if err := tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil { - return err - } - - if err := writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, - shipNotifyAuditMetadata(in, nextStatus, message)); err != nil { - return err - } - - var updated model.FulfillmentOrder - if err := tx.Preload("MerchantProduct.Product").First(&updated, order.ID).Error; err != nil { - return err - } - if s.callbacks != nil { - if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&updated)); err != nil { - return err - } - } - result = ShipNotifyResult{ - OrderNo: order.OrderNo, - Status: nextStatus, - Message: message, - } - return nil - }); err != nil { - return nil, err - } - if rejectionErr != nil { - return nil, rejectionErr - } - - return &result, nil -} - -func writeShipNotifyRejectedAudit(tx *gorm.DB, order *model.FulfillmentOrder, in ShipNotifyInput, message string) error { - return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, - shipNotifyAuditMetadata(in, normalizeOrderStatus(order), message)) -} - -func shipNotifyAuditMetadata(in ShipNotifyInput, resultStatus, message string) map[string]interface{} { - metadata := map[string]interface{}{ - "ship_status": in.ShipStatus, - "provider_order_no": in.ProviderOrderNo, - "fail_reason": in.FailReason, - "result_status": resultStatus, - "message": message, - "payload": in.RawPayload, - } - return metadata -} - -// buildShipNotifyResultData 把推送结果与游戏字段合并进 ResultData JSON。 -func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *time.Time) string { - m := map[string]interface{}{} - if existing != "" && json.Valid([]byte(existing)) { - _ = json.Unmarshal([]byte(existing), &m) - } - m["ship_status"] = in.ShipStatus - if in.ProviderOrderNo != "" { - m["provider_order_no"] = in.ProviderOrderNo - } - if in.ShipStatus == "success" { - delete(m, "fail_reason") - } else if in.FailReason != "" { - m["fail_reason"] = in.FailReason - } - if shippedAt != nil { - m["shipped_at"] = timeutil.FormatAPITime(*shippedAt) - } - if in.GameChannel != nil { - m["game_channel"] = *in.GameChannel - } - if in.GameUID != nil { - m["game_uid"] = *in.GameUID - } - if in.RoleName != nil { - m["role_name"] = *in.RoleName - } - if in.PayScore != nil { - m["pay_score"] = *in.PayScore - } - raw, err := json.Marshal(m) - if err != nil { - return existing - } - return string(raw) -} - -func buildProcessingTimeoutResultData(existing string, timeout time.Duration, now time.Time, reason string) string { - return mergeResultData(existing, map[string]interface{}{ - "timeout": true, - "timeout_minutes": int(timeout.Minutes()), - "timeout_at": timeutil.FormatAPITime(now), - "ship_status": "failed", - "fail_reason": reason, - }) -} - -// mergeResultData 保留已有 JSON 字段,仅覆盖或新增 patch 中的字段,避免各发货阶段互相清空上下文。 -func mergeResultData(existing string, patch map[string]interface{}) string { - if len(patch) == 0 { - return existing - } - m := map[string]interface{}{} - if existing != "" && json.Valid([]byte(existing)) { - _ = json.Unmarshal([]byte(existing), &m) - } - for k, v := range patch { - m[k] = v - } - raw, err := json.Marshal(m) - if err != nil { - return existing - } - return string(raw) -} - -func resultDataMap(raw string) map[string]interface{} { - m := map[string]interface{}{} - if raw != "" && json.Valid([]byte(raw)) { - _ = json.Unmarshal([]byte(raw), &m) - } - return m -} - -// requestDataMap 解析下单透传的 data(RequestData),非法或为空时返回空 map。 -func requestDataMap(raw string) map[string]interface{} { - m := map[string]interface{}{} - if raw != "" && json.Valid([]byte(raw)) { - _ = json.Unmarshal([]byte(raw), &m) - } - return m -} - -// requestDataString 读取下单透传 data 中的字符串字段。 -func requestDataString(raw, key string) string { - if v, ok := requestDataMap(raw)[key].(string); ok { - return strings.TrimSpace(v) - } - return "" -} - -func resultDataString(raw, key string) string { - if v, ok := resultDataMap(raw)[key].(string); ok { - return v - } - return "" -} - -func resultDataNumber(raw, key string) int64 { - switch v := resultDataMap(raw)[key].(type) { - case float64: - return int64(v) - case int64: - return v - case int: - return int64(v) - case string: - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - return n - } - } - return 0 -} - -// extractGameFields 从 JSON 文本中还原游戏相关字段(仅填充当前为空的字段)。 -func extractGameFields(raw string, out *OpenOrderQuery) { - if raw == "" || !json.Valid([]byte(raw)) { - return - } - var m map[string]interface{} - if err := json.Unmarshal([]byte(raw), &m); err != nil { - return - } - if out.GameChannel == "" { - if v, ok := m["game_channel"].(string); ok { - out.GameChannel = v - } - } - if out.GameUID == "" { - if v, ok := m["game_uid"].(string); ok { - out.GameUID = v - } - } - if out.RoleName == "" { - if v, ok := m["role_name"].(string); ok { - out.RoleName = v - } - } - if out.PayScore == 0 { - if v, ok := toInt(m["pay_score"]); ok { - out.PayScore = v - } - } -} - -func toInt(v interface{}) (int, bool) { - switch n := v.(type) { - case float64: - return int(n), true - case int: - return n, true - case int64: - return int(n), true - } - return 0, false -} diff --git a/backend/internal/service/fulfillment_dashboard.go b/backend/internal/service/fulfillment_dashboard.go new file mode 100644 index 0000000..a3a3b80 --- /dev/null +++ b/backend/internal/service/fulfillment_dashboard.go @@ -0,0 +1,195 @@ +package service + +import ( + "time" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/timeutil" + + "gorm.io/gorm" +) + +type DashboardStats struct { + Scope string `json:"scope"` + CatalogProductCount int64 `json:"catalog_product_count"` + ProductCount int64 `json:"product_count"` + ActiveProductCount int64 `json:"active_product_count"` + MerchantCount int64 `json:"merchant_count"` + ActiveMerchantCount int64 `json:"active_merchant_count"` + UserCount int64 `json:"user_count"` + OrderCount int64 `json:"order_count"` + TodayOrderCount int64 `json:"today_order_count"` + TotalSales int64 `json:"total_sales"` + TodaySales int64 `json:"today_sales"` + TotalFees int64 `json:"total_fees"` + TodayFees int64 `json:"today_fees"` + PaidOrderCount int64 `json:"paid_order_count"` + DeliveringOrderCount int64 `json:"delivering_order_count"` + DeliveredOrderCount int64 `json:"delivered_order_count"` + ShipFailedOrderCount int64 `json:"ship_failed_order_count"` + CancelledOrderCount int64 `json:"cancelled_order_count"` + WalletAvailableBalance int64 `json:"wallet_available_balance"` + WalletFrozenBalance int64 `json:"wallet_frozen_balance"` + APIClientCount int64 `json:"api_client_count"` + ActiveAPIClientCount int64 `json:"active_api_client_count"` + CallbackSubscriptionCount int64 `json:"callback_subscription_count"` + PendingCallbackCount int64 `json:"pending_callback_count"` + FailedCallbackCount int64 `json:"failed_callback_count"` +} + +type dashboardStatusCount struct { + Status string + Count int64 +} + +// Dashboard 按角色汇总运营指标:平台管理员看全平台,商户账号看当前商户。 +func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*DashboardStats, error) { + stats := &DashboardStats{Scope: "merchant"} + if isPlatformAdmin { + stats.Scope = "platform" + } + todayStart := timeutil.StartOfDay(time.Now()) + + productScope := func() *gorm.DB { + tx := s.db.Model(&model.MerchantProduct{}) + if !isPlatformAdmin { + tx = tx.Where("merchant_id = ?", merchantID) + } + return tx + } + orderScope := func() *gorm.DB { + tx := s.db.Model(&model.FulfillmentOrder{}) + if !isPlatformAdmin { + tx = tx.Where("merchant_id = ?", merchantID) + } + return tx + } + walletScope := func() *gorm.DB { + tx := s.db.Model(&model.WalletAccount{}) + if !isPlatformAdmin { + tx = tx.Where("merchant_id = ?", merchantID) + } + return tx + } + apiClientScope := func() *gorm.DB { + tx := s.db.Model(&model.APIClient{}) + if !isPlatformAdmin { + tx = tx.Where("merchant_id = ?", merchantID) + } + return tx + } + callbackScope := func() *gorm.DB { + tx := s.db.Model(&model.CallbackSubscription{}) + if !isPlatformAdmin { + tx = tx.Where("merchant_id = ?", merchantID) + } + return tx + } + callbackDeliveryScope := func() *gorm.DB { + tx := s.db.Model(&model.CallbackDelivery{}) + if !isPlatformAdmin { + tx = tx.Where("merchant_id = ?", merchantID) + } + return tx + } + + if err := s.db.Model(&model.Product{}).Count(&stats.CatalogProductCount).Error; err != nil { + return nil, err + } + if err := productScope().Count(&stats.ProductCount).Error; err != nil { + return nil, err + } + if err := productScope().Where("status = ?", model.ProductStatusActive).Count(&stats.ActiveProductCount).Error; err != nil { + return nil, err + } + if isPlatformAdmin { + if err := s.db.Model(&model.Merchant{}).Count(&stats.MerchantCount).Error; err != nil { + return nil, err + } + if err := s.db.Model(&model.Merchant{}).Where("status = ?", model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil { + return nil, err + } + if err := s.db.Model(&model.User{}).Count(&stats.UserCount).Error; err != nil { + return nil, err + } + } else { + if err := s.db.Model(&model.Merchant{}).Where("id = ?", merchantID).Count(&stats.MerchantCount).Error; err != nil { + return nil, err + } + if err := s.db.Model(&model.Merchant{}).Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil { + return nil, err + } + if err := s.db.Model(&model.User{}). + Joins("JOIN merchant_members ON merchant_members.user_id = users.id"). + Where("merchant_members.merchant_id = ?", merchantID). + Count(&stats.UserCount).Error; err != nil { + return nil, err + } + stats.CatalogProductCount = stats.ProductCount + } + if err := orderScope().Count(&stats.OrderCount).Error; err != nil { + return nil, err + } + if err := orderScope().Where("created_at >= ?", todayStart).Count(&stats.TodayOrderCount).Error; err != nil { + return nil, err + } + if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled). + Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales).Error; err != nil { + return nil, err + } + if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled). + Where("created_at >= ?", todayStart). + Select("COALESCE(SUM(amount),0)").Scan(&stats.TodaySales).Error; err != nil { + return nil, err + } + if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled). + Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TotalFees).Error; err != nil { + return nil, err + } + if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled). + Where("created_at >= ?", todayStart). + Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TodayFees).Error; err != nil { + return nil, err + } + var orderStatusCounts []dashboardStatusCount + if err := orderScope().Select("order_status AS status, COUNT(*) AS count"). + Group("order_status").Scan(&orderStatusCounts).Error; err != nil { + return nil, err + } + for _, item := range orderStatusCounts { + switch item.Status { + case model.OrderStatusPaid: + stats.PaidOrderCount = item.Count + case model.OrderStatusDelivering: + stats.DeliveringOrderCount = item.Count + case model.OrderStatusDelivered: + stats.DeliveredOrderCount = item.Count + case model.OrderStatusShipFailed: + stats.ShipFailedOrderCount = item.Count + case model.OrderStatusCancelled: + stats.CancelledOrderCount = item.Count + } + } + if err := walletScope().Select("COALESCE(SUM(available_balance),0)").Scan(&stats.WalletAvailableBalance).Error; err != nil { + return nil, err + } + if err := walletScope().Select("COALESCE(SUM(frozen_balance),0)").Scan(&stats.WalletFrozenBalance).Error; err != nil { + return nil, err + } + if err := apiClientScope().Count(&stats.APIClientCount).Error; err != nil { + return nil, err + } + if err := apiClientScope().Where("status = ?", model.APIClientStatusActive).Count(&stats.ActiveAPIClientCount).Error; err != nil { + return nil, err + } + if err := callbackScope().Count(&stats.CallbackSubscriptionCount).Error; err != nil { + return nil, err + } + if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryPending).Count(&stats.PendingCallbackCount).Error; err != nil { + return nil, err + } + if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryFailed).Count(&stats.FailedCallbackCount).Error; err != nil { + return nil, err + } + return stats, nil +} diff --git a/backend/internal/service/fulfillment_data.go b/backend/internal/service/fulfillment_data.go new file mode 100644 index 0000000..cf6edad --- /dev/null +++ b/backend/internal/service/fulfillment_data.go @@ -0,0 +1,157 @@ +package service + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "affiliate_dash/internal/pkg/timeutil" +) + +// buildShipNotifyResultData 把推送结果与游戏字段合并进 ResultData JSON。 +func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *time.Time) string { + m := map[string]interface{}{} + if existing != "" && json.Valid([]byte(existing)) { + _ = json.Unmarshal([]byte(existing), &m) + } + m["ship_status"] = in.ShipStatus + if in.ProviderOrderNo != "" { + m["provider_order_no"] = in.ProviderOrderNo + } + if in.ShipStatus == "success" { + delete(m, "fail_reason") + } else if in.FailReason != "" { + m["fail_reason"] = in.FailReason + } + if shippedAt != nil { + m["shipped_at"] = timeutil.FormatAPITime(*shippedAt) + } + if in.GameChannel != nil { + m["game_channel"] = *in.GameChannel + } + if in.GameUID != nil { + m["game_uid"] = *in.GameUID + } + if in.RoleName != nil { + m["role_name"] = *in.RoleName + } + if in.PayScore != nil { + m["pay_score"] = *in.PayScore + } + raw, err := json.Marshal(m) + if err != nil { + return existing + } + return string(raw) +} + +// mergeResultData 保留已有 JSON 字段,仅覆盖或新增 patch 中的字段,避免各发货阶段互相清空上下文。 +func mergeResultData(existing string, patch map[string]interface{}) string { + if len(patch) == 0 { + return existing + } + m := map[string]interface{}{} + if existing != "" && json.Valid([]byte(existing)) { + _ = json.Unmarshal([]byte(existing), &m) + } + for k, v := range patch { + m[k] = v + } + raw, err := json.Marshal(m) + if err != nil { + return existing + } + return string(raw) +} + +func resultDataMap(raw string) map[string]interface{} { + m := map[string]interface{}{} + if raw != "" && json.Valid([]byte(raw)) { + _ = json.Unmarshal([]byte(raw), &m) + } + return m +} + +// requestDataMap 解析下单透传的 data(RequestData),非法或为空时返回空 map。 +func requestDataMap(raw string) map[string]interface{} { + m := map[string]interface{}{} + if raw != "" && json.Valid([]byte(raw)) { + _ = json.Unmarshal([]byte(raw), &m) + } + return m +} + +// requestDataString 读取下单透传 data 中的字符串字段。 +func requestDataString(raw, key string) string { + if v, ok := requestDataMap(raw)[key].(string); ok { + return strings.TrimSpace(v) + } + return "" +} + +func resultDataString(raw, key string) string { + if v, ok := resultDataMap(raw)[key].(string); ok { + return v + } + return "" +} + +func resultDataNumber(raw, key string) int64 { + switch v := resultDataMap(raw)[key].(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + case string: + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n + } + } + return 0 +} + +// extractGameFields 从 JSON 文本中还原游戏相关字段(仅填充当前为空的字段)。 +func extractGameFields(raw string, out *OpenOrderQuery) { + if raw == "" || !json.Valid([]byte(raw)) { + return + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return + } + if out.GameChannel == "" { + if v, ok := m["game_channel"].(string); ok { + out.GameChannel = v + } + } + if out.GameUID == "" { + if v, ok := m["game_uid"].(string); ok { + out.GameUID = v + } + } + if out.RoleName == "" { + if v, ok := m["role_name"].(string); ok { + out.RoleName = v + } + } + if out.PayScore == 0 { + if v, ok := toInt(m["pay_score"]); ok { + out.PayScore = v + } + } +} + +func toInt(v interface{}) (int, bool) { + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + case int64: + return int(n), true + } + return 0, false +} diff --git a/backend/internal/service/fulfillment_order.go b/backend/internal/service/fulfillment_order.go new file mode 100644 index 0000000..39cbefd --- /dev/null +++ b/backend/internal/service/fulfillment_order.go @@ -0,0 +1,45 @@ +package service + +import ( + "errors" + "math" + "strings" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/timeutil" + + "github.com/google/uuid" +) + +func newFulfillmentOrderNo() string { + return "FO" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "") +} + +func newTestFulfillmentOrderNo() string { + return "O" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "") +} + +// calculateServiceFee 按"百分比或固定"二选一计算手续费: +// - feeType=rate:按 baseAmount * feeRateBP / 10000 计算 +// - feeType=fixed:直接取 feeFixedAmount +// +// 二者互斥,不会叠加。 +func calculateServiceFee(baseAmount int64, feeType string, feeRateBP, feeFixedAmount int64) (int64, error) { + if baseAmount < 0 || feeRateBP < 0 || feeFixedAmount < 0 { + return 0, errors.New("订单金额或手续费配置无效") + } + switch feeType { + case model.FeeTypeFixed: + return feeFixedAmount, nil + case model.FeeTypeRate, "": + if feeRateBP > 10000 { + return 0, errors.New("手续费比例不能超过 10000 BP") + } + if feeRateBP > 0 && baseAmount > math.MaxInt64/feeRateBP { + return 0, errors.New("手续费金额超出范围") + } + return baseAmount * feeRateBP / 10000, nil + default: + return 0, errors.New("无效的手续费类型") + } +} diff --git a/backend/internal/service/fulfillment_query.go b/backend/internal/service/fulfillment_query.go new file mode 100644 index 0000000..53b196a --- /dev/null +++ b/backend/internal/service/fulfillment_query.go @@ -0,0 +1,116 @@ +package service + +import ( + "errors" + "time" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" +) + +func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) { + var order model.FulfillmentOrder + err := s.db.Preload("MerchantProduct.Product"). + Where("merchant_id = ? AND order_no = ?", merchantID, orderNo). + First(&order).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("订单不存在") + } + if err != nil { + return nil, err + } + return &order, nil +} + +func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus string) ([]model.FulfillmentOrder, int64, error) { + page, size = normalizePage(page, size) + tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID) + if orderStatus != "" { + tx = tx.Where("order_status = ?", orderStatus) + } + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + var orders []model.FulfillmentOrder + err := tx.Preload("MerchantProduct.Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error + return orders, total, err +} + +type OpenOrderQuery struct { + OrderNo string `json:"order_no"` + Status string `json:"status"` + CanShip bool `json:"can_ship"` + CannotShipReason string `json:"cannot_ship_reason,omitempty"` + Product *OpenOrderProduct `json:"product,omitempty"` + BuyerName string `json:"buyer_name"` + Amount int64 `json:"amount"` + ProviderOrderNo string `json:"provider_order_no,omitempty"` + CreatedAt time.Time `json:"created_at"` + ShippedAt *time.Time `json:"shipped_at"` + ShipFailReason string `json:"ship_fail_reason,omitempty"` + GameChannel string `json:"game_channel,omitempty"` + GameUID string `json:"game_uid,omitempty"` + RoleName string `json:"role_name,omitempty"` + PayScore int `json:"pay_score,omitempty"` +} + +// OpenOrderProduct 开放接口返回的商品快照。 +type OpenOrderProduct struct { + Name string `json:"name"` + SKU string `json:"sku"` + Game string `json:"game"` +} + +// GetByOrderNo 按订单号查询(不限定商户,供上游 SourceOpen 使用)。 +func (s *FulfillmentService) GetByOrderNo(orderNo string) (*model.FulfillmentOrder, error) { + var order model.FulfillmentOrder + err := s.db.Preload("MerchantProduct.Product").Where("order_no = ?", orderNo).First(&order).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("订单不存在") + } + if err != nil { + return nil, err + } + return &order, nil +} + +// QueryOpenOrder 供上游查询:商品信息 + 是否可发货。 +func (s *FulfillmentService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, error) { + if orderNo == "" { + return nil, errors.New("订单号不能为空") + } + order, err := s.GetByOrderNo(orderNo) + if err != nil { + return nil, err + } + canShip, reason := CanFulfill(order) + out := &OpenOrderQuery{ + OrderNo: order.OrderNo, + Status: normalizeOrderStatus(order), + CanShip: canShip, + CannotShipReason: reason, + BuyerName: order.BuyerReference, + Amount: order.Amount, + ProviderOrderNo: order.ProviderOrderNo, + CreatedAt: order.CreatedAt, + ShippedAt: order.DeliveredAt, + ShipFailReason: order.FailureReason, + } + if order.MerchantProduct != nil { + game := "" + if order.MerchantProduct.Product != nil { + game = order.MerchantProduct.Product.Category + } + out.Product = &OpenOrderProduct{ + Name: order.ProductName, + SKU: order.ProductSKU, + Game: game, + } + } + // 新模型无独立的游戏字段列,从 RequestData / ResultData JSON 中还原。 + extractGameFields(order.RequestData, out) + extractGameFields(order.ResultData, out) + return out, nil +} diff --git a/backend/internal/service/fulfillment_shipnotify.go b/backend/internal/service/fulfillment_shipnotify.go new file mode 100644 index 0000000..4be0fdc --- /dev/null +++ b/backend/internal/service/fulfillment_shipnotify.go @@ -0,0 +1,190 @@ +package service + +import ( + "errors" + "strings" + "time" + "unicode/utf8" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ShipNotifyInput 上游发货结果推送。 +type ShipNotifyInput struct { + OrderNo string + ShipStatus string // success / failed + ProviderOrderNo string + ShippedAt *time.Time + FailReason string + RawPayload string + GameChannel *string + GameUID *string + RoleName *string + PayScore *int +} + +// ShipNotifyResult 上游推送处理结果。 +type ShipNotifyResult struct { + OrderNo string `json:"order_no"` + Status string `json:"status"` + Message string `json:"message"` +} + +// HandleShipNotify 处理上游发货结果推送(幂等),基于 FulfillmentOrder。 +func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) { + in.ShipStatus = strings.TrimSpace(in.ShipStatus) + in.FailReason = strings.TrimSpace(in.FailReason) + if in.OrderNo == "" { + return nil, errors.New("订单号不能为空") + } + if in.ShipStatus != "success" && in.ShipStatus != "failed" { + return nil, errors.New("无效的 ship_status,仅支持 success/failed") + } + if in.ShipStatus == "failed" && in.FailReason == "" { + return nil, errors.New("发货失败时 fail_reason 必填") + } + if utf8.RuneCountInString(in.FailReason) > 512 { + return nil, errors.New("fail_reason 最长 512 个字符") + } + + var nextStatus string + switch in.ShipStatus { + case "success": + nextStatus = model.OrderStatusDelivered + case "failed": + nextStatus = model.OrderStatusShipFailed + } + + var result ShipNotifyResult + var rejectionErr error + if err := s.db.Transaction(func(tx *gorm.DB) error { + var order model.FulfillmentOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Preload("MerchantProduct.Product"). + Where("order_no = ?", in.OrderNo). + First(&order).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("订单不存在") + } + return err + } + + // 已交付:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。 + if normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" { + result = ShipNotifyResult{ + OrderNo: order.OrderNo, + Status: normalizeOrderStatus(&order), + Message: "订单已交付,幂等成功", + } + return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, + shipNotifyAuditMetadata(in, normalizeOrderStatus(&order), "订单已交付,幂等忽略")) + } + + if normalizeOrderStatus(&order) == model.OrderStatusCancelled { + if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil { + return err + } + rejectionErr = errors.New("订单已取消,无法更新发货状态") + return nil + } + + now := time.Now() + shippedAt := in.ShippedAt + if shippedAt == nil && in.ShipStatus == "success" { + shippedAt = &now + } + + updates := map[string]interface{}{ + "order_status": nextStatus, + } + var message string + switch in.ShipStatus { + case "success": + if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil { + message := "当前状态不允许标记发货成功" + if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { + return err + } + rejectionErr = err + return nil + } + updates["delivered_at"] = shippedAt + updates["failure_reason"] = "" + if in.ProviderOrderNo != "" { + updates["provider_order_no"] = in.ProviderOrderNo + } + message = "发货成功,订单已交付" + case "failed": + if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil { + message := "当前状态不允许标记发货失败" + if normalizeOrderStatus(&order) == model.OrderStatusDelivered { + message = "订单已交付,拒绝失败推送" + } + if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { + return err + } + rejectionErr = err + return nil + } + updates["failure_reason"] = in.FailReason + if in.ProviderOrderNo != "" { + updates["provider_order_no"] = in.ProviderOrderNo + } + message = "已记录发货失败" + } + + resultData := buildShipNotifyResultData(order.ResultData, in, shippedAt) + updates["result_data"] = resultData + if err := tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil { + return err + } + + if err := writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, + shipNotifyAuditMetadata(in, nextStatus, message)); err != nil { + return err + } + + var updated model.FulfillmentOrder + if err := tx.Preload("MerchantProduct.Product").First(&updated, order.ID).Error; err != nil { + return err + } + if s.callbacks != nil { + if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&updated)); err != nil { + return err + } + } + result = ShipNotifyResult{ + OrderNo: order.OrderNo, + Status: nextStatus, + Message: message, + } + return nil + }); err != nil { + return nil, err + } + if rejectionErr != nil { + return nil, rejectionErr + } + + return &result, nil +} + +func writeShipNotifyRejectedAudit(tx *gorm.DB, order *model.FulfillmentOrder, in ShipNotifyInput, message string) error { + return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo, + shipNotifyAuditMetadata(in, normalizeOrderStatus(order), message)) +} + +func shipNotifyAuditMetadata(in ShipNotifyInput, resultStatus, message string) map[string]interface{} { + metadata := map[string]interface{}{ + "ship_status": in.ShipStatus, + "provider_order_no": in.ProviderOrderNo, + "fail_reason": in.FailReason, + "result_status": resultStatus, + "message": message, + "payload": in.RawPayload, + } + return metadata +} diff --git a/backend/internal/service/fulfillment_timeout.go b/backend/internal/service/fulfillment_timeout.go new file mode 100644 index 0000000..33bd4d3 --- /dev/null +++ b/backend/internal/service/fulfillment_timeout.go @@ -0,0 +1,131 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/timeutil" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func (s *FulfillmentService) MarkProcessingTimeouts(timeout time.Duration, limit int) (int, error) { + if timeout <= 0 { + return 0, nil + } + if limit <= 0 || limit > 100 { + limit = 50 + } + now := time.Now() + cutoff := now.Add(-timeout) + var ids []uint + if err := s.db.Model(&model.FulfillmentOrder{}). + Where("order_status = ? AND updated_at < ?", model.OrderStatusDelivering, cutoff). + Order("updated_at ASC, id ASC"). + Limit(limit). + Pluck("id", &ids).Error; err != nil { + return 0, err + } + changed := 0 + for _, id := range ids { + updated, err := s.markProcessingTimeout(id, timeout, now) + if err != nil { + return changed, err + } + if updated { + changed++ + } + } + return changed, nil +} + +func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duration, now time.Time) (bool, error) { + returned := false + err := s.db.Transaction(func(tx *gorm.DB) error { + var order model.FulfillmentOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if !processingTimedOut(&order, timeout, now) { + return nil + } + if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil { + return nil + } + submittedUpstream := deliverySubmittedUpstream(&order) + var reason string + if submittedUpstream { + reason = fmt.Sprintf("发货超时:订单已提交上游但超过 %d 分钟未回传结果,可能仍在处理;请勿直接重试,先在上游确认订单状态", int(timeout.Minutes())) + } else { + reason = fmt.Sprintf("发货超时:发货提交中断,请重新提交(已停留 delivering 超过 %d 分钟)", int(timeout.Minutes())) + } + updates := map[string]interface{}{"order_status": model.OrderStatusShipFailed} + updates["failure_reason"] = reason + updates["result_data"] = buildProcessingTimeoutResultData(order.ResultData, timeout, now, reason) + if err := tx.Model(&order).Updates(updates).Error; err != nil { + return err + } + var out model.FulfillmentOrder + if err := tx.First(&out, order.ID).Error; err != nil { + return err + } + if err := writeAudit(tx, &order.MerchantID, nil, nil, "fulfillment.timeout", "fulfillment_order", order.OrderNo, map[string]interface{}{ + "from": normalizeOrderStatus(&order), + "to": model.OrderStatusShipFailed, + "timeout_minutes": int(timeout.Minutes()), + "reason": reason, + }); err != nil { + return err + } + if s.callbacks != nil { + if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&out)); err != nil { + return err + } + } + returned = true + return nil + }) + return returned, err +} + +func (s *FulfillmentService) RunProcessingTimeoutMonitor(ctx context.Context, timeout, interval time.Duration) { + if timeout <= 0 { + return + } + if interval <= 0 { + interval = time.Minute + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + changed, err := s.MarkProcessingTimeouts(timeout, 50) + if err != nil { + log.Printf("[fulfillment] timeout scan error: %v", err) + } else if changed > 0 { + log.Printf("[fulfillment] timeout scan marked failed count=%d", changed) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func buildProcessingTimeoutResultData(existing string, timeout time.Duration, now time.Time, reason string) string { + return mergeResultData(existing, map[string]interface{}{ + "timeout": true, + "timeout_minutes": int(timeout.Minutes()), + "timeout_at": timeutil.FormatAPITime(now), + "ship_status": "failed", + "fail_reason": reason, + }) +} diff --git a/backend/internal/service/fulfillment_wallet.go b/backend/internal/service/fulfillment_wallet.go new file mode 100644 index 0000000..1509c2e --- /dev/null +++ b/backend/internal/service/fulfillment_wallet.go @@ -0,0 +1,110 @@ +package service + +import ( + "errors" + "fmt" + + "affiliate_dash/internal/model" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type WalletAdjustInput struct { + MerchantID uint + ActorUserID uint + Amount int64 + IdempotencyKey string + Note string +} + +func (s *FulfillmentService) AdjustWallet(in WalletAdjustInput) (*model.WalletAccount, error) { + if in.Amount == 0 { + return nil, errors.New("调整金额不能为零") + } + if in.IdempotencyKey == "" { + return nil, errors.New("账务调整必须提供幂等键") + } + var out model.WalletAccount + err := s.db.Transaction(func(tx *gorm.DB) error { + var existing model.WalletLedgerEntry + if err := tx.Where("merchant_id = ? AND idempotency_key = ?", in.MerchantID, in.IdempotencyKey).First(&existing).Error; err == nil { + if err := tx.First(&out, existing.WalletAccountID).Error; err != nil { + return err + } + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var wallet model.WalletAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil { + return err + } + newBalance := wallet.AvailableBalance + in.Amount + if newBalance < 0 { + return errors.New("调整后余额不能小于零") + } + if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil { + return err + } + entryType := model.WalletLedgerAdjust + if in.Amount > 0 { + entryType = model.WalletLedgerCredit + } else { + entryType = model.WalletLedgerDebit + } + idempotencyKey := in.IdempotencyKey + if err := tx.Create(&model.WalletLedgerEntry{ + MerchantID: in.MerchantID, + WalletAccountID: wallet.ID, + EntryNo: "WL" + uuid.NewString(), + Type: entryType, + Amount: in.Amount, + BalanceAfter: newBalance, + ReferenceType: "manual_adjustment", + ReferenceNo: in.IdempotencyKey, + IdempotencyKey: &idempotencyKey, + Note: in.Note, + }).Error; err != nil { + return err + } + out = wallet + out.AvailableBalance = newBalance + return writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "wallet.adjust", "wallet_account", fmt.Sprint(wallet.ID), map[string]int64{"amount": in.Amount}) + }) + if err != nil { + return nil, err + } + return &out, nil +} + +func (s *FulfillmentService) GetWallet(merchantID uint) (*model.WalletAccount, error) { + var wallet model.WalletAccount + if err := s.db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("商户钱包不存在") + } + return nil, err + } + return &wallet, nil +} + +func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int, referenceNo, entryType string) ([]model.WalletLedgerEntry, int64, error) { + page, size = normalizePage(page, size) + tx := s.db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID) + if referenceNo != "" { + tx = tx.Where("reference_no LIKE ?", "%"+referenceNo+"%") + } + if entryType != "" { + tx = tx.Where("type = ?", entryType) + } + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + var entries []model.WalletLedgerEntry + err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&entries).Error + return entries, total, err +} diff --git a/backend/internal/service/merchant.go b/backend/internal/service/merchant.go index a0bb0a0..33a4c78 100644 --- a/backend/internal/service/merchant.go +++ b/backend/internal/service/merchant.go @@ -1,19 +1,15 @@ package service import ( - "crypto/rand" - "encoding/base64" "errors" "fmt" "regexp" "strings" - "time" "affiliate_dash/internal/model" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" - "gorm.io/gorm/clause" ) var merchantCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,63}$`) @@ -251,605 +247,3 @@ func (s *MerchantService) GetMerchant(merchantID uint) (*model.Merchant, error) } return &merchant, nil } - -type AddMemberInput struct { - UserID uint - Role string - IsDefault bool -} - -func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) { - if !isValidMemberRole(in.Role) { - return nil, errors.New("无效的商户成员角色") - } - member := &model.MerchantMember{ - MerchantID: merchantID, - UserID: in.UserID, - Role: in.Role, - Status: 1, - IsDefault: in.IsDefault, - } - err := s.db.Transaction(func(tx *gorm.DB) error { - var merchant model.Merchant - if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { - return errors.New("商户不存在或已禁用") - } - var user model.User - if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil { - return errors.New("用户不存在或已禁用") - } - if err := tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}}, - DoUpdates: clause.Assignments(map[string]interface{}{ - "role": in.Role, - "status": 1, - "is_default": in.IsDefault, - }), - }).Create(member).Error; err != nil { - return err - } - return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, in.UserID), map[string]string{"role": in.Role}) - }) - if err != nil { - return nil, err - } - if err := s.db.Where("merchant_id = ? AND user_id = ?", merchantID, in.UserID).First(member).Error; err != nil { - return nil, err - } - return member, nil -} - -func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) { - var members []model.MerchantMember - err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error - return members, err -} - -type CreateMerchantProductInput struct { - ProductCode string - ProductName string - Category string - Description string - Attributes string - SKU string - DisplayName string - PriceAmount int64 - CostAmount int64 - Currency string - Stock int64 - Status string - FulfillmentConfig string -} - -func (s *MerchantService) CreateMerchantProduct(merchantID uint, in CreateMerchantProductInput, actorUserID uint) (*model.MerchantProduct, error) { - in.SKU = strings.TrimSpace(in.SKU) - in.ProductCode = strings.TrimSpace(in.ProductCode) - in.ProductName = strings.TrimSpace(in.ProductName) - if in.SKU == "" { - return nil, errors.New("商户商品 SKU 不能为空") - } - if in.PriceAmount < 0 || in.CostAmount < 0 { - return nil, errors.New("商品金额不能小于零") - } - if in.Stock < -1 { - return nil, errors.New("库存只能为 -1 或非负整数") - } - if in.Currency == "" { - in.Currency = "POINT" - } - in.Currency = strings.ToUpper(in.Currency) - if in.Status == "" { - in.Status = model.ProductStatusActive - } - if in.Status != model.ProductStatusActive && in.Status != model.ProductStatusInactive { - return nil, errors.New("无效的商品状态") - } - - merchantProduct := &model.MerchantProduct{} - err := s.db.Transaction(func(tx *gorm.DB) error { - var merchant model.Merchant - if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { - return errors.New("商户不存在或已禁用") - } - product, err := ensureProduct(tx, in) - if err != nil { - return err - } - merchantProduct = &model.MerchantProduct{ - MerchantID: merchantID, - ProductID: product.ID, - SKU: in.SKU, - DisplayName: fallbackName(in.DisplayName, product.Name), - PriceAmount: in.PriceAmount, - CostAmount: in.CostAmount, - Currency: in.Currency, - Stock: in.Stock, - Status: in.Status, - FulfillmentConfig: in.FulfillmentConfig, - } - if err := tx.Create(merchantProduct).Error; err != nil { - return err - } - return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.create", "merchant_product", fmt.Sprint(merchantProduct.ID), map[string]string{"sku": in.SKU}) - }) - if err != nil { - return nil, err - } - return merchantProduct, nil -} - -func (s *MerchantService) ListMerchantProducts(merchantID uint, page, size int, activeOnly bool) ([]model.MerchantProduct, int64, error) { - page, size = normalizePage(page, size) - tx := s.db.Model(&model.MerchantProduct{}).Where("merchant_id = ?", merchantID) - if activeOnly { - tx = tx.Where("status = ?", model.ProductStatusActive) - } - var total int64 - if err := tx.Count(&total).Error; err != nil { - return nil, 0, err - } - var products []model.MerchantProduct - err := tx.Preload("Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&products).Error - return products, total, err -} - -type UpdateMerchantProductInput struct { - DisplayName *string - PriceAmount *int64 - CostAmount *int64 - Stock *int64 - Status *string - FulfillmentConfig *string -} - -func (s *MerchantService) UpdateMerchantProduct(merchantID, id uint, in UpdateMerchantProductInput, actorUserID uint) error { - updates := make(map[string]interface{}) - if in.DisplayName != nil { - updates["display_name"] = *in.DisplayName - } - if in.PriceAmount != nil { - if *in.PriceAmount < 0 { - return errors.New("商品售价不能小于零") - } - updates["price_amount"] = *in.PriceAmount - } - if in.CostAmount != nil { - if *in.CostAmount < 0 { - return errors.New("商品成本不能小于零") - } - updates["cost_amount"] = *in.CostAmount - } - if in.Stock != nil { - if *in.Stock < -1 { - return errors.New("库存只能为 -1 或非负整数") - } - updates["stock"] = *in.Stock - } - if in.Status != nil { - if *in.Status != model.ProductStatusActive && *in.Status != model.ProductStatusInactive { - return errors.New("无效的商品状态") - } - updates["status"] = *in.Status - } - if in.FulfillmentConfig != nil { - updates["fulfillment_config"] = *in.FulfillmentConfig - } - if len(updates) == 0 { - return errors.New("没有可更新字段") - } - return s.db.Transaction(func(tx *gorm.DB) error { - result := tx.Model(&model.MerchantProduct{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return errors.New("商户商品不存在") - } - return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.update", "merchant_product", fmt.Sprint(id), nil) - }) -} - -type APICredential struct { - Client *model.APIClient `json:"client"` - Secret string `json:"secret"` -} - -type CreateAPIClientInput struct { - Name string - Scopes string - SignatureVersion string - ExpiresAt *time.Time -} - -// MaxAPIClientsPerMerchant 每个商户最多可创建的 API 密钥数量,防止密钥滥用。 -const MaxAPIClientsPerMerchant = 5 - -func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInput, actorUserID uint) (*APICredential, error) { - in.Name = strings.TrimSpace(in.Name) - if in.Name == "" { - return nil, errors.New("API 客户端名称不能为空") - } - if len(ParseScopes(in.Scopes)) == 0 { - return nil, errors.New("至少配置一个 API 权限") - } - if in.SignatureVersion == "" { - in.SignatureVersion = "v1" - } - if in.SignatureVersion != "v1" { - return nil, errors.New("无效的签名版本") - } - appKey, err := randomToken("ak_", 24) - if err != nil { - return nil, err - } - secret, err := randomToken("sk_", 32) - if err != nil { - return nil, err - } - ciphertext, err := s.codec.Encrypt(secret) - if err != nil { - return nil, err - } - client := &model.APIClient{ - MerchantID: merchantID, - Name: in.Name, - AppKey: appKey, - SecretCiphertext: ciphertext, - SignatureVersion: in.SignatureVersion, - Scopes: strings.Join(scopeList(in.Scopes), ","), - Status: model.APIClientStatusActive, - ExpiresAt: in.ExpiresAt, - } - err = s.db.Transaction(func(tx *gorm.DB) error { - var merchant model.Merchant - if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { - return errors.New("商户不存在或已禁用") - } - var clientCount int64 - if err := tx.Model(&model.APIClient{}). - Where("merchant_id = ? AND status = ?", merchantID, model.APIClientStatusActive). - Count(&clientCount).Error; err != nil { - return err - } - if clientCount >= MaxAPIClientsPerMerchant { - return fmt.Errorf("每个商户最多可创建 %d 个 API 密钥", MaxAPIClientsPerMerchant) - } - if err := tx.Create(client).Error; err != nil { - return err - } - return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.create", "api_client", fmt.Sprint(client.ID), map[string]string{"name": in.Name}) - }) - if err != nil { - return nil, err - } - return &APICredential{Client: client, Secret: secret}, nil -} - -func (s *MerchantService) ListAPIClients(merchantID uint) ([]model.APIClient, error) { - var clients []model.APIClient - err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&clients).Error - return clients, err -} - -func (s *MerchantService) UpdateAPIClientStatus(merchantID, id uint, status string, actorUserID uint) error { - if status != model.APIClientStatusActive && status != model.APIClientStatusDisabled { - return errors.New("无效的 API 客户端状态") - } - return s.db.Transaction(func(tx *gorm.DB) error { - result := tx.Model(&model.APIClient{}).Where("id = ? AND merchant_id = ?", id, merchantID).Update("status", status) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return errors.New("API 客户端不存在") - } - return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.status.update", "api_client", fmt.Sprint(id), map[string]string{"status": status}) - }) -} - -// DeleteAPIClient 删除商户的 API 密钥(物理删除,不可恢复)。 -// 删除前要求先停用,避免在用的密钥被误删。 -func (s *MerchantService) DeleteAPIClient(merchantID, id uint, actorUserID uint) error { - return s.db.Transaction(func(tx *gorm.DB) error { - var client model.APIClient - if err := tx.Where("id = ? AND merchant_id = ?", id, merchantID).First(&client).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("API 客户端不存在") - } - return err - } - if client.Status == model.APIClientStatusActive { - return errors.New("请先停用该密钥再删除") - } - if err := tx.Delete(&client).Error; err != nil { - return err - } - return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.delete", "api_client", fmt.Sprint(id), map[string]string{"name": client.Name, "app_key": client.AppKey}) - }) -} - -// ProductCatalogItem 是商品目录(自营商户可售商品)的展示项,供平台管理员分配商品时勾选。 -type ProductCatalogItem struct { - ID uint `json:"id"` - ProductID uint `json:"product_id"` - SKU string `json:"sku"` - DisplayName string `json:"display_name"` - Category string `json:"category"` - PriceAmount int64 `json:"price_amount"` - CostAmount int64 `json:"cost_amount"` - Currency string `json:"currency"` - Stock int64 `json:"stock"` - Status string `json:"status"` -} - -// ListProductCatalog 返回自营商户的全部可售商品,作为平台默认商品目录供分配。 -func (s *MerchantService) ListProductCatalog() ([]ProductCatalogItem, error) { - var selfMerchant model.Merchant - if err := s.db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("平台商品目录尚未初始化") - } - return nil, err - } - var products []model.MerchantProduct - if err := s.db.Preload("Product").Where("merchant_id = ?", selfMerchant.ID).Order("id ASC").Find(&products).Error; err != nil { - return nil, err - } - items := make([]ProductCatalogItem, 0, len(products)) - for _, p := range products { - category := "" - if p.Product != nil { - category = p.Product.Category - } - items = append(items, ProductCatalogItem{ - ID: p.ID, - ProductID: p.ProductID, - SKU: p.SKU, - DisplayName: p.DisplayName, - Category: category, - PriceAmount: p.PriceAmount, - CostAmount: p.CostAmount, - Currency: p.Currency, - Stock: p.Stock, - Status: p.Status, - }) - } - return items, nil -} - -// ListMerchantProductsByAdmin 供平台管理员查看指定商户的可售商品(不限功能开关)。 -func (s *MerchantService) ListMerchantProductsByAdmin(merchantID uint) ([]ProductCatalogItem, error) { - var products []model.MerchantProduct - if err := s.db.Preload("Product").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&products).Error; err != nil { - return nil, err - } - items := make([]ProductCatalogItem, 0, len(products)) - for _, p := range products { - category := "" - if p.Product != nil { - category = p.Product.Category - } - items = append(items, ProductCatalogItem{ - ID: p.ID, - ProductID: p.ProductID, - SKU: p.SKU, - DisplayName: p.DisplayName, - Category: category, - PriceAmount: p.PriceAmount, - CostAmount: p.CostAmount, - Currency: p.Currency, - Stock: p.Stock, - Status: p.Status, - }) - } - return items, nil -} - -// AssignProductsInput 批量分配商品给商户的入参。 -type AssignProductsInput struct { - // CatalogIDs 为自营商户商品目录 ID 列表;为空表示清空该商户全部商品。 - CatalogIDs []uint -} - -// AssignProducts 按自营商户商品目录 ID 批量同步商户的可售商品: -// 目录中勾选的商品会被复制(已存在则跳过),未勾选的已有商品会被移除。 -func (s *MerchantService) AssignProducts(merchantID uint, in AssignProductsInput, actorUserID uint) (int, error) { - assigned := 0 - err := s.db.Transaction(func(tx *gorm.DB) error { - var merchant model.Merchant - if err := tx.Where("id = ?", merchantID).First(&merchant).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("商户不存在") - } - return err - } - if merchant.Code == model.MerchantCodeSelfOperated { - return errors.New("自营商户的商品目录由平台维护,不可分配") - } - var selfMerchant model.Merchant - if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { - return errors.New("平台商品目录尚未初始化") - } - - // 读取目录全量,构造 id -> 模板 的映射 - var templates []model.MerchantProduct - if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil { - return err - } - tmplByID := make(map[uint]model.MerchantProduct, len(templates)) - for _, t := range templates { - tmplByID[t.ID] = t - } - - // 读取商户已有商品,构造 sku -> 已有 的映射 - var existing []model.MerchantProduct - if err := tx.Where("merchant_id = ?", merchantID).Find(&existing).Error; err != nil { - return err - } - existBySKU := make(map[string]model.MerchantProduct, len(existing)) - for _, e := range existing { - existBySKU[e.SKU] = e - } - - // 计算需要新增的 SKU 集合 - wantSKUs := make(map[string]bool, len(in.CatalogIDs)) - toCreate := make([]model.MerchantProduct, 0, len(in.CatalogIDs)) - for _, id := range in.CatalogIDs { - t, ok := tmplByID[id] - if !ok { - continue - } - wantSKUs[t.SKU] = true - if _, has := existBySKU[t.SKU]; !has { - toCreate = append(toCreate, model.MerchantProduct{ - MerchantID: merchantID, - ProductID: t.ProductID, - SKU: t.SKU, - DisplayName: t.DisplayName, - PriceAmount: t.PriceAmount, - CostAmount: t.CostAmount, - Currency: t.Currency, - Stock: t.Stock, - Status: t.Status, - FulfillmentConfig: t.FulfillmentConfig, - }) - } - } - - // 移除未勾选的已有商品 - var removeIDs []uint - for _, e := range existing { - if !wantSKUs[e.SKU] { - removeIDs = append(removeIDs, e.ID) - } - } - if len(removeIDs) > 0 { - if err := tx.Where("merchant_id = ? AND id IN ?", merchantID, removeIDs).Delete(&model.MerchantProduct{}).Error; err != nil { - return err - } - } - - // 新增勾选但尚未拥有的商品 - if len(toCreate) > 0 { - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&toCreate).Error; err != nil { - return err - } - } - - assigned = len(wantSKUs) - return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.products.assign", "merchant", fmt.Sprint(merchantID), map[string]string{"assigned": fmt.Sprint(assigned)}) - }) - if err != nil { - return 0, err - } - return assigned, nil -} - -// copyDefaultProducts 将自营商户的全部可售商品复制给新建商户,作为默认商品目录。 -// 自营商户(self-operated)充当平台默认商品模板,新商户开箱即用。 -// 使用 OnConflict DoNothing 保证幂等:即使重复调用也不会报唯一索引冲突。 -func copyDefaultProducts(tx *gorm.DB, merchantID uint) error { - var selfMerchant model.Merchant - if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil // 自营商户不存在时跳过,不阻断建商户 - } - return err - } - if selfMerchant.ID == merchantID { - return nil // 自营商户自身无需复制 - } - var templates []model.MerchantProduct - if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil { - return err - } - if len(templates) == 0 { - return nil - } - products := make([]model.MerchantProduct, 0, len(templates)) - for _, t := range templates { - products = append(products, model.MerchantProduct{ - MerchantID: merchantID, - ProductID: t.ProductID, - SKU: t.SKU, - DisplayName: t.DisplayName, - PriceAmount: t.PriceAmount, - CostAmount: t.CostAmount, - Currency: t.Currency, - Stock: t.Stock, - Status: t.Status, - FulfillmentConfig: t.FulfillmentConfig, - }) - } - return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&products).Error -} - -func ensureProduct(tx *gorm.DB, in CreateMerchantProductInput) (*model.Product, error) { - if in.ProductCode != "" { - var product model.Product - err := tx.Where("code = ?", in.ProductCode).First(&product).Error - if err == nil { - return &product, nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - } - if in.ProductName == "" { - return nil, errors.New("新建平台商品时商品名称不能为空") - } - code := in.ProductCode - if code == "" { - token, err := randomToken("prd_", 12) - if err != nil { - return nil, err - } - code = token - } - product := &model.Product{ - Code: code, - Name: in.ProductName, - Category: in.Category, - Description: in.Description, - Attributes: in.Attributes, - Status: model.ProductStatusActive, - } - if err := tx.Create(product).Error; err != nil { - return nil, err - } - return product, nil -} - -func normalizePage(page, size int) (int, int) { - if page < 1 { - page = 1 - } - if size < 1 || size > 100 { - size = 20 - } - return page, size -} - -func randomToken(prefix string, byteCount int) (string, error) { - raw := make([]byte, byteCount) - if _, err := rand.Read(raw); err != nil { - return "", err - } - return prefix + base64.RawURLEncoding.EncodeToString(raw), nil -} - -func scopeList(scopes string) []string { - set := ParseScopes(scopes) - items := make([]string, 0, len(set)) - for scope := range set { - items = append(items, scope) - } - return items -} - -func fallbackName(value, fallback string) string { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - return fallback -} diff --git a/backend/internal/service/merchant_api_client.go b/backend/internal/service/merchant_api_client.go new file mode 100644 index 0000000..989af2d --- /dev/null +++ b/backend/internal/service/merchant_api_client.go @@ -0,0 +1,131 @@ +package service + +import ( + "errors" + "fmt" + "strings" + "time" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" +) + +type APICredential struct { + Client *model.APIClient `json:"client"` + Secret string `json:"secret"` +} + +type CreateAPIClientInput struct { + Name string + Scopes string + SignatureVersion string + ExpiresAt *time.Time +} + +// MaxAPIClientsPerMerchant 每个商户最多可创建的 API 密钥数量,防止密钥滥用。 +const MaxAPIClientsPerMerchant = 5 + +func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInput, actorUserID uint) (*APICredential, error) { + in.Name = strings.TrimSpace(in.Name) + if in.Name == "" { + return nil, errors.New("API 客户端名称不能为空") + } + if len(ParseScopes(in.Scopes)) == 0 { + return nil, errors.New("至少配置一个 API 权限") + } + if in.SignatureVersion == "" { + in.SignatureVersion = "v1" + } + if in.SignatureVersion != "v1" { + return nil, errors.New("无效的签名版本") + } + appKey, err := randomToken("ak_", 24) + if err != nil { + return nil, err + } + secret, err := randomToken("sk_", 32) + if err != nil { + return nil, err + } + ciphertext, err := s.codec.Encrypt(secret) + if err != nil { + return nil, err + } + client := &model.APIClient{ + MerchantID: merchantID, + Name: in.Name, + AppKey: appKey, + SecretCiphertext: ciphertext, + SignatureVersion: in.SignatureVersion, + Scopes: strings.Join(scopeList(in.Scopes), ","), + Status: model.APIClientStatusActive, + ExpiresAt: in.ExpiresAt, + } + err = s.db.Transaction(func(tx *gorm.DB) error { + var merchant model.Merchant + if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { + return errors.New("商户不存在或已禁用") + } + var clientCount int64 + if err := tx.Model(&model.APIClient{}). + Where("merchant_id = ? AND status = ?", merchantID, model.APIClientStatusActive). + Count(&clientCount).Error; err != nil { + return err + } + if clientCount >= MaxAPIClientsPerMerchant { + return fmt.Errorf("每个商户最多可创建 %d 个 API 密钥", MaxAPIClientsPerMerchant) + } + if err := tx.Create(client).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.create", "api_client", fmt.Sprint(client.ID), map[string]string{"name": in.Name}) + }) + if err != nil { + return nil, err + } + return &APICredential{Client: client, Secret: secret}, nil +} + +func (s *MerchantService) ListAPIClients(merchantID uint) ([]model.APIClient, error) { + var clients []model.APIClient + err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&clients).Error + return clients, err +} + +func (s *MerchantService) UpdateAPIClientStatus(merchantID, id uint, status string, actorUserID uint) error { + if status != model.APIClientStatusActive && status != model.APIClientStatusDisabled { + return errors.New("无效的 API 客户端状态") + } + return s.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.APIClient{}).Where("id = ? AND merchant_id = ?", id, merchantID).Update("status", status) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("API 客户端不存在") + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.status.update", "api_client", fmt.Sprint(id), map[string]string{"status": status}) + }) +} + +// DeleteAPIClient 删除商户的 API 密钥(物理删除,不可恢复)。 +// 删除前要求先停用,避免在用的密钥被误删。 +func (s *MerchantService) DeleteAPIClient(merchantID, id uint, actorUserID uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + var client model.APIClient + if err := tx.Where("id = ? AND merchant_id = ?", id, merchantID).First(&client).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("API 客户端不存在") + } + return err + } + if client.Status == model.APIClientStatusActive { + return errors.New("请先停用该密钥再删除") + } + if err := tx.Delete(&client).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.delete", "api_client", fmt.Sprint(id), map[string]string{"name": client.Name, "app_key": client.AppKey}) + }) +} diff --git a/backend/internal/service/merchant_catalog.go b/backend/internal/service/merchant_catalog.go new file mode 100644 index 0000000..73e2d74 --- /dev/null +++ b/backend/internal/service/merchant_catalog.go @@ -0,0 +1,262 @@ +package service + +import ( + "errors" + "fmt" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type ProductCatalogItem struct { + ID uint `json:"id"` + ProductID uint `json:"product_id"` + SKU string `json:"sku"` + DisplayName string `json:"display_name"` + Category string `json:"category"` + PriceAmount int64 `json:"price_amount"` + CostAmount int64 `json:"cost_amount"` + Currency string `json:"currency"` + Stock int64 `json:"stock"` + Status string `json:"status"` +} + +// ListProductCatalog 返回自营商户的全部可售商品,作为平台默认商品目录供分配。 +func (s *MerchantService) ListProductCatalog() ([]ProductCatalogItem, error) { + var selfMerchant model.Merchant + if err := s.db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("平台商品目录尚未初始化") + } + return nil, err + } + var products []model.MerchantProduct + if err := s.db.Preload("Product").Where("merchant_id = ?", selfMerchant.ID).Order("id ASC").Find(&products).Error; err != nil { + return nil, err + } + items := make([]ProductCatalogItem, 0, len(products)) + for _, p := range products { + category := "" + if p.Product != nil { + category = p.Product.Category + } + items = append(items, ProductCatalogItem{ + ID: p.ID, + ProductID: p.ProductID, + SKU: p.SKU, + DisplayName: p.DisplayName, + Category: category, + PriceAmount: p.PriceAmount, + CostAmount: p.CostAmount, + Currency: p.Currency, + Stock: p.Stock, + Status: p.Status, + }) + } + return items, nil +} + +// ListMerchantProductsByAdmin 供平台管理员查看指定商户的可售商品(不限功能开关)。 +func (s *MerchantService) ListMerchantProductsByAdmin(merchantID uint) ([]ProductCatalogItem, error) { + var products []model.MerchantProduct + if err := s.db.Preload("Product").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&products).Error; err != nil { + return nil, err + } + items := make([]ProductCatalogItem, 0, len(products)) + for _, p := range products { + category := "" + if p.Product != nil { + category = p.Product.Category + } + items = append(items, ProductCatalogItem{ + ID: p.ID, + ProductID: p.ProductID, + SKU: p.SKU, + DisplayName: p.DisplayName, + Category: category, + PriceAmount: p.PriceAmount, + CostAmount: p.CostAmount, + Currency: p.Currency, + Stock: p.Stock, + Status: p.Status, + }) + } + return items, nil +} + +// AssignProductsInput 批量分配商品给商户的入参。 +type AssignProductsInput struct { + // CatalogIDs 为自营商户商品目录 ID 列表;为空表示清空该商户全部商品。 + CatalogIDs []uint +} + +// AssignProducts 按自营商户商品目录 ID 批量同步商户的可售商品: +// 目录中勾选的商品会被复制(已存在则跳过),未勾选的已有商品会被移除。 +func (s *MerchantService) AssignProducts(merchantID uint, in AssignProductsInput, actorUserID uint) (int, error) { + assigned := 0 + err := s.db.Transaction(func(tx *gorm.DB) error { + var merchant model.Merchant + if err := tx.Where("id = ?", merchantID).First(&merchant).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("商户不存在") + } + return err + } + if merchant.Code == model.MerchantCodeSelfOperated { + return errors.New("自营商户的商品目录由平台维护,不可分配") + } + var selfMerchant model.Merchant + if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { + return errors.New("平台商品目录尚未初始化") + } + + // 读取目录全量,构造 id -> 模板 的映射 + var templates []model.MerchantProduct + if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil { + return err + } + tmplByID := make(map[uint]model.MerchantProduct, len(templates)) + for _, t := range templates { + tmplByID[t.ID] = t + } + + // 读取商户已有商品,构造 sku -> 已有 的映射 + var existing []model.MerchantProduct + if err := tx.Where("merchant_id = ?", merchantID).Find(&existing).Error; err != nil { + return err + } + existBySKU := make(map[string]model.MerchantProduct, len(existing)) + for _, e := range existing { + existBySKU[e.SKU] = e + } + + // 计算需要新增的 SKU 集合 + wantSKUs := make(map[string]bool, len(in.CatalogIDs)) + toCreate := make([]model.MerchantProduct, 0, len(in.CatalogIDs)) + for _, id := range in.CatalogIDs { + t, ok := tmplByID[id] + if !ok { + continue + } + wantSKUs[t.SKU] = true + if _, has := existBySKU[t.SKU]; !has { + toCreate = append(toCreate, model.MerchantProduct{ + MerchantID: merchantID, + ProductID: t.ProductID, + SKU: t.SKU, + DisplayName: t.DisplayName, + PriceAmount: t.PriceAmount, + CostAmount: t.CostAmount, + Currency: t.Currency, + Stock: t.Stock, + Status: t.Status, + FulfillmentConfig: t.FulfillmentConfig, + }) + } + } + + // 移除未勾选的已有商品 + var removeIDs []uint + for _, e := range existing { + if !wantSKUs[e.SKU] { + removeIDs = append(removeIDs, e.ID) + } + } + if len(removeIDs) > 0 { + if err := tx.Where("merchant_id = ? AND id IN ?", merchantID, removeIDs).Delete(&model.MerchantProduct{}).Error; err != nil { + return err + } + } + + // 新增勾选但尚未拥有的商品 + if len(toCreate) > 0 { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&toCreate).Error; err != nil { + return err + } + } + + assigned = len(wantSKUs) + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.products.assign", "merchant", fmt.Sprint(merchantID), map[string]string{"assigned": fmt.Sprint(assigned)}) + }) + if err != nil { + return 0, err + } + return assigned, nil +} + +// copyDefaultProducts 将自营商户的全部可售商品复制给新建商户,作为默认商品目录。 +// 自营商户(self-operated)充当平台默认商品模板,新商户开箱即用。 +// 使用 OnConflict DoNothing 保证幂等:即使重复调用也不会报唯一索引冲突。 +func copyDefaultProducts(tx *gorm.DB, merchantID uint) error { + var selfMerchant model.Merchant + if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil // 自营商户不存在时跳过,不阻断建商户 + } + return err + } + if selfMerchant.ID == merchantID { + return nil // 自营商户自身无需复制 + } + var templates []model.MerchantProduct + if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil { + return err + } + if len(templates) == 0 { + return nil + } + products := make([]model.MerchantProduct, 0, len(templates)) + for _, t := range templates { + products = append(products, model.MerchantProduct{ + MerchantID: merchantID, + ProductID: t.ProductID, + SKU: t.SKU, + DisplayName: t.DisplayName, + PriceAmount: t.PriceAmount, + CostAmount: t.CostAmount, + Currency: t.Currency, + Stock: t.Stock, + Status: t.Status, + FulfillmentConfig: t.FulfillmentConfig, + }) + } + return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&products).Error +} + +func ensureProduct(tx *gorm.DB, in CreateMerchantProductInput) (*model.Product, error) { + if in.ProductCode != "" { + var product model.Product + err := tx.Where("code = ?", in.ProductCode).First(&product).Error + if err == nil { + return &product, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + if in.ProductName == "" { + return nil, errors.New("新建平台商品时商品名称不能为空") + } + code := in.ProductCode + if code == "" { + token, err := randomToken("prd_", 12) + if err != nil { + return nil, err + } + code = token + } + product := &model.Product{ + Code: code, + Name: in.ProductName, + Category: in.Category, + Description: in.Description, + Attributes: in.Attributes, + Status: model.ProductStatusActive, + } + if err := tx.Create(product).Error; err != nil { + return nil, err + } + return product, nil +} diff --git a/backend/internal/service/merchant_helpers.go b/backend/internal/service/merchant_helpers.go new file mode 100644 index 0000000..cf31f78 --- /dev/null +++ b/backend/internal/service/merchant_helpers.go @@ -0,0 +1,41 @@ +package service + +import ( + "crypto/rand" + "encoding/base64" + "strings" +) + +func normalizePage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + return page, size +} + +func randomToken(prefix string, byteCount int) (string, error) { + raw := make([]byte, byteCount) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return prefix + base64.RawURLEncoding.EncodeToString(raw), nil +} + +func scopeList(scopes string) []string { + set := ParseScopes(scopes) + items := make([]string, 0, len(set)) + for scope := range set { + items = append(items, scope) + } + return items +} + +func fallbackName(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return fallback +} diff --git a/backend/internal/service/merchant_member.go b/backend/internal/service/merchant_member.go new file mode 100644 index 0000000..aedde28 --- /dev/null +++ b/backend/internal/service/merchant_member.go @@ -0,0 +1,64 @@ +package service + +import ( + "errors" + "fmt" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type AddMemberInput struct { + UserID uint + Role string + IsDefault bool +} + +func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) { + if !isValidMemberRole(in.Role) { + return nil, errors.New("无效的商户成员角色") + } + member := &model.MerchantMember{ + MerchantID: merchantID, + UserID: in.UserID, + Role: in.Role, + Status: 1, + IsDefault: in.IsDefault, + } + err := s.db.Transaction(func(tx *gorm.DB) error { + var merchant model.Merchant + if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { + return errors.New("商户不存在或已禁用") + } + var user model.User + if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil { + return errors.New("用户不存在或已禁用") + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "role": in.Role, + "status": 1, + "is_default": in.IsDefault, + }), + }).Create(member).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, in.UserID), map[string]string{"role": in.Role}) + }) + if err != nil { + return nil, err + } + if err := s.db.Where("merchant_id = ? AND user_id = ?", merchantID, in.UserID).First(member).Error; err != nil { + return nil, err + } + return member, nil +} + +func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) { + var members []model.MerchantMember + err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error + return members, err +} diff --git a/backend/internal/service/merchant_product.go b/backend/internal/service/merchant_product.go new file mode 100644 index 0000000..ee9eafa --- /dev/null +++ b/backend/internal/service/merchant_product.go @@ -0,0 +1,155 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + "affiliate_dash/internal/model" + + "gorm.io/gorm" +) + +type CreateMerchantProductInput struct { + ProductCode string + ProductName string + Category string + Description string + Attributes string + SKU string + DisplayName string + PriceAmount int64 + CostAmount int64 + Currency string + Stock int64 + Status string + FulfillmentConfig string +} + +func (s *MerchantService) CreateMerchantProduct(merchantID uint, in CreateMerchantProductInput, actorUserID uint) (*model.MerchantProduct, error) { + in.SKU = strings.TrimSpace(in.SKU) + in.ProductCode = strings.TrimSpace(in.ProductCode) + in.ProductName = strings.TrimSpace(in.ProductName) + if in.SKU == "" { + return nil, errors.New("商户商品 SKU 不能为空") + } + if in.PriceAmount < 0 || in.CostAmount < 0 { + return nil, errors.New("商品金额不能小于零") + } + if in.Stock < -1 { + return nil, errors.New("库存只能为 -1 或非负整数") + } + if in.Currency == "" { + in.Currency = "POINT" + } + in.Currency = strings.ToUpper(in.Currency) + if in.Status == "" { + in.Status = model.ProductStatusActive + } + if in.Status != model.ProductStatusActive && in.Status != model.ProductStatusInactive { + return nil, errors.New("无效的商品状态") + } + + merchantProduct := &model.MerchantProduct{} + err := s.db.Transaction(func(tx *gorm.DB) error { + var merchant model.Merchant + if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { + return errors.New("商户不存在或已禁用") + } + product, err := ensureProduct(tx, in) + if err != nil { + return err + } + merchantProduct = &model.MerchantProduct{ + MerchantID: merchantID, + ProductID: product.ID, + SKU: in.SKU, + DisplayName: fallbackName(in.DisplayName, product.Name), + PriceAmount: in.PriceAmount, + CostAmount: in.CostAmount, + Currency: in.Currency, + Stock: in.Stock, + Status: in.Status, + FulfillmentConfig: in.FulfillmentConfig, + } + if err := tx.Create(merchantProduct).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.create", "merchant_product", fmt.Sprint(merchantProduct.ID), map[string]string{"sku": in.SKU}) + }) + if err != nil { + return nil, err + } + return merchantProduct, nil +} + +func (s *MerchantService) ListMerchantProducts(merchantID uint, page, size int, activeOnly bool) ([]model.MerchantProduct, int64, error) { + page, size = normalizePage(page, size) + tx := s.db.Model(&model.MerchantProduct{}).Where("merchant_id = ?", merchantID) + if activeOnly { + tx = tx.Where("status = ?", model.ProductStatusActive) + } + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + var products []model.MerchantProduct + err := tx.Preload("Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&products).Error + return products, total, err +} + +type UpdateMerchantProductInput struct { + DisplayName *string + PriceAmount *int64 + CostAmount *int64 + Stock *int64 + Status *string + FulfillmentConfig *string +} + +func (s *MerchantService) UpdateMerchantProduct(merchantID, id uint, in UpdateMerchantProductInput, actorUserID uint) error { + updates := make(map[string]interface{}) + if in.DisplayName != nil { + updates["display_name"] = *in.DisplayName + } + if in.PriceAmount != nil { + if *in.PriceAmount < 0 { + return errors.New("商品售价不能小于零") + } + updates["price_amount"] = *in.PriceAmount + } + if in.CostAmount != nil { + if *in.CostAmount < 0 { + return errors.New("商品成本不能小于零") + } + updates["cost_amount"] = *in.CostAmount + } + if in.Stock != nil { + if *in.Stock < -1 { + return errors.New("库存只能为 -1 或非负整数") + } + updates["stock"] = *in.Stock + } + if in.Status != nil { + if *in.Status != model.ProductStatusActive && *in.Status != model.ProductStatusInactive { + return errors.New("无效的商品状态") + } + updates["status"] = *in.Status + } + if in.FulfillmentConfig != nil { + updates["fulfillment_config"] = *in.FulfillmentConfig + } + if len(updates) == 0 { + return errors.New("没有可更新字段") + } + return s.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.MerchantProduct{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("商户商品不存在") + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.update", "merchant_product", fmt.Sprint(id), nil) + }) +} diff --git a/docker-compose.yml b/docker-compose.yml index fb0731b..d0e4eeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,7 @@ services: - JWT_SECRET=${JWT_SECRET} - "DATABASE_URL=${DOCKER_DATABASE_URL:-postgres://${POSTGRES_USER:-affiliate}:${POSTGRES_PASSWORD:-affiliate_dev_password}@postgres:5432/${POSTGRES_DB:-affiliate_dash}?sslmode=disable&TimeZone=${TZ:-Asia/Shanghai}}" - GIN_MODE=${GIN_MODE:-release} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-} - OPEN_API_KEY=${OPEN_API_KEY} - OPEN_API_SECRET=${OPEN_API_SECRET} - OPEN_SIGN_SKEW=${OPEN_SIGN_SKEW:-300} diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 9b1452f..4bbcf65 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -2,38 +2,23 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { Button, - Card, - Col, Descriptions, Form, Input, InputNumber, Modal, - Popconfirm, - Row, Select, Space, - Table, Tabs, Tag, Typography, message, } from 'antd' import { - ClockCircleOutlined, - CodeOutlined, CopyOutlined, - DollarOutlined, - FileTextOutlined, - KeyOutlined, LinkOutlined, - PlusOutlined, ReloadOutlined, - SafetyCertificateOutlined, - SendOutlined, - WalletOutlined, } from '@ant-design/icons' -import type { ColumnsType } from 'antd/es/table' import { useAuth } from '../store/auth' import { merchantApi } from '../api' import { formatDateTime } from '../utils/time' @@ -52,50 +37,29 @@ import type { WalletAccount, WalletLedgerEntry, } from '../types' - -const orderStatusMap: Record = { - paid: { color: 'blue', text: '待发货' }, - delivering: { color: 'cyan', text: '发货中' }, - delivered: { color: 'green', text: '已交付' }, - ship_failed: { color: 'red', text: '发货失败' }, - cancelled: { color: 'default', text: '已取消' }, -} - -const memberRoleOptions = [ - { value: 'owner', label: '负责人' }, - { value: 'operator', label: '运营' }, - { value: 'finance', label: '财务' }, - { value: 'viewer', label: '只读' }, -] - -const apiClientMax = 5 - -const scopeOptions = [ - { value: 'products:read', label: '商品读取' }, - { value: 'orders:read', label: '订单读取' }, - { value: 'orders:write', label: '订单写入' }, - { value: 'shipping:read', label: '发货读取' }, - { value: 'wallet:read', label: '钱包读取' }, -] - -const eventOptions = [ - { value: 'order.created', label: '订单创建' }, - { value: 'order.shipping.updated', label: '发货更新' }, - { value: 'order.cancelled', label: '订单取消' }, -] - -const testOrderStatusOptions = [ - { value: 'paid', label: '已支付,可发货' }, - { value: 'ship_failed', label: '发货失败,可重试' }, - { value: 'cancelled', label: '已取消,不可发货' }, -] - -type MerchantCenterTab = 'products' | 'orders' | 'wallet' | 'api' | 'callbacks' | 'members' - -interface MerchantCenterProps { - fixedTab?: MerchantCenterTab - title?: string -} +import { + apiClientMax, + defaultCallbackFormValues, + eventsToValue, + featuresToList, + memberRoleOptions, + productOptionLabel, + resolveEnabledTab, + roleText, + scopeOptions, + tabFromSearch, + testOrderStatusOptions, +} from './merchantCenterUtils' +import type { MerchantCenterProps, MerchantCenterTab } from './merchantCenterUtils' +import { + ApiKeysTab, + CallbacksTab, + MembersTab, + OrdersTab, + ProductsTab, + SecretBlock, + WalletTab, +} from './merchantCenterTabs' export default function MerchantCenter({ fixedTab, title = '商户中心' }: MerchantCenterProps = {}) { const { isAdmin } = useAuth() @@ -159,23 +123,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer } : prev) }, []) - const loadCurrent = useCallback(async () => { - const data = await merchantApi.current() - setMerchant(data.merchant) - setMerchantRole(data.role) - return data - }, []) - - const loadProducts = useCallback(async (page = products.page, size = products.size) => { - const data = await merchantApi.products({ page, size }) - setProducts(data) - }, [products.page, products.size]) - - const loadOrders = useCallback(async (page = orders.page, size = orders.size) => { - const data = await merchantApi.orders({ page, size }) - setOrders(data) - }, [orders.page, orders.size]) - const revokeDeliveryLink = useCallback((orderNo: string) => { merchantApi.revokeDeliveryLink(orderNo) .then(() => { @@ -198,6 +145,23 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer .catch((e) => message.error(e instanceof Error ? e.message : '恢复失败')) }, [patchDeliveryLinkState]) + const loadCurrent = useCallback(async () => { + const data = await merchantApi.current() + setMerchant(data.merchant) + setMerchantRole(data.role) + return data + }, []) + + const loadProducts = useCallback(async (page = products.page, size = products.size) => { + const data = await merchantApi.products({ page, size }) + setProducts(data) + }, [products.page, products.size]) + + const loadOrders = useCallback(async (page = orders.page, size = orders.size) => { + const data = await merchantApi.orders({ page, size }) + setOrders(data) + }, [orders.page, orders.size]) + const loadWallet = useCallback(async ( page = ledger.page, size = ledger.size, @@ -314,6 +278,14 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer loadWallet(1, ledger.size, merchantRole, {}) } + const handleLoadLedger = useCallback((page: number, size: number) => { + const values = walletFilterForm.getFieldsValue() + loadWallet(page, size, merchantRole, { + reference_no: values.reference_no || undefined, + type: values.type || undefined, + }) + }, [loadWallet, merchantRole, walletFilterForm]) + const openTestOrderCreate = () => { testOrderForm.resetFields() testOrderForm.setFieldsValue({ @@ -448,538 +420,25 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer } } - const productColumns: ColumnsType = [ - { - title: '商户 SKU', - dataIndex: 'sku', - width: 180, - ellipsis: true, - render: (v) => {v}, - }, - { - title: '商品名称', - dataIndex: 'display_name', - width: 220, - ellipsis: true, - render: (_, r) => ( -
-
{r.display_name || r.product?.name || '-'}
- {r.product?.category && ( - {r.product.category} - )} -
- ), - }, - { - title: '目录编码', - dataIndex: ['product', 'code'], - width: 150, - ellipsis: true, - render: (_, r) => r.product?.code ? {r.product.code} : '-', - }, - { - title: '对外售价', - dataIndex: 'price_amount', - width: 120, - render: (v) => {money(v)}, - }, - { - title: '成本价', - dataIndex: 'cost_amount', - width: 120, - render: (v) => {money(v)}, - }, - { - title: '当前库存', - dataIndex: 'stock', - width: 110, - render: (v) => (v < 0 ? 无限库存 : v === 0 ? 已缺货 : `${v} 件`), - }, - { - title: '状态', - dataIndex: 'status', - width: 100, - render: productStatusTag, - }, - { - title: '操作', - key: 'action', - width: 90, - fixed: 'right', - render: (_, record) => canManage ? ( - openProductToggle(record)} - > - - - ) : '-', - }, - ] - - const orderColumns: ColumnsType = [ - { - title: '平台订单号', - dataIndex: 'order_no', - width: 190, - ellipsis: true, - render: (v) => {v}, - }, - { title: '商户单号', dataIndex: 'client_order_no', width: 140, ellipsis: true }, - { - title: 'SKU', - dataIndex: 'product_sku', - width: 130, - ellipsis: true, - render: (v) => {v}, - }, - { title: '商品', dataIndex: 'product_name', width: 220, ellipsis: true }, - { title: '基础金额', dataIndex: 'base_amount', width: 88, render: money }, - { title: '手续费', dataIndex: 'service_fee_amount', width: 82, render: money }, - { title: '扣款合计', dataIndex: 'amount', width: 88, render: money }, - { title: '状态', dataIndex: 'order_status', width: 92, render: orderStatusTag }, - { - title: '链接有效期', - dataIndex: 'delivery_link_expires_at', - width: 140, - render: (_, record) => record.delivery_link_revoked_at ? 已作废 : formatDateTime(record.delivery_link_expires_at), - }, - { title: '时间', dataIndex: 'created_at', width: 150, render: formatDateTime }, - { - title: '发货链接', - key: 'delivery_link', - width: 150, - render: (_, record) => record.delivery_link_revoked_at ? ( - - ) : ( - - - - - - ), - }, - ] - - const ledgerColumns: ColumnsType = [ - { title: '流水号', dataIndex: 'entry_no', width: 260, render: (v) => {v} }, - { title: '类型', dataIndex: 'type', width: 90, render: ledgerTypeTag }, - { title: '变动积分', dataIndex: 'amount', width: 130, render: moneyWithSign }, - { title: '结余积分', dataIndex: 'balance_after', width: 130, render: (v) => {money(v)} }, - { title: '关联单号', dataIndex: 'reference_no', width: 200, ellipsis: true, render: (v) => v ? {v} : '-' }, - { title: '备注说明', dataIndex: 'note', width: 220, ellipsis: { showTitle: false }, render: (v) => {v || '-'} }, - { title: '变动时间', dataIndex: 'created_at', width: 170, render: formatDateTime }, - ] - - const apiClientColumns: ColumnsType = [ - { title: '名称', dataIndex: 'name', width: 140, ellipsis: true }, - { title: 'App Key', dataIndex: 'app_key', width: 230, render: (v) => {v} }, - { title: '签名', dataIndex: 'signature_version', width: 80, render: (v) => {v} }, - { title: '权限', dataIndex: 'scopes', width: 250, render: scopesTag }, - { title: '状态', dataIndex: 'status', width: 80, render: activeStatusTag }, - { title: '最后使用', dataIndex: 'last_used_at', width: 170, render: formatDateTime }, - { - title: '操作', - key: 'action', - width: 150, - render: (_, record) => canManage ? ( - - - deleteAPIClient(record)} - > - - - - ) : '-', - }, - ] - - const memberColumns: ColumnsType = [ - { title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` }, - { title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' }, - { title: '角色', dataIndex: 'role', width: 120, render: memberRoleTag }, - { title: '默认', dataIndex: 'is_default', width: 80, render: (v) => (v ? 默认 : '-') }, - { title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? 启用 : 禁用) }, - ] - - const productContent = ( - - -
-
- 商品上架与售卖管理 - - 商户可自主上架/下架商品;下架后不可再创建该商品订单。 - -
-
-
- - - ) - - const orderContent = ( - - - 发货链接由后端生成并带签名,用户只填写游戏 UID。 - {canManage && ( - - - - )} - -
- - ) - - const walletContent = ( - - - -
-
- 可用余额 -
-
-
- {(wallet?.available_balance ?? 0).toLocaleString('zh-CN')} - 积分 -
-
- 充值/调账 - 仅平台管理员可操作 -
-
- - -
-
- 冻结余额 -
-
-
- {(wallet?.frozen_balance ?? 0).toLocaleString('zh-CN')} - 积分 -
-
- 提现/发货预扣 - 0 笔预扣 -
-
- - -
-
- 结算币种与状态 -
-
-
- - {wallet?.currency || 'POINT'} - - - - 账户正常 - -
-
- 计费方式 - 自动按单扣减 -
-
- - - - {canFinance && ( - -
- - - - - - - - - - - - - -
- )} - {canFinance ? ( -
{ - const values = walletFilterForm.getFieldsValue() - loadWallet(page, size, merchantRole, { - reference_no: values.reference_no || undefined, - type: values.type || undefined, - }) - })} - /> - ) : ( - 当前角色可查看余额,钱包流水仅财务或负责人可见。 - )} - - ) - - const apiKeyContent = ( - - - - - API 密钥与开放接口鉴权 - - 已配额 {apiClients.length} / {apiClientMax} - - - - - {isAdmin && ( - - )} - {canManage && ( - - )} - - - } - > -
- - 用于第三方分销系统或自建后台调用开放 API (商品查询、订单发货) 的 HMAC-SHA256 签名鉴权凭证。新生成的 App Secret 仅在创建成功时展示一次,请妥善保存。 - -
- -
- - - ) - - const callbackContent = ( - - - - - Webhook 异步回调订阅设置 - {callback?.status === 'active' ? ( - - - 推送已启用 - - ) : ( - - - 推送已禁用 - - )} - - - HMAC-SHA256 签名保护 - - - } - > -
- -
-
重试机制
-
- Outbox 队列 + 指数退避 (最多 16 次) -
- - -
推送信道签名 Header
-
- X-Signature (HMAC-SHA256) -
- - -
推送超时标准
-
- 5000 ms 强制响应超时 -
- - - - - - - } - placeholder="https://your-domain.com/api/v1/webhook" - allowClear - /> - - - - - - -
- - {callback?.updated_at ? `上次保存:${formatDateTime(callback.updated_at)}` : '尚未保存回调配置'} - - - - {canManage && ( - - - - )} - {canManage && ( - - )} - -
- - - - ) - - const memberContent = ( - - - 成员角色独立于平台账号角色。 - {merchantRole === 'owner' && } - -
- - ) - const tabItems = [ - { key: 'products', label: '商品列表', disabled: !hasFeature('products'), children: productContent }, - { key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: orderContent }, - { key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: walletContent }, - { key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: apiKeyContent }, - { key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: callbackContent }, - { key: 'members', label: '成员', children: memberContent }, + { key: 'products', label: '商品列表', disabled: !hasFeature('products'), children: ( + + ) }, + { key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: ( + + ) }, + { key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: ( + + ) }, + { key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: ( + setApiClientOpen(true)} onNavigateDocs={() => navigate('/open-api')} onNavigateDebug={() => navigate('/api-debug')} onToggleClient={toggleAPIClient} onDeleteClient={deleteAPIClient} /> + ) }, + { key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: ( + + ) }, + { key: 'members', label: '成员', children: ( + setMemberOpen(true)} /> + ) }, ] const fixedTabContent = tabItems.find((item) => item.key === fixedTab)?.children @@ -1145,183 +604,3 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer ) } - -function pageConfig( - data: T, - load: (page: number, size: number) => void, -) { - return { - current: data.page, - pageSize: data.size, - total: data.total, - showSizeChanger: true, - showTotal: (total: number) => `共 ${total} 条`, - onChange: load, - } -} - -function SecretBlock({ appKey, secret }: { appKey?: string; secret: string }) { - return ( - - {appKey && ( -
- App Key - {appKey} -
- )} -
- Secret - {secret} -
- -
- ) -} - -function featuresToList(features?: string) { - const list = (features || '') - .split(/[,\s]+/) - .map((item) => item.trim()) - .filter(Boolean) - return list.length > 0 ? list : ['products', 'orders', 'wallet', 'api', 'callbacks'] -} - -function defaultCallbackFormValues() { - return { - events: ['order.shipping.updated'], - status: 'active', - } -} - -function eventsToValue(events?: string) { - return (events || '') - .split(',') - .map((item) => item.trim()) - .filter(Boolean) -} - -function tabFromSearch(search: string): MerchantCenterTab | null { - const tab = new URLSearchParams(search).get('tab') - const allowedTabs: MerchantCenterTab[] = ['products', 'orders', 'wallet', 'api', 'callbacks', 'members'] - return allowedTabs.includes(tab as MerchantCenterTab) ? tab as MerchantCenterTab : null -} - -function resolveEnabledTab(current: MerchantCenterTab, features?: string): MerchantCenterTab { - const enabled = new Set(featuresToList(features)) - const tabFeatures: Record = { - products: 'products', - orders: 'orders', - wallet: 'wallet', - api: 'api', - callbacks: 'callbacks', - members: null, - } - const feature = tabFeatures[current] - if (feature === null || enabled.has(feature)) { - return current - } - return (['products', 'orders', 'wallet', 'api', 'callbacks'] as MerchantCenterTab[]).find((key) => enabled.has(key)) || 'members' -} - -function money(value?: number | null) { - return `${Number(value || 0)} 积分` -} - -function moneyWithSign(value?: number | null) { - const amount = Number(value || 0) - if (amount > 0) { - return +{money(amount)} - } - if (amount < 0) { - return {money(amount)} - } - return {money(amount)} -} - -function productStatusTag(value: string) { - return value === 'active' ? ( - - - 已上架 - - ) : ( - - - 已下架 - - ) -} - -function activeStatusTag(value: string) { - return value === 'active' ? ( - - - 已启用 - - ) : ( - - - 已禁用 - - ) -} - -function productOptionLabel(item: MerchantProduct) { - const name = item.display_name || item.product?.name || item.sku - return name === item.sku ? item.sku : `${name} / ${item.sku}` -} - -function orderStatusTag(value: string) { - const item = orderStatusMap[value] || { color: 'default', text: value } - const tagClassMap: Record = { - paid: 'status-tag--blue', - delivering: 'status-tag--cyan', - delivered: 'status-tag--green', - ship_failed: 'status-tag--red', - cancelled: 'status-tag--gray', - } - const tagClass = tagClassMap[value] || 'status-tag--gray' - return ( - - - {item.text} - - ) -} - -function ledgerTypeTag(value: string) { - const map: Record = { - credit: { color: 'green', text: '入账' }, - debit: { color: 'red', text: '扣款' }, - refund: { color: 'purple', text: '退款' }, - adjust: { color: 'blue', text: '调整' }, - } - const item = map[value] || { color: 'default', text: value } - return {item.text} -} - -function memberRoleTag(value: MerchantMember['role']) { - return {roleText(value)} -} - -function scopesTag(value: string) { - const scopes = (value || '').split(',').filter(Boolean) - if (scopes.length === 0) { - return '-' - } - return ( - - {scopes.map((s) => {scopeLabel(s)})} - - ) -} - -function scopeLabel(scope: string) { - return scopeOptions.find((item) => item.value === scope)?.label || scope -} - -function roleText(value: MerchantMember['role']) { - return memberRoleOptions.find((item) => item.value === value)?.label || value -} diff --git a/frontend/src/pages/merchantCenterTabs.tsx b/frontend/src/pages/merchantCenterTabs.tsx new file mode 100644 index 0000000..74f0757 --- /dev/null +++ b/frontend/src/pages/merchantCenterTabs.tsx @@ -0,0 +1,769 @@ +import { Button, Card, Col, Form, Input, Popconfirm, Row, Select, Space, Table, Tag, Typography, message } from 'antd' +import type { FormInstance } from 'antd' +import { + ClockCircleOutlined, + CodeOutlined, + CopyOutlined, + DollarOutlined, + FileTextOutlined, + KeyOutlined, + LinkOutlined, + PlusOutlined, + SafetyCertificateOutlined, + SendOutlined, + WalletOutlined, +} from '@ant-design/icons' +import type { ColumnsType } from 'antd/es/table' +import { formatDateTime } from '../utils/time' +import type { + ApiClient, + CallbackSubscription, + FulfillmentOrder, + MerchantMember, + MerchantProduct, + PageResult, + WalletAccount, + WalletLedgerEntry, +} from '../types' +import { + defaultCallbackFormValues, + eventOptions, + money, + orderStatusMap, + pageConfig, + roleText, + scopeLabel, +} from './merchantCenterUtils' + +export function SecretBlock({ appKey, secret }: { appKey?: string; secret: string }) { + return ( + + {appKey && ( +
+ App Key + {appKey} +
+ )} +
+ Secret + {secret} +
+ +
+ ) +} + +export function ProductsTab({ + loading, + products, + canManage, + onToggleProduct, + onLoadProducts, +}: { + loading: boolean + products: PageResult + canManage: boolean + onToggleProduct: (record: MerchantProduct) => void + onLoadProducts: (page?: number, size?: number) => void +}) { + const productColumns: ColumnsType = [ + { + title: '商户 SKU', + dataIndex: 'sku', + width: 180, + ellipsis: true, + render: (v) => {v}, + }, + { + title: '商品名称', + dataIndex: 'display_name', + width: 220, + ellipsis: true, + render: (_, r) => ( +
+
{r.display_name || r.product?.name || '-'}
+ {r.product?.category && ( + {r.product.category} + )} +
+ ), + }, + { + title: '目录编码', + dataIndex: ['product', 'code'], + width: 150, + ellipsis: true, + render: (_, r) => r.product?.code ? {r.product.code} : '-', + }, + { + title: '对外售价', + dataIndex: 'price_amount', + width: 120, + render: (v) => {money(v)}, + }, + { + title: '成本价', + dataIndex: 'cost_amount', + width: 120, + render: (v) => {money(v)}, + }, + { + title: '当前库存', + dataIndex: 'stock', + width: 110, + render: (v) => (v < 0 ? 无限库存 : v === 0 ? 已缺货 : `${v} 件`), + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: productStatusTag, + }, + { + title: '操作', + key: 'action', + width: 90, + fixed: 'right', + render: (_, record) => canManage ? ( + onToggleProduct(record)} + > + + + ) : '-', + }, + ] + + return ( + + +
+
+ 商品上架与售卖管理 + + 商户可自主上架/下架商品;下架后不可再创建该商品订单。 + +
+
+
+
+ + ) +} + +export function OrdersTab({ + loading, + orders, + canManage, + onCreateTestOrder, + onLoadOrders, + onCopyLink, + onOpenLink, + onRevokeLink, + onRestoreLink, +}: { + loading: boolean + orders: PageResult + canManage: boolean + onCreateTestOrder: () => void + onLoadOrders: (page?: number, size?: number) => void + onCopyLink: (orderNo: string) => void + onOpenLink: (orderNo: string) => void + onRevokeLink: (orderNo: string) => void + onRestoreLink: (orderNo: string) => void +}) { + const orderColumns: ColumnsType = [ + { + title: '平台订单号', + dataIndex: 'order_no', + width: 190, + ellipsis: true, + render: (v) => {v}, + }, + { title: '商户单号', dataIndex: 'client_order_no', width: 140, ellipsis: true }, + { + title: 'SKU', + dataIndex: 'product_sku', + width: 130, + ellipsis: true, + render: (v) => {v}, + }, + { title: '商品', dataIndex: 'product_name', width: 220, ellipsis: true }, + { title: '基础金额', dataIndex: 'base_amount', width: 88, render: money }, + { title: '手续费', dataIndex: 'service_fee_amount', width: 82, render: money }, + { title: '扣款合计', dataIndex: 'amount', width: 88, render: money }, + { title: '状态', dataIndex: 'order_status', width: 92, render: orderStatusTag }, + { + title: '链接有效期', + dataIndex: 'delivery_link_expires_at', + width: 140, + render: (_, record) => record.delivery_link_revoked_at ? 已作废 : formatDateTime(record.delivery_link_expires_at), + }, + { title: '时间', dataIndex: 'created_at', width: 150, render: formatDateTime }, + { + title: '发货链接', + key: 'delivery_link', + width: 150, + render: (_, record) => record.delivery_link_revoked_at ? ( + + ) : ( + + + + + + ), + }, + ] + + return ( + + + 发货链接由后端生成并带签名,用户只填写游戏 UID。 + {canManage && ( + + + + )} + +
+ + ) +} + +export function WalletTab({ + loading, + wallet, + canFinance, + ledger, + form, + onFilter, + onResetFilter, + onLoadLedger, +}: { + loading: boolean + wallet: WalletAccount | null + canFinance: boolean + ledger: PageResult + form: FormInstance + onFilter: () => void + onResetFilter: () => void + onLoadLedger: (page: number, size: number) => void +}) { + const ledgerColumns: ColumnsType = [ + { title: '流水号', dataIndex: 'entry_no', width: 260, render: (v) => {v} }, + { title: '类型', dataIndex: 'type', width: 90, render: ledgerTypeTag }, + { title: '变动积分', dataIndex: 'amount', width: 130, render: moneyWithSign }, + { title: '结余积分', dataIndex: 'balance_after', width: 130, render: (v) => {money(v)} }, + { title: '关联单号', dataIndex: 'reference_no', width: 200, ellipsis: true, render: (v) => v ? {v} : '-' }, + { title: '备注说明', dataIndex: 'note', width: 220, ellipsis: { showTitle: false }, render: (v) => {v || '-'} }, + { title: '变动时间', dataIndex: 'created_at', width: 170, render: formatDateTime }, + ] + + return ( + + + +
+
+ 可用余额 +
+
+
+ {(wallet?.available_balance ?? 0).toLocaleString('zh-CN')} + 积分 +
+
+ 充值/调账 + 仅平台管理员可操作 +
+
+ + +
+
+ 冻结余额 +
+
+
+ {(wallet?.frozen_balance ?? 0).toLocaleString('zh-CN')} + 积分 +
+
+ 提现/发货预扣 + 0 笔预扣 +
+
+ + +
+
+ 结算币种与状态 +
+
+
+ + {wallet?.currency || 'POINT'} + + + + 账户正常 + +
+
+ 计费方式 + 自动按单扣减 +
+
+ + + + {canFinance && ( + +
+ + + + + + + + + + + + + +
+ )} + {canFinance ? ( +
+ ) : ( + 当前角色可查看余额,钱包流水仅财务或负责人可见。 + )} + + ) +} + +export function ApiKeysTab({ + loading, + clients, + clientMax, + isAdmin, + canManage, + form, + onOpenCreate, + onNavigateDocs, + onNavigateDebug, + onToggleClient, + onDeleteClient, +}: { + loading: boolean + clients: ApiClient[] + clientMax: number + isAdmin: boolean + canManage: boolean + form: FormInstance + onOpenCreate: () => void + onNavigateDocs: () => void + onNavigateDebug: () => void + onToggleClient: (record: ApiClient) => void + onDeleteClient: (record: ApiClient) => void +}) { + const apiClientColumns: ColumnsType = [ + { title: '名称', dataIndex: 'name', width: 140, ellipsis: true }, + { title: 'App Key', dataIndex: 'app_key', width: 230, render: (v) => {v} }, + { title: '签名', dataIndex: 'signature_version', width: 80, render: (v) => {v} }, + { title: '权限', dataIndex: 'scopes', width: 250, render: scopesTag }, + { title: '状态', dataIndex: 'status', width: 80, render: activeStatusTag }, + { title: '最后使用', dataIndex: 'last_used_at', width: 170, render: formatDateTime }, + { + title: '操作', + key: 'action', + width: 150, + render: (_, record) => canManage ? ( + + + onDeleteClient(record)} + > + + + + ) : '-', + }, + ] + + return ( + + + + + API 密钥与开放接口鉴权 + + 已配额 {clients.length} / {clientMax} + + + + + {isAdmin && ( + + )} + {canManage && ( + + )} + + + } + > +
+ + 用于第三方分销系统或自建后台调用开放 API (商品查询、订单发货) 的 HMAC-SHA256 签名鉴权凭证。新生成的 App Secret 仅在创建成功时展示一次,请妥善保存。 + +
+ +
+ + + ) +} + +export function CallbacksTab({ + canManage, + callback, + form, + onSubmit, + onRotateSecret, +}: { + canManage: boolean + callback: CallbackSubscription | null + form: FormInstance + onSubmit: () => void + onRotateSecret: () => void +}) { + return ( + + + + + Webhook 异步回调订阅设置 + {callback?.status === 'active' ? ( + + + 推送已启用 + + ) : ( + + + 推送已禁用 + + )} + + + HMAC-SHA256 签名保护 + + + } + > +
+ +
+
重试机制
+
+ Outbox 队列 + 指数退避 (最多 16 次) +
+ + +
推送信道签名 Header
+
+ X-Signature (HMAC-SHA256) +
+ + +
推送超时标准
+
+ 5000 ms 强制响应超时 +
+ + + + + + + } + placeholder="https://your-domain.com/api/v1/webhook" + allowClear + /> + + + + + + +
+ + {callback?.updated_at ? `上次保存:${formatDateTime(callback.updated_at)}` : '尚未保存回调配置'} + + + + {canManage && ( + + + + )} + {canManage && ( + + )} + +
+ + + + ) +} + +export function MembersTab({ + loading, + members, + isOwner, + form, + onOpenAdd, +}: { + loading: boolean + members: MerchantMember[] + isOwner: boolean + form: FormInstance + onOpenAdd: () => void +}) { + const memberColumns: ColumnsType = [ + { title: '用户', dataIndex: ['user', 'username'], render: (_, r) => r.user?.username || `#${r.user_id}` }, + { title: '昵称', dataIndex: ['user', 'nickname'], render: (_, r) => r.user?.nickname || '-' }, + { title: '角色', dataIndex: 'role', width: 120, render: memberRoleTag }, + { title: '默认', dataIndex: 'is_default', width: 80, render: (v) => (v ? 默认 : '-') }, + { title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? 启用 : 禁用) }, + ] + + return ( + + + 成员角色独立于平台账号角色。 + {isOwner && } + +
+ + ) +} + +function moneyWithSign(value?: number | null) { + const amount = Number(value || 0) + if (amount > 0) { + return +{money(amount)} + } + if (amount < 0) { + return {money(amount)} + } + return {money(amount)} +} + +function productStatusTag(value: string) { + return value === 'active' ? ( + + + 已上架 + + ) : ( + + + 已下架 + + ) +} + +function activeStatusTag(value: string) { + return value === 'active' ? ( + + + 已启用 + + ) : ( + + + 已禁用 + + ) +} + +function orderStatusTag(value: string) { + const item = orderStatusMap[value] || { color: 'default', text: value } + const tagClassMap: Record = { + paid: 'status-tag--blue', + delivering: 'status-tag--cyan', + delivered: 'status-tag--green', + ship_failed: 'status-tag--red', + cancelled: 'status-tag--gray', + } + const tagClass = tagClassMap[value] || 'status-tag--gray' + return ( + + + {item.text} + + ) +} + +function ledgerTypeTag(value: string) { + const map: Record = { + credit: { color: 'green', text: '入账' }, + debit: { color: 'red', text: '扣款' }, + refund: { color: 'purple', text: '退款' }, + adjust: { color: 'blue', text: '调整' }, + } + const item = map[value] || { color: 'default', text: value } + return {item.text} +} + +function memberRoleTag(value: MerchantMember['role']) { + return {roleText(value)} +} + +function scopesTag(value: string) { + const scopes = (value || '').split(',').filter(Boolean) + if (scopes.length === 0) { + return '-' + } + return ( + + {scopes.map((s) => {scopeLabel(s)})} + + ) +} diff --git a/frontend/src/pages/merchantCenterUtils.ts b/frontend/src/pages/merchantCenterUtils.ts new file mode 100644 index 0000000..24ea185 --- /dev/null +++ b/frontend/src/pages/merchantCenterUtils.ts @@ -0,0 +1,119 @@ +import type { MerchantMember, MerchantProduct } from '../types' + +export const orderStatusMap: Record = { + paid: { color: 'blue', text: '待发货' }, + delivering: { color: 'cyan', text: '发货中' }, + delivered: { color: 'green', text: '已交付' }, + ship_failed: { color: 'red', text: '发货失败' }, + cancelled: { color: 'default', text: '已取消' }, +} + +export const memberRoleOptions = [ + { value: 'owner', label: '负责人' }, + { value: 'operator', label: '运营' }, + { value: 'finance', label: '财务' }, + { value: 'viewer', label: '只读' }, +] + +export const apiClientMax = 5 + +export const scopeOptions = [ + { value: 'products:read', label: '商品读取' }, + { value: 'orders:read', label: '订单读取' }, + { value: 'orders:write', label: '订单写入' }, + { value: 'shipping:read', label: '发货读取' }, + { value: 'wallet:read', label: '钱包读取' }, +] + +export const eventOptions = [ + { value: 'order.created', label: '订单创建' }, + { value: 'order.shipping.updated', label: '发货更新' }, + { value: 'order.cancelled', label: '订单取消' }, +] + +export const testOrderStatusOptions = [ + { value: 'paid', label: '已支付,可发货' }, + { value: 'ship_failed', label: '发货失败,可重试' }, + { value: 'cancelled', label: '已取消,不可发货' }, +] + +export type MerchantCenterTab = 'products' | 'orders' | 'wallet' | 'api' | 'callbacks' | 'members' + +export interface MerchantCenterProps { + fixedTab?: MerchantCenterTab + title?: string +} + +export function pageConfig( + data: T, + load: (page: number, size: number) => void, +) { + return { + current: data.page, + pageSize: data.size, + total: data.total, + showSizeChanger: true, + showTotal: (total: number) => `共 ${total} 条`, + onChange: load, + } +} + +export function featuresToList(features?: string) { + const list = (features || '') + .split(/[,\s]+/) + .map((item) => item.trim()) + .filter(Boolean) + return list.length > 0 ? list : ['products', 'orders', 'wallet', 'api', 'callbacks'] +} + +export function defaultCallbackFormValues() { + return { + events: ['order.shipping.updated'], + status: 'active', + } +} + +export function eventsToValue(events?: string) { + return (events || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +export function tabFromSearch(search: string): MerchantCenterTab | null { + const tab = new URLSearchParams(search).get('tab') + const allowedTabs: MerchantCenterTab[] = ['products', 'orders', 'wallet', 'api', 'callbacks', 'members'] + return allowedTabs.includes(tab as MerchantCenterTab) ? tab as MerchantCenterTab : null +} + +export function resolveEnabledTab(current: MerchantCenterTab, features?: string): MerchantCenterTab { + const enabled = new Set(featuresToList(features)) + const tabFeatures: Record = { + products: 'products', + orders: 'orders', + wallet: 'wallet', + api: 'api', + callbacks: 'callbacks', + members: null, + } + const feature = tabFeatures[current] + if (feature === null || enabled.has(feature)) { + return current + } + return (['products', 'orders', 'wallet', 'api', 'callbacks'] as MerchantCenterTab[]).find((key) => enabled.has(key)) || 'members' +} + +export function money(value?: number | null) { + return `${Number(value || 0)} 积分` +} +export function productOptionLabel(item: MerchantProduct) { + const name = item.display_name || item.product?.name || item.sku + return name === item.sku ? item.sku : `${name} / ${item.sku}` +} +export function scopeLabel(scope: string) { + return scopeOptions.find((item) => item.value === scope)?.label || scope +} + +export function roleText(value: MerchantMember['role']) { + return memberRoleOptions.find((item) => item.value === value)?.label || value +}