拆分 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:
@@ -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]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, auth DeliveryLinkAuth) error {
|
||||
if order == nil {
|
||||
return newDeliveryHTTPError(http.StatusNotFound, "订单不存在")
|
||||
}
|
||||
if auth.Exp <= 0 || strings.TrimSpace(auth.Sign) == "" {
|
||||
return newDeliveryHTTPError(http.StatusForbidden, "链接参数缺失")
|
||||
}
|
||||
if order.DeliveryLinkRevokedAt != nil {
|
||||
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废")
|
||||
}
|
||||
if order.DeliveryLinkExpiresAt == nil {
|
||||
return newDeliveryHTTPError(http.StatusForbidden, "发货链接未生成")
|
||||
}
|
||||
expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)
|
||||
linkExpires := time.Unix(auth.Exp, 0).UTC()
|
||||
if !linkExpires.Equal(expiresAt) {
|
||||
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已失效")
|
||||
}
|
||||
if time.Now().UTC().After(linkExpires) {
|
||||
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已过期")
|
||||
}
|
||||
expected := s.signDeliveryLink(order.OrderNo, auth.Exp)
|
||||
if !hmac.Equal([]byte(strings.ToLower(strings.TrimSpace(auth.Sign))), []byte(expected)) {
|
||||
return newDeliveryHTTPError(http.StatusForbidden, "发货链接签名无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) buildDeliveryLinkResult(orderNo string, expiresAt time.Time, requestBaseURL string) *DeliveryLinkResult {
|
||||
expiresAt = expiresAt.UTC()
|
||||
exp := expiresAt.Unix()
|
||||
sign := s.signDeliveryLink(orderNo, exp)
|
||||
return &DeliveryLinkResult{
|
||||
OrderNo: orderNo,
|
||||
DeliveryURL: s.buildDeliveryURL(orderNo, exp, sign, requestBaseURL),
|
||||
ExpiresAt: expiresAt,
|
||||
Exp: exp,
|
||||
Sign: sign,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeliveryService) buildDeliveryURL(orderNo string, exp int64, sign, requestBaseURL string) string {
|
||||
path := fmt.Sprintf("/delivery/%s/%s?exp=%d&sign=%s", url.PathEscape(s.channel), url.PathEscape(orderNo), exp, url.QueryEscape(sign))
|
||||
base := strings.TrimRight(s.linkBaseURL, "/")
|
||||
if base == "" {
|
||||
base = strings.TrimRight(requestBaseURL, "/")
|
||||
}
|
||||
if base == "" {
|
||||
return path
|
||||
}
|
||||
return base + path
|
||||
}
|
||||
|
||||
func (s *DeliveryService) signDeliveryLink(orderNo string, exp int64) string {
|
||||
mac := hmac.New(sha256.New, []byte(s.linkSecret))
|
||||
_, _ = mac.Write([]byte(orderNo + "|" + strconv.FormatInt(exp, 10)))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/pkg/timeutil"
|
||||
)
|
||||
|
||||
func buildExistingDeliverySubmitResult(order *model.FulfillmentOrder) *DeliverySubmitResult {
|
||||
if order == nil {
|
||||
return nil
|
||||
}
|
||||
switch normalizeOrderStatus(order) {
|
||||
case model.OrderStatusDelivering, model.OrderStatusDelivered:
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
var resultData map[string]interface{}
|
||||
if json.Valid([]byte(order.ResultData)) {
|
||||
_ = json.Unmarshal([]byte(order.ResultData), &resultData)
|
||||
}
|
||||
providerOrderNo := firstNonEmpty(
|
||||
order.ProviderOrderNo,
|
||||
stringFromMap(resultData, "provider_order_no"),
|
||||
)
|
||||
message := "订单暂时正在发货中,请稍后查询"
|
||||
status := normalizeOrderStatus(order)
|
||||
if status == model.OrderStatusDelivered {
|
||||
message = "订单已交付"
|
||||
}
|
||||
return &DeliverySubmitResult{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: status,
|
||||
Message: message,
|
||||
ProviderOrderNo: providerOrderNo,
|
||||
UpstreamOrder: resultData,
|
||||
}
|
||||
}
|
||||
|
||||
func upstreamDeliverySucceeded(order map[string]interface{}) bool {
|
||||
return strings.EqualFold(stringFromMap(order, "status"), "FINISHED") ||
|
||||
strings.EqualFold(stringFromMap(order, "send_status"), "SUCCESS")
|
||||
}
|
||||
|
||||
func deliverySubmissionInProgress(stage string) bool {
|
||||
return stage == deliveryStageClaimed || stage == deliveryStageQueueCreated
|
||||
}
|
||||
|
||||
func deliverySubmissionStale(order *model.FulfillmentOrder, now time.Time) bool {
|
||||
if order == nil {
|
||||
return false
|
||||
}
|
||||
startedAtRaw := resultDataString(order.ResultData, "submit_started_at")
|
||||
if startedAtRaw != "" {
|
||||
if startedAt, err := time.Parse(timeutil.APITimeLayout, startedAtRaw); err == nil {
|
||||
return now.Sub(startedAt) >= deliverySubmissionStaleTimeout
|
||||
}
|
||||
}
|
||||
return !order.UpdatedAt.IsZero() && now.Sub(order.UpdatedAt) >= deliverySubmissionStaleTimeout
|
||||
}
|
||||
|
||||
func optionalUint(value uint) *uint {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
out := value
|
||||
return &out
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *DeliveryService) goodsDetail(goodID string) (map[string]interface{}, error) {
|
||||
var out struct {
|
||||
Good map[string]interface{} `json:"good"`
|
||||
}
|
||||
if err := s.signProxy("/public/goods/detail", "POST", map[string]interface{}{
|
||||
"good_id": goodID,
|
||||
}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Good, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) accountBound(bindUUID, goodID string) (map[string]interface{}, error) {
|
||||
var out struct {
|
||||
GameAccount map[string]interface{} `json:"gameAccount"`
|
||||
Snake map[string]interface{} `json:"game_account"`
|
||||
}
|
||||
if err := s.signProxy("/public/games/account-bound", "POST", map[string]interface{}{
|
||||
"bind_uuid": bindUUID,
|
||||
"bindUuid": bindUUID,
|
||||
"goodId": goodID,
|
||||
"good_id": goodID,
|
||||
}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.GameAccount == nil {
|
||||
out.GameAccount = out.Snake
|
||||
}
|
||||
if out.GameAccount == nil {
|
||||
return nil, errors.New("账号尚未绑定,请扫码完成绑定后再提交")
|
||||
}
|
||||
return out.GameAccount, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) createOrderQueue(goodID, orderNo string) (string, error) {
|
||||
var out struct {
|
||||
Orders []map[string]interface{} `json:"orders"`
|
||||
}
|
||||
if err := s.signProxy("/public/users/orders-queue", "POST", map[string]interface{}{
|
||||
"good_id": goodID,
|
||||
"quantity": 1,
|
||||
"order_sn": orderNo,
|
||||
}, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(out.Orders) == 0 {
|
||||
return "", errors.New("发货服务未返回队列订单")
|
||||
}
|
||||
orderID := firstNonEmpty(stringFromMap(out.Orders[0], "_id"), stringFromMap(out.Orders[0], "id"))
|
||||
if orderID == "" {
|
||||
return "", errors.New("发货服务未返回队列订单 ID")
|
||||
}
|
||||
return orderID, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) patchOrderQueue(orderID, gameAccount, bindUUID string) error {
|
||||
var out map[string]interface{}
|
||||
return s.signProxy("/public/users/orders-queue", "PATCH", map[string]interface{}{
|
||||
"order_id": orderID,
|
||||
"game_account": gameAccount,
|
||||
"bind_uuid": bindUUID,
|
||||
}, &out)
|
||||
}
|
||||
|
||||
func (s *DeliveryService) createUpstreamOrder(orderID, gameAccount, goodID, orderNo string) (map[string]interface{}, error) {
|
||||
var out struct {
|
||||
Order map[string]interface{} `json:"order"`
|
||||
}
|
||||
if err := s.signProxy("/public/users/orders", "POST", map[string]interface{}{
|
||||
"order_id": orderID,
|
||||
"game_account": gameAccount,
|
||||
"good_id": goodID,
|
||||
"h5_prefix": s.channel,
|
||||
"order_sn": orderNo,
|
||||
}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.Order == nil {
|
||||
out.Order = map[string]interface{}{"order_id": orderID, "order_sn": orderNo}
|
||||
}
|
||||
return out.Order, nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) signProxy(path, method string, data interface{}, out interface{}) error {
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"path": path,
|
||||
"method": strings.ToUpper(method),
|
||||
"data": data,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, s.bffBaseURL+"/sign-proxy", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("发货服务请求失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var envelope struct {
|
||||
Code interface{} `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return fmt.Errorf("发货服务响应无法解析:%s", truncateDeliveryText(string(body)))
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || !isZeroCode(envelope.Code) {
|
||||
if envelope.Message != "" {
|
||||
return errors.New(envelope.Message)
|
||||
}
|
||||
return fmt.Errorf("发货服务请求失败:HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Data, out); err != nil {
|
||||
return fmt.Errorf("发货服务数据无法解析:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/pkg/timeutil"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
@@ -326,35 +320,6 @@ func (s *FulfillmentService) CreateTestOrder(in CreateTestOrderInput) (*model.Fu
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) {
|
||||
var order model.FulfillmentOrder
|
||||
err := s.db.Preload("MerchantProduct.Product").
|
||||
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
|
||||
First(&order).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus string) ([]model.FulfillmentOrder, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID)
|
||||
if orderStatus != "" {
|
||||
tx = tx.Where("order_status = ?", orderStatus)
|
||||
}
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var orders []model.FulfillmentOrder
|
||||
err := tx.Preload("MerchantProduct.Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error
|
||||
return orders, total, err
|
||||
}
|
||||
|
||||
type FulfillmentUpdateInput struct {
|
||||
MerchantID uint
|
||||
APIClientID uint
|
||||
@@ -436,112 +401,6 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) MarkProcessingTimeouts(timeout time.Duration, limit int) (int, error) {
|
||||
if timeout <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-timeout)
|
||||
var ids []uint
|
||||
if err := s.db.Model(&model.FulfillmentOrder{}).
|
||||
Where("order_status = ? AND updated_at < ?", model.OrderStatusDelivering, cutoff).
|
||||
Order("updated_at ASC, id ASC").
|
||||
Limit(limit).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
updated, err := s.markProcessingTimeout(id, timeout, now)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
if updated {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duration, now time.Time) (bool, error) {
|
||||
returned := false
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.FulfillmentOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !processingTimedOut(&order, timeout, now) {
|
||||
return nil
|
||||
}
|
||||
if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil {
|
||||
return nil
|
||||
}
|
||||
submittedUpstream := deliverySubmittedUpstream(&order)
|
||||
var reason string
|
||||
if submittedUpstream {
|
||||
reason = fmt.Sprintf("发货超时:订单已提交上游但超过 %d 分钟未回传结果,可能仍在处理;请勿直接重试,先在上游确认订单状态", int(timeout.Minutes()))
|
||||
} else {
|
||||
reason = fmt.Sprintf("发货超时:发货提交中断,请重新提交(已停留 delivering 超过 %d 分钟)", int(timeout.Minutes()))
|
||||
}
|
||||
updates := map[string]interface{}{"order_status": model.OrderStatusShipFailed}
|
||||
updates["failure_reason"] = reason
|
||||
updates["result_data"] = buildProcessingTimeoutResultData(order.ResultData, timeout, now, reason)
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var out model.FulfillmentOrder
|
||||
if err := tx.First(&out, order.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAudit(tx, &order.MerchantID, nil, nil, "fulfillment.timeout", "fulfillment_order", order.OrderNo, map[string]interface{}{
|
||||
"from": normalizeOrderStatus(&order),
|
||||
"to": model.OrderStatusShipFailed,
|
||||
"timeout_minutes": int(timeout.Minutes()),
|
||||
"reason": reason,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.callbacks != nil {
|
||||
if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&out)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
returned = true
|
||||
return nil
|
||||
})
|
||||
return returned, err
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) RunProcessingTimeoutMonitor(ctx context.Context, timeout, interval time.Duration) {
|
||||
if timeout <= 0 {
|
||||
return
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
changed, err := s.MarkProcessingTimeouts(timeout, 50)
|
||||
if err != nil {
|
||||
log.Printf("[fulfillment] timeout scan error: %v", err)
|
||||
} else if changed > 0 {
|
||||
log.Printf("[fulfillment] timeout scan marked failed count=%d", changed)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) {
|
||||
var out model.FulfillmentOrder
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -619,137 +478,6 @@ func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo,
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
type WalletAdjustInput struct {
|
||||
MerchantID uint
|
||||
ActorUserID uint
|
||||
Amount int64
|
||||
IdempotencyKey string
|
||||
Note string
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) AdjustWallet(in WalletAdjustInput) (*model.WalletAccount, error) {
|
||||
if in.Amount == 0 {
|
||||
return nil, errors.New("调整金额不能为零")
|
||||
}
|
||||
if in.IdempotencyKey == "" {
|
||||
return nil, errors.New("账务调整必须提供幂等键")
|
||||
}
|
||||
var out model.WalletAccount
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.WalletLedgerEntry
|
||||
if err := tx.Where("merchant_id = ? AND idempotency_key = ?", in.MerchantID, in.IdempotencyKey).First(&existing).Error; err == nil {
|
||||
if err := tx.First(&out, existing.WalletAccountID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
var wallet model.WalletAccount
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newBalance := wallet.AvailableBalance + in.Amount
|
||||
if newBalance < 0 {
|
||||
return errors.New("调整后余额不能小于零")
|
||||
}
|
||||
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
entryType := model.WalletLedgerAdjust
|
||||
if in.Amount > 0 {
|
||||
entryType = model.WalletLedgerCredit
|
||||
} else {
|
||||
entryType = model.WalletLedgerDebit
|
||||
}
|
||||
idempotencyKey := in.IdempotencyKey
|
||||
if err := tx.Create(&model.WalletLedgerEntry{
|
||||
MerchantID: in.MerchantID,
|
||||
WalletAccountID: wallet.ID,
|
||||
EntryNo: "WL" + uuid.NewString(),
|
||||
Type: entryType,
|
||||
Amount: in.Amount,
|
||||
BalanceAfter: newBalance,
|
||||
ReferenceType: "manual_adjustment",
|
||||
ReferenceNo: in.IdempotencyKey,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Note: in.Note,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
out = wallet
|
||||
out.AvailableBalance = newBalance
|
||||
return writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "wallet.adjust", "wallet_account", fmt.Sprint(wallet.ID), map[string]int64{"amount": in.Amount})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) GetWallet(merchantID uint) (*model.WalletAccount, error) {
|
||||
var wallet model.WalletAccount
|
||||
if err := s.db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("商户钱包不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &wallet, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int, referenceNo, entryType string) ([]model.WalletLedgerEntry, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx := s.db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID)
|
||||
if referenceNo != "" {
|
||||
tx = tx.Where("reference_no LIKE ?", "%"+referenceNo+"%")
|
||||
}
|
||||
if entryType != "" {
|
||||
tx = tx.Where("type = ?", entryType)
|
||||
}
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var entries []model.WalletLedgerEntry
|
||||
err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&entries).Error
|
||||
return entries, total, err
|
||||
}
|
||||
|
||||
func newFulfillmentOrderNo() string {
|
||||
return "FO" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
|
||||
}
|
||||
|
||||
func newTestFulfillmentOrderNo() string {
|
||||
return "O" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
|
||||
}
|
||||
|
||||
// calculateServiceFee 按"百分比或固定"二选一计算手续费:
|
||||
// - feeType=rate:按 baseAmount * feeRateBP / 10000 计算
|
||||
// - feeType=fixed:直接取 feeFixedAmount
|
||||
//
|
||||
// 二者互斥,不会叠加。
|
||||
func calculateServiceFee(baseAmount int64, feeType string, feeRateBP, feeFixedAmount int64) (int64, error) {
|
||||
if baseAmount < 0 || feeRateBP < 0 || feeFixedAmount < 0 {
|
||||
return 0, errors.New("订单金额或手续费配置无效")
|
||||
}
|
||||
switch feeType {
|
||||
case model.FeeTypeFixed:
|
||||
return feeFixedAmount, nil
|
||||
case model.FeeTypeRate, "":
|
||||
if feeRateBP > 10000 {
|
||||
return 0, errors.New("手续费比例不能超过 10000 BP")
|
||||
}
|
||||
if feeRateBP > 0 && baseAmount > math.MaxInt64/feeRateBP {
|
||||
return 0, errors.New("手续费金额超出范围")
|
||||
}
|
||||
return baseAmount * feeRateBP / 10000, nil
|
||||
default:
|
||||
return 0, errors.New("无效的手续费类型")
|
||||
}
|
||||
}
|
||||
|
||||
func CanFulfill(order *model.FulfillmentOrder) (bool, string) {
|
||||
switch normalizeOrderStatus(order) {
|
||||
case model.OrderStatusPaid:
|
||||
@@ -797,601 +525,3 @@ func orderCallbackData(order *model.FulfillmentOrder) map[string]interface{} {
|
||||
// ----- 仪表盘统计 -----
|
||||
|
||||
// DashboardStats 仪表盘聚合指标。
|
||||
type DashboardStats struct {
|
||||
Scope string `json:"scope"`
|
||||
CatalogProductCount int64 `json:"catalog_product_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
ActiveProductCount int64 `json:"active_product_count"`
|
||||
MerchantCount int64 `json:"merchant_count"`
|
||||
ActiveMerchantCount int64 `json:"active_merchant_count"`
|
||||
UserCount int64 `json:"user_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TodayOrderCount int64 `json:"today_order_count"`
|
||||
TotalSales int64 `json:"total_sales"`
|
||||
TodaySales int64 `json:"today_sales"`
|
||||
TotalFees int64 `json:"total_fees"`
|
||||
TodayFees int64 `json:"today_fees"`
|
||||
PaidOrderCount int64 `json:"paid_order_count"`
|
||||
DeliveringOrderCount int64 `json:"delivering_order_count"`
|
||||
DeliveredOrderCount int64 `json:"delivered_order_count"`
|
||||
ShipFailedOrderCount int64 `json:"ship_failed_order_count"`
|
||||
CancelledOrderCount int64 `json:"cancelled_order_count"`
|
||||
WalletAvailableBalance int64 `json:"wallet_available_balance"`
|
||||
WalletFrozenBalance int64 `json:"wallet_frozen_balance"`
|
||||
APIClientCount int64 `json:"api_client_count"`
|
||||
ActiveAPIClientCount int64 `json:"active_api_client_count"`
|
||||
CallbackSubscriptionCount int64 `json:"callback_subscription_count"`
|
||||
PendingCallbackCount int64 `json:"pending_callback_count"`
|
||||
FailedCallbackCount int64 `json:"failed_callback_count"`
|
||||
}
|
||||
|
||||
type dashboardStatusCount struct {
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// Dashboard 按角色汇总运营指标:平台管理员看全平台,商户账号看当前商户。
|
||||
func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*DashboardStats, error) {
|
||||
stats := &DashboardStats{Scope: "merchant"}
|
||||
if isPlatformAdmin {
|
||||
stats.Scope = "platform"
|
||||
}
|
||||
todayStart := timeutil.StartOfDay(time.Now())
|
||||
|
||||
productScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.MerchantProduct{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
orderScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.FulfillmentOrder{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
walletScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.WalletAccount{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
apiClientScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.APIClient{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
callbackScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.CallbackSubscription{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
callbackDeliveryScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.CallbackDelivery{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
if err := s.db.Model(&model.Product{}).Count(&stats.CatalogProductCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := productScope().Count(&stats.ProductCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := productScope().Where("status = ?", model.ProductStatusActive).Count(&stats.ActiveProductCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isPlatformAdmin {
|
||||
if err := s.db.Model(&model.Merchant{}).Count(&stats.MerchantCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.Merchant{}).Where("status = ?", model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.User{}).Count(&stats.UserCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := s.db.Model(&model.Merchant{}).Where("id = ?", merchantID).Count(&stats.MerchantCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.Merchant{}).Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.User{}).
|
||||
Joins("JOIN merchant_members ON merchant_members.user_id = users.id").
|
||||
Where("merchant_members.merchant_id = ?", merchantID).
|
||||
Count(&stats.UserCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.CatalogProductCount = stats.ProductCount
|
||||
}
|
||||
if err := orderScope().Count(&stats.OrderCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := orderScope().Where("created_at >= ?", todayStart).Count(&stats.TodayOrderCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
|
||||
Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
|
||||
Where("created_at >= ?", todayStart).
|
||||
Select("COALESCE(SUM(amount),0)").Scan(&stats.TodaySales).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
|
||||
Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TotalFees).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
|
||||
Where("created_at >= ?", todayStart).
|
||||
Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TodayFees).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var orderStatusCounts []dashboardStatusCount
|
||||
if err := orderScope().Select("order_status AS status, COUNT(*) AS count").
|
||||
Group("order_status").Scan(&orderStatusCounts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range orderStatusCounts {
|
||||
switch item.Status {
|
||||
case model.OrderStatusPaid:
|
||||
stats.PaidOrderCount = item.Count
|
||||
case model.OrderStatusDelivering:
|
||||
stats.DeliveringOrderCount = item.Count
|
||||
case model.OrderStatusDelivered:
|
||||
stats.DeliveredOrderCount = item.Count
|
||||
case model.OrderStatusShipFailed:
|
||||
stats.ShipFailedOrderCount = item.Count
|
||||
case model.OrderStatusCancelled:
|
||||
stats.CancelledOrderCount = item.Count
|
||||
}
|
||||
}
|
||||
if err := walletScope().Select("COALESCE(SUM(available_balance),0)").Scan(&stats.WalletAvailableBalance).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := walletScope().Select("COALESCE(SUM(frozen_balance),0)").Scan(&stats.WalletFrozenBalance).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apiClientScope().Count(&stats.APIClientCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apiClientScope().Where("status = ?", model.APIClientStatusActive).Count(&stats.ActiveAPIClientCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := callbackScope().Count(&stats.CallbackSubscriptionCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryPending).Count(&stats.PendingCallbackCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryFailed).Count(&stats.FailedCallbackCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ----- 上游 SourceOpen 接口(基于 FulfillmentOrder)-----
|
||||
|
||||
// OpenOrderQuery 开放接口订单查询结果。
|
||||
type OpenOrderQuery struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
CanShip bool `json:"can_ship"`
|
||||
CannotShipReason string `json:"cannot_ship_reason,omitempty"`
|
||||
Product *OpenOrderProduct `json:"product,omitempty"`
|
||||
BuyerName string `json:"buyer_name"`
|
||||
Amount int64 `json:"amount"`
|
||||
ProviderOrderNo string `json:"provider_order_no,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ShippedAt *time.Time `json:"shipped_at"`
|
||||
ShipFailReason string `json:"ship_fail_reason,omitempty"`
|
||||
GameChannel string `json:"game_channel,omitempty"`
|
||||
GameUID string `json:"game_uid,omitempty"`
|
||||
RoleName string `json:"role_name,omitempty"`
|
||||
PayScore int `json:"pay_score,omitempty"`
|
||||
}
|
||||
|
||||
// OpenOrderProduct 开放接口返回的商品快照。
|
||||
type OpenOrderProduct struct {
|
||||
Name string `json:"name"`
|
||||
SKU string `json:"sku"`
|
||||
Game string `json:"game"`
|
||||
}
|
||||
|
||||
// ShipNotifyInput 上游发货结果推送。
|
||||
type ShipNotifyInput struct {
|
||||
OrderNo string
|
||||
ShipStatus string // success / failed
|
||||
ProviderOrderNo string
|
||||
ShippedAt *time.Time
|
||||
FailReason string
|
||||
RawPayload string
|
||||
GameChannel *string
|
||||
GameUID *string
|
||||
RoleName *string
|
||||
PayScore *int
|
||||
}
|
||||
|
||||
// ShipNotifyResult 上游推送处理结果。
|
||||
type ShipNotifyResult struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// GetByOrderNo 按订单号查询(不限定商户,供上游 SourceOpen 使用)。
|
||||
func (s *FulfillmentService) GetByOrderNo(orderNo string) (*model.FulfillmentOrder, error) {
|
||||
var order model.FulfillmentOrder
|
||||
err := s.db.Preload("MerchantProduct.Product").Where("order_no = ?", orderNo).First(&order).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// QueryOpenOrder 供上游查询:商品信息 + 是否可发货。
|
||||
func (s *FulfillmentService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, error) {
|
||||
if orderNo == "" {
|
||||
return nil, errors.New("订单号不能为空")
|
||||
}
|
||||
order, err := s.GetByOrderNo(orderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canShip, reason := CanFulfill(order)
|
||||
out := &OpenOrderQuery{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: normalizeOrderStatus(order),
|
||||
CanShip: canShip,
|
||||
CannotShipReason: reason,
|
||||
BuyerName: order.BuyerReference,
|
||||
Amount: order.Amount,
|
||||
ProviderOrderNo: order.ProviderOrderNo,
|
||||
CreatedAt: order.CreatedAt,
|
||||
ShippedAt: order.DeliveredAt,
|
||||
ShipFailReason: order.FailureReason,
|
||||
}
|
||||
if order.MerchantProduct != nil {
|
||||
game := ""
|
||||
if order.MerchantProduct.Product != nil {
|
||||
game = order.MerchantProduct.Product.Category
|
||||
}
|
||||
out.Product = &OpenOrderProduct{
|
||||
Name: order.ProductName,
|
||||
SKU: order.ProductSKU,
|
||||
Game: game,
|
||||
}
|
||||
}
|
||||
// 新模型无独立的游戏字段列,从 RequestData / ResultData JSON 中还原。
|
||||
extractGameFields(order.RequestData, out)
|
||||
extractGameFields(order.ResultData, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HandleShipNotify 处理上游发货结果推送(幂等),基于 FulfillmentOrder。
|
||||
func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) {
|
||||
in.ShipStatus = strings.TrimSpace(in.ShipStatus)
|
||||
in.FailReason = strings.TrimSpace(in.FailReason)
|
||||
if in.OrderNo == "" {
|
||||
return nil, errors.New("订单号不能为空")
|
||||
}
|
||||
if in.ShipStatus != "success" && in.ShipStatus != "failed" {
|
||||
return nil, errors.New("无效的 ship_status,仅支持 success/failed")
|
||||
}
|
||||
if in.ShipStatus == "failed" && in.FailReason == "" {
|
||||
return nil, errors.New("发货失败时 fail_reason 必填")
|
||||
}
|
||||
if utf8.RuneCountInString(in.FailReason) > 512 {
|
||||
return nil, errors.New("fail_reason 最长 512 个字符")
|
||||
}
|
||||
|
||||
var nextStatus string
|
||||
switch in.ShipStatus {
|
||||
case "success":
|
||||
nextStatus = model.OrderStatusDelivered
|
||||
case "failed":
|
||||
nextStatus = model.OrderStatusShipFailed
|
||||
}
|
||||
|
||||
var result ShipNotifyResult
|
||||
var rejectionErr error
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.FulfillmentOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Preload("MerchantProduct.Product").
|
||||
Where("order_no = ?", in.OrderNo).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("订单不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 已交付:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。
|
||||
if normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" {
|
||||
result = ShipNotifyResult{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: normalizeOrderStatus(&order),
|
||||
Message: "订单已交付,幂等成功",
|
||||
}
|
||||
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
|
||||
shipNotifyAuditMetadata(in, normalizeOrderStatus(&order), "订单已交付,幂等忽略"))
|
||||
}
|
||||
|
||||
if normalizeOrderStatus(&order) == model.OrderStatusCancelled {
|
||||
if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil {
|
||||
return err
|
||||
}
|
||||
rejectionErr = errors.New("订单已取消,无法更新发货状态")
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
shippedAt := in.ShippedAt
|
||||
if shippedAt == nil && in.ShipStatus == "success" {
|
||||
shippedAt = &now
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"order_status": nextStatus,
|
||||
}
|
||||
var message string
|
||||
switch in.ShipStatus {
|
||||
case "success":
|
||||
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
|
||||
message := "当前状态不允许标记发货成功"
|
||||
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
|
||||
return err
|
||||
}
|
||||
rejectionErr = err
|
||||
return nil
|
||||
}
|
||||
updates["delivered_at"] = shippedAt
|
||||
updates["failure_reason"] = ""
|
||||
if in.ProviderOrderNo != "" {
|
||||
updates["provider_order_no"] = in.ProviderOrderNo
|
||||
}
|
||||
message = "发货成功,订单已交付"
|
||||
case "failed":
|
||||
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
|
||||
message := "当前状态不允许标记发货失败"
|
||||
if normalizeOrderStatus(&order) == model.OrderStatusDelivered {
|
||||
message = "订单已交付,拒绝失败推送"
|
||||
}
|
||||
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
|
||||
return err
|
||||
}
|
||||
rejectionErr = err
|
||||
return nil
|
||||
}
|
||||
updates["failure_reason"] = in.FailReason
|
||||
if in.ProviderOrderNo != "" {
|
||||
updates["provider_order_no"] = in.ProviderOrderNo
|
||||
}
|
||||
message = "已记录发货失败"
|
||||
}
|
||||
|
||||
resultData := buildShipNotifyResultData(order.ResultData, in, shippedAt)
|
||||
updates["result_data"] = resultData
|
||||
if err := tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
|
||||
shipNotifyAuditMetadata(in, nextStatus, message)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var updated model.FulfillmentOrder
|
||||
if err := tx.Preload("MerchantProduct.Product").First(&updated, order.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if s.callbacks != nil {
|
||||
if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&updated)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result = ShipNotifyResult{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: nextStatus,
|
||||
Message: message,
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rejectionErr != nil {
|
||||
return nil, rejectionErr
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func writeShipNotifyRejectedAudit(tx *gorm.DB, order *model.FulfillmentOrder, in ShipNotifyInput, message string) error {
|
||||
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
|
||||
shipNotifyAuditMetadata(in, normalizeOrderStatus(order), message))
|
||||
}
|
||||
|
||||
func shipNotifyAuditMetadata(in ShipNotifyInput, resultStatus, message string) map[string]interface{} {
|
||||
metadata := map[string]interface{}{
|
||||
"ship_status": in.ShipStatus,
|
||||
"provider_order_no": in.ProviderOrderNo,
|
||||
"fail_reason": in.FailReason,
|
||||
"result_status": resultStatus,
|
||||
"message": message,
|
||||
"payload": in.RawPayload,
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
// buildShipNotifyResultData 把推送结果与游戏字段合并进 ResultData JSON。
|
||||
func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *time.Time) string {
|
||||
m := map[string]interface{}{}
|
||||
if existing != "" && json.Valid([]byte(existing)) {
|
||||
_ = json.Unmarshal([]byte(existing), &m)
|
||||
}
|
||||
m["ship_status"] = in.ShipStatus
|
||||
if in.ProviderOrderNo != "" {
|
||||
m["provider_order_no"] = in.ProviderOrderNo
|
||||
}
|
||||
if in.ShipStatus == "success" {
|
||||
delete(m, "fail_reason")
|
||||
} else if in.FailReason != "" {
|
||||
m["fail_reason"] = in.FailReason
|
||||
}
|
||||
if shippedAt != nil {
|
||||
m["shipped_at"] = timeutil.FormatAPITime(*shippedAt)
|
||||
}
|
||||
if in.GameChannel != nil {
|
||||
m["game_channel"] = *in.GameChannel
|
||||
}
|
||||
if in.GameUID != nil {
|
||||
m["game_uid"] = *in.GameUID
|
||||
}
|
||||
if in.RoleName != nil {
|
||||
m["role_name"] = *in.RoleName
|
||||
}
|
||||
if in.PayScore != nil {
|
||||
m["pay_score"] = *in.PayScore
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return existing
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func buildProcessingTimeoutResultData(existing string, timeout time.Duration, now time.Time, reason string) string {
|
||||
return mergeResultData(existing, map[string]interface{}{
|
||||
"timeout": true,
|
||||
"timeout_minutes": int(timeout.Minutes()),
|
||||
"timeout_at": timeutil.FormatAPITime(now),
|
||||
"ship_status": "failed",
|
||||
"fail_reason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
// mergeResultData 保留已有 JSON 字段,仅覆盖或新增 patch 中的字段,避免各发货阶段互相清空上下文。
|
||||
func mergeResultData(existing string, patch map[string]interface{}) string {
|
||||
if len(patch) == 0 {
|
||||
return existing
|
||||
}
|
||||
m := map[string]interface{}{}
|
||||
if existing != "" && json.Valid([]byte(existing)) {
|
||||
_ = json.Unmarshal([]byte(existing), &m)
|
||||
}
|
||||
for k, v := range patch {
|
||||
m[k] = v
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return existing
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func resultDataMap(raw string) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
if raw != "" && json.Valid([]byte(raw)) {
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// requestDataMap 解析下单透传的 data(RequestData),非法或为空时返回空 map。
|
||||
func requestDataMap(raw string) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
if raw != "" && json.Valid([]byte(raw)) {
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// requestDataString 读取下单透传 data 中的字符串字段。
|
||||
func requestDataString(raw, key string) string {
|
||||
if v, ok := requestDataMap(raw)[key].(string); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resultDataString(raw, key string) string {
|
||||
if v, ok := resultDataMap(raw)[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resultDataNumber(raw, key string) int64 {
|
||||
switch v := resultDataMap(raw)[key].(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
case string:
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// extractGameFields 从 JSON 文本中还原游戏相关字段(仅填充当前为空的字段)。
|
||||
func extractGameFields(raw string, out *OpenOrderQuery) {
|
||||
if raw == "" || !json.Valid([]byte(raw)) {
|
||||
return
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return
|
||||
}
|
||||
if out.GameChannel == "" {
|
||||
if v, ok := m["game_channel"].(string); ok {
|
||||
out.GameChannel = v
|
||||
}
|
||||
}
|
||||
if out.GameUID == "" {
|
||||
if v, ok := m["game_uid"].(string); ok {
|
||||
out.GameUID = v
|
||||
}
|
||||
}
|
||||
if out.RoleName == "" {
|
||||
if v, ok := m["role_name"].(string); ok {
|
||||
out.RoleName = v
|
||||
}
|
||||
}
|
||||
if out.PayScore == 0 {
|
||||
if v, ok := toInt(m["pay_score"]); ok {
|
||||
out.PayScore = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/pkg/timeutil"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DashboardStats struct {
|
||||
Scope string `json:"scope"`
|
||||
CatalogProductCount int64 `json:"catalog_product_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
ActiveProductCount int64 `json:"active_product_count"`
|
||||
MerchantCount int64 `json:"merchant_count"`
|
||||
ActiveMerchantCount int64 `json:"active_merchant_count"`
|
||||
UserCount int64 `json:"user_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TodayOrderCount int64 `json:"today_order_count"`
|
||||
TotalSales int64 `json:"total_sales"`
|
||||
TodaySales int64 `json:"today_sales"`
|
||||
TotalFees int64 `json:"total_fees"`
|
||||
TodayFees int64 `json:"today_fees"`
|
||||
PaidOrderCount int64 `json:"paid_order_count"`
|
||||
DeliveringOrderCount int64 `json:"delivering_order_count"`
|
||||
DeliveredOrderCount int64 `json:"delivered_order_count"`
|
||||
ShipFailedOrderCount int64 `json:"ship_failed_order_count"`
|
||||
CancelledOrderCount int64 `json:"cancelled_order_count"`
|
||||
WalletAvailableBalance int64 `json:"wallet_available_balance"`
|
||||
WalletFrozenBalance int64 `json:"wallet_frozen_balance"`
|
||||
APIClientCount int64 `json:"api_client_count"`
|
||||
ActiveAPIClientCount int64 `json:"active_api_client_count"`
|
||||
CallbackSubscriptionCount int64 `json:"callback_subscription_count"`
|
||||
PendingCallbackCount int64 `json:"pending_callback_count"`
|
||||
FailedCallbackCount int64 `json:"failed_callback_count"`
|
||||
}
|
||||
|
||||
type dashboardStatusCount struct {
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// Dashboard 按角色汇总运营指标:平台管理员看全平台,商户账号看当前商户。
|
||||
func (s *FulfillmentService) Dashboard(merchantID uint, isPlatformAdmin bool) (*DashboardStats, error) {
|
||||
stats := &DashboardStats{Scope: "merchant"}
|
||||
if isPlatformAdmin {
|
||||
stats.Scope = "platform"
|
||||
}
|
||||
todayStart := timeutil.StartOfDay(time.Now())
|
||||
|
||||
productScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.MerchantProduct{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
orderScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.FulfillmentOrder{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
walletScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.WalletAccount{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
apiClientScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.APIClient{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
callbackScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.CallbackSubscription{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
callbackDeliveryScope := func() *gorm.DB {
|
||||
tx := s.db.Model(&model.CallbackDelivery{})
|
||||
if !isPlatformAdmin {
|
||||
tx = tx.Where("merchant_id = ?", merchantID)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
if err := s.db.Model(&model.Product{}).Count(&stats.CatalogProductCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := productScope().Count(&stats.ProductCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := productScope().Where("status = ?", model.ProductStatusActive).Count(&stats.ActiveProductCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isPlatformAdmin {
|
||||
if err := s.db.Model(&model.Merchant{}).Count(&stats.MerchantCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.Merchant{}).Where("status = ?", model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.User{}).Count(&stats.UserCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := s.db.Model(&model.Merchant{}).Where("id = ?", merchantID).Count(&stats.MerchantCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.Merchant{}).Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).Count(&stats.ActiveMerchantCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.User{}).
|
||||
Joins("JOIN merchant_members ON merchant_members.user_id = users.id").
|
||||
Where("merchant_members.merchant_id = ?", merchantID).
|
||||
Count(&stats.UserCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.CatalogProductCount = stats.ProductCount
|
||||
}
|
||||
if err := orderScope().Count(&stats.OrderCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := orderScope().Where("created_at >= ?", todayStart).Count(&stats.TodayOrderCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
|
||||
Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
|
||||
Where("created_at >= ?", todayStart).
|
||||
Select("COALESCE(SUM(amount),0)").Scan(&stats.TodaySales).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
|
||||
Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TotalFees).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := orderScope().Where("order_status <> ?", model.OrderStatusCancelled).
|
||||
Where("created_at >= ?", todayStart).
|
||||
Select("COALESCE(SUM(service_fee_amount),0)").Scan(&stats.TodayFees).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var orderStatusCounts []dashboardStatusCount
|
||||
if err := orderScope().Select("order_status AS status, COUNT(*) AS count").
|
||||
Group("order_status").Scan(&orderStatusCounts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range orderStatusCounts {
|
||||
switch item.Status {
|
||||
case model.OrderStatusPaid:
|
||||
stats.PaidOrderCount = item.Count
|
||||
case model.OrderStatusDelivering:
|
||||
stats.DeliveringOrderCount = item.Count
|
||||
case model.OrderStatusDelivered:
|
||||
stats.DeliveredOrderCount = item.Count
|
||||
case model.OrderStatusShipFailed:
|
||||
stats.ShipFailedOrderCount = item.Count
|
||||
case model.OrderStatusCancelled:
|
||||
stats.CancelledOrderCount = item.Count
|
||||
}
|
||||
}
|
||||
if err := walletScope().Select("COALESCE(SUM(available_balance),0)").Scan(&stats.WalletAvailableBalance).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := walletScope().Select("COALESCE(SUM(frozen_balance),0)").Scan(&stats.WalletFrozenBalance).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apiClientScope().Count(&stats.APIClientCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := apiClientScope().Where("status = ?", model.APIClientStatusActive).Count(&stats.ActiveAPIClientCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := callbackScope().Count(&stats.CallbackSubscriptionCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryPending).Count(&stats.PendingCallbackCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := callbackDeliveryScope().Where("status = ?", model.CallbackDeliveryFailed).Count(&stats.FailedCallbackCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/pkg/timeutil"
|
||||
)
|
||||
|
||||
// buildShipNotifyResultData 把推送结果与游戏字段合并进 ResultData JSON。
|
||||
func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *time.Time) string {
|
||||
m := map[string]interface{}{}
|
||||
if existing != "" && json.Valid([]byte(existing)) {
|
||||
_ = json.Unmarshal([]byte(existing), &m)
|
||||
}
|
||||
m["ship_status"] = in.ShipStatus
|
||||
if in.ProviderOrderNo != "" {
|
||||
m["provider_order_no"] = in.ProviderOrderNo
|
||||
}
|
||||
if in.ShipStatus == "success" {
|
||||
delete(m, "fail_reason")
|
||||
} else if in.FailReason != "" {
|
||||
m["fail_reason"] = in.FailReason
|
||||
}
|
||||
if shippedAt != nil {
|
||||
m["shipped_at"] = timeutil.FormatAPITime(*shippedAt)
|
||||
}
|
||||
if in.GameChannel != nil {
|
||||
m["game_channel"] = *in.GameChannel
|
||||
}
|
||||
if in.GameUID != nil {
|
||||
m["game_uid"] = *in.GameUID
|
||||
}
|
||||
if in.RoleName != nil {
|
||||
m["role_name"] = *in.RoleName
|
||||
}
|
||||
if in.PayScore != nil {
|
||||
m["pay_score"] = *in.PayScore
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return existing
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// mergeResultData 保留已有 JSON 字段,仅覆盖或新增 patch 中的字段,避免各发货阶段互相清空上下文。
|
||||
func mergeResultData(existing string, patch map[string]interface{}) string {
|
||||
if len(patch) == 0 {
|
||||
return existing
|
||||
}
|
||||
m := map[string]interface{}{}
|
||||
if existing != "" && json.Valid([]byte(existing)) {
|
||||
_ = json.Unmarshal([]byte(existing), &m)
|
||||
}
|
||||
for k, v := range patch {
|
||||
m[k] = v
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return existing
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func resultDataMap(raw string) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
if raw != "" && json.Valid([]byte(raw)) {
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// requestDataMap 解析下单透传的 data(RequestData),非法或为空时返回空 map。
|
||||
func requestDataMap(raw string) map[string]interface{} {
|
||||
m := map[string]interface{}{}
|
||||
if raw != "" && json.Valid([]byte(raw)) {
|
||||
_ = json.Unmarshal([]byte(raw), &m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// requestDataString 读取下单透传 data 中的字符串字段。
|
||||
func requestDataString(raw, key string) string {
|
||||
if v, ok := requestDataMap(raw)[key].(string); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resultDataString(raw, key string) string {
|
||||
if v, ok := resultDataMap(raw)[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resultDataNumber(raw, key string) int64 {
|
||||
switch v := resultDataMap(raw)[key].(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
case int:
|
||||
return int64(v)
|
||||
case string:
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// extractGameFields 从 JSON 文本中还原游戏相关字段(仅填充当前为空的字段)。
|
||||
func extractGameFields(raw string, out *OpenOrderQuery) {
|
||||
if raw == "" || !json.Valid([]byte(raw)) {
|
||||
return
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return
|
||||
}
|
||||
if out.GameChannel == "" {
|
||||
if v, ok := m["game_channel"].(string); ok {
|
||||
out.GameChannel = v
|
||||
}
|
||||
}
|
||||
if out.GameUID == "" {
|
||||
if v, ok := m["game_uid"].(string); ok {
|
||||
out.GameUID = v
|
||||
}
|
||||
}
|
||||
if out.RoleName == "" {
|
||||
if v, ok := m["role_name"].(string); ok {
|
||||
out.RoleName = v
|
||||
}
|
||||
}
|
||||
if out.PayScore == 0 {
|
||||
if v, ok := toInt(m["pay_score"]); ok {
|
||||
out.PayScore = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
case int64:
|
||||
return int(n), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/pkg/timeutil"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func newFulfillmentOrderNo() string {
|
||||
return "FO" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
|
||||
}
|
||||
|
||||
func newTestFulfillmentOrderNo() string {
|
||||
return "O" + timeutil.Now().Format(timeutil.OrderNoLayout) + strings.ReplaceAll(uuid.NewString()[:12], "-", "")
|
||||
}
|
||||
|
||||
// calculateServiceFee 按"百分比或固定"二选一计算手续费:
|
||||
// - feeType=rate:按 baseAmount * feeRateBP / 10000 计算
|
||||
// - feeType=fixed:直接取 feeFixedAmount
|
||||
//
|
||||
// 二者互斥,不会叠加。
|
||||
func calculateServiceFee(baseAmount int64, feeType string, feeRateBP, feeFixedAmount int64) (int64, error) {
|
||||
if baseAmount < 0 || feeRateBP < 0 || feeFixedAmount < 0 {
|
||||
return 0, errors.New("订单金额或手续费配置无效")
|
||||
}
|
||||
switch feeType {
|
||||
case model.FeeTypeFixed:
|
||||
return feeFixedAmount, nil
|
||||
case model.FeeTypeRate, "":
|
||||
if feeRateBP > 10000 {
|
||||
return 0, errors.New("手续费比例不能超过 10000 BP")
|
||||
}
|
||||
if feeRateBP > 0 && baseAmount > math.MaxInt64/feeRateBP {
|
||||
return 0, errors.New("手续费金额超出范围")
|
||||
}
|
||||
return baseAmount * feeRateBP / 10000, nil
|
||||
default:
|
||||
return 0, errors.New("无效的手续费类型")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *FulfillmentService) GetOrder(merchantID uint, orderNo string) (*model.FulfillmentOrder, error) {
|
||||
var order model.FulfillmentOrder
|
||||
err := s.db.Preload("MerchantProduct.Product").
|
||||
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
|
||||
First(&order).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) ListOrders(merchantID uint, page, size int, orderStatus string) ([]model.FulfillmentOrder, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx := s.db.Model(&model.FulfillmentOrder{}).Where("merchant_id = ?", merchantID)
|
||||
if orderStatus != "" {
|
||||
tx = tx.Where("order_status = ?", orderStatus)
|
||||
}
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var orders []model.FulfillmentOrder
|
||||
err := tx.Preload("MerchantProduct.Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&orders).Error
|
||||
return orders, total, err
|
||||
}
|
||||
|
||||
type OpenOrderQuery struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
CanShip bool `json:"can_ship"`
|
||||
CannotShipReason string `json:"cannot_ship_reason,omitempty"`
|
||||
Product *OpenOrderProduct `json:"product,omitempty"`
|
||||
BuyerName string `json:"buyer_name"`
|
||||
Amount int64 `json:"amount"`
|
||||
ProviderOrderNo string `json:"provider_order_no,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ShippedAt *time.Time `json:"shipped_at"`
|
||||
ShipFailReason string `json:"ship_fail_reason,omitempty"`
|
||||
GameChannel string `json:"game_channel,omitempty"`
|
||||
GameUID string `json:"game_uid,omitempty"`
|
||||
RoleName string `json:"role_name,omitempty"`
|
||||
PayScore int `json:"pay_score,omitempty"`
|
||||
}
|
||||
|
||||
// OpenOrderProduct 开放接口返回的商品快照。
|
||||
type OpenOrderProduct struct {
|
||||
Name string `json:"name"`
|
||||
SKU string `json:"sku"`
|
||||
Game string `json:"game"`
|
||||
}
|
||||
|
||||
// GetByOrderNo 按订单号查询(不限定商户,供上游 SourceOpen 使用)。
|
||||
func (s *FulfillmentService) GetByOrderNo(orderNo string) (*model.FulfillmentOrder, error) {
|
||||
var order model.FulfillmentOrder
|
||||
err := s.db.Preload("MerchantProduct.Product").Where("order_no = ?", orderNo).First(&order).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// QueryOpenOrder 供上游查询:商品信息 + 是否可发货。
|
||||
func (s *FulfillmentService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, error) {
|
||||
if orderNo == "" {
|
||||
return nil, errors.New("订单号不能为空")
|
||||
}
|
||||
order, err := s.GetByOrderNo(orderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canShip, reason := CanFulfill(order)
|
||||
out := &OpenOrderQuery{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: normalizeOrderStatus(order),
|
||||
CanShip: canShip,
|
||||
CannotShipReason: reason,
|
||||
BuyerName: order.BuyerReference,
|
||||
Amount: order.Amount,
|
||||
ProviderOrderNo: order.ProviderOrderNo,
|
||||
CreatedAt: order.CreatedAt,
|
||||
ShippedAt: order.DeliveredAt,
|
||||
ShipFailReason: order.FailureReason,
|
||||
}
|
||||
if order.MerchantProduct != nil {
|
||||
game := ""
|
||||
if order.MerchantProduct.Product != nil {
|
||||
game = order.MerchantProduct.Product.Category
|
||||
}
|
||||
out.Product = &OpenOrderProduct{
|
||||
Name: order.ProductName,
|
||||
SKU: order.ProductSKU,
|
||||
Game: game,
|
||||
}
|
||||
}
|
||||
// 新模型无独立的游戏字段列,从 RequestData / ResultData JSON 中还原。
|
||||
extractGameFields(order.RequestData, out)
|
||||
extractGameFields(order.ResultData, out)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ShipNotifyInput 上游发货结果推送。
|
||||
type ShipNotifyInput struct {
|
||||
OrderNo string
|
||||
ShipStatus string // success / failed
|
||||
ProviderOrderNo string
|
||||
ShippedAt *time.Time
|
||||
FailReason string
|
||||
RawPayload string
|
||||
GameChannel *string
|
||||
GameUID *string
|
||||
RoleName *string
|
||||
PayScore *int
|
||||
}
|
||||
|
||||
// ShipNotifyResult 上游推送处理结果。
|
||||
type ShipNotifyResult struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// HandleShipNotify 处理上游发货结果推送(幂等),基于 FulfillmentOrder。
|
||||
func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) {
|
||||
in.ShipStatus = strings.TrimSpace(in.ShipStatus)
|
||||
in.FailReason = strings.TrimSpace(in.FailReason)
|
||||
if in.OrderNo == "" {
|
||||
return nil, errors.New("订单号不能为空")
|
||||
}
|
||||
if in.ShipStatus != "success" && in.ShipStatus != "failed" {
|
||||
return nil, errors.New("无效的 ship_status,仅支持 success/failed")
|
||||
}
|
||||
if in.ShipStatus == "failed" && in.FailReason == "" {
|
||||
return nil, errors.New("发货失败时 fail_reason 必填")
|
||||
}
|
||||
if utf8.RuneCountInString(in.FailReason) > 512 {
|
||||
return nil, errors.New("fail_reason 最长 512 个字符")
|
||||
}
|
||||
|
||||
var nextStatus string
|
||||
switch in.ShipStatus {
|
||||
case "success":
|
||||
nextStatus = model.OrderStatusDelivered
|
||||
case "failed":
|
||||
nextStatus = model.OrderStatusShipFailed
|
||||
}
|
||||
|
||||
var result ShipNotifyResult
|
||||
var rejectionErr error
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.FulfillmentOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Preload("MerchantProduct.Product").
|
||||
Where("order_no = ?", in.OrderNo).
|
||||
First(&order).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("订单不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 已交付:success 推送幂等成功。状态读取和后续更新必须在同一把行锁内完成。
|
||||
if normalizeOrderStatus(&order) == model.OrderStatusDelivered && in.ShipStatus == "success" {
|
||||
result = ShipNotifyResult{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: normalizeOrderStatus(&order),
|
||||
Message: "订单已交付,幂等成功",
|
||||
}
|
||||
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
|
||||
shipNotifyAuditMetadata(in, normalizeOrderStatus(&order), "订单已交付,幂等忽略"))
|
||||
}
|
||||
|
||||
if normalizeOrderStatus(&order) == model.OrderStatusCancelled {
|
||||
if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已取消,拒绝更新"); err != nil {
|
||||
return err
|
||||
}
|
||||
rejectionErr = errors.New("订单已取消,无法更新发货状态")
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
shippedAt := in.ShippedAt
|
||||
if shippedAt == nil && in.ShipStatus == "success" {
|
||||
shippedAt = &now
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"order_status": nextStatus,
|
||||
}
|
||||
var message string
|
||||
switch in.ShipStatus {
|
||||
case "success":
|
||||
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
|
||||
message := "当前状态不允许标记发货成功"
|
||||
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
|
||||
return err
|
||||
}
|
||||
rejectionErr = err
|
||||
return nil
|
||||
}
|
||||
updates["delivered_at"] = shippedAt
|
||||
updates["failure_reason"] = ""
|
||||
if in.ProviderOrderNo != "" {
|
||||
updates["provider_order_no"] = in.ProviderOrderNo
|
||||
}
|
||||
message = "发货成功,订单已交付"
|
||||
case "failed":
|
||||
if err := validateOrderStatusTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil {
|
||||
message := "当前状态不允许标记发货失败"
|
||||
if normalizeOrderStatus(&order) == model.OrderStatusDelivered {
|
||||
message = "订单已交付,拒绝失败推送"
|
||||
}
|
||||
if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil {
|
||||
return err
|
||||
}
|
||||
rejectionErr = err
|
||||
return nil
|
||||
}
|
||||
updates["failure_reason"] = in.FailReason
|
||||
if in.ProviderOrderNo != "" {
|
||||
updates["provider_order_no"] = in.ProviderOrderNo
|
||||
}
|
||||
message = "已记录发货失败"
|
||||
}
|
||||
|
||||
resultData := buildShipNotifyResultData(order.ResultData, in, shippedAt)
|
||||
updates["result_data"] = resultData
|
||||
if err := tx.Model(&model.FulfillmentOrder{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
|
||||
shipNotifyAuditMetadata(in, nextStatus, message)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var updated model.FulfillmentOrder
|
||||
if err := tx.Preload("MerchantProduct.Product").First(&updated, order.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if s.callbacks != nil {
|
||||
if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&updated)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result = ShipNotifyResult{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: nextStatus,
|
||||
Message: message,
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rejectionErr != nil {
|
||||
return nil, rejectionErr
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func writeShipNotifyRejectedAudit(tx *gorm.DB, order *model.FulfillmentOrder, in ShipNotifyInput, message string) error {
|
||||
return writeAudit(tx, &order.MerchantID, nil, nil, "ship.notify", "fulfillment_order", order.OrderNo,
|
||||
shipNotifyAuditMetadata(in, normalizeOrderStatus(order), message))
|
||||
}
|
||||
|
||||
func shipNotifyAuditMetadata(in ShipNotifyInput, resultStatus, message string) map[string]interface{} {
|
||||
metadata := map[string]interface{}{
|
||||
"ship_status": in.ShipStatus,
|
||||
"provider_order_no": in.ProviderOrderNo,
|
||||
"fail_reason": in.FailReason,
|
||||
"result_status": resultStatus,
|
||||
"message": message,
|
||||
"payload": in.RawPayload,
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"affiliate_dash/internal/pkg/timeutil"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (s *FulfillmentService) MarkProcessingTimeouts(timeout time.Duration, limit int) (int, error) {
|
||||
if timeout <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-timeout)
|
||||
var ids []uint
|
||||
if err := s.db.Model(&model.FulfillmentOrder{}).
|
||||
Where("order_status = ? AND updated_at < ?", model.OrderStatusDelivering, cutoff).
|
||||
Order("updated_at ASC, id ASC").
|
||||
Limit(limit).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
updated, err := s.markProcessingTimeout(id, timeout, now)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
if updated {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duration, now time.Time) (bool, error) {
|
||||
returned := false
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.FulfillmentOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !processingTimedOut(&order, timeout, now) {
|
||||
return nil
|
||||
}
|
||||
if err := validateOrderStatusTransition(&order, model.OrderStatusShipFailed, fulfillmentTransitionTimeout); err != nil {
|
||||
return nil
|
||||
}
|
||||
submittedUpstream := deliverySubmittedUpstream(&order)
|
||||
var reason string
|
||||
if submittedUpstream {
|
||||
reason = fmt.Sprintf("发货超时:订单已提交上游但超过 %d 分钟未回传结果,可能仍在处理;请勿直接重试,先在上游确认订单状态", int(timeout.Minutes()))
|
||||
} else {
|
||||
reason = fmt.Sprintf("发货超时:发货提交中断,请重新提交(已停留 delivering 超过 %d 分钟)", int(timeout.Minutes()))
|
||||
}
|
||||
updates := map[string]interface{}{"order_status": model.OrderStatusShipFailed}
|
||||
updates["failure_reason"] = reason
|
||||
updates["result_data"] = buildProcessingTimeoutResultData(order.ResultData, timeout, now, reason)
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var out model.FulfillmentOrder
|
||||
if err := tx.First(&out, order.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAudit(tx, &order.MerchantID, nil, nil, "fulfillment.timeout", "fulfillment_order", order.OrderNo, map[string]interface{}{
|
||||
"from": normalizeOrderStatus(&order),
|
||||
"to": model.OrderStatusShipFailed,
|
||||
"timeout_minutes": int(timeout.Minutes()),
|
||||
"reason": reason,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.callbacks != nil {
|
||||
if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.shipping.updated", orderCallbackData(&out)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
returned = true
|
||||
return nil
|
||||
})
|
||||
return returned, err
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) RunProcessingTimeoutMonitor(ctx context.Context, timeout, interval time.Duration) {
|
||||
if timeout <= 0 {
|
||||
return
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
changed, err := s.MarkProcessingTimeouts(timeout, 50)
|
||||
if err != nil {
|
||||
log.Printf("[fulfillment] timeout scan error: %v", err)
|
||||
} else if changed > 0 {
|
||||
log.Printf("[fulfillment] timeout scan marked failed count=%d", changed)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildProcessingTimeoutResultData(existing string, timeout time.Duration, now time.Time, reason string) string {
|
||||
return mergeResultData(existing, map[string]interface{}{
|
||||
"timeout": true,
|
||||
"timeout_minutes": int(timeout.Minutes()),
|
||||
"timeout_at": timeutil.FormatAPITime(now),
|
||||
"ship_status": "failed",
|
||||
"fail_reason": reason,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type WalletAdjustInput struct {
|
||||
MerchantID uint
|
||||
ActorUserID uint
|
||||
Amount int64
|
||||
IdempotencyKey string
|
||||
Note string
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) AdjustWallet(in WalletAdjustInput) (*model.WalletAccount, error) {
|
||||
if in.Amount == 0 {
|
||||
return nil, errors.New("调整金额不能为零")
|
||||
}
|
||||
if in.IdempotencyKey == "" {
|
||||
return nil, errors.New("账务调整必须提供幂等键")
|
||||
}
|
||||
var out model.WalletAccount
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.WalletLedgerEntry
|
||||
if err := tx.Where("merchant_id = ? AND idempotency_key = ?", in.MerchantID, in.IdempotencyKey).First(&existing).Error; err == nil {
|
||||
if err := tx.First(&out, existing.WalletAccountID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
var wallet model.WalletAccount
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("merchant_id = ?", in.MerchantID).First(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newBalance := wallet.AvailableBalance + in.Amount
|
||||
if newBalance < 0 {
|
||||
return errors.New("调整后余额不能小于零")
|
||||
}
|
||||
if err := tx.Model(&wallet).Update("available_balance", newBalance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
entryType := model.WalletLedgerAdjust
|
||||
if in.Amount > 0 {
|
||||
entryType = model.WalletLedgerCredit
|
||||
} else {
|
||||
entryType = model.WalletLedgerDebit
|
||||
}
|
||||
idempotencyKey := in.IdempotencyKey
|
||||
if err := tx.Create(&model.WalletLedgerEntry{
|
||||
MerchantID: in.MerchantID,
|
||||
WalletAccountID: wallet.ID,
|
||||
EntryNo: "WL" + uuid.NewString(),
|
||||
Type: entryType,
|
||||
Amount: in.Amount,
|
||||
BalanceAfter: newBalance,
|
||||
ReferenceType: "manual_adjustment",
|
||||
ReferenceNo: in.IdempotencyKey,
|
||||
IdempotencyKey: &idempotencyKey,
|
||||
Note: in.Note,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
out = wallet
|
||||
out.AvailableBalance = newBalance
|
||||
return writeAudit(tx, &in.MerchantID, &in.ActorUserID, nil, "wallet.adjust", "wallet_account", fmt.Sprint(wallet.ID), map[string]int64{"amount": in.Amount})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) GetWallet(merchantID uint) (*model.WalletAccount, error) {
|
||||
var wallet model.WalletAccount
|
||||
if err := s.db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("商户钱包不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &wallet, nil
|
||||
}
|
||||
|
||||
func (s *FulfillmentService) ListWalletLedger(merchantID uint, page, size int, referenceNo, entryType string) ([]model.WalletLedgerEntry, int64, error) {
|
||||
page, size = normalizePage(page, size)
|
||||
tx := s.db.Model(&model.WalletLedgerEntry{}).Where("merchant_id = ?", merchantID)
|
||||
if referenceNo != "" {
|
||||
tx = tx.Where("reference_no LIKE ?", "%"+referenceNo+"%")
|
||||
}
|
||||
if entryType != "" {
|
||||
tx = tx.Where("type = ?", entryType)
|
||||
}
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var entries []model.WalletLedgerEntry
|
||||
err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&entries).Error
|
||||
return entries, total, err
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
package service
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user