支持 .env 配置,并完善源头对接文档与测试订单
- 增加 .env.example 与 godotenv 加载,start.sh 自动读环境变量 - 重写发给源头的开放接口对接文档 - 订单页支持创建测试订单(可直接已支付并复制店铺订单号)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# ========== 后端(Go)==========
|
||||
# 服务端口
|
||||
PORT=8080
|
||||
# Gin 模式:debug / release
|
||||
GIN_MODE=debug
|
||||
# SQLite 数据库路径(相对 backend 工作目录,或写绝对路径)
|
||||
DB_PATH=data/app.db
|
||||
# JWT 密钥(生产务必更换)
|
||||
JWT_SECRET=affiliate-dash-dev-secret-change-me
|
||||
|
||||
# 皮肤源头开放接口鉴权
|
||||
# X-Api-Key 标识(可给对接方)
|
||||
OPEN_API_KEY=sk_source_dev_key_change_me
|
||||
# HMAC 签名密钥(仅服务端与对接方本地使用,不要放前端)
|
||||
OPEN_API_SECRET=sk_source_dev_secret_change_me
|
||||
# 签名时间戳允许偏差(秒)
|
||||
OPEN_SIGN_SKEW=300
|
||||
|
||||
# ========== 前端(Vite / 一键启动)==========
|
||||
# 前端开发端口
|
||||
FRONTEND_PORT=5173
|
||||
# 开发代理到后端地址(可选,默认 http://localhost:8080)
|
||||
VITE_API_PROXY_TARGET=http://localhost:8080
|
||||
@@ -1,3 +1,12 @@
|
||||
# env(保留示例文件)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
backend/.env
|
||||
frontend/.env
|
||||
!.env.example
|
||||
!frontend/.env.example
|
||||
|
||||
# backend
|
||||
backend/bin/
|
||||
backend/data/
|
||||
|
||||
@@ -6,6 +6,7 @@ require (
|
||||
github.com/gin-contrib/cors v1.7.7
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.54.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
|
||||
@@ -38,6 +38,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
|
||||
@@ -2,7 +2,10 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -18,7 +21,9 @@ type Config struct {
|
||||
OpenSignSkew int64
|
||||
}
|
||||
|
||||
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
||||
func Load() *Config {
|
||||
loadDotEnv()
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
|
||||
@@ -30,6 +35,32 @@ func Load() *Config {
|
||||
}
|
||||
}
|
||||
|
||||
// loadDotEnv 依次尝试常见路径,不覆盖已有环境变量
|
||||
func loadDotEnv() {
|
||||
var paths []string
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
paths = append(paths,
|
||||
filepath.Join(wd, ".env"),
|
||||
filepath.Join(wd, "..", ".env"), // 在 backend/ 下启动时读仓库根 .env
|
||||
filepath.Join(wd, "backend", ".env"), // 在仓库根启动时
|
||||
)
|
||||
}
|
||||
paths = append(paths, ".env")
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
for _, p := range paths {
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[abs]; ok {
|
||||
continue
|
||||
}
|
||||
seen[abs] = struct{}{}
|
||||
_ = godotenv.Load(abs) // 忽略不存在;不覆盖已在环境中的变量
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
|
||||
@@ -48,27 +48,40 @@ type createOrderReq struct {
|
||||
SkinID uint `json:"skin_id" binding:"required"`
|
||||
BuyerName string `json:"buyer_name"`
|
||||
Remark string `json:"remark"`
|
||||
Status string `json:"status"` // 管理员可传 paid 直接可发货
|
||||
DistributorID *uint `json:"distributor_id"` // 管理员可指定分销商
|
||||
}
|
||||
|
||||
func (h *OrderHandler) Create(c *gin.Context) {
|
||||
var req createOrderReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
response.BadRequest(c, "参数错误:请选择商品")
|
||||
return
|
||||
}
|
||||
distributorID := middleware.GetUserID(c)
|
||||
// 管理员可指定分销商
|
||||
status := ""
|
||||
// 管理员可指定分销商、创建已支付测试单
|
||||
if middleware.GetRole(c) == model.RoleAdmin {
|
||||
if req.DistributorID != nil && *req.DistributorID > 0 {
|
||||
distributorID = *req.DistributorID
|
||||
}
|
||||
if d := c.Query("distributor_id"); d != "" {
|
||||
id, _ := strconv.ParseUint(d, 10, 64)
|
||||
distributorID = uint(id)
|
||||
}
|
||||
if req.Status == model.OrderStatusPaid {
|
||||
status = model.OrderStatusPaid
|
||||
}
|
||||
}
|
||||
if req.BuyerName == "" {
|
||||
req.BuyerName = "测试买家"
|
||||
}
|
||||
order, err := h.svc.Create(service.CreateOrderInput{
|
||||
SkinID: req.SkinID,
|
||||
DistributorID: distributorID,
|
||||
BuyerName: req.BuyerName,
|
||||
Remark: req.Remark,
|
||||
Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
|
||||
@@ -31,6 +31,8 @@ type CreateOrderInput struct {
|
||||
DistributorID uint
|
||||
BuyerName string
|
||||
Remark string
|
||||
// Status 可选:pending(默认)/ paid(联调测试可直接创建可发货订单)
|
||||
Status string
|
||||
}
|
||||
|
||||
func (s *OrderService) List(q OrderListQuery) ([]model.Order, int64, error) {
|
||||
@@ -71,6 +73,11 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
|
||||
return nil, errors.New("库存不足")
|
||||
}
|
||||
|
||||
status := model.OrderStatusPending
|
||||
if in.Status == model.OrderStatusPaid {
|
||||
status = model.OrderStatusPaid
|
||||
}
|
||||
|
||||
order := &model.Order{
|
||||
OrderNo: generateOrderNo(),
|
||||
SkinID: in.SkinID,
|
||||
@@ -78,7 +85,7 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
|
||||
BuyerName: in.BuyerName,
|
||||
Amount: skin.Price,
|
||||
CommissionAmt: skin.Price * skin.Commission,
|
||||
Status: model.OrderStatusPending,
|
||||
Status: status,
|
||||
Remark: in.Remark,
|
||||
}
|
||||
|
||||
@@ -99,6 +106,8 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 带回商品信息,方便前端展示 sku / 订单号联调
|
||||
_ = s.db.Preload("Skin").First(order, order.ID).Error
|
||||
return order, nil
|
||||
}
|
||||
|
||||
|
||||
+137
-176
@@ -1,8 +1,31 @@
|
||||
# 皮肤源头开放接口对接文档
|
||||
# 店铺订单对接 API 说明(发给皮肤源头)
|
||||
|
||||
版本:`v1`
|
||||
Base URL:`https://{你们的域名}`(开发环境示例:`http://localhost:8080`)
|
||||
统一响应:
|
||||
> 文档对象:皮肤源头 / 发货系统技术人员
|
||||
> 版本:v1
|
||||
> 更新说明:鉴权为 **ApiKey + HMAC 签名**,请按本文实现,勿只传 Key。
|
||||
|
||||
---
|
||||
|
||||
## 0. 你们要做什么(一句话)
|
||||
|
||||
1. 用**我们店铺的订单号**(`order_no`)调【订单查询】,确认商品 `sku`、是否可发货 `can_ship`
|
||||
2. 可发则在你们系统发货
|
||||
3. 发货结果调【发货推送】告诉我们,我们同步订单状态
|
||||
|
||||
发货记录、积分消耗在你们后台给店铺看;我们这边以订单状态为准。
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境信息(由店铺方填写后发给你们)
|
||||
|
||||
| 项 | 值(示例 / 请替换) |
|
||||
|----|---------------------|
|
||||
| **API 根地址 Base URL** | `https://api.example.com`(开发:`http://主机:8080`) |
|
||||
| **X-Api-Key** | 由店铺方分配,如 `sk_xxxx` |
|
||||
| **api_secret** | 由店铺方单独发送,**只用于本地算签名,不要写在 URL/Header** |
|
||||
| **时间偏差** | 默认允许 ±300 秒 |
|
||||
|
||||
统一响应格式:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -12,32 +35,21 @@ Base URL:`https://{你们的域名}`(开发环境示例:`http://localhost:
|
||||
}
|
||||
```
|
||||
|
||||
- `code = 0` 表示成功
|
||||
- `code != 0` 表示失败,以 `message` 为准
|
||||
- `code = 0`:成功
|
||||
- `code != 0`:失败,看 `message`
|
||||
|
||||
---
|
||||
|
||||
## 1. 鉴权(ApiKey + 签名)
|
||||
|
||||
开放接口采用 **双因子**:
|
||||
|
||||
| 凭证 | 说明 |
|
||||
|------|------|
|
||||
| `api_key` | 标识对接方,放在请求头 `X-Api-Key` |
|
||||
| `api_secret` | **仅用于本地算签名,禁止放在 Header / 前端 / 日志明文传输** |
|
||||
|
||||
每次请求必须带齐:
|
||||
## 2. 鉴权(每次请求必带)
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
|--------|------|------|
|
||||
| `X-Api-Key` | 是 | 对接方 Key |
|
||||
| `X-Timestamp` | 是 | Unix **秒**级时间戳 |
|
||||
| `X-Nonce` | 是 | 随机串,长度 8~64,同一 Key 下有效期内不可重复 |
|
||||
| `X-Sign` | 是 | HMAC-SHA256 签名,小写十六进制 |
|
||||
| `X-Api-Key` | 是 | 店铺分配的 Key |
|
||||
| `X-Timestamp` | 是 | 当前 Unix **秒**时间戳 |
|
||||
| `X-Nonce` | 是 | 随机串,长度 8~64;同一 Key 在有效期内不可重复 |
|
||||
| `X-Sign` | 是 | 见下方算法 |
|
||||
|
||||
### 1.1 签名算法
|
||||
|
||||
待签名字符串(UTF-8,行之间用 `\n`,共 6 行):
|
||||
### 2.1 签名字符串(6 行,用 `\n` 连接)
|
||||
|
||||
```text
|
||||
{api_key}
|
||||
@@ -48,105 +60,63 @@ Base URL:`https://{你们的域名}`(开发环境示例:`http://localhost:
|
||||
{body}
|
||||
```
|
||||
|
||||
| 项 | 规则 |
|
||||
|----|------|
|
||||
| METHOD | 大写,如 `GET` / `POST` |
|
||||
| path | `URL.Path`,**不含** query,如 `/api/open/v1/orders/O123` |
|
||||
| body | 原始请求体字符串;GET 无 body 时用 **空字符串**(仍保留最后一行空内容,即末尾仍有换行结构中的 body 段为空) |
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| METHOD | 大写:`GET` / `POST` |
|
||||
| path | 仅路径,**不要**域名、**不要** query。例:`/api/open/v1/orders/O123` |
|
||||
| body | 原始 HTTP body 字符串;GET 用**空字符串** |
|
||||
| X-Sign | `hex( HMAC-SHA256( api_secret, 签名字符串 ) )`,**小写**十六进制 |
|
||||
|
||||
计算:
|
||||
|
||||
```text
|
||||
X-Sign = hex( HMAC-SHA256( api_secret, string_to_sign ) )
|
||||
```
|
||||
|
||||
- 使用 **小写** hex
|
||||
- 时间戳与服务器偏差超过 **300 秒**(可配置)则拒绝
|
||||
- 同一 `api_key + nonce` 在有效期内重复使用 → 拒绝(防重放)
|
||||
|
||||
### 1.2 Python 示例
|
||||
### 2.2 Python 参考实现
|
||||
|
||||
```python
|
||||
import hmac, hashlib, time, uuid, requests
|
||||
|
||||
API_KEY = "sk_source_dev_key_change_me"
|
||||
API_SECRET = "sk_source_dev_secret_change_me"
|
||||
BASE = "http://localhost:8080"
|
||||
API_KEY = "请替换为店铺下发的 key"
|
||||
API_SECRET = "请替换为店铺下发的 secret"
|
||||
BASE = "https://api.example.com" # 请替换
|
||||
|
||||
def sign(method: str, path: str, body: str = "") -> dict:
|
||||
def sign_headers(method: str, path: str, body: str = "") -> dict:
|
||||
ts = str(int(time.time()))
|
||||
nonce = uuid.uuid4().hex
|
||||
string_to_sign = "\n".join([API_KEY, ts, nonce, method.upper(), path, body])
|
||||
sign_hex = hmac.new(
|
||||
API_SECRET.encode(), string_to_sign.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
raw = "\n".join([API_KEY, ts, nonce, method.upper(), path, body])
|
||||
sign = hmac.new(API_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return {
|
||||
"X-Api-Key": API_KEY,
|
||||
"X-Timestamp": ts,
|
||||
"X-Nonce": nonce,
|
||||
"X-Sign": sign_hex,
|
||||
"X-Sign": sign,
|
||||
}
|
||||
|
||||
# 查询订单
|
||||
path = "/api/open/v1/orders/O202607201550038000"
|
||||
r = requests.get(BASE + path, headers=sign("GET", path))
|
||||
print(r.json())
|
||||
# —— 查询订单 ——
|
||||
path = "/api/open/v1/orders/O202607201550038000" # 换成真实店铺订单号
|
||||
print(requests.get(BASE + path, headers=sign_headers("GET", path)).json())
|
||||
|
||||
# 发货推送
|
||||
# —— 推送发货成功(body 必须与签名用的 body 完全一致)——
|
||||
path = "/api/open/v1/orders/ship-notify"
|
||||
body = '{"order_no":"O202607201550038000","ship_status":"success","provider_order_no":"SRC001"}'
|
||||
headers = {"Content-Type": "application/json", **sign("POST", path, body)}
|
||||
r = requests.post(BASE + path, headers=headers, data=body.encode())
|
||||
print(r.json())
|
||||
headers = {"Content-Type": "application/json", **sign_headers("POST", path, body)}
|
||||
print(requests.post(BASE + path, headers=headers, data=body.encode("utf-8")).json())
|
||||
```
|
||||
|
||||
> **注意**:POST 签名用的 `body` 必须与实际发送的 body **字节级一致**(不要先 `json.dumps` 又改空格)。
|
||||
**注意:**
|
||||
|
||||
### 1.3 鉴权失败示例
|
||||
|
||||
```json
|
||||
{ "code": 401, "message": "签名校验失败" }
|
||||
```
|
||||
|
||||
常见原因:secret 错误、body 不一致、timestamp 过期、nonce 重复、path 写错(多了 query / 域名)。
|
||||
|
||||
### 1.4 环境变量(我方)
|
||||
|
||||
| 变量 | 说明 | 开发默认 |
|
||||
|------|------|----------|
|
||||
| `OPEN_API_KEY` | Api Key | `sk_source_dev_key_change_me` |
|
||||
| `OPEN_API_SECRET` | 签名密钥 | `sk_source_dev_secret_change_me` |
|
||||
| `OPEN_SIGN_SKEW` | 时间偏差秒数 | `300` |
|
||||
|
||||
生产务必更换 Key / Secret。
|
||||
---
|
||||
|
||||
## 2. 对接流程
|
||||
|
||||
```text
|
||||
1. 买家在店铺下单并完成支付
|
||||
2. 上游拿到「店铺订单号 order_no」
|
||||
3. 调用【订单查询】确认商品 sku、can_ship
|
||||
4. can_ship=true 时执行发货
|
||||
5. 发货结果调用【发货推送】回传
|
||||
6. 我方同步订单状态;重复推送 success 幂等成功
|
||||
```
|
||||
- POST 时 `json.dumps` 后的字符串要原样发送,不要一边签名一边再改空格/字段顺序。
|
||||
- 签名失败常见原因:secret 错、path 多了 query、body 不一致、时间戳过期、nonce 重复。
|
||||
|
||||
---
|
||||
|
||||
## 3. 订单查询(发货前置)
|
||||
## 3. 接口一:订单查询(发货前置)
|
||||
|
||||
### 请求
|
||||
### 3.1 请求
|
||||
|
||||
```http
|
||||
GET /api/open/v1/orders/{order_no}
|
||||
X-Api-Key: {api_key}
|
||||
X-Timestamp: {unix_seconds}
|
||||
X-Nonce: {random_8_to_64}
|
||||
X-Sign: {hmac_sha256_hex}
|
||||
```
|
||||
|
||||
### 成功响应
|
||||
`order_no`:店铺订单号(由买家/店铺提供,格式类似 `O202607201550038000`)。
|
||||
|
||||
### 3.2 成功示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -172,60 +142,51 @@ X-Sign: {hmac_sha256_hex}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
### 3.3 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| order_no | string | 店铺订单号 |
|
||||
| status | string | 订单状态,见下表 |
|
||||
| can_ship | bool | **是否允许发货**,请以此为准 |
|
||||
| cannot_ship_reason | string | 不可发货原因(can_ship=false 时) |
|
||||
| product.name | string | 商品中文名 |
|
||||
| product.sku | string | **商品英文固定标识,发货请用此字段** |
|
||||
| product.game | string | 游戏,如「和平精英」 |
|
||||
| buyer_name | string | 买家备注名 |
|
||||
| amount | number | 订单金额 |
|
||||
| provider_order_no | string | 上游单号(若已回传) |
|
||||
| created_at | string | 下单时间 |
|
||||
| shipped_at | string\|null | 发货成功时间 |
|
||||
| ship_fail_reason | string | 最近一次发货失败原因 |
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `order_no` | 店铺订单号 |
|
||||
| `status` | 订单状态,见下表 |
|
||||
| **`can_ship`** | **是否允许发货,发货前必须为 true** |
|
||||
| `cannot_ship_reason` | 不可发原因(can_ship=false 时) |
|
||||
| **`product.sku`** | **发货商品标识(英文固定码),请按此发货** |
|
||||
| `product.name` | 中文名(展示用) |
|
||||
| `product.game` | 游戏,如「和平精英」 |
|
||||
| `buyer_name` | 买家备注 |
|
||||
| `amount` | 金额 |
|
||||
| `provider_order_no` | 若已回传过你们的单号 |
|
||||
| `shipped_at` | 发货成功时间 |
|
||||
| `ship_fail_reason` | 最近失败原因 |
|
||||
|
||||
### 订单状态 status
|
||||
### 3.4 订单状态与 can_ship
|
||||
|
||||
| 值 | 含义 | can_ship |
|
||||
|----|------|----------|
|
||||
| pending | 待支付 | false |
|
||||
| paid | 已支付 | **true** |
|
||||
| delivering | 发货中 | false |
|
||||
| delivered | 已交付 | false |
|
||||
| ship_failed | 发货失败(可重试) | **true** |
|
||||
| cancelled | 已取消 | false |
|
||||
| status | 含义 | can_ship |
|
||||
|--------|------|----------|
|
||||
| `pending` | 待支付 | **false** |
|
||||
| `paid` | 已支付 | **true** ← 正常可发 |
|
||||
| `delivering` | 发货中 | false |
|
||||
| `delivered` | 已交付 | false |
|
||||
| `ship_failed` | 发货失败 | **true** ← 可重试 |
|
||||
| `cancelled` | 已取消 | false |
|
||||
|
||||
### 错误
|
||||
### 3.5 错误
|
||||
|
||||
| HTTP | message |
|
||||
|------|---------|
|
||||
| HTTP / code | 含义 |
|
||||
|-------------|------|
|
||||
| 401 | 鉴权失败(Key/签名/时间/Nonce) |
|
||||
| 404 | 订单不存在 |
|
||||
| 400 | 订单号不能为空 |
|
||||
|
||||
### 调用说明
|
||||
|
||||
请使用上一节 Python 示例生成签名后请求;仅带 `X-Api-Key` 而不带签名会被拒绝。
|
||||
| 404 | 订单号不存在 |
|
||||
| 400 | 参数错误 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 发货结果推送
|
||||
## 4. 接口二:发货结果推送
|
||||
|
||||
### 请求
|
||||
### 4.1 请求
|
||||
|
||||
```http
|
||||
POST /api/open/v1/orders/ship-notify
|
||||
Content-Type: application/json
|
||||
X-Api-Key: {api_key}
|
||||
X-Timestamp: {unix_seconds}
|
||||
X-Nonce: {random_8_to_64}
|
||||
X-Sign: {hmac_sha256_hex}
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -238,31 +199,29 @@ X-Sign: {hmac_sha256_hex}
|
||||
}
|
||||
```
|
||||
|
||||
### 请求字段
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| order_no | 是 | 店铺订单号 |
|
||||
| ship_status | 是 | `success` / `failed` / `processing` |
|
||||
| provider_order_no | 否 | 上游发货单号 |
|
||||
| shipped_at | 否 | 发货时间,RFC3339;success 时缺省用服务端时间 |
|
||||
| fail_reason | 否 | 失败原因(failed 时建议填写) |
|
||||
| `order_no` | 是 | 店铺订单号 |
|
||||
| `ship_status` | 是 | `success` / `failed` / `processing` |
|
||||
| `provider_order_no` | 否 | 你们系统的发货单号 |
|
||||
| `shipped_at` | 否 | RFC3339;success 未传则用服务端时间 |
|
||||
| `fail_reason` | 否 | 失败原因(failed 时建议填) |
|
||||
|
||||
### ship_status 与订单状态映射
|
||||
### 4.2 ship_status → 我们订单状态
|
||||
|
||||
| ship_status | 订单变为 | 说明 |
|
||||
|-------------|---------|------|
|
||||
| processing | delivering | 已接单/发货中 |
|
||||
| success | delivered | 发货成功 |
|
||||
| failed | ship_failed | 发货失败,允许再次查询后重试 |
|
||||
| `processing` | `delivering` | 已接单 / 发货中 |
|
||||
| `success` | `delivered` | 发货成功 |
|
||||
| `failed` | `ship_failed` | 失败,允许之后再次查询并重试 |
|
||||
|
||||
### 幂等
|
||||
### 4.3 幂等与限制
|
||||
|
||||
- 订单已是 `delivered`,再次推送 `success` → **返回成功**,不重复处理
|
||||
- 订单已是 `delivered`,推送 `failed` → 拒绝
|
||||
- 订单已是 `delivered`,再推 `success` → **仍返回成功**,不重复处理
|
||||
- 订单已是 `delivered`,推 `failed` → 拒绝
|
||||
- 订单 `cancelled` → 拒绝更新
|
||||
|
||||
### 成功响应
|
||||
### 4.4 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -276,48 +235,50 @@ X-Sign: {hmac_sha256_hex}
|
||||
}
|
||||
```
|
||||
|
||||
### 调用说明
|
||||
|
||||
Body 与签名字符串中的 body **必须完全一致**。完整示例见 **§1.2 Python**。
|
||||
|
||||
| ship_status | body 示例 |
|
||||
|-------------|-----------|
|
||||
| processing | `{"order_no":"O...","ship_status":"processing","provider_order_no":"SRC..."}` |
|
||||
| success | `{"order_no":"O...","ship_status":"success","provider_order_no":"SRC...","shipped_at":"2026-07-20T16:00:00+08:00"}` |
|
||||
| failed | `{"order_no":"O...","ship_status":"failed","fail_reason":"账号不存在"}` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 推荐调用顺序
|
||||
|
||||
```text
|
||||
query → can_ship?
|
||||
├─ false → 停止,展示 cannot_ship_reason
|
||||
└─ true
|
||||
→(可选)push processing
|
||||
→ 执行发货
|
||||
→ push success / failed
|
||||
拿到店铺订单号 order_no
|
||||
↓
|
||||
GET 订单查询
|
||||
↓
|
||||
can_ship == false ? → 停止,展示 cannot_ship_reason
|
||||
↓ true
|
||||
(可选)POST ship_status=processing
|
||||
↓
|
||||
按 product.sku 发货
|
||||
↓
|
||||
POST ship_status=success 或 failed
|
||||
```
|
||||
|
||||
发货务必使用查询结果中的 **`product.sku`**,不要仅依赖中文名。
|
||||
**发货请以 `product.sku` 为准**,不要只依赖中文名。
|
||||
|
||||
---
|
||||
|
||||
## 6. 联调检查清单
|
||||
|
||||
- [ ] 缺少 Sign / 错误 Secret 返回 401
|
||||
- [ ] 错误 ApiKey 返回 401
|
||||
- [ ] 过期 Timestamp / 重复 Nonce 返回 401
|
||||
- [ ] 不存在的订单号返回 404
|
||||
- [ ] `pending` 订单 `can_ship=false`
|
||||
- [ ] `paid` 订单 `can_ship=true` 且 sku 正确
|
||||
- [ ] success 推送后状态为 delivered
|
||||
- [ ] 只带 Key 不带签名 → 401
|
||||
- [ ] Secret 错误 → 401「签名校验失败」
|
||||
- [ ] 错误/过期 Timestamp、重复 Nonce → 401
|
||||
- [ ] 不存在的 order_no → 404
|
||||
- [ ] 未支付订单 can_ship=false
|
||||
- [ ] 已支付订单 can_ship=true,且 sku 正确
|
||||
- [ ] success 后订单 delivered
|
||||
- [ ] 重复 success 幂等成功
|
||||
- [ ] failed 后可再次 query 且 can_ship=true
|
||||
- [ ] failed 后可再 query 且 can_ship=true
|
||||
|
||||
---
|
||||
|
||||
## 7. 联系方式
|
||||
## 7. 商品 sku 说明
|
||||
|
||||
接口问题、密钥申请、联调环境请联系店铺方技术对接人。
|
||||
商品英文 sku 对照见:`docs/商品英文名对照.md`
|
||||
商品英文码与中文名对照由店铺维护(如:套装-糯粉咩咩 → `suit_pink_sheep`)。
|
||||
查询接口返回的 `product.sku` 即当前订单应发商品。
|
||||
|
||||
---
|
||||
|
||||
## 8. 联系方式
|
||||
|
||||
接口问题、密钥申请、联调订单号请联系**店铺方技术对接人**。
|
||||
本文档由店铺系统生成/维护,以店铺最新版本为准。
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# 前端开发配置示例
|
||||
# 复制为 frontend/.env 后生效(Vite 仅暴露 VITE_ 前缀变量)
|
||||
|
||||
# 开发服务器端口(也可由根目录 FRONTEND_PORT 通过 start.sh 传入)
|
||||
# PORT=5173
|
||||
|
||||
# 后端 API 代理目标(见 vite.config.ts)
|
||||
VITE_API_PROXY_TARGET=http://localhost:8080
|
||||
@@ -45,8 +45,13 @@ export const skinApi = {
|
||||
export const orderApi = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get('/orders', { params }).then((r) => r.data.data as PageResult<Order>),
|
||||
create: (data: { skin_id: number; buyer_name?: string; remark?: string }) =>
|
||||
request.post('/orders', data).then((r) => r.data.data as Order),
|
||||
create: (data: {
|
||||
skin_id: number
|
||||
buyer_name?: string
|
||||
remark?: string
|
||||
status?: string
|
||||
distributor_id?: number
|
||||
}) => request.post('/orders', data).then((r) => r.data.data as Order),
|
||||
updateStatus: (id: number, status: string) =>
|
||||
request.patch(`/orders/${id}/status`, { status }).then((r) => r.data.data),
|
||||
}
|
||||
|
||||
+218
-10
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@@ -8,12 +12,12 @@ import {
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { orderApi } from '../api'
|
||||
import { orderApi, skinApi } from '../api'
|
||||
import { useAuth } from '../store/auth'
|
||||
import type { Order } from '../types'
|
||||
import type { Order, Skin } from '../types'
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'orange', text: '待支付' },
|
||||
@@ -32,6 +36,11 @@ export default function Orders() {
|
||||
const [size, setSize] = useState(10)
|
||||
const [status, setStatus] = useState<string | undefined>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [skins, setSkins] = useState<Skin[]>([])
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createdOrder, setCreatedOrder] = useState<Order | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -50,6 +59,52 @@ export default function Orders() {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const openCreate = async () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
buyer_name: '测试买家',
|
||||
mark_paid: true,
|
||||
remark: '联调测试订单',
|
||||
})
|
||||
setCreatedOrder(null)
|
||||
setCreateOpen(true)
|
||||
try {
|
||||
const data = await skinApi.list({ page: 1, size: 100, status: 1 })
|
||||
setSkins(data.list || [])
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载商品失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onCreate = async () => {
|
||||
const values = await form.validateFields()
|
||||
setCreating(true)
|
||||
try {
|
||||
const order = await orderApi.create({
|
||||
skin_id: values.skin_id,
|
||||
buyer_name: values.buyer_name,
|
||||
remark: values.remark,
|
||||
status: isAdmin && values.mark_paid ? 'paid' : undefined,
|
||||
})
|
||||
setCreatedOrder(order)
|
||||
message.success(`测试订单已创建:${order.order_no}`)
|
||||
load()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (text: string, tip = '已复制') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success(tip)
|
||||
} catch {
|
||||
message.error('复制失败,请手动选择')
|
||||
}
|
||||
}
|
||||
|
||||
const changeStatus = async (id: number, next: string) => {
|
||||
try {
|
||||
await orderApi.updateStatus(id, next)
|
||||
@@ -61,7 +116,19 @@ export default function Orders() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Order> = [
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 190, ellipsis: true },
|
||||
{
|
||||
title: '店铺订单号',
|
||||
dataIndex: 'order_no',
|
||||
width: 210,
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<Space size={4}>
|
||||
<Typography.Text copyable={{ text: v }} style={{ maxWidth: 160 }} ellipsis>
|
||||
{v}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '皮肤',
|
||||
dataIndex: ['skin', 'name'],
|
||||
@@ -71,9 +138,16 @@ export default function Orders() {
|
||||
},
|
||||
{
|
||||
title: 'SKU',
|
||||
width: 140,
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (_, r) => r.skin?.sku || '-',
|
||||
render: (_, r) =>
|
||||
r.skin?.sku ? (
|
||||
<Typography.Text code copyable={{ text: r.skin.sku }}>
|
||||
{r.skin.sku}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分销商',
|
||||
@@ -117,9 +191,10 @@ export default function Orders() {
|
||||
columns.push({
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 220,
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Space size={0}>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Button type="link" size="small" onClick={() => changeStatus(record.id, 'paid')}>
|
||||
@@ -130,7 +205,9 @@ export default function Orders() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(record.status === 'paid' || record.status === 'ship_failed' || record.status === 'delivering') && (
|
||||
{(record.status === 'paid' ||
|
||||
record.status === 'ship_failed' ||
|
||||
record.status === 'delivering') && (
|
||||
<Button type="link" size="small" onClick={() => changeStatus(record.id, 'delivered')}>
|
||||
标记交付
|
||||
</Button>
|
||||
@@ -143,9 +220,14 @@ export default function Orders() {
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
订单管理
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
店铺订单号 order_no 给源头查询发货;已支付订单 can_ship=true
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Select
|
||||
allowClear
|
||||
@@ -168,6 +250,9 @@ export default function Orders() {
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
创建测试订单
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
@@ -177,18 +262,141 @@ export default function Orders() {
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, s) => {
|
||||
setPage(p)
|
||||
setSize(s)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="创建测试订单(联调用)"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
footer={
|
||||
createdOrder
|
||||
? [
|
||||
<Button key="close" onClick={() => setCreateOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button
|
||||
key="again"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setCreatedOrder(null)
|
||||
form.setFieldsValue({ mark_paid: true })
|
||||
}}
|
||||
>
|
||||
再下一单
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="cancel" onClick={() => setCreateOpen(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="ok" type="primary" loading={creating} onClick={onCreate}>
|
||||
创建
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
>
|
||||
{createdOrder ? (
|
||||
<div>
|
||||
<Typography.Paragraph>
|
||||
订单已创建。把下面的 <Typography.Text strong>店铺订单号</Typography.Text>{' '}
|
||||
发给源头,或用开放接口查询:
|
||||
</Typography.Paragraph>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 8, color: '#666' }}>店铺订单号 order_no</div>
|
||||
<Space>
|
||||
<Typography.Title level={4} style={{ margin: 0 }} copyable>
|
||||
{createdOrder.order_no}
|
||||
</Typography.Title>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyText(createdOrder.order_no, '订单号已复制')}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div>
|
||||
<div>商品:{createdOrder.skin?.name || `#${createdOrder.skin_id}`}</div>
|
||||
<div>
|
||||
SKU:
|
||||
<Typography.Text code copyable>
|
||||
{createdOrder.skin?.sku || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
状态:
|
||||
<Tag color={statusMap[createdOrder.status]?.color}>
|
||||
{statusMap[createdOrder.status]?.text || createdOrder.status}
|
||||
</Tag>
|
||||
{createdOrder.status === 'paid' && (
|
||||
<Typography.Text type="success">(可发货 can_ship=true)</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div>买家:{createdOrder.buyer_name}</div>
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }}>
|
||||
源头查询:GET /api/open/v1/orders/{createdOrder.order_no}
|
||||
<br />
|
||||
需带 X-Api-Key / X-Timestamp / X-Nonce / X-Sign
|
||||
</Typography.Paragraph>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
|
||||
<Form.Item
|
||||
name="skin_id"
|
||||
label="商品皮肤"
|
||||
rules={[{ required: true, message: '请选择商品' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择要发货的皮肤"
|
||||
options={skins.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name}(${s.sku})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="buyer_name" label="买家名称">
|
||||
<Input placeholder="测试买家" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="联调说明" />
|
||||
</Form.Item>
|
||||
{isAdmin && (
|
||||
<Form.Item name="mark_paid" valuePropName="checked">
|
||||
<Checkbox>直接标记为已支付(源头可立即查询并发货)</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0 }}>
|
||||
创建后系统会生成店铺订单号(如 O20260720…),这就是源头「输入订单号」要用的号。
|
||||
</Typography.Paragraph>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+12
-5
@@ -1,19 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 读取 frontend/.env* 与进程环境(start.sh 会注入根目录 .env)
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:8080'
|
||||
const port = Number(env.PORT || env.FRONTEND_PORT || 5173)
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
port,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,12 +3,24 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKEND_PORT="${PORT:-8080}"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-5173}"
|
||||
PIDS=()
|
||||
|
||||
log() { echo ">>> $*"; }
|
||||
|
||||
# 加载仓库根目录 .env(source 后可供 PORT / FRONTEND_PORT 使用)
|
||||
if [[ -f "$ROOT/.env" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT/.env"
|
||||
set +a
|
||||
log "已加载 $ROOT/.env"
|
||||
elif [[ -f "$ROOT/.env.example" ]]; then
|
||||
log "未找到 .env,可执行: cp .env.example .env"
|
||||
fi
|
||||
|
||||
BACKEND_PORT="${PORT:-8080}"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-5173}"
|
||||
|
||||
# 结束指定端口上的进程(避免残留占用)
|
||||
free_port() {
|
||||
local port=$1
|
||||
|
||||
Reference in New Issue
Block a user