From e4d0a719638fc8b4c5f1f063461e1ceda99406f5 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 20 Jul 2026 16:19:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=20.env=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84=E6=BA=90=E5=A4=B4=E5=AF=B9?= =?UTF-8?q?=E6=8E=A5=E6=96=87=E6=A1=A3=E4=B8=8E=E6=B5=8B=E8=AF=95=E8=AE=A2?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 .env.example 与 godotenv 加载,start.sh 自动读环境变量 - 重写发给源头的开放接口对接文档 - 订单页支持创建测试订单(可直接已支付并复制店铺订单号) --- .env.example | 23 +++ .gitignore | 9 + backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/config/config.go | 31 +++ backend/internal/handler/order.go | 23 ++- backend/internal/service/order.go | 11 +- docs/开放接口-皮肤源头对接.md | 317 +++++++++++++----------------- frontend/.env.example | 8 + frontend/src/api/index.ts | 9 +- frontend/src/pages/Orders.tsx | 234 ++++++++++++++++++++-- frontend/vite.config.ts | 35 ++-- start.sh | 16 +- 13 files changed, 504 insertions(+), 215 deletions(-) create mode 100644 .env.example create mode 100644 frontend/.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..16db981 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 10c0371..fbcf18c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ +# env(保留示例文件) +.env +.env.local +.env.*.local +backend/.env +frontend/.env +!.env.example +!frontend/.env.example + # backend backend/bin/ backend/data/ diff --git a/backend/go.mod b/backend/go.mod index d98bd78..46bbbb7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 42f0bd9..9824e6b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 6d9f48d..2105993 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 diff --git a/backend/internal/handler/order.go b/backend/internal/handler/order.go index e17d0b3..e1995fa 100644 --- a/backend/internal/handler/order.go +++ b/backend/internal/handler/order.go @@ -45,30 +45,43 @@ func (h *OrderHandler) List(c *gin.Context) { } type createOrderReq struct { - SkinID uint `json:"skin_id" binding:"required"` - BuyerName string `json:"buyer_name"` - Remark string `json:"remark"` + 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()) diff --git a/backend/internal/service/order.go b/backend/internal/service/order.go index eaa573c..0bd591c 100644 --- a/backend/internal/service/order.go +++ b/backend/internal/service/order.go @@ -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 } diff --git a/docs/开放接口-皮肤源头对接.md b/docs/开放接口-皮肤源头对接.md index 09a48e6..c20c5db 100644 --- a/docs/开放接口-皮肤源头对接.md +++ b/docs/开放接口-皮肤源头对接.md @@ -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 | -|------|---------| -| 401 | 鉴权失败(Key / 签名 / 时间 / Nonce) | -| 404 | 订单不存在 | -| 400 | 订单号不能为空 | - -### 调用说明 - -请使用上一节 Python 示例生成签名后请求;仅带 `X-Api-Key` 而不带签名会被拒绝。 +| HTTP / code | 含义 | +|-------------|------| +| 401 | 鉴权失败(Key/签名/时间/Nonce) | +| 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` → 拒绝 -- 订单 `cancelled` → 拒绝更新 +- 订单已是 `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. 联系方式 + +接口问题、密钥申请、联调订单号请联系**店铺方技术对接人**。 +本文档由店铺系统生成/维护,以店铺最新版本为准。 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..6bd612a --- /dev/null +++ b/frontend/.env.example @@ -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 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f274802..eb65345 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -45,8 +45,13 @@ export const skinApi = { export const orderApi = { list: (params?: Record) => request.get('/orders', { params }).then((r) => r.data.data as PageResult), - 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), } diff --git a/frontend/src/pages/Orders.tsx b/frontend/src/pages/Orders.tsx index 1d3b6e7..0b32ad1 100644 --- a/frontend/src/pages/Orders.tsx +++ b/frontend/src/pages/Orders.tsx @@ -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 = { pending: { color: 'orange', text: '待支付' }, @@ -32,6 +36,11 @@ export default function Orders() { const [size, setSize] = useState(10) const [status, setStatus] = useState() const [loading, setLoading] = useState(false) + const [createOpen, setCreateOpen] = useState(false) + const [skins, setSkins] = useState([]) + const [creating, setCreating] = useState(false) + const [createdOrder, setCreatedOrder] = useState(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 = [ - { title: '订单号', dataIndex: 'order_no', width: 190, ellipsis: true }, + { + title: '店铺订单号', + dataIndex: 'order_no', + width: 210, + ellipsis: true, + render: (v: string) => ( + + + {v} + + + ), + }, { 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 ? ( + + {r.skin.sku} + + ) : ( + '-' + ), }, { title: '分销商', @@ -117,9 +191,10 @@ export default function Orders() { columns.push({ title: '操作', key: 'action', - width: 220, + width: 200, + fixed: 'right', render: (_, record) => ( - + {record.status === 'pending' && ( <> )} - {(record.status === 'paid' || record.status === 'ship_failed' || record.status === 'delivering') && ( + {(record.status === 'paid' || + record.status === 'ship_failed' || + record.status === 'delivering') && ( @@ -143,9 +220,14 @@ export default function Orders() { return (
- - 订单管理 - +
+ + 订单管理 + + + 店铺订单号 order_no 给源头查询发货;已支付订单 can_ship=true + +
({ + value: s.id, + label: `${s.name}(${s.sku})`, + }))} + /> + + + + + + + + {isAdmin && ( + + 直接标记为已支付(源头可立即查询并发货) + + )} + + 创建后系统会生成店铺订单号(如 O20260720…),这就是源头「输入订单号」要用的号。 + + + )} +
) } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 526f3b9..766d0ec 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,19 +1,26 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' -export default defineConfig({ - plugins: [react()], - server: { - port: 5173, - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true, - }, - '/health': { - target: 'http://localhost:8080', - changeOrigin: true, +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, + proxy: { + '/api': { + target: apiTarget, + changeOrigin: true, + }, + '/health': { + target: apiTarget, + changeOrigin: true, + }, }, }, - }, + } }) diff --git a/start.sh b/start.sh index c49f126..dc4cfe0 100755 --- a/start.sh +++ b/start.sh @@ -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