拆分 service 与前端大文件,修复 CORS 配置与格式问题
- 后端 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 与文件尾部多余空行
This commit is contained in:
@@ -30,6 +30,11 @@ DELIVERY_LINK_TTL_MINUTES=120
|
|||||||
FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=30
|
FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=30
|
||||||
FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=60
|
FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=60
|
||||||
|
|
||||||
|
# 跨域来源白名单(逗号分隔)。生产前后端同域(Caddy 统一入口)无需配置;
|
||||||
|
# 仅前后端分离部署时设置,例如:CORS_ALLOWED_ORIGINS=https://admin.example.com
|
||||||
|
# 为空时仅放行本地开发端口 localhost:15173 / 18080;不支持 "*"(与凭证模式冲突)。
|
||||||
|
# CORS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
# Docker 构建基础镜像;如镜像源不可用,可改为官方镜像或你的私有镜像源
|
# Docker 构建基础镜像;如镜像源不可用,可改为官方镜像或你的私有镜像源
|
||||||
POSTGRES_IMAGE=docker.m.daocloud.io/library/postgres:16-alpine
|
POSTGRES_IMAGE=docker.m.daocloud.io/library/postgres:16-alpine
|
||||||
GO_BUILDER_IMAGE=docker.m.daocloud.io/library/golang:1.26-alpine
|
GO_BUILDER_IMAGE=docker.m.daocloud.io/library/golang:1.26-alpine
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ frontend/dist/
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# reasonix 本地会话数据
|
||||||
|
.reasonix/
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ make docker-down # 停止并移除
|
|||||||
| `OPEN_API_DEBUG` | 开放接口调试日志 | debug 模式默认开启 |
|
| `OPEN_API_DEBUG` | 开放接口调试日志 | debug 模式默认开启 |
|
||||||
| `LOG_FILE` | 日志文件路径(按天分割为 `app-YYYYMMDD.log`) | `logs/app.log` |
|
| `LOG_FILE` | 日志文件路径(按天分割为 `app-YYYYMMDD.log`) | `logs/app.log` |
|
||||||
| `LOG_RETAIN_DAYS` | 日志文件保留天数,到期自动清理 | `30` |
|
| `LOG_RETAIN_DAYS` | 日志文件保留天数,到期自动清理 | `30` |
|
||||||
|
| `CORS_ALLOWED_ORIGINS` | 跨域来源白名单(逗号分隔);生产同域部署无需配置,为空时仅放行本地开发端口,不支持 `*` | 空 |
|
||||||
| `FULFILLMENT_PROCESSING_TIMEOUT_MINUTES` | 发货中订单自动标记失败的超时分钟数,<=0 关闭 | `30` |
|
| `FULFILLMENT_PROCESSING_TIMEOUT_MINUTES` | 发货中订单自动标记失败的超时分钟数,<=0 关闭 | `30` |
|
||||||
| `FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS` | 发货超时巡检间隔秒数 | `60` |
|
| `FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS` | 发货超时巡检间隔秒数 | `60` |
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ func main() {
|
|||||||
OpenSignSkew: cfg.OpenSignSkew,
|
OpenSignSkew: cfg.OpenSignSkew,
|
||||||
OpenAPIDebug: cfg.OpenAPIDebug,
|
OpenAPIDebug: cfg.OpenAPIDebug,
|
||||||
UploadDir: cfg.UploadDir,
|
UploadDir: cfg.UploadDir,
|
||||||
|
CORSAllowedOrigins: cfg.CORSAllowedOrigins,
|
||||||
}
|
}
|
||||||
|
|
||||||
go callbackSvc.Run(context.Background())
|
go callbackSvc.Run(context.Background())
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ type Config struct {
|
|||||||
CallbackRetryScheduleSeconds []int
|
CallbackRetryScheduleSeconds []int
|
||||||
// UploadDir 凭证等上传文件的存储目录(默认 data/uploads,通过 /uploads 公开访问)。
|
// UploadDir 凭证等上传文件的存储目录(默认 data/uploads,通过 /uploads 公开访问)。
|
||||||
UploadDir string
|
UploadDir string
|
||||||
|
// CORSAllowedOrigins 跨域允许的来源(逗号分隔);为空时仅放行常见本地开发端口。
|
||||||
|
// 生产前后端同域(Caddy 统一入口)时无需配置;仅在前后端分离部署时设置。
|
||||||
|
CORSAllowedOrigins []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
||||||
@@ -82,9 +85,28 @@ func Load() *Config {
|
|||||||
CallbackPushTimeoutSeconds: getEnvInt("CALLBACK_PUSH_TIMEOUT_SECONDS", 15),
|
CallbackPushTimeoutSeconds: getEnvInt("CALLBACK_PUSH_TIMEOUT_SECONDS", 15),
|
||||||
CallbackRetryScheduleSeconds: getEnvIntList("CALLBACK_RETRY_SCHEDULE", nil),
|
CallbackRetryScheduleSeconds: getEnvIntList("CALLBACK_RETRY_SCHEDULE", nil),
|
||||||
UploadDir: getEnv("UPLOAD_DIR", "data/uploads"),
|
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。
|
// getEnvIntList 解析逗号分隔的整数列表(如 "15,15,30,180");空串返回 nil。
|
||||||
func getEnvIntList(key string, def []int) []int {
|
func getEnvIntList(key string, def []int) []int {
|
||||||
v := os.Getenv(key)
|
v := os.Getenv(key)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"affiliate_dash/internal/handler"
|
"affiliate_dash/internal/handler"
|
||||||
"affiliate_dash/internal/middleware"
|
"affiliate_dash/internal/middleware"
|
||||||
"affiliate_dash/internal/model"
|
"affiliate_dash/internal/model"
|
||||||
@@ -30,6 +32,33 @@ type Handlers struct {
|
|||||||
OpenSignSkew int64
|
OpenSignSkew int64
|
||||||
OpenAPIDebug bool
|
OpenAPIDebug bool
|
||||||
UploadDir string
|
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 秒一次,避免刷屏)。
|
// requestLogger 访问日志中间件:跳过 /health 健康检查(每 30 秒一次,避免刷屏)。
|
||||||
@@ -49,7 +78,7 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
r.Use(requestLogger(), gin.Recovery())
|
r.Use(requestLogger(), gin.Recovery())
|
||||||
|
|
||||||
r.Use(cors.New(cors.Config{
|
r.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: []string{"*"},
|
AllowOrigins: corsOrigins(h),
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
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"},
|
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"},
|
ExposeHeaders: []string{"Content-Length", "X-Request-ID"},
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -616,67 +608,6 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m
|
|||||||
return info, order, goodID, nil
|
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) {
|
func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) {
|
||||||
orderNo = strings.TrimSpace(orderNo)
|
orderNo = strings.TrimSpace(orderNo)
|
||||||
if orderNo == "" {
|
if orderNo == "" {
|
||||||
@@ -826,207 +757,6 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth
|
|||||||
return info, order, goodID, nil
|
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 {
|
func deliveryGoodID(channel, sku string) string {
|
||||||
if channel != "dlc" {
|
if channel != "dlc" {
|
||||||
return ""
|
return ""
|
||||||
@@ -1067,58 +797,3 @@ func deliveryGoodID(channel, sku string) string {
|
|||||||
"lucky_coin_x90": "682ef39ca8f40c4234c59f42",
|
"lucky_coin_x90": "682ef39ca8f40c4234c59f42",
|
||||||
}[sku]
|
}[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]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,19 +1,13 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"affiliate_dash/internal/model"
|
"affiliate_dash/internal/model"
|
||||||
"affiliate_dash/internal/pkg/timeutil"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -326,35 +320,6 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu
|
|||||||
return &out, nil
|
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 {
|
type FulfillmentUpdateInput struct {
|
||||||
MerchantID uint
|
MerchantID uint
|
||||||
APIClientID uint
|
APIClientID uint
|
||||||
@@ -436,112 +401,6 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode
|
|||||||
return &out, nil
|
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) {
|
func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) {
|
||||||
var out model.FulfillmentOrder
|
var out model.FulfillmentOrder
|
||||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -619,137 +478,6 @@ func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo,
|
|||||||
return &out, nil
|
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) {
|
func CanFulfill(order *model.FulfillmentOrder) (bool, string) {
|
||||||
switch normalizeOrderStatus(order) {
|
switch normalizeOrderStatus(order) {
|
||||||
case model.OrderStatusPaid:
|
case model.OrderStatusPaid:
|
||||||
@@ -797,601 +525,3 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
|||||||
// ----- 仪表盘统计 -----
|
// ----- 仪表盘统计 -----
|
||||||
|
|
||||||
// DashboardStats 仪表盘聚合指标。
|
// 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("无效的手续费类型")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"affiliate_dash/internal/model"
|
"affiliate_dash/internal/model"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var merchantCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,63}$`)
|
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
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ services:
|
|||||||
- JWT_SECRET=${JWT_SECRET}
|
- 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}}"
|
- "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}
|
- GIN_MODE=${GIN_MODE:-release}
|
||||||
|
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
|
||||||
- OPEN_API_KEY=${OPEN_API_KEY}
|
- OPEN_API_KEY=${OPEN_API_KEY}
|
||||||
- OPEN_API_SECRET=${OPEN_API_SECRET}
|
- OPEN_API_SECRET=${OPEN_API_SECRET}
|
||||||
- OPEN_SIGN_SKEW=${OPEN_SIGN_SKEW:-300}
|
- OPEN_SIGN_SKEW=${OPEN_SIGN_SKEW:-300}
|
||||||
|
|||||||
@@ -2,38 +2,23 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
|
||||||
Row,
|
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
ClockCircleOutlined,
|
|
||||||
CodeOutlined,
|
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DollarOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
KeyOutlined,
|
|
||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
PlusOutlined,
|
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SafetyCertificateOutlined,
|
|
||||||
SendOutlined,
|
|
||||||
WalletOutlined,
|
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
|
||||||
import { useAuth } from '../store/auth'
|
import { useAuth } from '../store/auth'
|
||||||
import { merchantApi } from '../api'
|
import { merchantApi } from '../api'
|
||||||
import { formatDateTime } from '../utils/time'
|
import { formatDateTime } from '../utils/time'
|
||||||
@@ -52,50 +37,29 @@ import type {
|
|||||||
WalletAccount,
|
WalletAccount,
|
||||||
WalletLedgerEntry,
|
WalletLedgerEntry,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
import {
|
||||||
const orderStatusMap: Record<string, { color: string; text: string }> = {
|
apiClientMax,
|
||||||
paid: { color: 'blue', text: '待发货' },
|
defaultCallbackFormValues,
|
||||||
delivering: { color: 'cyan', text: '发货中' },
|
eventsToValue,
|
||||||
delivered: { color: 'green', text: '已交付' },
|
featuresToList,
|
||||||
ship_failed: { color: 'red', text: '发货失败' },
|
memberRoleOptions,
|
||||||
cancelled: { color: 'default', text: '已取消' },
|
productOptionLabel,
|
||||||
}
|
resolveEnabledTab,
|
||||||
|
roleText,
|
||||||
const memberRoleOptions = [
|
scopeOptions,
|
||||||
{ value: 'owner', label: '负责人' },
|
tabFromSearch,
|
||||||
{ value: 'operator', label: '运营' },
|
testOrderStatusOptions,
|
||||||
{ value: 'finance', label: '财务' },
|
} from './merchantCenterUtils'
|
||||||
{ value: 'viewer', label: '只读' },
|
import type { MerchantCenterProps, MerchantCenterTab } from './merchantCenterUtils'
|
||||||
]
|
import {
|
||||||
|
ApiKeysTab,
|
||||||
const apiClientMax = 5
|
CallbacksTab,
|
||||||
|
MembersTab,
|
||||||
const scopeOptions = [
|
OrdersTab,
|
||||||
{ value: 'products:read', label: '商品读取' },
|
ProductsTab,
|
||||||
{ value: 'orders:read', label: '订单读取' },
|
SecretBlock,
|
||||||
{ value: 'orders:write', label: '订单写入' },
|
WalletTab,
|
||||||
{ value: 'shipping:read', label: '发货读取' },
|
} from './merchantCenterTabs'
|
||||||
{ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MerchantCenter({ fixedTab, title = '商户中心' }: MerchantCenterProps = {}) {
|
export default function MerchantCenter({ fixedTab, title = '商户中心' }: MerchantCenterProps = {}) {
|
||||||
const { isAdmin } = useAuth()
|
const { isAdmin } = useAuth()
|
||||||
@@ -159,23 +123,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
} : prev)
|
} : 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) => {
|
const revokeDeliveryLink = useCallback((orderNo: string) => {
|
||||||
merchantApi.revokeDeliveryLink(orderNo)
|
merchantApi.revokeDeliveryLink(orderNo)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -198,6 +145,23 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
.catch((e) => message.error(e instanceof Error ? e.message : '恢复失败'))
|
.catch((e) => message.error(e instanceof Error ? e.message : '恢复失败'))
|
||||||
}, [patchDeliveryLinkState])
|
}, [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 (
|
const loadWallet = useCallback(async (
|
||||||
page = ledger.page,
|
page = ledger.page,
|
||||||
size = ledger.size,
|
size = ledger.size,
|
||||||
@@ -314,6 +278,14 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
loadWallet(1, ledger.size, merchantRole, {})
|
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 = () => {
|
const openTestOrderCreate = () => {
|
||||||
testOrderForm.resetFields()
|
testOrderForm.resetFields()
|
||||||
testOrderForm.setFieldsValue({
|
testOrderForm.setFieldsValue({
|
||||||
@@ -448,538 +420,25 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const productColumns: ColumnsType<MerchantProduct> = [
|
|
||||||
{
|
|
||||||
title: '商户 SKU',
|
|
||||||
dataIndex: 'sku',
|
|
||||||
width: 180,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v) => <Typography.Text code copyable={{ tooltips: false }} style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '商品名称',
|
|
||||||
dataIndex: 'display_name',
|
|
||||||
width: 220,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600, color: '#0f172a' }}>{r.display_name || r.product?.name || '-'}</div>
|
|
||||||
{r.product?.category && (
|
|
||||||
<span style={{ fontSize: 11.5, color: '#64748b' }}>{r.product.category}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '目录编码',
|
|
||||||
dataIndex: ['product', 'code'],
|
|
||||||
width: 150,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => r.product?.code ? <Typography.Text code>{r.product.code}</Typography.Text> : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '对外售价',
|
|
||||||
dataIndex: 'price_amount',
|
|
||||||
width: 120,
|
|
||||||
render: (v) => <span style={{ fontWeight: 700, color: '#2563eb' }}>{money(v)}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '成本价',
|
|
||||||
dataIndex: 'cost_amount',
|
|
||||||
width: 120,
|
|
||||||
render: (v) => <span style={{ color: '#64748b' }}>{money(v)}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '当前库存',
|
|
||||||
dataIndex: 'stock',
|
|
||||||
width: 110,
|
|
||||||
render: (v) => (v < 0 ? <Tag color="blue">无限库存</Tag> : v === 0 ? <Tag color="red">已缺货</Tag> : `${v} 件`),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
width: 100,
|
|
||||||
render: productStatusTag,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'action',
|
|
||||||
width: 90,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_, record) => canManage ? (
|
|
||||||
<Popconfirm
|
|
||||||
title={record.status === 'active' ? '下架该商品?' : '上架该商品?'}
|
|
||||||
description={record.status === 'active' ? '下架后商户将无法再创建该商品订单' : '上架后商户可正常下单'}
|
|
||||||
okText="确认"
|
|
||||||
onConfirm={() => openProductToggle(record)}
|
|
||||||
>
|
|
||||||
<Button type="link" size="small">
|
|
||||||
{record.status === 'active' ? '下架' : '上架'}
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
) : '-',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const orderColumns: ColumnsType<FulfillmentOrder> = [
|
|
||||||
{
|
|
||||||
title: '平台订单号',
|
|
||||||
dataIndex: 'order_no',
|
|
||||||
width: 190,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v) => <Typography.Text copyable ellipsis style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
|
|
||||||
},
|
|
||||||
{ title: '商户单号', dataIndex: 'client_order_no', width: 140, ellipsis: true },
|
|
||||||
{
|
|
||||||
title: 'SKU',
|
|
||||||
dataIndex: 'product_sku',
|
|
||||||
width: 130,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (v) => <Typography.Text code ellipsis style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
|
|
||||||
},
|
|
||||||
{ 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 ? <Tag color="red">已作废</Tag> : 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 ? (
|
|
||||||
<Button type="link" size="small" onClick={() => restoreDeliveryLink(record.order_no)}>取消作废</Button>
|
|
||||||
) : (
|
|
||||||
<Space size={4} wrap>
|
|
||||||
<Button type="link" size="small" onClick={() => copyDeliveryLink(record.order_no)}>复制</Button>
|
|
||||||
<Button type="link" size="small" onClick={() => openDeliveryLink(record.order_no)}>打开</Button>
|
|
||||||
<Button type="link" danger size="small" onClick={() => revokeDeliveryLink(record.order_no)}>作废</Button>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const ledgerColumns: ColumnsType<WalletLedgerEntry> = [
|
|
||||||
{ title: '流水号', dataIndex: 'entry_no', width: 260, render: (v) => <Typography.Text code copyable={{ tooltips: false }}>{v}</Typography.Text> },
|
|
||||||
{ title: '类型', dataIndex: 'type', width: 90, render: ledgerTypeTag },
|
|
||||||
{ title: '变动积分', dataIndex: 'amount', width: 130, render: moneyWithSign },
|
|
||||||
{ title: '结余积分', dataIndex: 'balance_after', width: 130, render: (v) => <span style={{ fontWeight: 600, color: '#0f172a' }}>{money(v)}</span> },
|
|
||||||
{ title: '关联单号', dataIndex: 'reference_no', width: 200, ellipsis: true, render: (v) => v ? <Typography.Text code ellipsis style={{ maxWidth: '100%' }}>{v}</Typography.Text> : '-' },
|
|
||||||
{ title: '备注说明', dataIndex: 'note', width: 220, ellipsis: { showTitle: false }, render: (v) => <Typography.Text ellipsis title={v}>{v || '-'}</Typography.Text> },
|
|
||||||
{ title: '变动时间', dataIndex: 'created_at', width: 170, render: formatDateTime },
|
|
||||||
]
|
|
||||||
|
|
||||||
const apiClientColumns: ColumnsType<ApiClient> = [
|
|
||||||
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
|
|
||||||
{ title: 'App Key', dataIndex: 'app_key', width: 230, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
|
|
||||||
{ title: '签名', dataIndex: 'signature_version', width: 80, render: (v) => <Tag>{v}</Tag> },
|
|
||||||
{ 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 ? (
|
|
||||||
<Space size={0}>
|
|
||||||
<Button type="link" size="small" onClick={() => toggleAPIClient(record)}>
|
|
||||||
{record.status === 'active' ? '禁用' : '启用'}
|
|
||||||
</Button>
|
|
||||||
<Popconfirm
|
|
||||||
title="删除该密钥?"
|
|
||||||
description={record.status === 'active' ? '请先停用该密钥再删除' : '删除后不可恢复,使用该密钥的对接方将无法调用开放接口。'}
|
|
||||||
okText="删除"
|
|
||||||
okButtonProps={{ danger: true }}
|
|
||||||
onConfirm={() => deleteAPIClient(record)}
|
|
||||||
>
|
|
||||||
<Button type="link" size="small" danger disabled={record.status === 'active'}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
) : '-',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const memberColumns: ColumnsType<MerchantMember> = [
|
|
||||||
{ 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 ? <Tag color="blue">默认</Tag> : '-') },
|
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>) },
|
|
||||||
]
|
|
||||||
|
|
||||||
const productContent = (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
||||||
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
||||||
<div>
|
|
||||||
<span style={{ fontWeight: 600, color: '#0f172a', marginRight: 8 }}>商品上架与售卖管理</span>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
|
||||||
商户可自主上架/下架商品;下架后不可再创建该商品订单。
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
columns={productColumns}
|
|
||||||
dataSource={products.list}
|
|
||||||
scroll={{ x: 1100 }}
|
|
||||||
pagination={pageConfig(products, loadProducts)}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
|
|
||||||
const orderContent = (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
||||||
<Typography.Text type="secondary">发货链接由后端生成并带签名,用户只填写游戏 UID。</Typography.Text>
|
|
||||||
{canManage && (
|
|
||||||
<Space>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openTestOrderCreate}>
|
|
||||||
创建测试订单
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
columns={orderColumns}
|
|
||||||
dataSource={orders.list}
|
|
||||||
tableLayout="fixed"
|
|
||||||
scroll={{ x: 1660 }}
|
|
||||||
pagination={pageConfig(orders, loadOrders)}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
|
|
||||||
const walletContent = (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
<Col xs={24} sm={8}>
|
|
||||||
<div className="metric-card-box">
|
|
||||||
<div className="metric-card-header">
|
|
||||||
<span className="metric-card-title">可用余额</span>
|
|
||||||
<div className="metric-card-icon metric-card-icon--emerald"><WalletOutlined /></div>
|
|
||||||
</div>
|
|
||||||
<div className="metric-card-value" style={{ color: '#16a34a' }}>
|
|
||||||
{(wallet?.available_balance ?? 0).toLocaleString('zh-CN')}
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>积分</span>
|
|
||||||
</div>
|
|
||||||
<div className="metric-card-footer">
|
|
||||||
<span>充值/调账</span>
|
|
||||||
<span style={{ fontWeight: 600, color: '#64748b' }}>仅平台管理员可操作</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={8}>
|
|
||||||
<div className="metric-card-box">
|
|
||||||
<div className="metric-card-header">
|
|
||||||
<span className="metric-card-title">冻结余额</span>
|
|
||||||
<div className="metric-card-icon"><ClockCircleOutlined /></div>
|
|
||||||
</div>
|
|
||||||
<div className="metric-card-value">
|
|
||||||
{(wallet?.frozen_balance ?? 0).toLocaleString('zh-CN')}
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>积分</span>
|
|
||||||
</div>
|
|
||||||
<div className="metric-card-footer">
|
|
||||||
<span>提现/发货预扣</span>
|
|
||||||
<span style={{ fontWeight: 600, color: '#0f172a' }}>0 笔预扣</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={8}>
|
|
||||||
<div className="metric-card-box">
|
|
||||||
<div className="metric-card-header">
|
|
||||||
<span className="metric-card-title">结算币种与状态</span>
|
|
||||||
<div className="metric-card-icon metric-card-icon--purple"><DollarOutlined /></div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
|
|
||||||
<Tag color="blue" style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, margin: 0, fontWeight: 600 }}>
|
|
||||||
{wallet?.currency || 'POINT'}
|
|
||||||
</Tag>
|
|
||||||
<span className="status-tag status-tag--green">
|
|
||||||
<span className="status-dot"></span>
|
|
||||||
账户正常
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="metric-card-footer">
|
|
||||||
<span>计费方式</span>
|
|
||||||
<span style={{ fontWeight: 600, color: '#0f172a' }}>自动按单扣减</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{canFinance && (
|
|
||||||
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
|
|
||||||
<Form form={walletFilterForm} layout="inline" onFinish={handleWalletFilter}>
|
|
||||||
<Form.Item name="reference_no" label="关联单号">
|
|
||||||
<Input placeholder="店铺单号 / 充值单号" allowClear style={{ width: 220, borderRadius: 6 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="type" label="收支类型">
|
|
||||||
<Select placeholder="全部类型" allowClear style={{ width: 140 }}>
|
|
||||||
<Select.Option value="credit">入账 (Credit)</Select.Option>
|
|
||||||
<Select.Option value="debit">扣款 (Debit)</Select.Option>
|
|
||||||
<Select.Option value="refund">退款 (Refund)</Select.Option>
|
|
||||||
<Select.Option value="adjust">调账 (Adjust)</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
|
||||||
<Space>
|
|
||||||
<Button onClick={handleWalletFilterReset}>重置</Button>
|
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
|
||||||
</Space>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{canFinance ? (
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
columns={ledgerColumns}
|
|
||||||
dataSource={ledger.list}
|
|
||||||
tableLayout="fixed"
|
|
||||||
scroll={{ x: 1300 }}
|
|
||||||
pagination={pageConfig(ledger, (page, size) => {
|
|
||||||
const values = walletFilterForm.getFieldsValue()
|
|
||||||
loadWallet(page, size, merchantRole, {
|
|
||||||
reference_no: values.reference_no || undefined,
|
|
||||||
type: values.type || undefined,
|
|
||||||
})
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">当前角色可查看余额,钱包流水仅财务或负责人可见。</Typography.Text>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
|
|
||||||
const apiKeyContent = (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
|
|
||||||
title={
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
|
||||||
<Space size={8}>
|
|
||||||
<KeyOutlined style={{ color: '#2563eb', fontSize: 16 }} />
|
|
||||||
<span style={{ fontWeight: 700, color: '#0f172a' }}>API 密钥与开放接口鉴权</span>
|
|
||||||
<Tag color="blue" style={{ borderRadius: 10, padding: '0 8px', fontWeight: 600 }}>
|
|
||||||
已配额 {apiClients.length} / {apiClientMax}
|
|
||||||
</Tag>
|
|
||||||
</Space>
|
|
||||||
<Space size={8}>
|
|
||||||
<Button icon={<FileTextOutlined />} onClick={() => navigate('/open-api')}>
|
|
||||||
对接文档
|
|
||||||
</Button>
|
|
||||||
{isAdmin && (
|
|
||||||
<Button icon={<CodeOutlined />} onClick={() => navigate('/api-debug')}>
|
|
||||||
接口调试
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canManage && (
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
disabled={apiClients.length >= apiClientMax}
|
|
||||||
onClick={() => {
|
|
||||||
apiClientForm.resetFields()
|
|
||||||
apiClientForm.setFieldsValue({
|
|
||||||
signature_version: 'v1',
|
|
||||||
scopes: ['products:read', 'orders:read', 'orders:write'],
|
|
||||||
})
|
|
||||||
setApiClientOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
新增 API 密钥
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div style={{ background: '#f8fafc', padding: '10px 14px', borderRadius: 8, marginBottom: 16, border: '1px solid #e2e8f0' }}>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
|
|
||||||
用于第三方分销系统或自建后台调用开放 API (商品查询、订单发货) 的 HMAC-SHA256 签名鉴权凭证。新生成的 App Secret 仅在创建成功时展示一次,请妥善保存。
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
columns={apiClientColumns}
|
|
||||||
dataSource={apiClients}
|
|
||||||
scroll={{ x: 1150 }}
|
|
||||||
pagination={false}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
|
|
||||||
const callbackContent = (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
|
|
||||||
title={
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
|
||||||
<Space size={8}>
|
|
||||||
<SendOutlined style={{ color: '#2563eb', fontSize: 16 }} />
|
|
||||||
<span style={{ fontWeight: 700, color: '#0f172a' }}>Webhook 异步回调订阅设置</span>
|
|
||||||
{callback?.status === 'active' ? (
|
|
||||||
<span className="status-tag status-tag--green">
|
|
||||||
<span className="status-dot"></span>
|
|
||||||
推送已启用
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="status-tag status-tag--gray">
|
|
||||||
<span className="status-dot"></span>
|
|
||||||
推送已禁用
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
<Tag color="cyan" style={{ borderRadius: 6, fontWeight: 500 }}>
|
|
||||||
<SafetyCertificateOutlined style={{ marginRight: 4 }} /> HMAC-SHA256 签名保护
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div style={{ background: '#f8fafc', padding: '12px 16px', borderRadius: 8, marginBottom: 20, border: '1px solid #e2e8f0' }}>
|
|
||||||
<Row gutter={[16, 8]}>
|
|
||||||
<Col xs={24} sm={8}>
|
|
||||||
<div style={{ fontSize: 12, color: '#64748b' }}>重试机制</div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
|
|
||||||
Outbox 队列 + 指数退避 (最多 16 次)
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={8}>
|
|
||||||
<div style={{ fontSize: 12, color: '#64748b' }}>推送信道签名 Header</div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
|
|
||||||
X-Signature (HMAC-SHA256)
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={8}>
|
|
||||||
<div style={{ fontSize: 12, color: '#64748b' }}>推送超时标准</div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
|
|
||||||
5000 ms 强制响应超时
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
form={callbackForm}
|
|
||||||
layout="vertical"
|
|
||||||
disabled={!canManage}
|
|
||||||
onFinish={submitCallback}
|
|
||||||
initialValues={defaultCallbackFormValues()}
|
|
||||||
>
|
|
||||||
<Form.Item
|
|
||||||
name="url"
|
|
||||||
label="回调接收端 URL"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请填写回调 URL' },
|
|
||||||
{ type: 'url', message: '请填写正确的 HTTP(S) 地址' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
prefix={<LinkOutlined style={{ color: '#94a3b8' }} />}
|
|
||||||
placeholder="https://your-domain.com/api/v1/webhook"
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="events" label="订阅事件类型" rules={[{ required: true, message: '请选择至少一个回调事件' }]}>
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
placeholder="请选择订阅的事件列表"
|
|
||||||
options={eventOptions}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="status" label="回调状态" rules={[{ required: true }]}>
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: 'active', label: '启用回调推送' },
|
|
||||||
{ value: 'disabled', label: '禁用回调推送' },
|
|
||||||
]}
|
|
||||||
style={{ width: 200 }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingTop: 16,
|
|
||||||
borderTop: '1px solid #f1f5f9',
|
|
||||||
marginTop: 20,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
|
|
||||||
{callback?.updated_at ? `上次保存:${formatDateTime(callback.updated_at)}` : '尚未保存回调配置'}
|
|
||||||
</Typography.Text>
|
|
||||||
|
|
||||||
<Space size={12}>
|
|
||||||
{canManage && (
|
|
||||||
<Popconfirm
|
|
||||||
title="确认重置回调 Secret?"
|
|
||||||
description="重置后旧 Secret 立即失效,平台将使用新 Secret 进行 HMAC 签名推送,新 Secret 仅展示一次。"
|
|
||||||
okText="确认重置"
|
|
||||||
okButtonProps={{ danger: true }}
|
|
||||||
onConfirm={rotateCallbackSecret}
|
|
||||||
>
|
|
||||||
<Button danger>重置 Secret</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
)}
|
|
||||||
{canManage && (
|
|
||||||
<Button type="primary" htmlType="submit">
|
|
||||||
保存回调配置
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
|
|
||||||
const memberContent = (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
|
||||||
<Typography.Text type="secondary">成员角色独立于平台账号角色。</Typography.Text>
|
|
||||||
{merchantRole === 'owner' && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
|
||||||
memberForm.resetFields()
|
|
||||||
memberForm.setFieldsValue({ role: 'operator' })
|
|
||||||
setMemberOpen(true)
|
|
||||||
}}>添加成员</Button>}
|
|
||||||
</Space>
|
|
||||||
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ key: 'products', label: '商品列表', disabled: !hasFeature('products'), children: productContent },
|
{ key: 'products', label: '商品列表', disabled: !hasFeature('products'), children: (
|
||||||
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: orderContent },
|
<ProductsTab loading={loading} products={products} canManage={canManage} onToggleProduct={openProductToggle} onLoadProducts={loadProducts} />
|
||||||
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: walletContent },
|
) },
|
||||||
{ key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: apiKeyContent },
|
{ key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: (
|
||||||
{ key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: callbackContent },
|
<OrdersTab loading={loading} orders={orders} canManage={canManage} onCreateTestOrder={openTestOrderCreate} onLoadOrders={loadOrders} onCopyLink={copyDeliveryLink} onOpenLink={openDeliveryLink} onRevokeLink={revokeDeliveryLink} onRestoreLink={restoreDeliveryLink} />
|
||||||
{ key: 'members', label: '成员', children: memberContent },
|
) },
|
||||||
|
{ key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: (
|
||||||
|
<WalletTab loading={loading} wallet={wallet} canFinance={canFinance} ledger={ledger} form={walletFilterForm} onFilter={handleWalletFilter} onResetFilter={handleWalletFilterReset} onLoadLedger={handleLoadLedger} />
|
||||||
|
) },
|
||||||
|
{ key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: (
|
||||||
|
<ApiKeysTab loading={loading} clients={apiClients} clientMax={apiClientMax} isAdmin={isAdmin} canManage={canManage} form={apiClientForm} onOpenCreate={() => setApiClientOpen(true)} onNavigateDocs={() => navigate('/open-api')} onNavigateDebug={() => navigate('/api-debug')} onToggleClient={toggleAPIClient} onDeleteClient={deleteAPIClient} />
|
||||||
|
) },
|
||||||
|
{ key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: (
|
||||||
|
<CallbacksTab canManage={canManage} callback={callback} form={callbackForm} onSubmit={submitCallback} onRotateSecret={rotateCallbackSecret} />
|
||||||
|
) },
|
||||||
|
{ key: 'members', label: '成员', children: (
|
||||||
|
<MembersTab loading={loading} members={members} isOwner={merchantRole === 'owner'} form={memberForm} onOpenAdd={() => setMemberOpen(true)} />
|
||||||
|
) },
|
||||||
]
|
]
|
||||||
const fixedTabContent = tabItems.find((item) => item.key === fixedTab)?.children
|
const fixedTabContent = tabItems.find((item) => item.key === fixedTab)?.children
|
||||||
|
|
||||||
@@ -1145,183 +604,3 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageConfig<T extends { page: number; size: number; total: number }>(
|
|
||||||
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 (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
||||||
{appKey && (
|
|
||||||
<div>
|
|
||||||
<Typography.Text type="secondary">App Key</Typography.Text>
|
|
||||||
<Typography.Paragraph copyable={{ text: appKey }} code style={{ marginTop: 8 }}>{appKey}</Typography.Paragraph>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<Typography.Text type="secondary">Secret</Typography.Text>
|
|
||||||
<Typography.Paragraph copyable={{ text: secret }} code style={{ marginTop: 8 }}>{secret}</Typography.Paragraph>
|
|
||||||
</div>
|
|
||||||
<Button icon={<CopyOutlined />} onClick={() => navigator.clipboard.writeText(secret).then(() => message.success('已复制'))}>
|
|
||||||
复制 Secret
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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<MerchantCenterTab, string | null> = {
|
|
||||||
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 <span style={{ fontWeight: 700, color: '#16a34a' }}>+{money(amount)}</span>
|
|
||||||
}
|
|
||||||
if (amount < 0) {
|
|
||||||
return <span style={{ fontWeight: 700, color: '#dc2626' }}>{money(amount)}</span>
|
|
||||||
}
|
|
||||||
return <span style={{ fontWeight: 600, color: '#64748b' }}>{money(amount)}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function productStatusTag(value: string) {
|
|
||||||
return value === 'active' ? (
|
|
||||||
<span className="status-tag status-tag--green">
|
|
||||||
<span className="status-dot"></span>
|
|
||||||
已上架
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="status-tag status-tag--gray">
|
|
||||||
<span className="status-dot"></span>
|
|
||||||
已下架
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeStatusTag(value: string) {
|
|
||||||
return value === 'active' ? (
|
|
||||||
<span className="status-tag status-tag--green">
|
|
||||||
<span className="status-dot"></span>
|
|
||||||
已启用
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="status-tag status-tag--gray">
|
|
||||||
<span className="status-dot"></span>
|
|
||||||
已禁用
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, string> = {
|
|
||||||
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 (
|
|
||||||
<span className={`status-tag ${tagClass}`}>
|
|
||||||
<span className="status-dot"></span>
|
|
||||||
{item.text}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ledgerTypeTag(value: string) {
|
|
||||||
const map: Record<string, { color: string; text: string }> = {
|
|
||||||
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 <Tag color={item.color}>{item.text}</Tag>
|
|
||||||
}
|
|
||||||
|
|
||||||
function memberRoleTag(value: MerchantMember['role']) {
|
|
||||||
return <Tag color={value === 'owner' ? 'gold' : 'blue'}>{roleText(value)}</Tag>
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopesTag(value: string) {
|
|
||||||
const scopes = (value || '').split(',').filter(Boolean)
|
|
||||||
if (scopes.length === 0) {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Space size={4} wrap>
|
|
||||||
{scopes.map((s) => <Tag key={s}>{scopeLabel(s)}</Tag>)}
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
{appKey && (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">App Key</Typography.Text>
|
||||||
|
<Typography.Paragraph copyable={{ text: appKey }} code style={{ marginTop: 8 }}>{appKey}</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">Secret</Typography.Text>
|
||||||
|
<Typography.Paragraph copyable={{ text: secret }} code style={{ marginTop: 8 }}>{secret}</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
<Button icon={<CopyOutlined />} onClick={() => navigator.clipboard.writeText(secret).then(() => message.success('已复制'))}>
|
||||||
|
复制 Secret
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductsTab({
|
||||||
|
loading,
|
||||||
|
products,
|
||||||
|
canManage,
|
||||||
|
onToggleProduct,
|
||||||
|
onLoadProducts,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
products: PageResult<MerchantProduct>
|
||||||
|
canManage: boolean
|
||||||
|
onToggleProduct: (record: MerchantProduct) => void
|
||||||
|
onLoadProducts: (page?: number, size?: number) => void
|
||||||
|
}) {
|
||||||
|
const productColumns: ColumnsType<MerchantProduct> = [
|
||||||
|
{
|
||||||
|
title: '商户 SKU',
|
||||||
|
dataIndex: 'sku',
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => <Typography.Text code copyable={{ tooltips: false }} style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品名称',
|
||||||
|
dataIndex: 'display_name',
|
||||||
|
width: 220,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: '#0f172a' }}>{r.display_name || r.product?.name || '-'}</div>
|
||||||
|
{r.product?.category && (
|
||||||
|
<span style={{ fontSize: 11.5, color: '#64748b' }}>{r.product.category}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '目录编码',
|
||||||
|
dataIndex: ['product', 'code'],
|
||||||
|
width: 150,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => r.product?.code ? <Typography.Text code>{r.product.code}</Typography.Text> : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '对外售价',
|
||||||
|
dataIndex: 'price_amount',
|
||||||
|
width: 120,
|
||||||
|
render: (v) => <span style={{ fontWeight: 700, color: '#2563eb' }}>{money(v)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成本价',
|
||||||
|
dataIndex: 'cost_amount',
|
||||||
|
width: 120,
|
||||||
|
render: (v) => <span style={{ color: '#64748b' }}>{money(v)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '当前库存',
|
||||||
|
dataIndex: 'stock',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => (v < 0 ? <Tag color="blue">无限库存</Tag> : v === 0 ? <Tag color="red">已缺货</Tag> : `${v} 件`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: productStatusTag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 90,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => canManage ? (
|
||||||
|
<Popconfirm
|
||||||
|
title={record.status === 'active' ? '下架该商品?' : '上架该商品?'}
|
||||||
|
description={record.status === 'active' ? '下架后商户将无法再创建该商品订单' : '上架后商户可正常下单'}
|
||||||
|
okText="确认"
|
||||||
|
onConfirm={() => onToggleProduct(record)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small">
|
||||||
|
{record.status === 'active' ? '下架' : '上架'}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : '-',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: 600, color: '#0f172a', marginRight: 8 }}>商品上架与售卖管理</span>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
商户可自主上架/下架商品;下架后不可再创建该商品订单。
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={productColumns}
|
||||||
|
dataSource={products.list}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
pagination={pageConfig(products, onLoadProducts)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrdersTab({
|
||||||
|
loading,
|
||||||
|
orders,
|
||||||
|
canManage,
|
||||||
|
onCreateTestOrder,
|
||||||
|
onLoadOrders,
|
||||||
|
onCopyLink,
|
||||||
|
onOpenLink,
|
||||||
|
onRevokeLink,
|
||||||
|
onRestoreLink,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
orders: PageResult<FulfillmentOrder>
|
||||||
|
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<FulfillmentOrder> = [
|
||||||
|
{
|
||||||
|
title: '平台订单号',
|
||||||
|
dataIndex: 'order_no',
|
||||||
|
width: 190,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => <Typography.Text copyable ellipsis style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
|
||||||
|
},
|
||||||
|
{ title: '商户单号', dataIndex: 'client_order_no', width: 140, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: 'SKU',
|
||||||
|
dataIndex: 'product_sku',
|
||||||
|
width: 130,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => <Typography.Text code ellipsis style={{ maxWidth: '100%' }}>{v}</Typography.Text>,
|
||||||
|
},
|
||||||
|
{ 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 ? <Tag color="red">已作废</Tag> : 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 ? (
|
||||||
|
<Button type="link" size="small" onClick={() => onRestoreLink(record.order_no)}>取消作废</Button>
|
||||||
|
) : (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
<Button type="link" size="small" onClick={() => onCopyLink(record.order_no)}>复制</Button>
|
||||||
|
<Button type="link" size="small" onClick={() => onOpenLink(record.order_no)}>打开</Button>
|
||||||
|
<Button type="link" danger size="small" onClick={() => onRevokeLink(record.order_no)}>作废</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Text type="secondary">发货链接由后端生成并带签名,用户只填写游戏 UID。</Typography.Text>
|
||||||
|
{canManage && (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateTestOrder}>
|
||||||
|
创建测试订单
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={orderColumns}
|
||||||
|
dataSource={orders.list}
|
||||||
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: 1660 }}
|
||||||
|
pagination={pageConfig(orders, onLoadOrders)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalletTab({
|
||||||
|
loading,
|
||||||
|
wallet,
|
||||||
|
canFinance,
|
||||||
|
ledger,
|
||||||
|
form,
|
||||||
|
onFilter,
|
||||||
|
onResetFilter,
|
||||||
|
onLoadLedger,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
wallet: WalletAccount | null
|
||||||
|
canFinance: boolean
|
||||||
|
ledger: PageResult<WalletLedgerEntry>
|
||||||
|
form: FormInstance
|
||||||
|
onFilter: () => void
|
||||||
|
onResetFilter: () => void
|
||||||
|
onLoadLedger: (page: number, size: number) => void
|
||||||
|
}) {
|
||||||
|
const ledgerColumns: ColumnsType<WalletLedgerEntry> = [
|
||||||
|
{ title: '流水号', dataIndex: 'entry_no', width: 260, render: (v) => <Typography.Text code copyable={{ tooltips: false }}>{v}</Typography.Text> },
|
||||||
|
{ title: '类型', dataIndex: 'type', width: 90, render: ledgerTypeTag },
|
||||||
|
{ title: '变动积分', dataIndex: 'amount', width: 130, render: moneyWithSign },
|
||||||
|
{ title: '结余积分', dataIndex: 'balance_after', width: 130, render: (v) => <span style={{ fontWeight: 600, color: '#0f172a' }}>{money(v)}</span> },
|
||||||
|
{ title: '关联单号', dataIndex: 'reference_no', width: 200, ellipsis: true, render: (v) => v ? <Typography.Text code ellipsis style={{ maxWidth: '100%' }}>{v}</Typography.Text> : '-' },
|
||||||
|
{ title: '备注说明', dataIndex: 'note', width: 220, ellipsis: { showTitle: false }, render: (v) => <Typography.Text ellipsis title={v}>{v || '-'}</Typography.Text> },
|
||||||
|
{ title: '变动时间', dataIndex: 'created_at', width: 170, render: formatDateTime },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<div className="metric-card-box">
|
||||||
|
<div className="metric-card-header">
|
||||||
|
<span className="metric-card-title">可用余额</span>
|
||||||
|
<div className="metric-card-icon metric-card-icon--emerald"><WalletOutlined /></div>
|
||||||
|
</div>
|
||||||
|
<div className="metric-card-value" style={{ color: '#16a34a' }}>
|
||||||
|
{(wallet?.available_balance ?? 0).toLocaleString('zh-CN')}
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>积分</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric-card-footer">
|
||||||
|
<span>充值/调账</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#64748b' }}>仅平台管理员可操作</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<div className="metric-card-box">
|
||||||
|
<div className="metric-card-header">
|
||||||
|
<span className="metric-card-title">冻结余额</span>
|
||||||
|
<div className="metric-card-icon"><ClockCircleOutlined /></div>
|
||||||
|
</div>
|
||||||
|
<div className="metric-card-value">
|
||||||
|
{(wallet?.frozen_balance ?? 0).toLocaleString('zh-CN')}
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 4, color: '#64748b' }}>积分</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric-card-footer">
|
||||||
|
<span>提现/发货预扣</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#0f172a' }}>0 笔预扣</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<div className="metric-card-box">
|
||||||
|
<div className="metric-card-header">
|
||||||
|
<span className="metric-card-title">结算币种与状态</span>
|
||||||
|
<div className="metric-card-icon metric-card-icon--purple"><DollarOutlined /></div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
|
||||||
|
<Tag color="blue" style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, margin: 0, fontWeight: 600 }}>
|
||||||
|
{wallet?.currency || 'POINT'}
|
||||||
|
</Tag>
|
||||||
|
<span className="status-tag status-tag--green">
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
账户正常
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric-card-footer">
|
||||||
|
<span>计费方式</span>
|
||||||
|
<span style={{ fontWeight: 600, color: '#0f172a' }}>自动按单扣减</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{canFinance && (
|
||||||
|
<Card size="small" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8 }}>
|
||||||
|
<Form form={form} layout="inline" onFinish={onFilter}>
|
||||||
|
<Form.Item name="reference_no" label="关联单号">
|
||||||
|
<Input placeholder="店铺单号 / 充值单号" allowClear style={{ width: 220, borderRadius: 6 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="type" label="收支类型">
|
||||||
|
<Select placeholder="全部类型" allowClear style={{ width: 140 }}>
|
||||||
|
<Select.Option value="credit">入账 (Credit)</Select.Option>
|
||||||
|
<Select.Option value="debit">扣款 (Debit)</Select.Option>
|
||||||
|
<Select.Option value="refund">退款 (Refund)</Select.Option>
|
||||||
|
<Select.Option value="adjust">调账 (Adjust)</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onResetFilter}>重置</Button>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{canFinance ? (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={ledgerColumns}
|
||||||
|
dataSource={ledger.list}
|
||||||
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: 1300 }}
|
||||||
|
pagination={pageConfig(ledger, onLoadLedger)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">当前角色可查看余额,钱包流水仅财务或负责人可见。</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ApiClient> = [
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
|
||||||
|
{ title: 'App Key', dataIndex: 'app_key', width: 230, render: (v) => <Typography.Text code copyable>{v}</Typography.Text> },
|
||||||
|
{ title: '签名', dataIndex: 'signature_version', width: 80, render: (v) => <Tag>{v}</Tag> },
|
||||||
|
{ 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 ? (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => onToggleClient(record)}>
|
||||||
|
{record.status === 'active' ? '禁用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="删除该密钥?"
|
||||||
|
description={record.status === 'active' ? '请先停用该密钥再删除' : '删除后不可恢复,使用该密钥的对接方将无法调用开放接口。'}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => onDeleteClient(record)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger disabled={record.status === 'active'}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
) : '-',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<Space size={8}>
|
||||||
|
<KeyOutlined style={{ color: '#2563eb', fontSize: 16 }} />
|
||||||
|
<span style={{ fontWeight: 700, color: '#0f172a' }}>API 密钥与开放接口鉴权</span>
|
||||||
|
<Tag color="blue" style={{ borderRadius: 10, padding: '0 8px', fontWeight: 600 }}>
|
||||||
|
已配额 {clients.length} / {clientMax}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
<Space size={8}>
|
||||||
|
<Button icon={<FileTextOutlined />} onClick={onNavigateDocs}>
|
||||||
|
对接文档
|
||||||
|
</Button>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button icon={<CodeOutlined />} onClick={onNavigateDebug}>
|
||||||
|
接口调试
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
disabled={clients.length >= clientMax}
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields()
|
||||||
|
form.setFieldsValue({
|
||||||
|
signature_version: 'v1',
|
||||||
|
scopes: ['products:read', 'orders:read', 'orders:write'],
|
||||||
|
})
|
||||||
|
onOpenCreate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新增 API 密钥
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ background: '#f8fafc', padding: '10px 14px', borderRadius: 8, marginBottom: 16, border: '1px solid #e2e8f0' }}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
|
||||||
|
用于第三方分销系统或自建后台调用开放 API (商品查询、订单发货) 的 HMAC-SHA256 签名鉴权凭证。新生成的 App Secret 仅在创建成功时展示一次,请妥善保存。
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={apiClientColumns}
|
||||||
|
dataSource={clients}
|
||||||
|
scroll={{ x: 1150 }}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallbacksTab({
|
||||||
|
canManage,
|
||||||
|
callback,
|
||||||
|
form,
|
||||||
|
onSubmit,
|
||||||
|
onRotateSecret,
|
||||||
|
}: {
|
||||||
|
canManage: boolean
|
||||||
|
callback: CallbackSubscription | null
|
||||||
|
form: FormInstance
|
||||||
|
onSubmit: () => void
|
||||||
|
onRotateSecret: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<Space size={8}>
|
||||||
|
<SendOutlined style={{ color: '#2563eb', fontSize: 16 }} />
|
||||||
|
<span style={{ fontWeight: 700, color: '#0f172a' }}>Webhook 异步回调订阅设置</span>
|
||||||
|
{callback?.status === 'active' ? (
|
||||||
|
<span className="status-tag status-tag--green">
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
推送已启用
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-tag status-tag--gray">
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
推送已禁用
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
<Tag color="cyan" style={{ borderRadius: 6, fontWeight: 500 }}>
|
||||||
|
<SafetyCertificateOutlined style={{ marginRight: 4 }} /> HMAC-SHA256 签名保护
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{ background: '#f8fafc', padding: '12px 16px', borderRadius: 8, marginBottom: 20, border: '1px solid #e2e8f0' }}>
|
||||||
|
<Row gutter={[16, 8]}>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b' }}>重试机制</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
|
||||||
|
Outbox 队列 + 指数退避 (最多 16 次)
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b' }}>推送信道签名 Header</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
|
||||||
|
X-Signature (HMAC-SHA256)
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<div style={{ fontSize: 12, color: '#64748b' }}>推送超时标准</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', marginTop: 2 }}>
|
||||||
|
5000 ms 强制响应超时
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
disabled={!canManage}
|
||||||
|
onFinish={onSubmit}
|
||||||
|
initialValues={defaultCallbackFormValues()}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="url"
|
||||||
|
label="回调接收端 URL"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写回调 URL' },
|
||||||
|
{ type: 'url', message: '请填写正确的 HTTP(S) 地址' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<LinkOutlined style={{ color: '#94a3b8' }} />}
|
||||||
|
placeholder="https://your-domain.com/api/v1/webhook"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="events" label="订阅事件类型" rules={[{ required: true, message: '请选择至少一个回调事件' }]}>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择订阅的事件列表"
|
||||||
|
options={eventOptions}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="status" label="回调状态" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: '启用回调推送' },
|
||||||
|
{ value: 'disabled', label: '禁用回调推送' },
|
||||||
|
]}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTop: '1px solid #f1f5f9',
|
||||||
|
marginTop: 20,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12.5 }}>
|
||||||
|
{callback?.updated_at ? `上次保存:${formatDateTime(callback.updated_at)}` : '尚未保存回调配置'}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
<Space size={12}>
|
||||||
|
{canManage && (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认重置回调 Secret?"
|
||||||
|
description="重置后旧 Secret 立即失效,平台将使用新 Secret 进行 HMAC 签名推送,新 Secret 仅展示一次。"
|
||||||
|
okText="确认重置"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={onRotateSecret}
|
||||||
|
>
|
||||||
|
<Button danger>重置 Secret</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
保存回调配置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MembersTab({
|
||||||
|
loading,
|
||||||
|
members,
|
||||||
|
isOwner,
|
||||||
|
form,
|
||||||
|
onOpenAdd,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
members: MerchantMember[]
|
||||||
|
isOwner: boolean
|
||||||
|
form: FormInstance
|
||||||
|
onOpenAdd: () => void
|
||||||
|
}) {
|
||||||
|
const memberColumns: ColumnsType<MerchantMember> = [
|
||||||
|
{ 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 ? <Tag color="blue">默认</Tag> : '-') },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90, render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>) },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Text type="secondary">成员角色独立于平台账号角色。</Typography.Text>
|
||||||
|
{isOwner && <Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
|
form.resetFields()
|
||||||
|
form.setFieldsValue({ role: 'operator' })
|
||||||
|
onOpenAdd()
|
||||||
|
}}>添加成员</Button>}
|
||||||
|
</Space>
|
||||||
|
<Table rowKey="id" loading={loading} columns={memberColumns} dataSource={members} tableLayout="fixed" />
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function moneyWithSign(value?: number | null) {
|
||||||
|
const amount = Number(value || 0)
|
||||||
|
if (amount > 0) {
|
||||||
|
return <span style={{ fontWeight: 700, color: '#16a34a' }}>+{money(amount)}</span>
|
||||||
|
}
|
||||||
|
if (amount < 0) {
|
||||||
|
return <span style={{ fontWeight: 700, color: '#dc2626' }}>{money(amount)}</span>
|
||||||
|
}
|
||||||
|
return <span style={{ fontWeight: 600, color: '#64748b' }}>{money(amount)}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function productStatusTag(value: string) {
|
||||||
|
return value === 'active' ? (
|
||||||
|
<span className="status-tag status-tag--green">
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
已上架
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-tag status-tag--gray">
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
已下架
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeStatusTag(value: string) {
|
||||||
|
return value === 'active' ? (
|
||||||
|
<span className="status-tag status-tag--green">
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
已启用
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="status-tag status-tag--gray">
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
已禁用
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderStatusTag(value: string) {
|
||||||
|
const item = orderStatusMap[value] || { color: 'default', text: value }
|
||||||
|
const tagClassMap: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<span className={`status-tag ${tagClass}`}>
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ledgerTypeTag(value: string) {
|
||||||
|
const map: Record<string, { color: string; text: string }> = {
|
||||||
|
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 <Tag color={item.color}>{item.text}</Tag>
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberRoleTag(value: MerchantMember['role']) {
|
||||||
|
return <Tag color={value === 'owner' ? 'gold' : 'blue'}>{roleText(value)}</Tag>
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopesTag(value: string) {
|
||||||
|
const scopes = (value || '').split(',').filter(Boolean)
|
||||||
|
if (scopes.length === 0) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{scopes.map((s) => <Tag key={s}>{scopeLabel(s)}</Tag>)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { MerchantMember, MerchantProduct } from '../types'
|
||||||
|
|
||||||
|
export const orderStatusMap: Record<string, { color: string; text: string }> = {
|
||||||
|
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<T extends { page: number; size: number; total: number }>(
|
||||||
|
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<MerchantCenterTab, string | null> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user