拆分 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:
yml2213
2026-08-05 13:32:11 +08:00
parent 569109cd92
commit 2264851d5d
29 changed files with 2990 additions and 2606 deletions
-325
View File
@@ -1,17 +1,9 @@
package service
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -616,67 +608,6 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m
return info, order, goodID, nil
}
func buildExistingDeliverySubmitResult(order *model.FulfillmentOrder) *DeliverySubmitResult {
if order == nil {
return nil
}
switch normalizeOrderStatus(order) {
case model.OrderStatusDelivering, model.OrderStatusDelivered:
default:
return nil
}
var resultData map[string]interface{}
if json.Valid([]byte(order.ResultData)) {
_ = json.Unmarshal([]byte(order.ResultData), &resultData)
}
providerOrderNo := firstNonEmpty(
order.ProviderOrderNo,
stringFromMap(resultData, "provider_order_no"),
)
message := "订单暂时正在发货中,请稍后查询"
status := normalizeOrderStatus(order)
if status == model.OrderStatusDelivered {
message = "订单已交付"
}
return &DeliverySubmitResult{
OrderNo: order.OrderNo,
Status: status,
Message: message,
ProviderOrderNo: providerOrderNo,
UpstreamOrder: resultData,
}
}
func upstreamDeliverySucceeded(order map[string]interface{}) bool {
return strings.EqualFold(stringFromMap(order, "status"), "FINISHED") ||
strings.EqualFold(stringFromMap(order, "send_status"), "SUCCESS")
}
func deliverySubmissionInProgress(stage string) bool {
return stage == deliveryStageClaimed || stage == deliveryStageQueueCreated
}
func deliverySubmissionStale(order *model.FulfillmentOrder, now time.Time) bool {
if order == nil {
return false
}
startedAtRaw := resultDataString(order.ResultData, "submit_started_at")
if startedAtRaw != "" {
if startedAt, err := time.Parse(timeutil.APITimeLayout, startedAtRaw); err == nil {
return now.Sub(startedAt) >= deliverySubmissionStaleTimeout
}
}
return !order.UpdatedAt.IsZero() && now.Sub(order.UpdatedAt) >= deliverySubmissionStaleTimeout
}
func optionalUint(value uint) *uint {
if value == 0 {
return nil
}
out := value
return &out
}
func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
@@ -826,207 +757,6 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth
return info, order, goodID, nil
}
func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, auth DeliveryLinkAuth) error {
if order == nil {
return newDeliveryHTTPError(http.StatusNotFound, "订单不存在")
}
if auth.Exp <= 0 || strings.TrimSpace(auth.Sign) == "" {
return newDeliveryHTTPError(http.StatusForbidden, "链接参数缺失")
}
if order.DeliveryLinkRevokedAt != nil {
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废")
}
if order.DeliveryLinkExpiresAt == nil {
return newDeliveryHTTPError(http.StatusForbidden, "发货链接未生成")
}
expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)
linkExpires := time.Unix(auth.Exp, 0).UTC()
if !linkExpires.Equal(expiresAt) {
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已失效")
}
if time.Now().UTC().After(linkExpires) {
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已过期")
}
expected := s.signDeliveryLink(order.OrderNo, auth.Exp)
if !hmac.Equal([]byte(strings.ToLower(strings.TrimSpace(auth.Sign))), []byte(expected)) {
return newDeliveryHTTPError(http.StatusForbidden, "发货链接签名无效")
}
return nil
}
func (s *DeliveryService) buildDeliveryLinkResult(orderNo string, expiresAt time.Time, requestBaseURL string) *DeliveryLinkResult {
expiresAt = expiresAt.UTC()
exp := expiresAt.Unix()
sign := s.signDeliveryLink(orderNo, exp)
return &DeliveryLinkResult{
OrderNo: orderNo,
DeliveryURL: s.buildDeliveryURL(orderNo, exp, sign, requestBaseURL),
ExpiresAt: expiresAt,
Exp: exp,
Sign: sign,
}
}
func (s *DeliveryService) buildDeliveryURL(orderNo string, exp int64, sign, requestBaseURL string) string {
path := fmt.Sprintf("/delivery/%s/%s?exp=%d&sign=%s", url.PathEscape(s.channel), url.PathEscape(orderNo), exp, url.QueryEscape(sign))
base := strings.TrimRight(s.linkBaseURL, "/")
if base == "" {
base = strings.TrimRight(requestBaseURL, "/")
}
if base == "" {
return path
}
return base + path
}
func (s *DeliveryService) signDeliveryLink(orderNo string, exp int64) string {
mac := hmac.New(sha256.New, []byte(s.linkSecret))
_, _ = mac.Write([]byte(orderNo + "|" + strconv.FormatInt(exp, 10)))
return hex.EncodeToString(mac.Sum(nil))
}
func buildDeliveryProduct(openProduct *OpenOrderProduct, good map[string]interface{}) *DeliveryProduct {
if openProduct == nil {
return nil
}
product := &DeliveryProduct{
Name: openProduct.Name,
SKU: openProduct.SKU,
Game: openProduct.Game,
}
if title := stringFromMap(good, "title"); title != "" {
product.Name = title
}
product.Image = stringFromMap(good, "image")
return product
}
func (s *DeliveryService) goodsDetail(goodID string) (map[string]interface{}, error) {
var out struct {
Good map[string]interface{} `json:"good"`
}
if err := s.signProxy("/public/goods/detail", "POST", map[string]interface{}{
"good_id": goodID,
}, &out); err != nil {
return nil, err
}
return out.Good, nil
}
func (s *DeliveryService) accountBound(bindUUID, goodID string) (map[string]interface{}, error) {
var out struct {
GameAccount map[string]interface{} `json:"gameAccount"`
Snake map[string]interface{} `json:"game_account"`
}
if err := s.signProxy("/public/games/account-bound", "POST", map[string]interface{}{
"bind_uuid": bindUUID,
"bindUuid": bindUUID,
"goodId": goodID,
"good_id": goodID,
}, &out); err != nil {
return nil, err
}
if out.GameAccount == nil {
out.GameAccount = out.Snake
}
if out.GameAccount == nil {
return nil, errors.New("账号尚未绑定,请扫码完成绑定后再提交")
}
return out.GameAccount, nil
}
func (s *DeliveryService) createOrderQueue(goodID, orderNo string) (string, error) {
var out struct {
Orders []map[string]interface{} `json:"orders"`
}
if err := s.signProxy("/public/users/orders-queue", "POST", map[string]interface{}{
"good_id": goodID,
"quantity": 1,
"order_sn": orderNo,
}, &out); err != nil {
return "", err
}
if len(out.Orders) == 0 {
return "", errors.New("发货服务未返回队列订单")
}
orderID := firstNonEmpty(stringFromMap(out.Orders[0], "_id"), stringFromMap(out.Orders[0], "id"))
if orderID == "" {
return "", errors.New("发货服务未返回队列订单 ID")
}
return orderID, nil
}
func (s *DeliveryService) patchOrderQueue(orderID, gameAccount, bindUUID string) error {
var out map[string]interface{}
return s.signProxy("/public/users/orders-queue", "PATCH", map[string]interface{}{
"order_id": orderID,
"game_account": gameAccount,
"bind_uuid": bindUUID,
}, &out)
}
func (s *DeliveryService) createUpstreamOrder(orderID, gameAccount, goodID, orderNo string) (map[string]interface{}, error) {
var out struct {
Order map[string]interface{} `json:"order"`
}
if err := s.signProxy("/public/users/orders", "POST", map[string]interface{}{
"order_id": orderID,
"game_account": gameAccount,
"good_id": goodID,
"h5_prefix": s.channel,
"order_sn": orderNo,
}, &out); err != nil {
return nil, err
}
if out.Order == nil {
out.Order = map[string]interface{}{"order_id": orderID, "order_sn": orderNo}
}
return out.Order, nil
}
func (s *DeliveryService) signProxy(path, method string, data interface{}, out interface{}) error {
payload, err := json.Marshal(map[string]interface{}{
"path": path,
"method": strings.ToUpper(method),
"data": data,
})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, s.bffBaseURL+"/sign-proxy", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("发货服务请求失败:%w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var envelope struct {
Code interface{} `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return fmt.Errorf("发货服务响应无法解析:%s", truncateDeliveryText(string(body)))
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || !isZeroCode(envelope.Code) {
if envelope.Message != "" {
return errors.New(envelope.Message)
}
return fmt.Errorf("发货服务请求失败:HTTP %d", resp.StatusCode)
}
if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return nil
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("发货服务数据无法解析:%w", err)
}
return nil
}
func deliveryGoodID(channel, sku string) string {
if channel != "dlc" {
return ""
@@ -1067,58 +797,3 @@ func deliveryGoodID(channel, sku string) string {
"lucky_coin_x90": "682ef39ca8f40c4234c59f42",
}[sku]
}
func isZeroCode(code interface{}) bool {
switch v := code.(type) {
case nil:
return true
case float64:
return v == 0
case string:
return v == "0"
default:
return false
}
}
func stringFromMap(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
switch v := m[key].(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
default:
if v != nil {
return fmt.Sprint(v)
}
return ""
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func gameChannelText(account map[string]interface{}) string {
area := stringFromMap(account, "game_account_area")
plat := stringFromMap(account, "game_account_plat")
if area == "" && plat == "" {
return ""
}
return strings.Trim(strings.Join([]string{area, plat}, "-"), "-")
}
func truncateDeliveryText(value string) string {
if len(value) <= 300 {
return value
}
return value[:300]
}
@@ -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]
}
+74
View File
@@ -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
}
-870
View File
@@ -1,19 +1,13 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"strconv"
"strings"
"time"
"unicode/utf8"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"github.com/google/uuid"
"gorm.io/gorm"
@@ -326,35 +320,6 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu
return &out, nil
}
func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) {
var order model.FulfillmentOrder
err := s.db.Preload("MerchantProduct.Product").
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("订单不存在")
}
if err != nil {
return nil, err
}
return &order, nil
}
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus string) ([]model.FulfillmentOrder, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID)
if orderStatus != "" {
tx = tx.Where("order_status = ?", orderStatus)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var orders []model.FulfillmentOrder
err := tx.Preload("MerchantProduct.Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error
return orders, total, err
}
type FulfillmentUpdateInput struct {
MerchantID uint
APIClientID uint
@@ -436,112 +401,6 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode
return &out, nil
}
func (s *FulfillmentService) MarkProcessingTimeouts(timeout time.Duration, limit int) (int, error) {
if timeout <= 0 {
return 0, nil
}
if limit <= 0 || limit > 100 {
limit = 50
}
now := time.Now()
cutoff := now.Add(-timeout)
var ids []uint
if err := s.db.Model(&model.FulfillmentOrder{}).
Where("order_status = ? AND updated_at < ?", model.OrderStatusDelivering, cutoff).
Order("updated_at ASC, id ASC").
Limit(limit).
Pluck("id", &ids).Error; err != nil {
return 0, err
}
changed := 0
for _, id := range ids {
updated, err := s.markProcessingTimeout(id, timeout, now)
if err != nil {
return changed, err
}
if updated {
changed++
}
}
return changed, nil
}
func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duration, now time.Time) (bool, error) {
returned := false
err := s.db.Transaction(func(tx *gorm.DB) error {
var order model.FulfillmentOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if !processingTimedOut(&order, timeout, now) {
return nil
}
if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil {
return nil
}
submittedUpstream := deliverySubmittedUpstream(&order)
var reason string
if submittedUpstream {
reason = fmt.Sprintf("发货超时:订单已提交上游但超过 %d 分钟未回传结果,可能仍在处理;请勿直接重试,先在上游确认订单状态", int(timeout.Minutes()))
} else {
reason = fmt.Sprintf("发货超时:发货提交中断,请重新提交(已停留 delivering 超过 %d 分钟)", int(timeout.Minutes()))
}
updates := map[string]interface{}{"order_status": model.OrderStatusShipFailed}
updates["failure_reason"] = reason
updates["result_data"] = buildProcessingTimeoutResultData(order.ResultData, timeout, now, reason)
if err := tx.Model(&order).Updates(updates).Error; err != nil {
return err
}
var out model.FulfillmentOrder
if err := tx.First(&out, order.ID).Error; err != nil {
return err
}
if err := writeAudit(tx, &order.MerchantID, nil, nil, "fulfillment.timeout", "fulfillment_order", order.OrderNo, map[string]interface{}{
"from": normalizeOrderStatus(&order),
"to": model.OrderStatusShipFailed,
"timeout_minutes": int(timeout.Minutes()),
"reason": reason,
}); err != nil {
return err
}
if s.callbacks != nil {
if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&out)); err != nil {
return err
}
}
returned = true
return nil
})
return returned, err
}
func (s *FulfillmentService) RunProcessingTimeoutMonitor(ctx context.Context, timeout, interval time.Duration) {
if timeout <= 0 {
return
}
if interval <= 0 {
interval = time.Minute
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
changed, err := s.MarkProcessingTimeouts(timeout, 50)
if err != nil {
log.Printf("[fulfillment] timeout scan error: %v", err)
} else if changed > 0 {
log.Printf("[fulfillment] timeout scan marked failed count=%d", changed)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) {
var out model.FulfillmentOrder
err := s.db.Transaction(func(tx *gorm.DB) error {
@@ -619,137 +478,6 @@ func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo,
return &out, nil
}
type WalletAdjustInput struct {
MerchantID uint
ActorUserID uint
Amount int64
IdempotencyKey string
Note string
}
func (s *FulfillmentService) AdjustWallet(in WalletAdjustInput) (*model.WalletAccount, error) {
if in.Amount == 0 {
return nil, errors.New("调整金额不能为零")
}
if in.IdempotencyKey == "" {
return nil, errors.New("账务调整必须提供幂等键")
}
var out model.WalletAccount
err := s.db.Transaction(func(tx *gorm.DB) error {
var existing model.WalletLedgerEntry
if err := tx.Where("merchant_id = ? AND idempotency_key = ?", in.MerchantID, in.IdempotencyKey).First(&existing).Error; err == nil {
if err := tx.First(&out, existing.WalletAccountID).Error; err != nil {
return err
}
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var wallet model.WalletAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil {
return err
}
newBalance := wallet.AvailableBalance + in.Amount
if newBalance < 0 {
return errors.New("调整后余额不能小于零")
}
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
return err
}
entryType := model.WalletLedgerAdjust
if in.Amount > 0 {
entryType = model.WalletLedgerCredit
} else {
entryType = model.WalletLedgerDebit
}
idempotencyKey := in.IdempotencyKey
if err := tx.Create(&model.WalletLedgerEntry{
MerchantID: in.MerchantID,
WalletAccountID: wallet.ID,
EntryNo: "WL" + uuid.NewString(),
Type: entryType,
Amount: in.Amount,
BalanceAfter: newBalance,
ReferenceType: "manual_adjustment",
ReferenceNo: in.IdempotencyKey,
IdempotencyKey: &idempotencyKey,
Note: in.Note,
}).Error; err != nil {
return err
}
out = wallet
out.AvailableBalance = newBalance
return writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "wallet.adjust", "wallet_account", fmt.Sprint(wallet.ID), map[string]int64{"amount": in.Amount})
})
if err != nil {
return nil, err
}
return &out, nil
}
func (s *FulfillmentService) GetWallet(merchantID uint) (*model.WalletAccount, error) {
var wallet model.WalletAccount
if err := s.db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("商户钱包不存在")
}
return nil, err
}
return &wallet, nil
}
func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int, referenceNo, entryType string) ([]model.WalletLedgerEntry, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID)
if referenceNo != "" {
tx = tx.Where("reference_no LIKE ?", "%"+referenceNo+"%")
}
if entryType != "" {
tx = tx.Where("type = ?", entryType)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var entries []model.WalletLedgerEntry
err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&entries).Error
return entries, total, err
}
func newFulfillmentOrderNo() string {
return "FO" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
}
func newTestFulfillmentOrderNo() string {
return "O" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
}
// calculateServiceFee 按"百分比或固定"二选一计算手续费:
// - feeType=rate:按 baseAmount * feeRateBP / 10000 计算
// - feeType=fixed:直接取 feeFixedAmount
//
// 二者互斥,不会叠加。
func calculateServiceFee(baseAmount int64, feeType string, feeRateBP, feeFixedAmount int64) (int64, error) {
if baseAmount < 0 || feeRateBP < 0 || feeFixedAmount < 0 {
return 0, errors.New("订单金额或手续费配置无效")
}
switch feeType {
case model.FeeTypeFixed:
return feeFixedAmount, nil
case model.FeeTypeRate, "":
if feeRateBP > 10000 {
return 0, errors.New("手续费比例不能超过 10000 BP")
}
if feeRateBP > 0 && baseAmount > math.MaxInt64/feeRateBP {
return 0, errors.New("手续费金额超出范围")
}
return baseAmount * feeRateBP / 10000, nil
default:
return 0, errors.New("无效的手续费类型")
}
}
func CanFulfill(order *model.FulfillmentOrder) (bool, string) {
switch normalizeOrderStatus(order) {
case model.OrderStatusPaid:
@@ -797,601 +525,3 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
// ----- 仪表盘统计 -----
// DashboardStats 仪表盘聚合指标。
type DashboardStats struct {
Scope string `json:"scope"`
CatalogProductCount int64 `json:"catalog_product_count"`
ProductCount int64 `json:"product_count"`
ActiveProductCount int64 `json:"active_product_count"`
MerchantCount int64 `json:"merchant_count"`
ActiveMerchantCount int64 `json:"active_merchant_count"`
UserCount int64 `json:"user_count"`
OrderCount int64 `json:"order_count"`
TodayOrderCount int64 `json:"today_order_count"`
TotalSales int64 `json:"total_sales"`
TodaySales int64 `json:"today_sales"`
TotalFees int64 `json:"total_fees"`
TodayFees int64 `json:"today_fees"`
PaidOrderCount int64 `json:"paid_order_count"`
DeliveringOrderCount int64 `json:"delivering_order_count"`
DeliveredOrderCount int64 `json:"delivered_order_count"`
ShipFailedOrderCount int64 `json:"ship_failed_order_count"`
CancelledOrderCount int64 `json:"cancelled_order_count"`
WalletAvailableBalance int64 `json:"wallet_available_balance"`
WalletFrozenBalance int64 `json:"wallet_frozen_balance"`
APIClientCount int64 `json:"api_client_count"`
ActiveAPIClientCount int64 `json:"active_api_client_count"`
CallbackSubscriptionCount int64 `json:"callback_subscription_count"`
PendingCallbackCount int64 `json:"pending_callback_count"`
FailedCallbackCount int64 `json:"failed_callback_count"`
}
type dashboardStatusCount struct {
Status string
Count int64
}
// Dashboard 按角色汇总运营指标:平台管理员看全平台,商户账号看当前商户。
func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*DashboardStats, error) {
stats := &DashboardStats{Scope: "merchant"}
if isPlatformAdmin {
stats.Scope = "platform"
}
todayStart := timeutil.StartOfDay(time.Now())
productScope := func() *gorm.DB {
tx := s.db.Model(&model.MerchantProduct{})
if !isPlatformAdmin {
tx = tx.Where("merchant_id = ?", merchantID)
}
return tx
}
orderScope := func() *gorm.DB {
tx := s.db.Model(&model.FulfillmentOrder{})
if !isPlatformAdmin {
tx = tx.Where("merchant_id = ?", merchantID)
}
return tx
}
walletScope := func() *gorm.DB {
tx := s.db.Model(&model.WalletAccount{})
if !isPlatformAdmin {
tx = tx.Where("merchant_id = ?", merchantID)
}
return tx
}
apiClientScope := func() *gorm.DB {
tx := s.db.Model(&model.APIClient{})
if !isPlatformAdmin {
tx = tx.Where("merchant_id = ?", merchantID)
}
return tx
}
callbackScope := func() *gorm.DB {
tx := s.db.Model(&model.CallbackSubscription{})
if !isPlatformAdmin {
tx = tx.Where("merchant_id = ?", merchantID)
}
return tx
}
callbackDeliveryScope := func() *gorm.DB {
tx := s.db.Model(&model.CallbackDelivery{})
if !isPlatformAdmin {
tx = tx.Where("merchant_id = ?", merchantID)
}
return tx
}
if err := s.db.Model(&model.Product{}).Count(&stats.CatalogProductCount).Error; err != nil {
return nil, err
}
if err := productScope().Count(&stats.ProductCount).Error; err != nil {
return nil, err
}
if err := productScope().Where("status = ?", model.ProductStatusActive).Count(&stats.ActiveProductCount).Error; err != nil {
return nil, err
}
if isPlatformAdmin {
if err := s.db.Model(&model.Merchant{}).Count(&stats.MerchantCount).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.Merchant{}).Where("status = ?", model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.User{}).Count(&stats.UserCount).Error; err != nil {
return nil, err
}
} else {
if err := s.db.Model(&model.Merchant{}).Where("id = ?", merchantID).Count(&stats.MerchantCount).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.Merchant{}).Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil {
return nil, err
}
if err := s.db.Model(&model.User{}).
Joins("JOIN merchant_members ON merchant_members.user_id = users.id").
Where("merchant_members.merchant_id = ?", merchantID).
Count(&stats.UserCount).Error; err != nil {
return nil, err
}
stats.CatalogProductCount = stats.ProductCount
}
if err := orderScope().Count(&stats.OrderCount).Error; err != nil {
return nil, err
}
if err := orderScope().Where("created_at >= ?", todayStart).Count(&stats.TodayOrderCount).Error; err != nil {
return nil, err
}
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales).Error; err != nil {
return nil, err
}
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
Where("created_at >= ?", todayStart).
Select("COALESCE(SUM(amount),0)").Scan(&stats.TodaySales).Error; err != nil {
return nil, err
}
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TotalFees).Error; err != nil {
return nil, err
}
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
Where("created_at >= ?", todayStart).
Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TodayFees).Error; err != nil {
return nil, err
}
var orderStatusCounts []dashboardStatusCount
if err := orderScope().Select("order_status AS status, COUNT(*) AS count").
Group("order_status").Scan(&orderStatusCounts).Error; err != nil {
return nil, err
}
for _, item := range orderStatusCounts {
switch item.Status {
case model.OrderStatusPaid:
stats.PaidOrderCount = item.Count
case model.OrderStatusDelivering:
stats.DeliveringOrderCount = item.Count
case model.OrderStatusDelivered:
stats.DeliveredOrderCount = item.Count
case model.OrderStatusShipFailed:
stats.ShipFailedOrderCount = item.Count
case model.OrderStatusCancelled:
stats.CancelledOrderCount = item.Count
}
}
if err := walletScope().Select("COALESCE(SUM(available_balance),0)").Scan(&stats.WalletAvailableBalance).Error; err != nil {
return nil, err
}
if err := walletScope().Select("COALESCE(SUM(frozen_balance),0)").Scan(&stats.WalletFrozenBalance).Error; err != nil {
return nil, err
}
if err := apiClientScope().Count(&stats.APIClientCount).Error; err != nil {
return nil, err
}
if err := apiClientScope().Where("status = ?", model.APIClientStatusActive).Count(&stats.ActiveAPIClientCount).Error; err != nil {
return nil, err
}
if err := callbackScope().Count(&stats.CallbackSubscriptionCount).Error; err != nil {
return nil, err
}
if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryPending).Count(&stats.PendingCallbackCount).Error; err != nil {
return nil, err
}
if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryFailed).Count(&stats.FailedCallbackCount).Error; err != nil {
return nil, err
}
return stats, nil
}
// ----- 上游 SourceOpen 接口(基于 FulfillmentOrder-----
// OpenOrderQuery 开放接口订单查询结果。
type OpenOrderQuery struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
CanShip bool `json:"can_ship"`
CannotShipReason string `json:"cannot_ship_reason,omitempty"`
Product *OpenOrderProduct `json:"product,omitempty"`
BuyerName string `json:"buyer_name"`
Amount int64 `json:"amount"`
ProviderOrderNo string `json:"provider_order_no,omitempty"`
CreatedAt time.Time `json:"created_at"`
ShippedAt *time.Time `json:"shipped_at"`
ShipFailReason string `json:"ship_fail_reason,omitempty"`
GameChannel string `json:"game_channel,omitempty"`
GameUID string `json:"game_uid,omitempty"`
RoleName string `json:"role_name,omitempty"`
PayScore int `json:"pay_score,omitempty"`
}
// OpenOrderProduct 开放接口返回的商品快照。
type OpenOrderProduct struct {
Name string `json:"name"`
SKU string `json:"sku"`
Game string `json:"game"`
}
// ShipNotifyInput 上游发货结果推送。
type ShipNotifyInput struct {
OrderNo string
ShipStatus string // success / failed
ProviderOrderNo string
ShippedAt *time.Time
FailReason string
RawPayload string
GameChannel *string
GameUID *string
RoleName *string
PayScore *int
}
// ShipNotifyResult 上游推送处理结果。
type ShipNotifyResult struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
Message string `json:"message"`
}
// GetByOrderNo 按订单号查询(不限定商户,供上游 SourceOpen 使用)。
func (s *FulfillmentService) GetByOrderNo(orderNo string) (*model.FulfillmentOrder, error) {
var order model.FulfillmentOrder
err := s.db.Preload("MerchantProduct.Product").Where("order_no = ?", orderNo).First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("订单不存在")
}
if err != nil {
return nil, err
}
return &order, nil
}
// QueryOpenOrder 供上游查询:商品信息 + 是否可发货。
func (s *FulfillmentService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, error) {
if orderNo == "" {
return nil, errors.New("订单号不能为空")
}
order, err := s.GetByOrderNo(orderNo)
if err != nil {
return nil, err
}
canShip, reason := CanFulfill(order)
out := &OpenOrderQuery{
OrderNo: order.OrderNo,
Status: normalizeOrderStatus(order),
CanShip: canShip,
CannotShipReason: reason,
BuyerName: order.BuyerReference,
Amount: order.Amount,
ProviderOrderNo: order.ProviderOrderNo,
CreatedAt: order.CreatedAt,
ShippedAt: order.DeliveredAt,
ShipFailReason: order.FailureReason,
}
if order.MerchantProduct != nil {
game := ""
if order.MerchantProduct.Product != nil {
game = order.MerchantProduct.Product.Category
}
out.Product = &OpenOrderProduct{
Name: order.ProductName,
SKU: order.ProductSKU,
Game: game,
}
}
// 新模型无独立的游戏字段列,从 RequestData / ResultData JSON 中还原。
extractGameFields(order.RequestData, out)
extractGameFields(order.ResultData, out)
return out, nil
}
// HandleShipNotify 处理上游发货结果推送(幂等),基于 FulfillmentOrder。
func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) {
in.ShipStatus = strings.TrimSpace(in.ShipStatus)
in.FailReason = strings.TrimSpace(in.FailReason)
if in.OrderNo == "" {
return nil, errors.New("订单号不能为空")
}
if in.ShipStatus != "success" && in.ShipStatus != "failed" {
return nil, errors.New("无效的 ship_status,仅支持 success/failed")
}
if in.ShipStatus == "failed" && in.FailReason == "" {
return nil, errors.New("发货失败时 fail_reason 必填")
}
if utf8.RuneCountInString(in.FailReason) > 512 {
return nil, errors.New("fail_reason 最长 512 个字符")
}
var nextStatus string
switch in.ShipStatus {
case "success":
nextStatus = model.OrderStatusDelivered
case "failed":
nextStatus = model.OrderStatusShipFailed
}
var result ShipNotifyResult
var rejectionErr error
if err := s.db.Transaction(func(tx *gorm.DB) error {
var order model.FulfillmentOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Preload("MerchantProduct.Product").
Where("order_no = ?", in.OrderNo).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("订单不存在")
}
return err
}
// 已交付:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。
if normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" {
result = ShipNotifyResult{
OrderNo: order.OrderNo,
Status: normalizeOrderStatus(&order),
Message: "订单已交付,幂等成功",
}
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
shipNotifyAuditMetadata(in, normalizeOrderStatus(&order), "订单已交付,幂等忽略"))
}
if normalizeOrderStatus(&order) == model.OrderStatusCancelled {
if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil {
return err
}
rejectionErr = errors.New("订单已取消,无法更新发货状态")
return nil
}
now := time.Now()
shippedAt := in.ShippedAt
if shippedAt == nil && in.ShipStatus == "success" {
shippedAt = &now
}
updates := map[string]interface{}{
"order_status": nextStatus,
}
var message string
switch in.ShipStatus {
case "success":
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
message := "当前状态不允许标记发货成功"
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
return err
}
rejectionErr = err
return nil
}
updates["delivered_at"] = shippedAt
updates["failure_reason"] = ""
if in.ProviderOrderNo != "" {
updates["provider_order_no"] = in.ProviderOrderNo
}
message = "发货成功,订单已交付"
case "failed":
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
message := "当前状态不允许标记发货失败"
if normalizeOrderStatus(&order) == model.OrderStatusDelivered {
message = "订单已交付,拒绝失败推送"
}
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
return err
}
rejectionErr = err
return nil
}
updates["failure_reason"] = in.FailReason
if in.ProviderOrderNo != "" {
updates["provider_order_no"] = in.ProviderOrderNo
}
message = "已记录发货失败"
}
resultData := buildShipNotifyResultData(order.ResultData, in, shippedAt)
updates["result_data"] = resultData
if err := tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil {
return err
}
if err := writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
shipNotifyAuditMetadata(in, nextStatus, message)); err != nil {
return err
}
var updated model.FulfillmentOrder
if err := tx.Preload("MerchantProduct.Product").First(&updated, order.ID).Error; err != nil {
return err
}
if s.callbacks != nil {
if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&updated)); err != nil {
return err
}
}
result = ShipNotifyResult{
OrderNo: order.OrderNo,
Status: nextStatus,
Message: message,
}
return nil
}); err != nil {
return nil, err
}
if rejectionErr != nil {
return nil, rejectionErr
}
return &result, nil
}
func writeShipNotifyRejectedAudit(tx *gorm.DB, order *model.FulfillmentOrder, in ShipNotifyInput, message string) error {
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
shipNotifyAuditMetadata(in, normalizeOrderStatus(order), message))
}
func shipNotifyAuditMetadata(in ShipNotifyInput, resultStatus, message string) map[string]interface{} {
metadata := map[string]interface{}{
"ship_status": in.ShipStatus,
"provider_order_no": in.ProviderOrderNo,
"fail_reason": in.FailReason,
"result_status": resultStatus,
"message": message,
"payload": in.RawPayload,
}
return metadata
}
// buildShipNotifyResultData 把推送结果与游戏字段合并进 ResultData JSON。
func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *time.Time) string {
m := map[string]interface{}{}
if existing != "" && json.Valid([]byte(existing)) {
_ = json.Unmarshal([]byte(existing), &m)
}
m["ship_status"] = in.ShipStatus
if in.ProviderOrderNo != "" {
m["provider_order_no"] = in.ProviderOrderNo
}
if in.ShipStatus == "success" {
delete(m, "fail_reason")
} else if in.FailReason != "" {
m["fail_reason"] = in.FailReason
}
if shippedAt != nil {
m["shipped_at"] = timeutil.FormatAPITime(*shippedAt)
}
if in.GameChannel != nil {
m["game_channel"] = *in.GameChannel
}
if in.GameUID != nil {
m["game_uid"] = *in.GameUID
}
if in.RoleName != nil {
m["role_name"] = *in.RoleName
}
if in.PayScore != nil {
m["pay_score"] = *in.PayScore
}
raw, err := json.Marshal(m)
if err != nil {
return existing
}
return string(raw)
}
func buildProcessingTimeoutResultData(existing string, timeout time.Duration, now time.Time, reason string) string {
return mergeResultData(existing, map[string]interface{}{
"timeout": true,
"timeout_minutes": int(timeout.Minutes()),
"timeout_at": timeutil.FormatAPITime(now),
"ship_status": "failed",
"fail_reason": reason,
})
}
// mergeResultData 保留已有 JSON 字段,仅覆盖或新增 patch 中的字段,避免各发货阶段互相清空上下文。
func mergeResultData(existing string, patch map[string]interface{}) string {
if len(patch) == 0 {
return existing
}
m := map[string]interface{}{}
if existing != "" && json.Valid([]byte(existing)) {
_ = json.Unmarshal([]byte(existing), &m)
}
for k, v := range patch {
m[k] = v
}
raw, err := json.Marshal(m)
if err != nil {
return existing
}
return string(raw)
}
func resultDataMap(raw string) map[string]interface{} {
m := map[string]interface{}{}
if raw != "" && json.Valid([]byte(raw)) {
_ = json.Unmarshal([]byte(raw), &m)
}
return m
}
// requestDataMap 解析下单透传的 dataRequestData),非法或为空时返回空 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 解析下单透传的 dataRequestData),非法或为空时返回空 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
}
-606
View File
@@ -1,19 +1,15 @@
package service
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"regexp"
"strings"
"time"
"affiliate_dash/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var merchantCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,63}$`)
@@ -251,605 +247,3 @@ func (s *MerchantService) GetMerchant(merchantID uint) (*model.Merchant, error)
}
return &merchant, nil
}
type AddMemberInput struct {
UserID uint
Role string
IsDefault bool
}
func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) {
if !isValidMemberRole(in.Role) {
return nil, errors.New("无效的商户成员角色")
}
member := &model.MerchantMember{
MerchantID: merchantID,
UserID: in.UserID,
Role: in.Role,
Status: 1,
IsDefault: in.IsDefault,
}
err := s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
var user model.User
if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil {
return errors.New("用户不存在或已禁用")
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"role": in.Role,
"status": 1,
"is_default": in.IsDefault,
}),
}).Create(member).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, in.UserID), map[string]string{"role": in.Role})
})
if err != nil {
return nil, err
}
if err := s.db.Where("merchant_id = ? AND user_id = ?", merchantID, in.UserID).First(member).Error; err != nil {
return nil, err
}
return member, nil
}
func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) {
var members []model.MerchantMember
err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error
return members, err
}
type CreateMerchantProductInput struct {
ProductCode string
ProductName string
Category string
Description string
Attributes string
SKU string
DisplayName string
PriceAmount int64
CostAmount int64
Currency string
Stock int64
Status string
FulfillmentConfig string
}
func (s *MerchantService) CreateMerchantProduct(merchantID uint, in CreateMerchantProductInput, actorUserID uint) (*model.MerchantProduct, error) {
in.SKU = strings.TrimSpace(in.SKU)
in.ProductCode = strings.TrimSpace(in.ProductCode)
in.ProductName = strings.TrimSpace(in.ProductName)
if in.SKU == "" {
return nil, errors.New("商户商品 SKU 不能为空")
}
if in.PriceAmount < 0 || in.CostAmount < 0 {
return nil, errors.New("商品金额不能小于零")
}
if in.Stock < -1 {
return nil, errors.New("库存只能为 -1 或非负整数")
}
if in.Currency == "" {
in.Currency = "POINT"
}
in.Currency = strings.ToUpper(in.Currency)
if in.Status == "" {
in.Status = model.ProductStatusActive
}
if in.Status != model.ProductStatusActive && in.Status != model.ProductStatusInactive {
return nil, errors.New("无效的商品状态")
}
merchantProduct := &model.MerchantProduct{}
err := s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
product, err := ensureProduct(tx, in)
if err != nil {
return err
}
merchantProduct = &model.MerchantProduct{
MerchantID: merchantID,
ProductID: product.ID,
SKU: in.SKU,
DisplayName: fallbackName(in.DisplayName, product.Name),
PriceAmount: in.PriceAmount,
CostAmount: in.CostAmount,
Currency: in.Currency,
Stock: in.Stock,
Status: in.Status,
FulfillmentConfig: in.FulfillmentConfig,
}
if err := tx.Create(merchantProduct).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.create", "merchant_product", fmt.Sprint(merchantProduct.ID), map[string]string{"sku": in.SKU})
})
if err != nil {
return nil, err
}
return merchantProduct, nil
}
func (s *MerchantService) ListMerchantProducts(merchantID uint, page, size int, activeOnly bool) ([]model.MerchantProduct, int64, error) {
page, size = normalizePage(page, size)
tx := s.db.Model(&model.MerchantProduct{}).Where("merchant_id = ?", merchantID)
if activeOnly {
tx = tx.Where("status = ?", model.ProductStatusActive)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var products []model.MerchantProduct
err := tx.Preload("Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&products).Error
return products, total, err
}
type UpdateMerchantProductInput struct {
DisplayName *string
PriceAmount *int64
CostAmount *int64
Stock *int64
Status *string
FulfillmentConfig *string
}
func (s *MerchantService) UpdateMerchantProduct(merchantID, id uint, in UpdateMerchantProductInput, actorUserID uint) error {
updates := make(map[string]interface{})
if in.DisplayName != nil {
updates["display_name"] = *in.DisplayName
}
if in.PriceAmount != nil {
if *in.PriceAmount < 0 {
return errors.New("商品售价不能小于零")
}
updates["price_amount"] = *in.PriceAmount
}
if in.CostAmount != nil {
if *in.CostAmount < 0 {
return errors.New("商品成本不能小于零")
}
updates["cost_amount"] = *in.CostAmount
}
if in.Stock != nil {
if *in.Stock < -1 {
return errors.New("库存只能为 -1 或非负整数")
}
updates["stock"] = *in.Stock
}
if in.Status != nil {
if *in.Status != model.ProductStatusActive && *in.Status != model.ProductStatusInactive {
return errors.New("无效的商品状态")
}
updates["status"] = *in.Status
}
if in.FulfillmentConfig != nil {
updates["fulfillment_config"] = *in.FulfillmentConfig
}
if len(updates) == 0 {
return errors.New("没有可更新字段")
}
return s.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.MerchantProduct{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("商户商品不存在")
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.update", "merchant_product", fmt.Sprint(id), nil)
})
}
type APICredential struct {
Client *model.APIClient `json:"client"`
Secret string `json:"secret"`
}
type CreateAPIClientInput struct {
Name string
Scopes string
SignatureVersion string
ExpiresAt *time.Time
}
// MaxAPIClientsPerMerchant 每个商户最多可创建的 API 密钥数量,防止密钥滥用。
const MaxAPIClientsPerMerchant = 5
func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInput, actorUserID uint) (*APICredential, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
return nil, errors.New("API 客户端名称不能为空")
}
if len(ParseScopes(in.Scopes)) == 0 {
return nil, errors.New("至少配置一个 API 权限")
}
if in.SignatureVersion == "" {
in.SignatureVersion = "v1"
}
if in.SignatureVersion != "v1" {
return nil, errors.New("无效的签名版本")
}
appKey, err := randomToken("ak_", 24)
if err != nil {
return nil, err
}
secret, err := randomToken("sk_", 32)
if err != nil {
return nil, err
}
ciphertext, err := s.codec.Encrypt(secret)
if err != nil {
return nil, err
}
client := &model.APIClient{
MerchantID: merchantID,
Name: in.Name,
AppKey: appKey,
SecretCiphertext: ciphertext,
SignatureVersion: in.SignatureVersion,
Scopes: strings.Join(scopeList(in.Scopes), ","),
Status: model.APIClientStatusActive,
ExpiresAt: in.ExpiresAt,
}
err = s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
var clientCount int64
if err := tx.Model(&model.APIClient{}).
Where("merchant_id = ? AND status = ?", merchantID, model.APIClientStatusActive).
Count(&clientCount).Error; err != nil {
return err
}
if clientCount >= MaxAPIClientsPerMerchant {
return fmt.Errorf("每个商户最多可创建 %d 个 API 密钥", MaxAPIClientsPerMerchant)
}
if err := tx.Create(client).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.create", "api_client", fmt.Sprint(client.ID), map[string]string{"name": in.Name})
})
if err != nil {
return nil, err
}
return &APICredential{Client: client, Secret: secret}, nil
}
func (s *MerchantService) ListAPIClients(merchantID uint) ([]model.APIClient, error) {
var clients []model.APIClient
err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&clients).Error
return clients, err
}
func (s *MerchantService) UpdateAPIClientStatus(merchantID, id uint, status string, actorUserID uint) error {
if status != model.APIClientStatusActive && status != model.APIClientStatusDisabled {
return errors.New("无效的 API 客户端状态")
}
return s.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.APIClient{}).Where("id = ? AND merchant_id = ?", id, merchantID).Update("status", status)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("API 客户端不存在")
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.status.update", "api_client", fmt.Sprint(id), map[string]string{"status": status})
})
}
// DeleteAPIClient 删除商户的 API 密钥(物理删除,不可恢复)。
// 删除前要求先停用,避免在用的密钥被误删。
func (s *MerchantService) DeleteAPIClient(merchantID, id uint, actorUserID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var client model.APIClient
if err := tx.Where("id = ? AND merchant_id = ?", id, merchantID).First(&client).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("API 客户端不存在")
}
return err
}
if client.Status == model.APIClientStatusActive {
return errors.New("请先停用该密钥再删除")
}
if err := tx.Delete(&client).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.delete", "api_client", fmt.Sprint(id), map[string]string{"name": client.Name, "app_key": client.AppKey})
})
}
// ProductCatalogItem 是商品目录(自营商户可售商品)的展示项,供平台管理员分配商品时勾选。
type ProductCatalogItem struct {
ID uint `json:"id"`
ProductID uint `json:"product_id"`
SKU string `json:"sku"`
DisplayName string `json:"display_name"`
Category string `json:"category"`
PriceAmount int64 `json:"price_amount"`
CostAmount int64 `json:"cost_amount"`
Currency string `json:"currency"`
Stock int64 `json:"stock"`
Status string `json:"status"`
}
// ListProductCatalog 返回自营商户的全部可售商品,作为平台默认商品目录供分配。
func (s *MerchantService) ListProductCatalog() ([]ProductCatalogItem, error) {
var selfMerchant model.Merchant
if err := s.db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("平台商品目录尚未初始化")
}
return nil, err
}
var products []model.MerchantProduct
if err := s.db.Preload("Product").Where("merchant_id = ?", selfMerchant.ID).Order("id ASC").Find(&products).Error; err != nil {
return nil, err
}
items := make([]ProductCatalogItem, 0, len(products))
for _, p := range products {
category := ""
if p.Product != nil {
category = p.Product.Category
}
items = append(items, ProductCatalogItem{
ID: p.ID,
ProductID: p.ProductID,
SKU: p.SKU,
DisplayName: p.DisplayName,
Category: category,
PriceAmount: p.PriceAmount,
CostAmount: p.CostAmount,
Currency: p.Currency,
Stock: p.Stock,
Status: p.Status,
})
}
return items, nil
}
// ListMerchantProductsByAdmin 供平台管理员查看指定商户的可售商品(不限功能开关)。
func (s *MerchantService) ListMerchantProductsByAdmin(merchantID uint) ([]ProductCatalogItem, error) {
var products []model.MerchantProduct
if err := s.db.Preload("Product").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&products).Error; err != nil {
return nil, err
}
items := make([]ProductCatalogItem, 0, len(products))
for _, p := range products {
category := ""
if p.Product != nil {
category = p.Product.Category
}
items = append(items, ProductCatalogItem{
ID: p.ID,
ProductID: p.ProductID,
SKU: p.SKU,
DisplayName: p.DisplayName,
Category: category,
PriceAmount: p.PriceAmount,
CostAmount: p.CostAmount,
Currency: p.Currency,
Stock: p.Stock,
Status: p.Status,
})
}
return items, nil
}
// AssignProductsInput 批量分配商品给商户的入参。
type AssignProductsInput struct {
// CatalogIDs 为自营商户商品目录 ID 列表;为空表示清空该商户全部商品。
CatalogIDs []uint
}
// AssignProducts 按自营商户商品目录 ID 批量同步商户的可售商品:
// 目录中勾选的商品会被复制(已存在则跳过),未勾选的已有商品会被移除。
func (s *MerchantService) AssignProducts(merchantID uint, in AssignProductsInput, actorUserID uint) (int, error) {
assigned := 0
err := s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ?", merchantID).First(&merchant).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("商户不存在")
}
return err
}
if merchant.Code == model.MerchantCodeSelfOperated {
return errors.New("自营商户的商品目录由平台维护,不可分配")
}
var selfMerchant model.Merchant
if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
return errors.New("平台商品目录尚未初始化")
}
// 读取目录全量,构造 id -> 模板 的映射
var templates []model.MerchantProduct
if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil {
return err
}
tmplByID := make(map[uint]model.MerchantProduct, len(templates))
for _, t := range templates {
tmplByID[t.ID] = t
}
// 读取商户已有商品,构造 sku -> 已有 的映射
var existing []model.MerchantProduct
if err := tx.Where("merchant_id = ?", merchantID).Find(&existing).Error; err != nil {
return err
}
existBySKU := make(map[string]model.MerchantProduct, len(existing))
for _, e := range existing {
existBySKU[e.SKU] = e
}
// 计算需要新增的 SKU 集合
wantSKUs := make(map[string]bool, len(in.CatalogIDs))
toCreate := make([]model.MerchantProduct, 0, len(in.CatalogIDs))
for _, id := range in.CatalogIDs {
t, ok := tmplByID[id]
if !ok {
continue
}
wantSKUs[t.SKU] = true
if _, has := existBySKU[t.SKU]; !has {
toCreate = append(toCreate, model.MerchantProduct{
MerchantID: merchantID,
ProductID: t.ProductID,
SKU: t.SKU,
DisplayName: t.DisplayName,
PriceAmount: t.PriceAmount,
CostAmount: t.CostAmount,
Currency: t.Currency,
Stock: t.Stock,
Status: t.Status,
FulfillmentConfig: t.FulfillmentConfig,
})
}
}
// 移除未勾选的已有商品
var removeIDs []uint
for _, e := range existing {
if !wantSKUs[e.SKU] {
removeIDs = append(removeIDs, e.ID)
}
}
if len(removeIDs) > 0 {
if err := tx.Where("merchant_id = ? AND id IN ?", merchantID, removeIDs).Delete(&model.MerchantProduct{}).Error; err != nil {
return err
}
}
// 新增勾选但尚未拥有的商品
if len(toCreate) > 0 {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&toCreate).Error; err != nil {
return err
}
}
assigned = len(wantSKUs)
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.products.assign", "merchant", fmt.Sprint(merchantID), map[string]string{"assigned": fmt.Sprint(assigned)})
})
if err != nil {
return 0, err
}
return assigned, nil
}
// copyDefaultProducts 将自营商户的全部可售商品复制给新建商户,作为默认商品目录。
// 自营商户(self-operated)充当平台默认商品模板,新商户开箱即用。
// 使用 OnConflict DoNothing 保证幂等:即使重复调用也不会报唯一索引冲突。
func copyDefaultProducts(tx *gorm.DB, merchantID uint) error {
var selfMerchant model.Merchant
if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil // 自营商户不存在时跳过,不阻断建商户
}
return err
}
if selfMerchant.ID == merchantID {
return nil // 自营商户自身无需复制
}
var templates []model.MerchantProduct
if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil {
return err
}
if len(templates) == 0 {
return nil
}
products := make([]model.MerchantProduct, 0, len(templates))
for _, t := range templates {
products = append(products, model.MerchantProduct{
MerchantID: merchantID,
ProductID: t.ProductID,
SKU: t.SKU,
DisplayName: t.DisplayName,
PriceAmount: t.PriceAmount,
CostAmount: t.CostAmount,
Currency: t.Currency,
Stock: t.Stock,
Status: t.Status,
FulfillmentConfig: t.FulfillmentConfig,
})
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&products).Error
}
func ensureProduct(tx *gorm.DB, in CreateMerchantProductInput) (*model.Product, error) {
if in.ProductCode != "" {
var product model.Product
err := tx.Where("code = ?", in.ProductCode).First(&product).Error
if err == nil {
return &product, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
if in.ProductName == "" {
return nil, errors.New("新建平台商品时商品名称不能为空")
}
code := in.ProductCode
if code == "" {
token, err := randomToken("prd_", 12)
if err != nil {
return nil, err
}
code = token
}
product := &model.Product{
Code: code,
Name: in.ProductName,
Category: in.Category,
Description: in.Description,
Attributes: in.Attributes,
Status: model.ProductStatusActive,
}
if err := tx.Create(product).Error; err != nil {
return nil, err
}
return product, nil
}
func normalizePage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
return page, size
}
func randomToken(prefix string, byteCount int) (string, error) {
raw := make([]byte, byteCount)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return prefix + base64.RawURLEncoding.EncodeToString(raw), nil
}
func scopeList(scopes string) []string {
set := ParseScopes(scopes)
items := make([]string, 0, len(set))
for scope := range set {
items = append(items, scope)
}
return items
}
func fallbackName(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return fallback
}
@@ -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)
})
}