对接皮肤源头开放接口:查询、发货推送与 HMAC 签名鉴权

- 新增订单查询与发货结果推送开放接口,支持 can_ship 与幂等
- 鉴权采用 X-Api-Key + Timestamp + Nonce + HMAC-SHA256 签名
- 订单扩展发货字段与 ship_logs,管理端增加发货记录与开放文档页
This commit is contained in:
yml2213
2026-07-20 16:06:44 +08:00
parent 829bea309d
commit 89cdd32181
16 changed files with 1584 additions and 39 deletions
+11 -6
View File
@@ -33,7 +33,7 @@ func main() {
log.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Skin{}, &model.Order{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.Skin{}, &model.Order{}, &model.ShipLog{}); err != nil {
log.Fatalf("migrate: %v", err)
}
@@ -51,17 +51,22 @@ func main() {
}
h := &router.Handlers{
Auth: handler.NewAuthHandler(authSvc),
Skin: handler.NewSkinHandler(skinSvc),
Order: handler.NewOrderHandler(orderSvc),
User: handler.NewUserHandler(userSvc),
JWT: jm,
Auth: handler.NewAuthHandler(authSvc),
Skin: handler.NewSkinHandler(skinSvc),
Order: handler.NewOrderHandler(orderSvc),
User: handler.NewUserHandler(userSvc),
Open: handler.NewOpenHandler(orderSvc),
JWT: jm,
OpenAPIKey: cfg.OpenAPIKey,
OpenAPISecret: cfg.OpenAPISecret,
OpenSignSkew: cfg.OpenSignSkew,
}
r := router.Setup(h)
addr := ":" + cfg.Port
log.Printf("游戏皮肤分销系统 API 启动: http://localhost%s", addr)
log.Printf("默认管理员: admin / admin123")
log.Printf("开放接口鉴权: X-Api-Key + X-Timestamp + X-Nonce + X-Sign (HMAC-SHA256)")
if err := r.Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
+13 -4
View File
@@ -10,14 +10,23 @@ type Config struct {
JWTSecret string
DBPath string
Mode string // debug / release
// OpenAPIKey 皮肤源头开放接口标识(Header: X-Api-Key,可公开给对接方)
OpenAPIKey string
// OpenAPISecret 签名密钥(仅用于 HMAC,不走 Header
OpenAPISecret string
// OpenSignSkew 签名时间戳允许偏差(秒)
OpenSignSkew int64
}
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
DBPath: getEnv("DB_PATH", "data/app.db"),
Mode: getEnv("GIN_MODE", "debug"),
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
DBPath: getEnv("DB_PATH", "data/app.db"),
Mode: getEnv("GIN_MODE", "debug"),
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
}
}
+86
View File
@@ -0,0 +1,86 @@
package handler
import (
"encoding/json"
"time"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
)
// OpenHandler 皮肤源头开放接口
type OpenHandler struct {
orderSvc *service.OrderService
}
func NewOpenHandler(orderSvc *service.OrderService) *OpenHandler {
return &OpenHandler{orderSvc: orderSvc}
}
// QueryOrder GET /api/open/v1/orders/:order_no
// 上游输入店铺订单号,查询商品与是否可发货
func (h *OpenHandler) QueryOrder(c *gin.Context) {
orderNo := c.Param("order_no")
if orderNo == "" {
orderNo = c.Query("order_no")
}
data, err := h.orderSvc.QueryOpenOrder(orderNo)
if err != nil {
if err.Error() == "订单不存在" {
response.NotFound(c, err.Error())
return
}
response.BadRequest(c, err.Error())
return
}
response.OK(c, data)
}
type shipNotifyReq struct {
OrderNo string `json:"order_no" binding:"required"`
ShipStatus string `json:"ship_status" binding:"required"` // success/failed/processing
ProviderOrderNo string `json:"provider_order_no"`
ShippedAt string `json:"shipped_at"` // RFC3339 可选
FailReason string `json:"fail_reason"`
}
// ShipNotify POST /api/open/v1/orders/ship-notify
// 上游发货后推送结果,同步订单状态
func (h *OpenHandler) ShipNotify(c *gin.Context) {
var req shipNotifyReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:order_no 与 ship_status 必填")
return
}
raw, _ := json.Marshal(req)
var shippedAt *time.Time
if req.ShippedAt != "" {
t, err := time.Parse(time.RFC3339, req.ShippedAt)
if err != nil {
response.BadRequest(c, "shipped_at 格式错误,请使用 RFC3339,如 2026-07-20T16:00:00+08:00")
return
}
shippedAt = &t
}
result, err := h.orderSvc.HandleShipNotify(service.ShipNotifyInput{
OrderNo: req.OrderNo,
ShipStatus: req.ShipStatus,
ProviderOrderNo: req.ProviderOrderNo,
ShippedAt: shippedAt,
FailReason: req.FailReason,
RawPayload: string(raw),
})
if err != nil {
if err.Error() == "订单不存在" {
response.NotFound(c, err.Error())
return
}
response.BadRequest(c, err.Error())
return
}
response.OK(c, result)
}
+17
View File
@@ -103,3 +103,20 @@ func (h *OrderHandler) Dashboard(c *gin.Context) {
}
response.OK(c, stats)
}
// ListShipLogs 发货推送记录(管理端)
func (h *OrderHandler) ListShipLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
list, total, err := h.svc.ListShipLogs(service.ShipLogListQuery{
Page: page,
Size: size,
OrderNo: c.Query("order_no"),
ShipStatus: c.Query("ship_status"),
})
if err != nil {
response.ServerError(c, err.Error())
return
}
response.Page(c, list, total, page, size)
}
+171
View File
@@ -0,0 +1,171 @@
package middleware
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"strconv"
"strings"
"sync"
"time"
"affiliate_dash/internal/pkg/response"
"github.com/gin-gonic/gin"
)
// OpenAuthConfig 开放接口鉴权配置
type OpenAuthConfig struct {
APIKey string
APISecret string
// 允许的时间偏差(秒),默认 300
SkewSeconds int64
}
// nonce 防重放(进程内,重启清空;生产可换 Redis)
type nonceStore struct {
mu sync.Mutex
data map[string]int64 // nonce -> expire unix
}
func newNonceStore() *nonceStore {
return &nonceStore{data: make(map[string]int64)}
}
func (s *nonceStore) seen(nonce string, now, ttl int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
// 清理过期
for k, exp := range s.data {
if exp < now {
delete(s.data, k)
}
}
if exp, ok := s.data[nonce]; ok && exp >= now {
return true
}
s.data[nonce] = now + ttl
return false
}
// OpenAuth 校验 X-Api-Key + 时间戳 + nonce + HMAC-SHA256 签名
//
// 待签名字符串(UTF-8,\n 换行):
//
// {apiKey}\n{timestamp}\n{nonce}\n{METHOD}\n{path}\n{body}
//
// path 为 URL.Path(不含 query),METHOD 大写;GET 时 body 为空字符串。
// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
if cfg.SkewSeconds <= 0 {
cfg.SkewSeconds = 300
}
store := newNonceStore()
return func(c *gin.Context) {
if cfg.APIKey == "" || cfg.APISecret == "" {
response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET")
c.Abort()
return
}
apiKey := c.GetHeader("X-Api-Key")
timestamp := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
sign := c.GetHeader("X-Sign")
if apiKey == "" || timestamp == "" || nonce == "" || sign == "" {
response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign")
c.Abort()
return
}
if apiKey != cfg.APIKey {
response.Unauthorized(c, "无效的 API Key")
c.Abort()
return
}
if len(nonce) < 8 || len(nonce) > 64 {
response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间")
c.Abort()
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
response.Unauthorized(c, "X-Timestamp 格式错误,需为 Unix 秒级时间戳")
c.Abort()
return
}
now := time.Now().Unix()
if abs64(now-ts) > cfg.SkewSeconds {
response.Unauthorized(c, "请求已过期或时间偏差过大")
c.Abort()
return
}
// 防重放:同一 nonce 在时间窗口内只能用一次
if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) {
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
c.Abort()
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
response.BadRequest(c, "读取请求体失败")
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
method := strings.ToUpper(c.Request.Method)
path := c.Request.URL.Path
body := string(bodyBytes)
stringToSign := strings.Join([]string{
apiKey,
timestamp,
nonce,
method,
path,
body,
}, "\n")
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
response.Unauthorized(c, "签名校验失败")
c.Abort()
return
}
c.Next()
}
}
func hmacSHA256Hex(secret, content string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(content))
return hex.EncodeToString(mac.Sum(nil))
}
func abs64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
// BuildOpenSign 供测试或内部生成签名(与 OpenAuth 规则一致)
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
stringToSign := strings.Join([]string{
apiKey,
timestamp,
nonce,
strings.ToUpper(method),
path,
body,
}, "\n")
return hmacSHA256Hex(apiSecret, stringToSign)
}
+34 -5
View File
@@ -63,14 +63,43 @@ type Order struct {
BuyerName string `gorm:"size:64" json:"buyer_name"`
Amount float64 `gorm:"not null" json:"amount"`
CommissionAmt float64 `gorm:"default:0" json:"commission_amt"`
Status string `gorm:"size:32;default:pending" json:"status"` // pending/paid/delivered/cancelled
Status string `gorm:"size:32;default:pending;index" json:"status"`
Remark string `gorm:"size:255" json:"remark"`
// 发货相关(上游皮肤源头对接)
ProviderOrderNo string `gorm:"size:64;index" json:"provider_order_no"` // 上游单号
ShippedAt *time.Time `json:"shipped_at"` // 发货成功时间
ShipFailReason string `gorm:"size:512" json:"ship_fail_reason"` // 最近一次失败原因
}
// 订单状态
const (
OrderStatusPending = "pending"
OrderStatusPaid = "paid"
OrderStatusDelivered = "delivered"
OrderStatusCancelled = "cancelled"
OrderStatusPending = "pending" // 待支付
OrderStatusPaid = "paid" // 已支付,可发货
OrderStatusDelivering = "delivering" // 发货中
OrderStatusDelivered = "delivered" // 已交付
OrderStatusShipFailed = "ship_failed" // 发货失败(可重试)
OrderStatusCancelled = "cancelled" // 已取消
)
// 上游推送的发货状态
const (
ShipNotifySuccess = "success"
ShipNotifyFailed = "failed"
ShipNotifyProcessing = "processing"
)
// ShipLog 发货推送记录(上游回调留痕)
type ShipLog struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
OrderNo string `gorm:"size:64;index;not null" json:"order_no"`
OrderID uint `gorm:"index" json:"order_id"`
ShipStatus string `gorm:"size:32;not null" json:"ship_status"` // success/failed/processing
ProviderOrderNo string `gorm:"size:64" json:"provider_order_no"`
FailReason string `gorm:"size:512" json:"fail_reason"`
Payload string `gorm:"type:text" json:"payload"` // 原始请求 JSON
ResultStatus string `gorm:"size:32" json:"result_status"` // 处理后订单状态
Message string `gorm:"size:255" json:"message"`
}
+24 -7
View File
@@ -11,11 +11,15 @@ import (
)
type Handlers struct {
Auth *handler.AuthHandler
Skin *handler.SkinHandler
Order *handler.OrderHandler
User *handler.UserHandler
JWT *jwt.Manager
Auth *handler.AuthHandler
Skin *handler.SkinHandler
Order *handler.OrderHandler
User *handler.UserHandler
Open *handler.OpenHandler
JWT *jwt.Manager
OpenAPIKey string
OpenAPISecret string
OpenSignSkew int64
}
func Setup(h *Handlers) *gin.Engine {
@@ -24,7 +28,7 @@ func Setup(h *Handlers) *gin.Engine {
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
}))
@@ -38,6 +42,18 @@ func Setup(h *Handlers) *gin.Engine {
api.POST("/auth/login", h.Auth.Login)
api.POST("/auth/register", h.Auth.Register)
// 皮肤源头开放接口(ApiKey + HMAC 签名)
open := api.Group("/open/v1")
open.Use(middleware.OpenAuth(middleware.OpenAuthConfig{
APIKey: h.OpenAPIKey,
APISecret: h.OpenAPISecret,
SkewSeconds: h.OpenSignSkew,
}))
{
open.GET("/orders/:order_no", h.Open.QueryOrder)
open.POST("/orders/ship-notify", h.Open.ShipNotify)
}
auth := api.Group("")
auth.Use(middleware.Auth(h.JWT))
{
@@ -56,13 +72,14 @@ func Setup(h *Handlers) *gin.Engine {
auth.POST("/orders", h.Order.Create)
auth.PATCH("/orders/:id/status", middleware.RequireRole(model.RoleAdmin), h.Order.UpdateStatus)
// 用户 / 分销商(仅管理员)
// 用户 / 分销商 / 发货记录(仅管理员)
admin := auth.Group("")
admin.Use(middleware.RequireRole(model.RoleAdmin))
{
admin.GET("/users", h.User.List)
admin.POST("/users", h.User.Create)
admin.PATCH("/users/:id/status", h.User.UpdateStatus)
admin.GET("/ship-logs", h.Order.ListShipLogs)
}
}
}
+262 -4
View File
@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"time"
@@ -103,10 +104,12 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
func (s *OrderService) UpdateStatus(id uint, status string) error {
allowed := map[string]bool{
model.OrderStatusPending: true,
model.OrderStatusPaid: true,
model.OrderStatusDelivered: true,
model.OrderStatusCancelled: true,
model.OrderStatusPending: true,
model.OrderStatusPaid: true,
model.OrderStatusDelivering: true,
model.OrderStatusDelivered: true,
model.OrderStatusShipFailed: true,
model.OrderStatusCancelled: true,
}
if !allowed[status] {
return errors.New("无效的订单状态")
@@ -121,6 +124,261 @@ func (s *OrderService) UpdateStatus(id uint, status string) error {
return nil
}
// ----- 开放接口:皮肤源头对接 -----
// 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 float64 `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"`
}
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 / processing
ProviderOrderNo string
ShippedAt *time.Time
FailReason string
RawPayload string
}
type ShipNotifyResult struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
Message string `json:"message"`
}
func (s *OrderService) GetByOrderNo(orderNo string) (*model.Order, error) {
var order model.Order
err := s.db.Preload("Skin").Where("order_no = ?", orderNo).First(&order).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("订单不存在")
}
return nil, err
}
return &order, nil
}
// QueryOpenOrder 供上游查询:商品信息 + 是否可发货
func (s *OrderService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, error) {
if orderNo == "" {
return nil, errors.New("订单号不能为空")
}
order, err := s.GetByOrderNo(orderNo)
if err != nil {
return nil, err
}
canShip, reason := evaluateCanShip(order)
out := &OpenOrderQuery{
OrderNo: order.OrderNo,
Status: order.Status,
CanShip: canShip,
CannotShipReason: reason,
BuyerName: order.BuyerName,
Amount: order.Amount,
ProviderOrderNo: order.ProviderOrderNo,
CreatedAt: order.CreatedAt,
ShippedAt: order.ShippedAt,
ShipFailReason: order.ShipFailReason,
}
if order.Skin != nil {
out.Product = &OpenOrderProduct{
Name: order.Skin.Name,
SKU: order.Skin.SKU,
Game: order.Skin.Game,
}
}
return out, nil
}
func evaluateCanShip(order *model.Order) (bool, string) {
switch order.Status {
case model.OrderStatusPaid, model.OrderStatusShipFailed:
return true, ""
case model.OrderStatusPending:
return false, "订单未支付"
case model.OrderStatusDelivering:
return false, "订单发货中"
case model.OrderStatusDelivered:
return false, "订单已发货完成"
case model.OrderStatusCancelled:
return false, "订单已取消"
default:
return false, "当前状态不可发货: " + order.Status
}
}
// HandleShipNotify 处理上游发货结果推送(幂等)
func (s *OrderService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) {
if in.OrderNo == "" {
return nil, errors.New("订单号不能为空")
}
switch in.ShipStatus {
case model.ShipNotifySuccess, model.ShipNotifyFailed, model.ShipNotifyProcessing:
default:
return nil, errors.New("无效的 ship_status,仅支持 success/failed/processing")
}
order, err := s.GetByOrderNo(in.OrderNo)
if err != nil {
return nil, err
}
// 已交付:success 推送幂等成功
if order.Status == model.OrderStatusDelivered && in.ShipStatus == model.ShipNotifySuccess {
_ = s.appendShipLog(order, in, order.Status, "订单已是已交付状态,幂等忽略")
return &ShipNotifyResult{
OrderNo: order.OrderNo,
Status: order.Status,
Message: "订单已交付,幂等成功",
}, nil
}
// 已取消不允许再推成功
if order.Status == model.OrderStatusCancelled {
_ = s.appendShipLog(order, in, order.Status, "订单已取消,拒绝更新")
return nil, errors.New("订单已取消,无法更新发货状态")
}
now := time.Now()
shippedAt := in.ShippedAt
if shippedAt == nil && in.ShipStatus == model.ShipNotifySuccess {
shippedAt = &now
}
updates := map[string]interface{}{}
var nextStatus string
var msg string
switch in.ShipStatus {
case model.ShipNotifySuccess:
// 仅 paid / ship_failed / delivering 可转为 delivered
if order.Status != model.OrderStatusPaid &&
order.Status != model.OrderStatusShipFailed &&
order.Status != model.OrderStatusDelivering {
_ = s.appendShipLog(order, in, order.Status, "当前状态不允许标记发货成功")
return nil, fmt.Errorf("当前状态 %s 不允许标记发货成功", order.Status)
}
nextStatus = model.OrderStatusDelivered
updates["status"] = nextStatus
updates["shipped_at"] = shippedAt
updates["ship_fail_reason"] = ""
if in.ProviderOrderNo != "" {
updates["provider_order_no"] = in.ProviderOrderNo
}
msg = "发货成功,订单已交付"
case model.ShipNotifyFailed:
if order.Status == model.OrderStatusDelivered {
_ = s.appendShipLog(order, in, order.Status, "订单已交付,忽略失败推送")
return nil, errors.New("订单已交付,不能标记发货失败")
}
nextStatus = model.OrderStatusShipFailed
updates["status"] = nextStatus
updates["ship_fail_reason"] = in.FailReason
if in.ProviderOrderNo != "" {
updates["provider_order_no"] = in.ProviderOrderNo
}
msg = "已记录发货失败"
case model.ShipNotifyProcessing:
if order.Status == model.OrderStatusDelivered {
_ = s.appendShipLog(order, in, order.Status, "订单已交付,忽略发货中推送")
return &ShipNotifyResult{
OrderNo: order.OrderNo,
Status: order.Status,
Message: "订单已交付,忽略 processing",
}, nil
}
if order.Status != model.OrderStatusPaid &&
order.Status != model.OrderStatusShipFailed &&
order.Status != model.OrderStatusDelivering {
_ = s.appendShipLog(order, in, order.Status, "当前状态不允许进入发货中")
return nil, fmt.Errorf("当前状态 %s 不允许进入发货中", order.Status)
}
nextStatus = model.OrderStatusDelivering
updates["status"] = nextStatus
if in.ProviderOrderNo != "" {
updates["provider_order_no"] = in.ProviderOrderNo
}
msg = "订单已标记为发货中"
}
if err := s.db.Model(&model.Order{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil {
return nil, err
}
_ = s.appendShipLog(order, in, nextStatus, msg)
return &ShipNotifyResult{
OrderNo: order.OrderNo,
Status: nextStatus,
Message: msg,
}, nil
}
func (s *OrderService) appendShipLog(order *model.Order, in ShipNotifyInput, resultStatus, message string) error {
payload := in.RawPayload
if payload == "" {
b, _ := json.Marshal(in)
payload = string(b)
}
log := &model.ShipLog{
OrderNo: order.OrderNo,
OrderID: order.ID,
ShipStatus: in.ShipStatus,
ProviderOrderNo: in.ProviderOrderNo,
FailReason: in.FailReason,
Payload: payload,
ResultStatus: resultStatus,
Message: message,
}
return s.db.Create(log).Error
}
type ShipLogListQuery struct {
Page int
Size int
OrderNo string
ShipStatus string
}
func (s *OrderService) ListShipLogs(q ShipLogListQuery) ([]model.ShipLog, int64, error) {
if q.Page < 1 {
q.Page = 1
}
if q.Size < 1 || q.Size > 100 {
q.Size = 20
}
tx := s.db.Model(&model.ShipLog{})
if q.OrderNo != "" {
tx = tx.Where("order_no LIKE ?", "%"+q.OrderNo+"%")
}
if q.ShipStatus != "" {
tx = tx.Where("ship_status = ?", q.ShipStatus)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.ShipLog
err := tx.Order("id DESC").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&list).Error
return list, total, err
}
type DashboardStats struct {
SkinCount int64 `json:"skin_count"`
DistributorCount int64 `json:"distributor_count"`