Merge #5 into dev from dev_jacy
发布商品,商品图片使用oss服务器地址 * dev_jacy: (4 commits) 小飞侠接口对接 v3计划内容上传 Merge commit 'ed1845c0ba98c2e1d6d80d5bf4eec2b46cdf0952' into dev_jacy(xiaofeixia ) 发布商品,商品图片使用oss服务器地址 Signed-off-by: jacy <moonjie444@163.com> Reviewed-by: jacy <moonjie444@163.com> Merged-by: jacy <moonjie444@163.com> CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/5
This commit is contained in:
@@ -50,7 +50,7 @@ Never scatter `if (process.env.MOCK_PAY)` in trade/benefit services.
|
||||
1. Align with V2 manual §五
|
||||
2. Notify table owner: jacy-dukang 或 刘京尧(store/redeem 相关表)
|
||||
3. `pnpm db:validate` + seed still works
|
||||
4. PR: jacy-dukang Review;涉及 store/redeem 表时 @刘京尧
|
||||
4. PR: jacy-dukang Review;涉及 store/redeem 表时通知刘京尧
|
||||
|
||||
## Forbidden
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ Inspect the diff (or named files) for:
|
||||
|
||||
| 逻辑域 | Git 账号 | Apps | Modules |
|
||||
|--------|----------|------|---------|
|
||||
| 主责 | jacy-dukang | h5-user, admin-web | iam, trade, benefit, analytics, catalog, settlement, ops |
|
||||
| 合伙人+门店 | 刘京尧 | h5-partner, h5-shop | store, redeem |
|
||||
| 主责 | jacy-dukang | h5-user, admin-web, 门店 | iam, trade, benefit, analytics, catalog, settlement, ops |
|
||||
| 合伙人 | 刘京尧 | h5-partner, h5-shop | store, redeem |
|
||||
| 横切 | jacy-dukang | — | packages, callbacks, jobs, common, integrations |
|
||||
|
||||
逻辑 A/B/C/D 边界仍有效;刘京尧 同时负责 B+D,但 **store 与 redeem 模块仍不可互写表**。
|
||||
|
||||
@@ -36,7 +36,7 @@ User (Owner A) generates token → Shop scans/confirms (you)
|
||||
## Hard rules
|
||||
|
||||
- Redis token: `redeem:token:{token}` TTL 300s
|
||||
- Amount: `0 < amount ≤ min(balance, 500)`
|
||||
- Amount: direct redeem `0 < amount ≤ total ACTIVE benefit balance`; document redeem `0 < amount ≤ document balance`
|
||||
- Transaction + coupon `version` optimistic lock
|
||||
- **Never** `prisma.order.update` in redeem module
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ alwaysApply: true
|
||||
## 核心业务常量
|
||||
|
||||
- 权益额 = `benefit_amount ?? price`
|
||||
- 核销:0 < amount ≤ min(balance, **500**)
|
||||
- 核销:直接核销 `0 < amount ≤ 全部 ACTIVE 权益总余额`;带单据核销 `0 < amount ≤ 该单据可用金额`
|
||||
- 订单 Tab:`all | pending_pay | pending_ship | pending_receive | completed`
|
||||
- API:`/api/v1`,响应 `{ code, message, data }`
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ src/
|
||||
| App | 端口 | 负责人 | 样式 | X-Client-App |
|
||||
|-----|------|--------|------|--------------|
|
||||
| h5-user | 5173 | jacy-dukang | shared-ui tokens | USER_H5 |
|
||||
| h5-shop | 5174 | 刘京尧 | shared-ui tokens | SHOP_H5 |
|
||||
| h5-shop | 5174 | jacy-dukang | shared-ui tokens | SHOP_H5 |
|
||||
| h5-partner | 5175 | 刘京尧 | shared-ui tokens | PARTNER_H5 |
|
||||
| admin-web | 5175 | jacy-dukang | Ant Design 5 | HQ_WEB |
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Conventional Commits,scope = 端或模块名:
|
||||
|
||||
```
|
||||
feat(trade): add order preview API
|
||||
fix(redeem): enforce 500 yuan cap
|
||||
fix(redeem): validate redeem amount against balance
|
||||
chore(shared-types): add OrderStatus enum
|
||||
```
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ alwaysApply: false
|
||||
## domain
|
||||
|
||||
- 纯函数,无 IO(无 Prisma/Redis/HTTP)
|
||||
- 起购 2/6 瓶、权益 `benefitAmount ?? price`、核销 ¥500 上限在此实现
|
||||
- 起购 2/6 瓶、权益 `benefitAmount ?? price`、V3 两路径核销规则在此实现
|
||||
- 变更必须有单元测试
|
||||
|
||||
```typescript
|
||||
const benefitAmount = product.benefitAmount ?? product.price;
|
||||
// 同城起购 2 瓶 / 跨城 6 瓶
|
||||
// 核销上限 ¥500
|
||||
// 直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
|
||||
```
|
||||
|
||||
## shared-ui
|
||||
|
||||
@@ -81,14 +81,14 @@ await this.prisma.order.update({ ... });
|
||||
## 数据库变更
|
||||
|
||||
1. 改 `server/dukang-api/prisma/schema.prisma` 对齐手册 §五
|
||||
2. 迁移需 OWNER Review(store/redeem 表 → @刘京尧)
|
||||
2. 迁移需 OWNER Review(store/redeem 表 → 刘景尧)
|
||||
3. 初始化 SQL:`server/dukang-api/prisma/init_v3.sql`
|
||||
|
||||
## 核心业务(packages/domain)
|
||||
|
||||
```typescript
|
||||
const benefitAmount = product.benefitAmount ?? product.price;
|
||||
// 核销:0 < amount ≤ min(balance, 500)
|
||||
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
|
||||
// 同城 min 2 瓶 / 跨城 min 6 瓶
|
||||
|
||||
// 支付成功 → log_third_party + user_order.pay_status=PAID
|
||||
|
||||
@@ -36,8 +36,8 @@ Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。
|
||||
| catalog | `/catalog`, `/admin/cities`, `/admin/products` | jacy-dukang |
|
||||
| trade | `/trade`, `/partner/orders`, `/admin/orders` | jacy-dukang |
|
||||
| benefit | `/benefit` | jacy-dukang |
|
||||
| store | `/stores`, `/partner/stores`, `/admin/store-audits` | 刘京尧 |
|
||||
| redeem | `/redeem`, `/shop/redeem` | 刘京尧 |
|
||||
| store | `/stores`, `/partner/stores`, `/admin/store-audits` | 刘景尧 |
|
||||
| redeem | `/redeem`, `/shop/redeem` | 刘景尧 |
|
||||
| settlement | `/settlement`, `/partner/settlement`, `/admin/settlement` | jacy-dukang |
|
||||
| ops | `/admin/dashboard`, `/admin/reports` | jacy-dukang |
|
||||
| analytics | `/analytics`, `/promo/touch` | jacy-dukang |
|
||||
@@ -48,8 +48,8 @@ Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。
|
||||
| 前缀 | 示例表 | 负责人 |
|
||||
|------|--------|--------|
|
||||
| user_ | user_user, user_order, user_benefit_coupon | jacy-dukang |
|
||||
| store_ | store_store, store_account, store_payout | 刘京尧 / jacy-dukang(settlement) |
|
||||
| partner_ | partner_partner, partner_bill | 刘京尧 / jacy-dukang(settlement) |
|
||||
| store_ | store_store, store_account, store_payout | 刘景尧 / jacy-dukang(settlement) |
|
||||
| partner_ | partner_partner, partner_bill | 刘景尧 / jacy-dukang(settlement) |
|
||||
| hq_ | hq_account | jacy-dukang |
|
||||
| common_ | common_product_item, common_event, common_city | jacy-dukang |
|
||||
| log_ | log_third_party, log_user_analytics | 写入方 Module |
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
| App | 目录 | 端口 | 负责人 | X-Client-App | 原型 |
|
||||
|-----|------|------|--------|--------------|------|
|
||||
| h5-user | apps/h5-user | 5173 | jacy-dukang | USER_H5 | pages/user/ |
|
||||
| h5-shop | apps/h5-shop | 5174 | 刘京尧 | SHOP_H5 | pages/shop/ |
|
||||
| h5-partner | apps/h5-partner | 5175 | 刘京尧 | PARTNER_H5 | pages/partner/ |
|
||||
| h5-shop | apps/h5-shop | 5174 | 刘景尧 | SHOP_H5 | pages/shop/ |
|
||||
| h5-partner | apps/h5-partner | 5175 | 刘景尧 | PARTNER_H5 | pages/partner/ |
|
||||
| admin-web | apps/admin-web | 5175 | jacy-dukang | HQ_WEB | pages/hq/ |
|
||||
|
||||
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
|
||||
@@ -51,7 +51,7 @@ H5 三端引用 `@dukang/shared-ui`(`tokens.css`)。admin-web 使用 Ant Des
|
||||
all | pending_pay | pending_ship | pending_receive | completed
|
||||
```
|
||||
|
||||
## h5-shop 路由(刘京尧)
|
||||
## h5-shop 路由(刘景尧)
|
||||
|
||||
| 路由 | Page | 主要 API |
|
||||
|------|------|----------|
|
||||
@@ -63,7 +63,7 @@ all | pending_pay | pending_ship | pending_receive | completed
|
||||
| /status | StatusPage | /shop/store |
|
||||
| /mine | MinePage | /shop/store |
|
||||
|
||||
## h5-partner 路由(刘京尧)
|
||||
## h5-partner 路由(刘景尧)
|
||||
|
||||
| 路由 | Page | 主要 API |
|
||||
|------|------|----------|
|
||||
|
||||
@@ -31,8 +31,8 @@ description: >-
|
||||
|------|--------|---------|
|
||||
| C 端 FE、h5-user、交易后端 | jacy-dukang | P1-M1-002, M2-* |
|
||||
| admin、catalog、settlement | jacy-dukang | M1-BE-CAT-* |
|
||||
| 合伙人、store | 刘京尧 | P1-M4-001 |
|
||||
| 门店核销、h5-shop | 刘京尧 | P1-M3-002 |
|
||||
| 合伙人、store | 刘景尧 | P1-M4-001 |
|
||||
| 门店核销、h5-shop | 刘景尧 | P1-M3-002 |
|
||||
| Monorepo/integrations | jacy-dukang | P1-M0-* |
|
||||
|
||||
## preV1 任务卡(精简)
|
||||
@@ -47,7 +47,7 @@ description: >-
|
||||
| P1-M2-001 | 起购校验 2/6 瓶 |
|
||||
| P1-M2-002 | Mock 支付发券 |
|
||||
| P1-M2-003 | 5 Tab 含 pending_ship |
|
||||
| P1-M3-001 | 核销 ¥500 上限 |
|
||||
| P1-M3-001 | V3 两路径核销金额限制 |
|
||||
| P1-M3-002 | 门店扫码核销 |
|
||||
| P1-M4-001 | 录店 AUTO_APPROVE → C 端可见 |
|
||||
| P1-M5-001 | 配送自动推进到 COMPLETED |
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install
|
||||
- run: pnpm db:generate
|
||||
- run: pnpm test
|
||||
- run: pnpm lint
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
| 阶段 | 手册 | 说明 |
|
||||
|------|------|------|
|
||||
| **当前 preV1** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | 三端 H5 + Mock 联调,同库同 API |
|
||||
| **目标 V2** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 四端小程序 + 真实第三方(唯一事实源) |
|
||||
| **当前 V3 交付** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 业务闭环交付验收标准 |
|
||||
| **preV1 历史** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | Mock 联调裁剪(历史参考) |
|
||||
| **V2 蓝图** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 完整产品蓝图(与 V3 冲突时 V3 优先) |
|
||||
|
||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
||||
|
||||
@@ -33,6 +34,7 @@ pnpm dev:partner # :5175
|
||||
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
|
||||
|
||||
# 验证
|
||||
node scripts/smoke-v3.mjs
|
||||
node scripts/smoke-prev1.mjs
|
||||
pnpm lint && pnpm test
|
||||
```
|
||||
@@ -43,27 +45,28 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
||||
|
||||
## 文档优先级(冲突时)
|
||||
|
||||
1. `杜康好客-V2编码手册.md` §二 — 业务规则
|
||||
2. `杜康好客-preV1编码手册.md` — preV1 裁剪(Mock / Flag)
|
||||
3. [`conventions.md`](./conventions.md) — 协作与模块边界
|
||||
4. V2 手册 §四 §五 §六 — 架构 / DB / API
|
||||
5. `pages/{user,shop,partner}/` + [`pages/ROUTE_MAP.md`](./pages/ROUTE_MAP.md) — UI 参照
|
||||
1. `杜康好客-v3编码手册.md` — V3 交付业务规则与验收
|
||||
2. `杜康好客-V2编码手册.md` §二 — 完整蓝图(与 V3 冲突时 V3 优先)
|
||||
3. `杜康好客-preV1编码手册.md` — preV1 裁剪(历史参考)
|
||||
4. [`conventions.md`](./conventions.md) — 协作与模块边界
|
||||
5. V2 手册 §四 §五 §六 — 架构 / DB / API
|
||||
6. `pages/{user,shop,partner}/` + [`pages/ROUTE_MAP.md`](./pages/ROUTE_MAP.md) — UI 参照
|
||||
|
||||
## 团队与 OWNER 边界(2 人)
|
||||
|
||||
| 负责人 | Git 账号 | 职责 |
|
||||
|--------|----------|------|
|
||||
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
|
||||
| **刘京尧** | `刘京尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块 |
|
||||
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块 |
|
||||
|
||||
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**人员合并**如下:
|
||||
|
||||
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|
||||
|------------|--------|----------|-------------|
|
||||
| **A + C + Lead** | jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | `iam`, `trade`, `benefit`, `analytics`, `catalog`, `settlement`, `ops` |
|
||||
| **B + D** | 刘京尧 | `apps/h5-partner/`, `apps/h5-shop/` | `store`, `redeem` |
|
||||
| **B + D** | 刘景尧 | `apps/h5-partner/`, `apps/h5-shop/` | `store`, `redeem` |
|
||||
|
||||
**Prisma 迁移**:jacy-dukang 主 Review;若改 `store_*` / 核销相关表,需 `@刘京尧` 共同 Review。
|
||||
**Prisma 迁移**:jacy-dukang 主 Review;若改 `store_*` / 核销相关表,需刘景尧共同 Review。
|
||||
|
||||
### 跨模块规则(R1–R8 摘要)
|
||||
|
||||
@@ -91,8 +94,8 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
||||
| `owner-a-user-trade` | jacy-dukang | C 端 H5、订单、支付、权益、埋点 |
|
||||
| `owner-c-catalog-ops` | jacy-dukang | admin-web、开城商品、结算、运营 |
|
||||
| `backend-lead` | jacy-dukang | packages、callbacks、jobs、Prisma 横切 |
|
||||
| `owner-b-partner-store` | 刘京尧 | 合伙人 H5、门店 CRUD/审核 |
|
||||
| `owner-d-shop-redeem` | 刘京尧 | 门店 H5、核销 |
|
||||
| `owner-b-partner-store` | 刘景尧 | 合伙人 H5、门店 CRUD/审核 |
|
||||
| `owner-d-shop-redeem` | 刘景尧 | 门店 H5、核销 |
|
||||
| `boundary-reviewer` | — | PR 前只读审查跨模块违规 |
|
||||
|
||||
## Skills(按需 @)
|
||||
@@ -116,7 +119,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
||||
```
|
||||
权益额 = benefit_amount ?? price
|
||||
同城起购 2 瓶 / 跨城 6 瓶
|
||||
核销:0 < amount ≤ min(balance, 500);Redis 码 5 分钟
|
||||
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 5 分钟
|
||||
C 端门店仅 status=OPEN
|
||||
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
||||
```
|
||||
@@ -124,7 +127,7 @@ C 端门店仅 status=OPEN
|
||||
## 提交与 PR
|
||||
|
||||
- Conventional Commits:`feat(trade):`、`fix(redeem):`;scope = 端或模块
|
||||
- 跨模块 PR → jacy-dukang + 刘京尧 共同 Review(若涉及双方模块)
|
||||
- 跨模块 PR → jacy-dukang + 刘景尧 共同 Review(若涉及双方模块)
|
||||
- 改 API/表 → 同步 V2 手册 §五/§六 + `shared-types`
|
||||
- 不提交 `.env`、`dist/`、`node_modules/`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 杜康好客 preV1
|
||||
# 杜康好客 V3 交付版
|
||||
|
||||
三端 H5 + NestJS Mock 联调版。完整规格见 `杜康好客-V2编码手册.md`,preV1 裁剪见 `杜康好客-preV1编码手册.md`。
|
||||
四端 H5/WebAdmin + NestJS 单体 API。**V3 交付验收**以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准;V2 为完整蓝图参考;preV1 为 Mock 联调历史参考。
|
||||
|
||||
## 快速启动
|
||||
|
||||
@@ -53,9 +53,12 @@ pnpm dev:partner # http://localhost:5175
|
||||
## 主链路冒烟
|
||||
|
||||
```bash
|
||||
node scripts/smoke-prev1.mjs
|
||||
node scripts/smoke-v3.mjs # V3 交付验收
|
||||
node scripts/smoke-prev1.mjs # preV1 历史回归
|
||||
```
|
||||
|
||||
验收清单见 [`V3_ACCEPTANCE_CHECKLIST.md`](./V3_ACCEPTANCE_CHECKLIST.md)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
- `apps/h5-user` · `apps/h5-shop` · `apps/h5-partner` — 三端 H5(Vite + React)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# V3 验收清单
|
||||
|
||||
> 业务事实源:[杜康好客-v3编码手册.md](./杜康好客-v3编码手册.md)
|
||||
> 自动化:`node scripts/smoke-v3.mjs`(Mock 环境)
|
||||
> 历史回归:`node scripts/smoke-prev1.mjs`(preV1 单链路)
|
||||
|
||||
## 必过主链路
|
||||
|
||||
| # | 验收项 | Owner | API / 页面 | 自动化 |
|
||||
|---|--------|-------|------------|--------|
|
||||
| 1 | C 端手机号登录 | jacy | `POST /auth/login/sms` · h5-user `/login` | smoke-v3 |
|
||||
| 2 | 首页郑州 4 商品 | jacy | `GET /catalog/products?cityCode=` · h5-user `/` | smoke-v3 |
|
||||
| 3 | 同城 1 瓶失败 | jacy | `POST /trade/orders/preview` | smoke-v3 |
|
||||
| 4 | 同城 2 瓶成功 | jacy | `POST /trade/orders` | smoke-v3 |
|
||||
| 5 | 跨城 5/6 瓶边界 | jacy | preview + create | smoke-v3 |
|
||||
| 6 | 支付后发权益 | jacy | pay + benefit | smoke-v3 |
|
||||
| 7 | 直接核销(无 couponId) | jacy + 刘京尧 | `POST /redeem/tokens` | smoke-v3 |
|
||||
| 8 | 单据核销 cap | jacy + 刘京尧 | `POST /redeem/tokens` + couponId | smoke-v3 |
|
||||
| 9 | 门店扫码确认 | 刘京尧 | `POST /shop/redeem/confirm` · h5-shop | smoke-v3 |
|
||||
| 10 | store_payout 生成 | jacy | settlement | smoke-v3 |
|
||||
| 11 | 关闭门店不可核销 | 刘京尧 | store status + redeem | smoke-v3 |
|
||||
| 12 | 录店审核后 C 端可见 | 刘京尧 + jacy | admin stores audit · h5-partner | 手动 |
|
||||
| 13 | 配送推进完成 | jacy | jobs / delivery callback | smoke-v3 |
|
||||
| 14 | 退款工单一致 | jacy | tickets + benefit void | smoke-v3 |
|
||||
| 15 | 门店 T+1 打款确认 | jacy | admin store-payouts | smoke-v3 |
|
||||
| 16 | 合伙人 T+30 账单 | jacy | admin partner-bills | smoke-v3 |
|
||||
|
||||
## 必过后台链路
|
||||
|
||||
| # | 验收项 | 页面 |
|
||||
|---|--------|------|
|
||||
| 1 | WebAdmin 登录 | admin-web `/login` |
|
||||
| 2 | 商品 CRUD | `/products` |
|
||||
| 3 | 审核门店 | `/stores` |
|
||||
| 4 | 订单与配送 | `/orders` · `/deliveries` |
|
||||
| 5 | 权益/核销/打款 | `/benefit/*` · `/redeem-records` · `/store-payouts` |
|
||||
| 6 | 退款/补发工单 | `/tickets` |
|
||||
| 7 | 第三方日志 | `/third-party-logs` |
|
||||
| 8 | 财务导出 | `/partner-bills` export |
|
||||
|
||||
## 环境矩阵
|
||||
|
||||
| 环境 | MOCK_SMS | MOCK_PAY | MOCK_DELIVERY_AUTO | 说明 |
|
||||
|------|----------|----------|-------------------|------|
|
||||
| 本地开发 | true | true | true | 固定验证码 123456 |
|
||||
| 测试沙箱 | false | false | false | 微信/配送沙箱 |
|
||||
| 生产 | false | false | false | 真实第三方 |
|
||||
|
||||
## 合伙人端交接(刘京尧)
|
||||
|
||||
Jacy 侧交付:`/partner/*` 后端 API、shared-types DTO、smoke 断言。
|
||||
刘京尧负责:`apps/h5-partner` 页面接入与验收。
|
||||
@@ -81,7 +81,7 @@
|
||||
| 负责人 | Git 账号 | App(preV1 → V2) | Module |
|
||||
|--------|----------|-------------------|--------|
|
||||
| Jacy | jacy-dukang | h5-user、admin-web → mini-user/mini-hq | iam, trade, benefit, analytics, catalog, settlement, ops |
|
||||
| 刘京尧 | 刘京尧 | h5-partner、h5-shop → mini-partner | store, redeem |
|
||||
| 刘景尧 | 刘景尧 | h5-partner、h5-shop → mini-partner | store, redeem |
|
||||
| Jacy(横切) | jacy-dukang | packages/*, callbacks/, jobs/, integrations/ | 基础设施 |
|
||||
|
||||
**禁止**:Module A 直写 Module B 的 Prisma 表;apps import server 源码。
|
||||
@@ -93,7 +93,7 @@
|
||||
```text
|
||||
权益发放额 = common_product_item.benefit_amount ?? price
|
||||
同城起购 2 瓶 / 跨城 6 瓶
|
||||
核销:0 < amount ≤ min(balance, 500)
|
||||
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
|
||||
C 端门店列表仅 status=OPEN
|
||||
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
||||
支付回调幂等 → 发券 → common_event(BENEFIT_LEDGER, GRANT)
|
||||
|
||||
+4
-4
@@ -7,8 +7,8 @@
|
||||
| App | 端口 | X-Client-App | 负责人 | 原型 |
|
||||
|-----|------|--------------|--------|------|
|
||||
| `h5-user` | 5173 | `USER_H5` | jacy-dukang | `pages/user/` |
|
||||
| `h5-shop` | 5174 | `SHOP_H5` | 刘京尧 | `pages/shop/` |
|
||||
| `h5-partner` | 5175 | `PARTNER_H5` | 刘京尧 | `pages/partner/` |
|
||||
| `h5-shop` | 5174 | `SHOP_H5` | 刘景尧 | `pages/shop/` |
|
||||
| `h5-partner` | 5175 | `PARTNER_H5` | 刘景尧 | `pages/partner/` |
|
||||
| `admin-web` | — | (Admin JWT) | jacy-dukang | preV1 内部 HQ 替代 |
|
||||
|
||||
V2 目标:`mini-user` / `mini-partner` / `mini-hq` 替换对应 H5(除门店仍 H5)。
|
||||
@@ -36,13 +36,13 @@ V2 目标:`mini-user` / `mini-partner` / `mini-hq` 替换对应 H5(除门店
|
||||
|
||||
**勿改**:门店核销确认 UI(属 h5-shop)、合伙人录店(属 h5-partner)
|
||||
|
||||
### h5-shop(刘京尧)
|
||||
### h5-shop(刘景尧)
|
||||
|
||||
主链路:门店登录 → 首页 → 扫码/输入核销 → 确认 → 记录 → 营业状态
|
||||
|
||||
**勿改**:C 端出码页面(属 h5-user)
|
||||
|
||||
### h5-partner(刘京尧)
|
||||
### h5-partner(刘景尧)
|
||||
|
||||
主链路:登录 → 工作台 → 录店 → 门店列表 → 辖区订单 → Mock 推进配送(preV1)
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ import CitiesPage from './pages/CitiesPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import ProductsPage from './pages/ProductsPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
import StorePayoutsPage from './pages/StorePayoutsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import TicketsPage from './pages/TicketsPage';
|
||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -49,6 +53,10 @@ export default function App() {
|
||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||
<Route path="/store-payouts" element={<StorePayoutsPage />} />
|
||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/third-party-logs" element={<ThirdPartyLogsPage />} />
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -52,8 +52,12 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/benefit/coupons', label: '权益券' },
|
||||
{ key: '/benefit/ledgers', label: '流水' },
|
||||
{ key: '/redeem-records', label: '核销记录' },
|
||||
{ key: '/store-payouts', label: '门店打款' },
|
||||
],
|
||||
},
|
||||
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
|
||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
||||
{ key: '/third-party-logs', icon: <CloudUploadOutlined />, label: '第三方日志' },
|
||||
{ key: '/deliveries', icon: <CarOutlined />, label: '配送单' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input, Select, Table, Typography, message, Modal, DatePicker } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
billNo: string;
|
||||
orderCommission: number;
|
||||
redeemCommission: number;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
partner?: { companyName: string };
|
||||
};
|
||||
|
||||
export default function PartnerBillsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partner-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [genOpen, setGenOpen] = useState(false);
|
||||
const [genForm] = Form.useForm();
|
||||
|
||||
async function generateBill(values: { partnerId: string; month: dayjs.Dayjs }) {
|
||||
await request('/admin/partner-bills/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
partnerId: values.partnerId,
|
||||
year: values.month.year(),
|
||||
month: values.month.month() + 1,
|
||||
}),
|
||||
});
|
||||
message.success('账单已生成');
|
||||
setGenOpen(false);
|
||||
reload();
|
||||
}
|
||||
|
||||
async function confirmBill(id: string) {
|
||||
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
|
||||
message.success('已确认');
|
||||
reload();
|
||||
}
|
||||
|
||||
async function markPaid(id: string) {
|
||||
await request(`/admin/partner-bills/${id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||
});
|
||||
message.success('已标记打款');
|
||||
reload();
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
const result = await request<{ csv: string }>('/admin/partner-bills/export');
|
||||
const blob = new Blob([result.csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'partner-bills.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180 },
|
||||
{ title: '合伙人', dataIndex: ['partner', 'companyName'] },
|
||||
{ title: '订单佣金', dataIndex: 'orderCommission', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '合计', dataIndex: 'totalAmount', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}` },
|
||||
{
|
||||
title: '操作', width: 180,
|
||||
render: (_, row) => (
|
||||
<>
|
||||
{row.status === 'DRAFT' && <Button type="link" size="small" onClick={() => confirmBill(row.id)}>确认</Button>}
|
||||
{row.status === 'CONFIRMED' && <Button type="link" size="small" onClick={() => markPaid(row.id)}>标记打款</Button>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>合伙人结算(T+30)</Typography.Title>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
|
||||
<Button type="primary" onClick={() => setGenOpen(true)}>生成账单</Button>
|
||||
<Button onClick={exportCsv}>导出 CSV</Button>
|
||||
</div>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={[
|
||||
{ value: 'DRAFT', label: '草稿' },
|
||||
{ value: 'CONFIRMED', label: '已确认' },
|
||||
{ value: 'PAID', label: '已打款' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="合伙人ID"><Input allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||
<Form form={genForm} layout="vertical" onFinish={generateBill}>
|
||||
<Form.Item name="partnerId" label="合伙人 ID" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}><DatePicker picker="month" style={{ width: '100%' }} /></Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>生成</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
redeemAmount: number;
|
||||
payoutAmount: number;
|
||||
status: string;
|
||||
expectedPayAt: string;
|
||||
paidAt?: string;
|
||||
store?: { name: string; cityName: string };
|
||||
redeemRecord?: { redeemNo: string };
|
||||
};
|
||||
|
||||
export default function StorePayoutsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-payouts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
async function confirmPayout(id: string) {
|
||||
await request(`/admin/store-payouts/${id}/confirm`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '财务确认打款' }),
|
||||
});
|
||||
message.success('已确认打款');
|
||||
reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 100 },
|
||||
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '打款额', dataIndex: 'payoutAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
||||
{ title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
|
||||
{
|
||||
title: '操作', width: 140,
|
||||
render: (_, row) => (
|
||||
<>
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/store-payouts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
{row.status === 'PENDING' && (
|
||||
<Button type="link" size="small" onClick={() => confirmPayout(row.id)}>确认打款</Button>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>门店打款(T+1)</Typography.Title>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={[
|
||||
{ value: 'PENDING', label: '待打款' },
|
||||
{ value: 'PAID', label: '已打款' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="打款详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销额">¥{String(detail.redeemAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="打款额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
provider: string;
|
||||
scene: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
externalNo?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function ThirdPartyLogsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/common/third-party-logs',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.provider) qs.set('provider', filters.provider);
|
||||
if (filters.refId) qs.set('refId', filters.refId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
||||
{ title: '场景', dataIndex: 'scene', width: 120 },
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '外部单号', dataIndex: 'externalNo', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/common/third-party-logs/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>第三方日志</Typography.Title>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="provider" label="Provider"><Input allowClear placeholder="WECHAT_PAY" /></Form.Item>
|
||||
<Form.Item name="refId" label="关联ID"><Input allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="Provider">{String(detail.provider)}</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">{String(detail.scene)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
ticketType: string;
|
||||
status: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/tickets',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
async function approve(id: string) {
|
||||
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
|
||||
message.success('已审批通过');
|
||||
reload();
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
async function reject(id: string) {
|
||||
await request(`/admin/tickets/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '驳回' }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
reload();
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{ title: '类型', dataIndex: 'ticketType', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/tickets/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>工单中心</Typography.Title>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="ticketType" label="类型">
|
||||
<Select allowClear style={{ width: 120 }} options={[
|
||||
{ value: 'REFUND', label: '退款' },
|
||||
{ value: 'RESHIPMENT', label: '补发' },
|
||||
{ value: 'ALERT', label: '异常' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="工单详情" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||
<>
|
||||
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>通过</Button>
|
||||
<Button danger onClick={() => reject(String(detail.id))}>驳回</Button>
|
||||
</>
|
||||
) : null}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ function WechatOAuthHandler() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !location.search.includes('code=')) return;
|
||||
if (location.pathname === '/login') return;
|
||||
void handlePartnerWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result || !savePartnerWechatAuth(result)) return;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { request, saveAuth } from '../lib/api';
|
||||
import {
|
||||
authorizePartnerWechat,
|
||||
handlePartnerWechatCallback,
|
||||
savePartnerWechatAuth,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -9,7 +15,21 @@ export default function LoginPage() {
|
||||
const [phone, setPhone] = useState('13700000001');
|
||||
const [code, setCode] = useState('123456');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv()) return;
|
||||
void handlePartnerWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
if (savePartnerWechatAuth(result)) {
|
||||
navigate('/');
|
||||
}
|
||||
})
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败'));
|
||||
}, [navigate]);
|
||||
|
||||
async function login() {
|
||||
setLoading(true);
|
||||
@@ -29,6 +49,21 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function wechatLogin() {
|
||||
setMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setMsg('请在微信内打开以使用微信一键登录');
|
||||
return;
|
||||
}
|
||||
setWechatLoading(true);
|
||||
try {
|
||||
await authorizePartnerWechat();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '微信登录失败');
|
||||
setWechatLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function sendCode() {
|
||||
if (codeCooldown > 0) return;
|
||||
request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
@@ -122,10 +157,17 @@ export default function LoginPage() {
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
||||
<button type="button" className="partner-btn-outline" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}
|
||||
disabled={wechatLoading || loading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="#07C160"><path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" /></svg>
|
||||
微信一键登录
|
||||
{wechatLoading ? '跳转授权中…' : '微信一键登录'}
|
||||
</button>
|
||||
{msg && <p className="partner-form-error" role="alert">{msg}</p>}
|
||||
<label className="partner-checkbox-row">
|
||||
<input type="checkbox" />
|
||||
<span>
|
||||
|
||||
@@ -6,6 +6,14 @@ function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
type Preview = {
|
||||
amount: number;
|
||||
expireInSeconds: number;
|
||||
user?: { userNo?: string; phone?: string; nickname?: string };
|
||||
redeemType?: string;
|
||||
boundStoreId?: string | null;
|
||||
};
|
||||
|
||||
export default function RedeemConfirmPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -13,11 +21,15 @@ export default function RedeemConfirmPage() {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [previewAmount, setPreviewAmount] = useState(100);
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '当前门店')))
|
||||
.then((s) => {
|
||||
setStoreName(String(s.name || '当前门店'));
|
||||
if (s.status && s.status !== 'OPEN') setStoreClosed(true);
|
||||
})
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
@@ -26,7 +38,27 @@ export default function RedeemConfirmPage() {
|
||||
if (scanned) setToken(scanned);
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token.trim()) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
request<Preview>('SHOP_H5', '/shop/redeem/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch((e) => {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
async function confirm() {
|
||||
if (storeClosed) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
}
|
||||
if (!token.trim()) {
|
||||
setMsg('请在开发者选项中输入核销码');
|
||||
return;
|
||||
@@ -47,6 +79,9 @@ export default function RedeemConfirmPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const previewAmount = preview?.amount ?? 0;
|
||||
const userLabel = preview?.user?.nickname || preview?.user?.phone || preview?.user?.userNo || '待扫码确认';
|
||||
|
||||
return (
|
||||
<div className="shop-redeem-page">
|
||||
<header className="shop-redeem-header">
|
||||
@@ -57,6 +92,9 @@ export default function RedeemConfirmPage() {
|
||||
</header>
|
||||
|
||||
<main className="shop-redeem-main">
|
||||
{storeClosed && (
|
||||
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店当前未营业,无法核销</p>
|
||||
)}
|
||||
<section className="shop-redeem-card">
|
||||
<div className="shop-redeem-banner">
|
||||
<div className="shop-redeem-banner-icon">
|
||||
@@ -75,7 +113,7 @@ export default function RedeemConfirmPage() {
|
||||
<span>下单用户</span>
|
||||
</div>
|
||||
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
||||
待扫码确认
|
||||
{userLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +129,7 @@ export default function RedeemConfirmPage() {
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 16, color: 'var(--color-aged-amber)' }}>
|
||||
confirmation_number
|
||||
</span>
|
||||
好客权益
|
||||
好客权益 · {preview?.redeemType === 'COUPON' ? '单据核销' : '直接核销'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -102,8 +140,14 @@ export default function RedeemConfirmPage() {
|
||||
</div>
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>有效期</span>
|
||||
<span>永久</span>
|
||||
<span>{preview ? `${preview.expireInSeconds} 秒` : '—'}</span>
|
||||
</div>
|
||||
{preview?.boundStoreId && (
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>绑定门店</span>
|
||||
<span>仅限指定门店</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
@@ -111,13 +155,13 @@ export default function RedeemConfirmPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
||||
disabled={loading}
|
||||
disabled={loading || storeClosed || !preview}
|
||||
onClick={confirm}
|
||||
>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{loading ? 'sync' : 'check_circle'}
|
||||
</span>
|
||||
<span>{loading ? '正在核销...' : `确认核销 ¥${formatAmount(previewAmount)}`}</span>
|
||||
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '等待扫码'}</span>
|
||||
</button>
|
||||
|
||||
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
||||
@@ -127,10 +171,7 @@ export default function RedeemConfirmPage() {
|
||||
<div className="shop-redeem-dev-body">
|
||||
<input
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
if (e.target.value) setPreviewAmount(100);
|
||||
}}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="粘贴用户核销码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,47 @@
|
||||
/** Stitch 用户端-商品详情页 原型图(杜康·白水古酿) */
|
||||
const STITCH_CAROUSEL = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDiJm2VWrwCv8wnC-dBgSfvlf66izs6faELgWXlyIAUpYWOKrLwfeyB0c0XT0vmDVJnfIkzbLNm_4NYASwH_ce7BotJDLCJcd3SfnxKIe7eso-c4mzzR-4LTv4y3ELhpXHfxVyu-5LVUEwuofvUuzdJELV6CK4MIcLW_9rMaOuXSADfz0mpP-MspQvhKhxJ0wpdAiBxBq8rqHNSKjx8dU7lcVc_smZGunmtkbhmnjAn4JU8nCDnvuDU5HECng82FbFM6rzdkFHoYR0',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDzBBRn0yOqhRJ4CbTEOx1aF4fJVIhsbIZgFR9RgdB5E0xcs_RdR1khLyR0OzysGzkW_tnrZTb0avVEZ91Nd81KRItrlTjrEFvrYj0Qag45iRo5wioY8E2gK5NGhILvDpWxakuSPIGGp00nLY_5HuuLwr-0_8ZabaUFAR4C9loXIX_lgCAgRMt7An_H0AitIOBvwOfNVTMkz-P7dXQFzSUvYpFvcmvzAOIWsbipnTrgNU5H8Os37-soM-eWUCfNtJUaD_uqQ3mwJI8',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDSSmH1ygwminKXiiIqOymnukbKJfnhfHnmCJTbNN2BEN2yF3vPtoMYOBAsDHxuldT9xg_ZBZhjh6QJjabvhu_HFB3WcNU53q_AjsD0mVWXInongiXqjOh8R-B2QW9Jfs786j3TSi2gVE57Ad1WskJji-xytI3aFEuk873xGXgdkn6EgzoAMOsKRaWF27DE3GBa48qAARYR92aEyMU_hcte6L2lkaF9brXshSmujiA_3ACK21TLsT2DCJ1Djacvh25J0LZ8v7BYVkk',
|
||||
] as const;
|
||||
/** 商品无图时的占位图 */
|
||||
export const PRODUCT_IMAGE_FALLBACK = '/images/1.png';
|
||||
|
||||
const STITCH_DETAIL = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuA2-IVt-apnEkj9QQ4rkjN5lb0oymgiJX1XfzAH8pRzSFzMVjYDtlMWE8GwpS7I6sth7CXJiKwNm9c-hpsYKqQ6pyb48yUO6NG8vky4E6qCjwgaCsCnlvoOVLroG4bmmL16-xl4-o28ZMvtzMoCKIiUK-_dQF7lx66nwnxFOP7PcUddDK-UItoO-Gp5iqxf6kp_-t_tjoPpo_ba25DBPG1QThlI8IJYqb9bNng5mIQzdnNul24rBy_JmgS5nsaYK0Wvo7907WT3ch4',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuBe0yoN6VDKu2SVqwOy9RedE-6Rh56-a_5ygo5raDOU3Y65m1fz9hNmqbrIpvTW8RsqAwPd3zfHnw96Bb4Ct7jtmq-pil1MEvPBL4G3C8Sym_LVuEzK---hgdim1wVx-qP1v1EPex2fXpVQEY27rEVINVaXk2L1F5elWKQhMHWVXjU8B2jvtyNzmlXBpynsocnCgcwM4RhaqYdVf1JZxcfScmJ34dO3QAUIli-RPzEYynLtnW2x4lEbRrpAdBnK2fuSggLnJU1nfSw',
|
||||
] as const;
|
||||
export type ProductImageSource = {
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
detailImageUrls?: string[] | null;
|
||||
};
|
||||
|
||||
/** 本地商品图占位 */
|
||||
export const PRODUCT_IMAGE_INDEX = [
|
||||
'/images/1.png',
|
||||
'/images/2.png',
|
||||
'/images/3.png',
|
||||
] as const;
|
||||
|
||||
export function getProductImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) {
|
||||
return [...STITCH_CAROUSEL.slice(0, 2)];
|
||||
function uniqueUrls(urls: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const url of urls) {
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
result.push(url);
|
||||
}
|
||||
const img = PRODUCT_IMAGE_INDEX[productIndex % PRODUCT_IMAGE_INDEX.length] ?? '/images/1.png';
|
||||
return [img];
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getProductMainImage(productIndex = 0): string {
|
||||
if (productIndex === 0) return STITCH_CAROUSEL[0];
|
||||
return PRODUCT_IMAGE_INDEX[productIndex % PRODUCT_IMAGE_INDEX.length] ?? '/images/1.png';
|
||||
/** 首页/列表轮播图:优先 CAROUSEL,否则封面 */
|
||||
export function getProductImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
|
||||
const main = source?.mainImageUrl;
|
||||
if (main) return [main];
|
||||
|
||||
return [PRODUCT_IMAGE_FALLBACK];
|
||||
}
|
||||
|
||||
/** 详情页轮播(首商品用 Stitch 三图,其余单图) */
|
||||
export function getProductCarouselImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) return [...STITCH_CAROUSEL];
|
||||
const img = getProductMainImage(productIndex);
|
||||
return [img];
|
||||
/** 单张主图:封面优先 */
|
||||
export function getProductMainImage(source?: ProductImageSource | null): string {
|
||||
return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK;
|
||||
}
|
||||
|
||||
/** 详情页顶部轮播 */
|
||||
export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
return getProductImages(source);
|
||||
}
|
||||
|
||||
/** 详情页图文长图 */
|
||||
export function getProductDetailImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) return [...STITCH_DETAIL];
|
||||
return [];
|
||||
export function getProductDetailImages(source?: ProductImageSource | null): string[] {
|
||||
return uniqueUrls(source?.detailImageUrls ?? []);
|
||||
}
|
||||
|
||||
@@ -13,27 +13,50 @@ type Product = {
|
||||
subtitle: string;
|
||||
price: number;
|
||||
benefitDisplay: number;
|
||||
mainImageUrl: string;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
detailImageUrls?: string[] | null;
|
||||
aromaType: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type City = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const AROMA_TABS = [
|
||||
{ key: 'QINGXIANG', label: '清香型', open: true },
|
||||
{ key: 'JIANGXIANG', label: '酱香型', open: false },
|
||||
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
||||
];
|
||||
|
||||
const CITY_STORAGE_KEY = 'dukang_selected_city';
|
||||
|
||||
export default function HomePage() {
|
||||
const [tab, setTab] = useState('QINGXIANG');
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request<Product[]>('USER_H5', '/catalog/products').then(setProducts);
|
||||
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
|
||||
setCities(list);
|
||||
if (!list.some((c) => c.code === cityCode) && list[0]) {
|
||||
setCityCode(list[0].code);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cityCode) return;
|
||||
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
|
||||
request<Product[]>('USER_H5', `/catalog/products?cityCode=${encodeURIComponent(cityCode)}`).then(setProducts);
|
||||
}, [cityCode]);
|
||||
|
||||
function showToast(message: string) {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(''), 2200);
|
||||
@@ -47,6 +70,7 @@ export default function HomePage() {
|
||||
setTab(key);
|
||||
}
|
||||
|
||||
const selectedCity = cities.find((c) => c.code === cityCode);
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
const onSale = tab === 'QINGXIANG';
|
||||
|
||||
@@ -57,7 +81,16 @@ export default function HomePage() {
|
||||
extra={(
|
||||
<div className="tab-main-city">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span>郑州市</span>
|
||||
<select
|
||||
value={cityCode}
|
||||
onChange={(e) => setCityCode(e.target.value)}
|
||||
style={{ border: 'none', background: 'transparent', font: 'inherit', color: 'inherit' }}
|
||||
>
|
||||
{cities.map((c) => (
|
||||
<option key={c.code} value={c.code}>{c.name}</option>
|
||||
))}
|
||||
{!cities.length && <option value={cityCode}>{selectedCity?.name ?? '郑州市'}</option>}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
@@ -78,10 +111,10 @@ export default function HomePage() {
|
||||
<section className="home-product-list">
|
||||
{!onSale && <div className="home-empty">该香型暂未上线,敬请期待</div>}
|
||||
{onSale &&
|
||||
filtered.map((p, index) => (
|
||||
filtered.map((p) => (
|
||||
<article key={p.id} className="home-product-card">
|
||||
<Link to={`/product/${p.id}`} className="home-product-link">
|
||||
<ProductCarousel images={getProductImages(index)} alt={p.name} />
|
||||
<ProductCarousel images={getProductImages(p)} alt={p.name} />
|
||||
<div className="home-product-body">
|
||||
<div className="home-product-row">
|
||||
<h3 className="home-product-name">{p.name}</h3>
|
||||
|
||||
@@ -4,8 +4,6 @@ import SubPageHeader from '../components/SubPageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { buildProductDetailUrl } from '../lib/navigation';
|
||||
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
||||
import { tryGetClientGpsLocation } from '../lib/client-location';
|
||||
import { getProductMainImage } from '../lib/product-images';
|
||||
import PhoneVerifySheet from '../components/PhoneVerifySheet';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
@@ -27,6 +25,8 @@ type PreviewProduct = {
|
||||
spec: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
@@ -113,9 +113,7 @@ export default function OrderConfirmPage() {
|
||||
|
||||
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
|
||||
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
|
||||
const productIndex = productId ? Math.max(0, Number(productId) - 1) : 0;
|
||||
const productImage =
|
||||
productIndex === 0 ? STITCH_ORDER_PRODUCT_IMAGE : getProductMainImage(productIndex);
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
|
||||
|
||||
async function doSubmit() {
|
||||
const clientLocation = await tryGetClientGpsLocation();
|
||||
|
||||
@@ -145,6 +145,7 @@ export default function OrderDetailPage() {
|
||||
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
|
||||
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
|
||||
const canConfirmReceive = order?.status === 'PENDING_RECEIVE' && !isReship;
|
||||
const canRefund = order && ['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
|
||||
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
|
||||
const freightTotal = Number(order?.freightAmount ?? 0);
|
||||
|
||||
@@ -174,6 +175,22 @@ export default function OrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRefund() {
|
||||
if (!id || !canRefund) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
await request('USER_H5', `/trade/orders/${id}/refund-requests`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '用户申请退款' }),
|
||||
});
|
||||
await loadOrder();
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '申请退款失败');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!order) return <div className="empty">加载中...</div>;
|
||||
|
||||
return (
|
||||
@@ -380,6 +397,11 @@ export default function OrderDetailPage() {
|
||||
<span className="material-symbols-outlined">headset_mic</span>
|
||||
联系客服
|
||||
</button>
|
||||
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
|
||||
<button type="button" className="order-detail-action-outline" disabled={confirming} onClick={requestRefund}>
|
||||
申请退款
|
||||
</button>
|
||||
)}
|
||||
{canConfirmReceive && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,8 +4,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import { request } from '../lib/api';
|
||||
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
|
||||
import type { ProductImageSource } from '../lib/product-images';
|
||||
|
||||
type Product = {
|
||||
type Product = ProductImageSource & {
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
@@ -22,7 +23,6 @@ export default function ProductDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const imageIndex = id ? Math.max(0, Number(id) - 1) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
|
||||
@@ -39,8 +39,8 @@ export default function ProductDetailPage() {
|
||||
if (!product) return <div className="empty">加载中...</div>;
|
||||
|
||||
const benefit = Number(product.benefitAmount ?? product.price);
|
||||
const carouselImages = getProductCarouselImages(imageIndex);
|
||||
const detailImages = getProductDetailImages(imageIndex);
|
||||
const carouselImages = getProductCarouselImages(product);
|
||||
const detailImages = getProductDetailImages(product);
|
||||
|
||||
return (
|
||||
<div className="product-detail-page">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
@@ -5,6 +6,19 @@ export default function RedeemCodePage() {
|
||||
const navigate = useNavigate();
|
||||
const token = sessionStorage.getItem('redeemToken') || '';
|
||||
const amount = sessionStorage.getItem('redeemAmount') || '0';
|
||||
const [secondsLeft, setSecondsLeft] = useState(300);
|
||||
|
||||
useEffect(() => {
|
||||
const expireAt = sessionStorage.getItem('redeemExpireAt');
|
||||
if (!expireAt) return;
|
||||
const tick = () => {
|
||||
const left = Math.max(0, Math.floor((new Date(expireAt).getTime() - Date.now()) / 1000));
|
||||
setSecondsLeft(left);
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab" style={{ textAlign: 'center' }}>
|
||||
@@ -14,10 +28,14 @@ export default function RedeemCodePage() {
|
||||
<div className="label-md text-muted">核销金额</div>
|
||||
<div className="amount-xl" style={{ margin: '8px 0 24px' }}>¥{amount}</div>
|
||||
<div className="code-box">{token}</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 16 }}>5 分钟内有效</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 16 }}>
|
||||
{secondsLeft > 0 ? `${secondsLeft} 秒后过期` : '已过期,请重新生成'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/redeem/success')}>模拟核销完成</button>
|
||||
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/benefit')}>
|
||||
返回权益页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+10
-9
@@ -1,7 +1,8 @@
|
||||
# 杜康好客 · 协同与开发规范
|
||||
|
||||
> **编码事实源**:根目录 [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md)(PRD + DB v3.1 + API + 计划 + 架构)
|
||||
> **preV1 Mock 联调**:[`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)
|
||||
> **V3 交付验收**:[`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md)
|
||||
> **完整蓝图**:[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md)(与 V3 冲突时 V3 优先)
|
||||
> **preV1 历史**:[`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -12,7 +13,7 @@
|
||||
| Git 账号 | 角色 | 主责 |
|
||||
|----------|------|------|
|
||||
| `jacy-dukang` | 管理员 + 主责开发 | C 端、admin-web、后端主模块、横切 |
|
||||
| `刘京尧` | 开发 | 合伙人 H5、门店 H5、store/redeem 模块 |
|
||||
| `刘景尧` | 刘景尧 | 合伙人 H5、门店 H5、store/redeem 模块 |
|
||||
|
||||
端与后端模块边界:
|
||||
|
||||
@@ -20,15 +21,15 @@
|
||||
|------|--------|------|
|
||||
| `apps/h5-user` | jacy-dukang | C 端 H5(V2 → mini-user) |
|
||||
| `apps/admin-web` | jacy-dukang | preV1 总部替代 |
|
||||
| `apps/h5-partner` | 刘京尧 | 合伙人 H5 |
|
||||
| `apps/h5-shop` | 刘京尧 | 门店 H5 |
|
||||
| `apps/h5-partner` | 刘景尧 | 合伙人 H5 |
|
||||
| `apps/h5-shop` | 刘景尧 | 门店 H5 |
|
||||
| `server/.../modules/{iam,trade,benefit,analytics}` | jacy-dukang | 交易与权益域 |
|
||||
| `server/.../modules/{catalog,settlement,ops}` | jacy-dukang | 开城与结算域 |
|
||||
| `server/.../modules/store` | 刘京尧 | 门店域 |
|
||||
| `server/.../modules/redeem` | 刘京尧 | 核销域 |
|
||||
| `server/.../modules/store` | 刘景尧 | 门店域 |
|
||||
| `server/.../modules/redeem` | 刘景尧 | 核销域 |
|
||||
| `server/.../callbacks`, `jobs`, `common`, `integrations` | jacy-dukang | 回调与任务 |
|
||||
| `packages/*` | jacy-dukang 主责;破坏性改动 @刘京尧 | 公共契约 |
|
||||
| `server/dukang-api/prisma` | jacy-dukang + 刘京尧(涉及 store/redeem 表时) | 迁移 Review |
|
||||
| `packages/*` | jacy-dukang 主责;破坏性改动通知刘景尧 | 公共契约 |
|
||||
| `server/dukang-api/prisma` | jacy-dukang + 刘景尧(涉及 store/redeem 表时) | 迁移 Review |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'**/prisma/**',
|
||||
'pages/**',
|
||||
'deploy/**',
|
||||
'**/*.config.js',
|
||||
'**/*.config.ts',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'prefer-const': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*.mjs'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
);
|
||||
+12
-2
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"name": "dukang-haoke",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"eslint": "^9.17.0",
|
||||
"globals": "^15.14.0",
|
||||
"typescript-eslint": "^8.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm -r --parallel dev",
|
||||
"dev:api": "pnpm --filter @dukang/api dev",
|
||||
@@ -9,7 +15,8 @@
|
||||
"dev:partner": "pnpm --filter @dukang/h5-partner dev",
|
||||
"dev:admin": "pnpm --filter @dukang/admin-web dev",
|
||||
"build": "pnpm -r build",
|
||||
"lint": "pnpm -r lint",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "pnpm -r exec tsc --noEmit",
|
||||
"test": "pnpm -r test",
|
||||
"db:generate": "pnpm --filter @dukang/api prisma:generate",
|
||||
"db:migrate": "pnpm --filter @dukang/api prisma:migrate",
|
||||
@@ -17,7 +24,10 @@
|
||||
"db:sync-benefit": "pnpm --filter @dukang/api prisma:sync-benefit",
|
||||
"db:validate": "pnpm --filter @dukang/api prisma:validate",
|
||||
"oss:cors": "node scripts/configure-oss-cors.mjs",
|
||||
"sync:stitch": "node scripts/sync-stitch.mjs"
|
||||
"sync:stitch": "node scripts/sync-stitch.mjs",
|
||||
"smoke": "node scripts/smoke-v3.mjs",
|
||||
"smoke:v3": "node scripts/smoke-v3.mjs",
|
||||
"smoke:prev1": "node scripts/smoke-prev1.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"lint": "echo ok"
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5",
|
||||
|
||||
@@ -31,12 +31,25 @@ describe('validateMinPurchase', () => {
|
||||
});
|
||||
|
||||
describe('validateRedeemAmount', () => {
|
||||
it('rejects over balance or non-positive', () => {
|
||||
it('rejects over balance or non-positive amount', () => {
|
||||
expect(validateRedeemAmount(100, 50).ok).toBe(true);
|
||||
expect(validateRedeemAmount(100, 100).ok).toBe(true);
|
||||
expect(validateRedeemAmount(50, 60).ok).toBe(false);
|
||||
expect(validateRedeemAmount(100, 0).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('allows direct redeem up to total balance without a document cap', () => {
|
||||
expect(validateRedeemAmount(700, 650).ok).toBe(true);
|
||||
expect(validateRedeemAmount(700, 701).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('caps document-based redeem by document amount', () => {
|
||||
expect(validateRedeemAmount(700, 300, 300).ok).toBe(true);
|
||||
expect(validateRedeemAmount(700, 301, 300)).toEqual({
|
||||
ok: false,
|
||||
message: '核销金额不能超过该核销单可用金额',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcRedeemSettleAmount', () => {
|
||||
@@ -65,6 +78,13 @@ describe('allocateBenefitCoupons', () => {
|
||||
it('rejects when total balance insufficient', () => {
|
||||
expect(allocateBenefitCoupons(coupons, 800).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when a document cap is lower than requested amount', () => {
|
||||
expect(allocateBenefitCoupons(coupons, 301, 300)).toEqual({
|
||||
ok: false,
|
||||
message: '核销金额不能超过该核销单可用金额',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calcBenefitSummary', () => {
|
||||
|
||||
@@ -29,9 +29,13 @@ export function validateMinPurchase(
|
||||
export function validateRedeemAmount(
|
||||
balance: number,
|
||||
amount: number,
|
||||
documentAmount?: number | null,
|
||||
): { ok: boolean; message?: string } {
|
||||
if (amount <= 0) return { ok: false, message: '核销金额必须大于 0' };
|
||||
if (amount > balance) return { ok: false, message: '核销金额不能超过可用余额' };
|
||||
if (documentAmount != null && amount > documentAmount) {
|
||||
return { ok: false, message: '核销金额不能超过该核销单可用金额' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -45,12 +49,13 @@ export interface BenefitCouponBalance {
|
||||
export function allocateBenefitCoupons(
|
||||
coupons: BenefitCouponBalance[],
|
||||
amount: number,
|
||||
documentAmount?: number | null,
|
||||
): { ok: true; allocations: Array<{ couponId: string; amount: number }> } | { ok: false; message: string } {
|
||||
const active = coupons
|
||||
.filter((c) => c.balance > 0)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
const totalBalance = active.reduce((sum, c) => sum + c.balance, 0);
|
||||
const check = validateRedeemAmount(totalBalance, amount);
|
||||
const check = validateRedeemAmount(totalBalance, amount, documentAmount);
|
||||
if (!check.ok) return { ok: false, message: check.message! };
|
||||
|
||||
let remaining = amount;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "echo ok"
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.5"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface BenefitCouponDto {
|
||||
id: string;
|
||||
couponNo: string;
|
||||
balance: number;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
sourceProduct?: string;
|
||||
}
|
||||
|
||||
export interface BenefitSummaryDto {
|
||||
totalBalance: number;
|
||||
couponCount: number;
|
||||
}
|
||||
|
||||
export interface BenefitLedgerDto {
|
||||
id: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface CityDto {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
province: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ProductDto {
|
||||
id: string;
|
||||
skuCode: string;
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
price: number;
|
||||
benefitAmount: number;
|
||||
benefitDisplay?: number;
|
||||
aromaType: string;
|
||||
status: string;
|
||||
/** 封面图(common_resource COVER / cover_resource_id) */
|
||||
mainImageUrl?: string | null;
|
||||
/** 轮播图(bizType=CAROUSEL;无则回退封面) */
|
||||
carouselUrls?: string[];
|
||||
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */
|
||||
detailImageUrls?: string[];
|
||||
}
|
||||
|
||||
export interface ProductListQuery {
|
||||
cityCode?: string;
|
||||
aromaType?: string;
|
||||
}
|
||||
@@ -77,5 +77,6 @@ export const CLIENT_APP_ACTOR_MAP: Record<ClientApp, ActorType> = {
|
||||
[ClientApp.SHOP_H5]: ActorType.STORE,
|
||||
};
|
||||
|
||||
export const REDEEM_MAX_AMOUNT = 500;
|
||||
export const REDEEM_DIRECT_LIMIT_POLICY = 'TOTAL_ACTIVE_BALANCE';
|
||||
export const REDEEM_DOCUMENT_LIMIT_POLICY = 'DOCUMENT_BALANCE';
|
||||
export const REDEEM_TOKEN_TTL_SECONDS = 300;
|
||||
|
||||
@@ -2,3 +2,10 @@ export * from './enums';
|
||||
export * from './api';
|
||||
export * from './config';
|
||||
export * from './wechat';
|
||||
export * from './catalog';
|
||||
export * from './trade';
|
||||
export * from './benefit';
|
||||
export * from './redeem';
|
||||
export * from './settlement';
|
||||
export * from './ops';
|
||||
export * from './ticket';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface AdminListQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminPageResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
format?: 'csv';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface RedeemTokenRequest {
|
||||
amount: number;
|
||||
couponId?: string;
|
||||
storeId?: string;
|
||||
}
|
||||
|
||||
export interface RedeemTokenResult {
|
||||
token: string;
|
||||
expireAt: string;
|
||||
amount: number;
|
||||
boundStoreId?: string | null;
|
||||
}
|
||||
|
||||
export interface RedeemPreviewDto {
|
||||
token: string;
|
||||
amount: number;
|
||||
expireInSeconds: number;
|
||||
redeemType?: 'DIRECT' | 'COUPON';
|
||||
boundStoreId?: string | null;
|
||||
}
|
||||
|
||||
export interface RedeemRecordDto {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
settleAmount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface StorePayoutDto {
|
||||
id: string;
|
||||
redeemAmount: number;
|
||||
payoutAmount: number;
|
||||
status: 'PENDING' | 'PAID';
|
||||
expectedPayAt: string;
|
||||
paidAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PartnerBillDto {
|
||||
id: string;
|
||||
billNo: string;
|
||||
orderCommission: number;
|
||||
redeemCommission: number;
|
||||
totalAmount: number;
|
||||
status: 'DRAFT' | 'CONFIRMED' | 'PAID';
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
export interface PartnerBillDetailDto extends PartnerBillDto {
|
||||
partnerId: string;
|
||||
confirmedAt?: string | null;
|
||||
paidAt?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type TicketTypeDto = 'REFUND' | 'RESHIPMENT' | 'ALERT';
|
||||
|
||||
export interface TicketDto {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
ticketType: TicketTypeDto;
|
||||
status: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
remark?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TicketActionRequest {
|
||||
remark?: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface OrderDto {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
payStatus: string;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
quantity: number;
|
||||
productName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface OrderPreviewRequest {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
addressId?: string;
|
||||
}
|
||||
|
||||
export interface OrderPreviewResult {
|
||||
productAmount: number;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY';
|
||||
}
|
||||
|
||||
export interface PayOrderResult {
|
||||
mode?: 'jsapi' | 'mock';
|
||||
orderId?: string;
|
||||
}
|
||||
|
||||
export interface DeliveryDto {
|
||||
provider?: string;
|
||||
shippingAt?: string;
|
||||
deliveredAt?: string;
|
||||
}
|
||||
Generated
+667
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
|
||||
|
||||
async function req(clientApp, path, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
};
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function expectFail(clientApp, path, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
};
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||
const json = await res.json();
|
||||
if (json.code === 0) throw new Error(`${path}: expected failure`);
|
||||
return json.message;
|
||||
}
|
||||
|
||||
async function adminLogin() {
|
||||
return req('HQ_WEB', '/admin/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13600000001', code: '123456' }),
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('1. Health');
|
||||
await req('USER_H5', '/health');
|
||||
|
||||
console.log('2. User login');
|
||||
const userLogin = await req('USER_H5', '/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13800000001', code: '123456' }),
|
||||
});
|
||||
const userToken = userLogin.accessToken;
|
||||
|
||||
console.log('3. Cities + products');
|
||||
const cities = await req('USER_H5', '/catalog/cities');
|
||||
const cityCode = cities[0]?.code ?? 'ZZ';
|
||||
const products = await req('USER_H5', `/catalog/products?cityCode=${cityCode}`);
|
||||
if (products.length < 4) throw new Error(`Expected >=4 products, got ${products.length}`);
|
||||
|
||||
console.log('4. Min purchase boundaries');
|
||||
const addr = await req('USER_H5', '/user/addresses', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({
|
||||
receiverName: '测试',
|
||||
phone: '13800000001',
|
||||
province: '河南省',
|
||||
city: '郑州市',
|
||||
district: '金水区',
|
||||
detail: 'V3冒烟地址',
|
||||
isDefault: true,
|
||||
}),
|
||||
});
|
||||
await expectFail('USER_H5', '/trade/orders/preview', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ productId: products[0].id, quantity: 1, addressId: addr.id }),
|
||||
});
|
||||
|
||||
const preview2 = await req('USER_H5', '/trade/orders/preview', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
|
||||
});
|
||||
if (preview2.payAmount <= 0) throw new Error('Preview qty=2 failed');
|
||||
|
||||
console.log('5. Create order + pay + benefit');
|
||||
const order = await req('USER_H5', '/trade/orders', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
|
||||
});
|
||||
await req('USER_H5', `/trade/orders/${order.id}/pay`, { method: 'POST', token: userToken });
|
||||
const paidOrder = await req('USER_H5', `/trade/orders/${order.id}`, { token: userToken });
|
||||
if (paidOrder.status !== 'PENDING_SHIP') throw new Error('Order not PENDING_SHIP after pay');
|
||||
const coupons = await req('USER_H5', '/benefit/coupons', { token: userToken });
|
||||
if (!coupons.length) throw new Error('No coupon after pay');
|
||||
const summary = await req('USER_H5', '/benefit/summary', { token: userToken });
|
||||
|
||||
console.log('6. Direct redeem (no couponId)');
|
||||
const directAmount = Math.min(100, Number(summary.totalBalance ?? summary.balance ?? 100));
|
||||
const directToken = await req('USER_H5', '/redeem/tokens', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ amount: directAmount }),
|
||||
});
|
||||
if (!directToken.token) throw new Error('Direct redeem token missing');
|
||||
|
||||
console.log('7. Coupon redeem cap');
|
||||
await expectFail('USER_H5', '/redeem/tokens', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ couponId: coupons[0].id, amount: Number(coupons[0].balance) + 1 }),
|
||||
});
|
||||
|
||||
console.log('8. Shop confirm redeem');
|
||||
const shopLogin = await req('SHOP_H5', '/shop/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13900000001', code: '123456' }),
|
||||
});
|
||||
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
|
||||
method: 'POST',
|
||||
token: shopLogin.accessToken,
|
||||
body: JSON.stringify({ token: directToken.token }),
|
||||
});
|
||||
if (!preview.amount) throw new Error('Redeem preview failed');
|
||||
await req('SHOP_H5', '/shop/redeem/confirm', {
|
||||
method: 'POST',
|
||||
token: shopLogin.accessToken,
|
||||
body: JSON.stringify({ token: directToken.token }),
|
||||
});
|
||||
|
||||
console.log('9. Admin login + store payout');
|
||||
const admin = await adminLogin();
|
||||
const payouts = await req('HQ_WEB', '/admin/store-payouts?status=PENDING', {
|
||||
token: admin.accessToken,
|
||||
});
|
||||
if (!payouts.items?.length) throw new Error('Expected pending store payout');
|
||||
const payoutId = payouts.items[0].id;
|
||||
await req('HQ_WEB', `/admin/store-payouts/${payoutId}/confirm`, {
|
||||
method: 'POST',
|
||||
token: admin.accessToken,
|
||||
body: JSON.stringify({ remark: 'smoke confirm' }),
|
||||
});
|
||||
|
||||
console.log('10. Refund ticket flow');
|
||||
const order2 = await req('USER_H5', '/trade/orders', {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
|
||||
});
|
||||
await req('USER_H5', `/trade/orders/${order2.id}/pay`, { method: 'POST', token: userToken });
|
||||
await req('USER_H5', `/trade/orders/${order2.id}/refund-requests`, {
|
||||
method: 'POST',
|
||||
token: userToken,
|
||||
body: JSON.stringify({ remark: 'smoke refund' }),
|
||||
});
|
||||
const tickets = await req('HQ_WEB', '/admin/tickets?ticketType=REFUND', {
|
||||
token: admin.accessToken,
|
||||
});
|
||||
const refundTicket = tickets.items?.find((t) => t.refId === order2.id);
|
||||
if (!refundTicket) throw new Error('Refund ticket not created');
|
||||
await req('HQ_WEB', `/admin/tickets/${refundTicket.id}/approve`, {
|
||||
method: 'POST',
|
||||
token: admin.accessToken,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
console.log('11. Partner bill');
|
||||
const partners = await req('HQ_WEB', '/admin/partners', { token: admin.accessToken });
|
||||
const partnerId = partners.items?.[0]?.id;
|
||||
if (partnerId) {
|
||||
const now = new Date();
|
||||
await req('HQ_WEB', '/admin/partner-bills/generate', {
|
||||
method: 'POST',
|
||||
token: admin.accessToken,
|
||||
body: JSON.stringify({ partnerId, year: now.getFullYear(), month: now.getMonth() + 1 }),
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n✅ V3 smoke passed');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('❌ V3 smoke failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -12,10 +12,10 @@
|
||||
|--------|-------------------|--------|
|
||||
| **iam** | User, UserAddress, … | jacy-dukang |
|
||||
| **catalog** | CommonCity, CommonProductItem, … | jacy-dukang |
|
||||
| **store** | Store, Partner | 刘京尧 |
|
||||
| **store** | Store, Partner | 刘景尧 |
|
||||
| **trade** | Order, OrderDelivery | jacy-dukang |
|
||||
| **benefit** | BenefitCoupon | jacy-dukang |
|
||||
| **redeem** | RedeemRecord, StoreRating | 刘京尧 |
|
||||
| **redeem** | RedeemRecord, StoreRating | 刘景尧 |
|
||||
| **settlement** | StorePayout, PartnerBill | jacy-dukang |
|
||||
| **ops** | 只读聚合 | jacy-dukang |
|
||||
| **analytics** | LogUserAnalytics | jacy-dukang |
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"lint": "echo ok",
|
||||
"lint": "eslint src",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:validate": "prisma validate",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../integrations/integrations.module';
|
||||
import { TradeModule } from '../modules/trade/trade.module';
|
||||
import { PrismaModule } from '../common/prisma/prisma.module';
|
||||
import { WechatPayCallbackController } from './wechat-pay.controller';
|
||||
import { WechatRefundCallbackController } from './wechat-refund.controller';
|
||||
import { DeliveryCallbackController } from './delivery-track.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, TradeModule],
|
||||
controllers: [WechatPayCallbackController],
|
||||
imports: [IntegrationsModule, TradeModule, PrismaModule],
|
||||
controllers: [WechatPayCallbackController, WechatRefundCallbackController, DeliveryCallbackController],
|
||||
})
|
||||
export class CallbacksModule {}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { TradeService } from '../modules/trade/trade.service';
|
||||
import { CourierService } from '../integrations/courier/courier.service';
|
||||
import { PrismaService } from '../common/prisma/prisma.module';
|
||||
|
||||
@Controller('callbacks/delivery')
|
||||
export class DeliveryCallbackController {
|
||||
constructor(
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly courier: CourierService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Post('track')
|
||||
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
|
||||
if (!body.orderId && !body.orderNo) {
|
||||
return this.courier.buildTrackCallbackResponse(false);
|
||||
}
|
||||
const order = body.orderId
|
||||
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
|
||||
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
|
||||
if (!order) return this.courier.buildTrackCallbackResponse(false);
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
SHIPPED: 'SHIPPING',
|
||||
OUT_WAREHOUSE: 'OUT_WAREHOUSE',
|
||||
DELIVERED: 'COMPLETED',
|
||||
COMPLETED: 'COMPLETED',
|
||||
};
|
||||
const target = statusMap[body.status ?? ''] ?? body.status;
|
||||
if (target && target !== order.status) {
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
|
||||
}
|
||||
return this.courier.buildTrackCallbackResponse(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Controller, Headers, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { PrismaService } from '../common/prisma/prisma.module';
|
||||
|
||||
@Controller('callbacks/wechat')
|
||||
export class WechatRefundCallbackController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Post('refund')
|
||||
async refundNotify(
|
||||
@Req() req: Request,
|
||||
@Headers() _headers: Record<string, string | string[] | undefined>,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
const body = typeof req.body === 'object' ? req.body : {};
|
||||
const outRefundNo = String((body as Record<string, unknown>).out_refund_no ?? '');
|
||||
const refundId = String((body as Record<string, unknown>).refund_id ?? outRefundNo);
|
||||
|
||||
const existing = await this.prisma.logThirdParty.findFirst({
|
||||
where: { provider: 'WECHAT_REFUND', externalNo: refundId, status: 'SUCCESS' },
|
||||
});
|
||||
if (existing) {
|
||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||
}
|
||||
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_REFUND',
|
||||
scene: 'ORDER_REFUND_CALLBACK',
|
||||
refType: 'TICKET',
|
||||
refId: BigInt(0),
|
||||
externalNo: refundId,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ code: 'SUCCESS', message: '成功' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '处理失败';
|
||||
return res.status(500).json({ code: 'FAIL', message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,8 @@ export class RedisService {
|
||||
async del(key: string) {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
|
||||
async ttl(key: string): Promise<number> {
|
||||
return this.redis.ttl(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CourierProviderCode } from './courier.constants';
|
||||
|
||||
export type XiaofeixiaSignType = 'MD5' | 'HMAC-SHA256';
|
||||
|
||||
export interface XiaofeixiaConfig {
|
||||
apiUrl: string;
|
||||
appId?: string;
|
||||
mchId: string;
|
||||
apiKey: string;
|
||||
signType: XiaofeixiaSignType;
|
||||
}
|
||||
|
||||
export interface CourierIntegrationConfig {
|
||||
provider: CourierProviderCode;
|
||||
xiaofeixia: XiaofeixiaConfig;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CourierConfigService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
load(): CourierIntegrationConfig {
|
||||
const providerRaw = (this.config.get<string>('COURIER_PROVIDER') ?? 'xiaofeixia').toLowerCase();
|
||||
const provider = this.resolveProvider(providerRaw);
|
||||
|
||||
return {
|
||||
provider,
|
||||
xiaofeixia: {
|
||||
apiUrl:
|
||||
this.config.get<string>('XIAOFEIXIA_API_URL') ??
|
||||
'https://beta.51xiaoju.cn/app/api/interface.do',
|
||||
appId: this.config.get<string>('XIAOFEIXIA_APP_ID') || undefined,
|
||||
mchId: this.config.get<string>('XIAOFEIXIA_MCH_ID') ?? '',
|
||||
apiKey: this.config.get<string>('XIAOFEIXIA_API_KEY') ?? '',
|
||||
signType: this.resolveSignType(this.config.get<string>('XIAOFEIXIA_SIGN_TYPE')),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private resolveProvider(raw: string): CourierProviderCode {
|
||||
switch (raw) {
|
||||
case 'xiaofeixia':
|
||||
return CourierProviderCode.XIAOFEIXIA;
|
||||
case 'sf':
|
||||
return CourierProviderCode.SF;
|
||||
case 'jd':
|
||||
return CourierProviderCode.JD;
|
||||
default:
|
||||
throw new Error(`Unsupported COURIER_PROVIDER: ${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSignType(raw?: string): XiaofeixiaSignType {
|
||||
if (raw?.toUpperCase() === 'HMAC-SHA256') {
|
||||
return 'HMAC-SHA256';
|
||||
}
|
||||
return 'MD5';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const COURIER_PROVIDER = 'COURIER_PROVIDER';
|
||||
|
||||
export enum CourierProviderCode {
|
||||
XIAOFEIXIA = 'XIAOFEIXIA',
|
||||
SF = 'SF',
|
||||
JD = 'JD',
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class CourierApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: string,
|
||||
readonly providerCode?: string,
|
||||
readonly raw?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CourierApiError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { COURIER_PROVIDER, CourierProviderCode } from './courier.constants';
|
||||
import { CourierConfigService } from './courier.config';
|
||||
import { CourierService } from './courier.service';
|
||||
import { XiaofeixiaClient } from './xiaofeixia/xiaofeixia.client';
|
||||
import { XiaofeixiaProvider } from './xiaofeixia/xiaofeixia.provider';
|
||||
import type { ICourierProvider } from './courier.types';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
CourierConfigService,
|
||||
XiaofeixiaClient,
|
||||
XiaofeixiaProvider,
|
||||
{
|
||||
provide: COURIER_PROVIDER,
|
||||
useFactory: (
|
||||
configService: CourierConfigService,
|
||||
xiaofeixia: XiaofeixiaProvider,
|
||||
): ICourierProvider => {
|
||||
const { provider } = configService.load();
|
||||
switch (provider) {
|
||||
case CourierProviderCode.XIAOFEIXIA:
|
||||
return xiaofeixia;
|
||||
default:
|
||||
throw new Error(`Courier provider not implemented: ${provider}`);
|
||||
}
|
||||
},
|
||||
inject: [CourierConfigService, XiaofeixiaProvider],
|
||||
},
|
||||
CourierService,
|
||||
],
|
||||
exports: [CourierService, COURIER_PROVIDER],
|
||||
})
|
||||
export class CourierModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { COURIER_PROVIDER } from './courier.constants';
|
||||
import type {
|
||||
BatchShipmentQuery,
|
||||
CreateShipmentInput,
|
||||
CreateShipmentResult,
|
||||
DeliveryCoverageResult,
|
||||
FreightEstimateResult,
|
||||
ICourierProvider,
|
||||
ShipmentDetail,
|
||||
ShipmentQuery,
|
||||
TrackCallbackResponse,
|
||||
TrackNode,
|
||||
} from './courier.types';
|
||||
|
||||
/**
|
||||
* 快递统一门面:业务层只依赖本 Service,不感知具体快递商实现。
|
||||
*/
|
||||
@Injectable()
|
||||
export class CourierService {
|
||||
constructor(@Inject(COURIER_PROVIDER) private readonly provider: ICourierProvider) {}
|
||||
|
||||
get activeProvider() {
|
||||
return this.provider.code;
|
||||
}
|
||||
|
||||
createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult> {
|
||||
return this.provider.createShipment(input);
|
||||
}
|
||||
|
||||
cancelShipment(query: ShipmentQuery): Promise<void> {
|
||||
return this.provider.cancelShipment(query);
|
||||
}
|
||||
|
||||
getShipment(query: ShipmentQuery): Promise<ShipmentDetail> {
|
||||
return this.provider.getShipment(query);
|
||||
}
|
||||
|
||||
batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]> {
|
||||
return this.provider.batchGetShipments(query);
|
||||
}
|
||||
|
||||
getTrack(query: ShipmentQuery): Promise<TrackNode[]> {
|
||||
return this.provider.getTrack(query);
|
||||
}
|
||||
|
||||
checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult> {
|
||||
return this.provider.checkDeliveryCoverage(toAddress);
|
||||
}
|
||||
|
||||
estimateFreight(weight: number): Promise<FreightEstimateResult> {
|
||||
return this.provider.estimateFreight(weight);
|
||||
}
|
||||
|
||||
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse {
|
||||
return this.provider.buildTrackCallbackResponse(success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { CourierProviderCode } from './courier.constants';
|
||||
|
||||
export interface CourierCoordinate {
|
||||
lng: number;
|
||||
lat: number;
|
||||
}
|
||||
|
||||
export interface CourierContact {
|
||||
name: string;
|
||||
mobile: string;
|
||||
address: string;
|
||||
addressDetail: string;
|
||||
coordinate?: CourierCoordinate;
|
||||
}
|
||||
|
||||
/** 付费对象:寄付 / 到付 */
|
||||
export enum CourierPayMode {
|
||||
SENDER = '1',
|
||||
RECEIVER = '2',
|
||||
}
|
||||
|
||||
export interface CreateShipmentInput {
|
||||
outNumber: string;
|
||||
customerId?: string;
|
||||
from: CourierContact;
|
||||
to: CourierContact;
|
||||
goodsName?: string;
|
||||
goodsNum?: number;
|
||||
weight?: number;
|
||||
insuredSumPrice?: number;
|
||||
collectionPrice?: number;
|
||||
payMode: CourierPayMode;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface CreateShipmentResult {
|
||||
providerShipmentId: number;
|
||||
trackingNumber: string;
|
||||
}
|
||||
|
||||
export interface ShipmentQuery {
|
||||
trackingNumber?: string;
|
||||
outNumber?: string;
|
||||
}
|
||||
|
||||
export interface BatchShipmentQuery {
|
||||
trackingNumbers?: string[];
|
||||
outNumbers?: string[];
|
||||
}
|
||||
|
||||
export interface TrackNode {
|
||||
trackInfo: string;
|
||||
createTime: string;
|
||||
statusName: string;
|
||||
}
|
||||
|
||||
export interface ShipmentDetail {
|
||||
trackingNumber: string;
|
||||
toCarrierName?: string;
|
||||
toSiteName?: string;
|
||||
toName: string;
|
||||
toMobile: string;
|
||||
toAddress: string;
|
||||
toAddressDetail?: string;
|
||||
fromCarrierName?: string;
|
||||
fromSiteName?: string;
|
||||
fromName: string;
|
||||
fromMobile: string;
|
||||
fromAddress: string;
|
||||
fromAddressDetail?: string;
|
||||
weight?: number;
|
||||
payModeName?: string;
|
||||
freightPrice?: string;
|
||||
insuredPrice?: number;
|
||||
collectionPrice?: number;
|
||||
sumPrice?: number;
|
||||
goodsName?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface DeliveryCoverageResult {
|
||||
arriveTime: string;
|
||||
siteName: string;
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export interface FreightEstimateResult {
|
||||
freightPrice: number;
|
||||
}
|
||||
|
||||
/** 路由变化回调(各快递商 POST 到业务方) */
|
||||
export interface TrackCallbackPayload {
|
||||
outNumber: string;
|
||||
trackingNumber: string;
|
||||
status: string;
|
||||
statusName: string;
|
||||
trackInfo: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export interface TrackCallbackResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ICourierProvider {
|
||||
readonly code: CourierProviderCode;
|
||||
|
||||
createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult>;
|
||||
cancelShipment(query: ShipmentQuery): Promise<void>;
|
||||
getShipment(query: ShipmentQuery): Promise<ShipmentDetail>;
|
||||
batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]>;
|
||||
getTrack(query: ShipmentQuery): Promise<TrackNode[]>;
|
||||
checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult>;
|
||||
estimateFreight(weight: number): Promise<FreightEstimateResult>;
|
||||
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './courier.constants';
|
||||
export * from './courier.types';
|
||||
export * from './courier.error';
|
||||
export * from './courier.service';
|
||||
export * from './courier.module';
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CourierApiError } from '../courier.error';
|
||||
import { CourierConfigService } from '../courier.config';
|
||||
import { buildXiaofeixiaSign } from './xiaofeixia.sign';
|
||||
import { XIAOFEIXIA_SUCCESS_CODE } from './xiaofeixia.constants';
|
||||
import type { XiaofeixiaApiResponse } from './xiaofeixia.types';
|
||||
|
||||
type RequestParams = Record<string, string | number | undefined>;
|
||||
|
||||
@Injectable()
|
||||
export class XiaofeixiaClient {
|
||||
constructor(private readonly courierConfig: CourierConfigService) {}
|
||||
|
||||
async request<T>(cmd: string, bizParams: RequestParams): Promise<T> {
|
||||
const cfg = this.courierConfig.load().xiaofeixia;
|
||||
|
||||
if (!cfg.mchId || !cfg.apiKey) {
|
||||
throw new CourierApiError(
|
||||
'小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY',
|
||||
'CONFIG_ERROR',
|
||||
'XIAOFEIXIA',
|
||||
);
|
||||
}
|
||||
|
||||
const baseParams: RequestParams = {
|
||||
mchId: cfg.mchId,
|
||||
cmd,
|
||||
signType: cfg.signType,
|
||||
...bizParams,
|
||||
};
|
||||
|
||||
if (cfg.appId) {
|
||||
baseParams.appId = cfg.appId;
|
||||
}
|
||||
|
||||
const sign = buildXiaofeixiaSign(baseParams, cfg.apiKey, cfg.signType);
|
||||
const body = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries({ ...baseParams, sign })) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
body.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(cfg.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new CourierApiError(
|
||||
'小飞侠接口网络异常',
|
||||
'200000',
|
||||
'XIAOFEIXIA',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new CourierApiError(
|
||||
`小飞侠 HTTP 请求失败: ${response.status}`,
|
||||
'200000',
|
||||
'XIAOFEIXIA',
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as XiaofeixiaApiResponse<T>;
|
||||
|
||||
if (payload.code !== XIAOFEIXIA_SUCCESS_CODE) {
|
||||
throw new CourierApiError(
|
||||
payload.message || '小飞侠接口业务失败',
|
||||
payload.code,
|
||||
'XIAOFEIXIA',
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
return payload.data as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 小飞侠接口 cmd 编号 */
|
||||
export const XIAOFEIXIA_CMD = {
|
||||
CREATE_ORDER: '100101',
|
||||
TRACK_ROUTE: '100102',
|
||||
CANCEL_ORDER: '100103',
|
||||
GET_ORDER: '100104',
|
||||
ESTIMATE_FREIGHT: '100105',
|
||||
BATCH_GET_ORDER: '100106',
|
||||
DELIVERY_COVERAGE: '100301',
|
||||
} as const;
|
||||
|
||||
export const XIAOFEIXIA_SUCCESS_CODE = '100000';
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CourierProviderCode } from '../courier.constants';
|
||||
import { CourierApiError } from '../courier.error';
|
||||
import type {
|
||||
BatchShipmentQuery,
|
||||
CreateShipmentInput,
|
||||
CreateShipmentResult,
|
||||
DeliveryCoverageResult,
|
||||
FreightEstimateResult,
|
||||
ICourierProvider,
|
||||
ShipmentDetail,
|
||||
ShipmentQuery,
|
||||
TrackCallbackResponse,
|
||||
TrackNode,
|
||||
} from '../courier.types';
|
||||
import { XiaofeixiaClient } from './xiaofeixia.client';
|
||||
import { XIAOFEIXIA_CMD } from './xiaofeixia.constants';
|
||||
import type {
|
||||
XiaofeixiaCreateOrderData,
|
||||
XiaofeixiaDeliveryCoverageData,
|
||||
XiaofeixiaFreightEstimateData,
|
||||
XiaofeixiaOrderDetail,
|
||||
XiaofeixiaTrackNode,
|
||||
} from './xiaofeixia.types';
|
||||
|
||||
@Injectable()
|
||||
export class XiaofeixiaProvider implements ICourierProvider {
|
||||
readonly code = CourierProviderCode.XIAOFEIXIA;
|
||||
|
||||
constructor(private readonly client: XiaofeixiaClient) {}
|
||||
|
||||
async createShipment(input: CreateShipmentInput): Promise<CreateShipmentResult> {
|
||||
const data = await this.client.request<XiaofeixiaCreateOrderData>(XIAOFEIXIA_CMD.CREATE_ORDER, {
|
||||
customerId: input.customerId,
|
||||
outNumber: input.outNumber,
|
||||
fromAddress: input.from.address,
|
||||
fromAddressDetail: input.from.addressDetail,
|
||||
fromCoordinate: this.formatCoordinate(input.from.coordinate),
|
||||
fromMobile: input.from.mobile,
|
||||
fromName: input.from.name,
|
||||
toAddress: input.to.address,
|
||||
toAddressDetail: input.to.addressDetail,
|
||||
toCoordinate: this.formatCoordinate(input.to.coordinate),
|
||||
toMobile: input.to.mobile,
|
||||
toName: input.to.name,
|
||||
goodsName: input.goodsName,
|
||||
goodsNum: input.goodsNum,
|
||||
weight: input.weight,
|
||||
insuredSumPrice: input.insuredSumPrice,
|
||||
collectionPrice: input.collectionPrice,
|
||||
payMode: input.payMode,
|
||||
remark: input.remark,
|
||||
});
|
||||
|
||||
return {
|
||||
providerShipmentId: data.id,
|
||||
trackingNumber: data.number,
|
||||
};
|
||||
}
|
||||
|
||||
async cancelShipment(query: ShipmentQuery): Promise<void> {
|
||||
this.assertShipmentQuery(query);
|
||||
await this.client.request(XIAOFEIXIA_CMD.CANCEL_ORDER, {
|
||||
number: query.trackingNumber,
|
||||
outNumber: query.outNumber,
|
||||
});
|
||||
}
|
||||
|
||||
async getShipment(query: ShipmentQuery): Promise<ShipmentDetail> {
|
||||
this.assertShipmentQuery(query);
|
||||
const data = await this.client.request<XiaofeixiaOrderDetail>(XIAOFEIXIA_CMD.GET_ORDER, {
|
||||
number: query.trackingNumber,
|
||||
outNumber: query.outNumber,
|
||||
});
|
||||
return this.mapOrderDetail(data);
|
||||
}
|
||||
|
||||
async batchGetShipments(query: BatchShipmentQuery): Promise<ShipmentDetail[]> {
|
||||
const number = query.trackingNumbers?.join(',');
|
||||
const outNumber = query.outNumbers?.join(',');
|
||||
|
||||
if (!number && !outNumber) {
|
||||
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
|
||||
}
|
||||
|
||||
const data = await this.client.request<XiaofeixiaOrderDetail[]>(XIAOFEIXIA_CMD.BATCH_GET_ORDER, {
|
||||
number,
|
||||
outNumber,
|
||||
});
|
||||
|
||||
return (data ?? []).map((item) => this.mapOrderDetail(item));
|
||||
}
|
||||
|
||||
async getTrack(query: ShipmentQuery): Promise<TrackNode[]> {
|
||||
this.assertShipmentQuery(query);
|
||||
const data = await this.client.request<XiaofeixiaTrackNode[]>(XIAOFEIXIA_CMD.TRACK_ROUTE, {
|
||||
number: query.trackingNumber,
|
||||
outNumber: query.outNumber,
|
||||
});
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async checkDeliveryCoverage(toAddress: string): Promise<DeliveryCoverageResult> {
|
||||
const data = await this.client.request<XiaofeixiaDeliveryCoverageData>(
|
||||
XIAOFEIXIA_CMD.DELIVERY_COVERAGE,
|
||||
{ toAddress },
|
||||
);
|
||||
|
||||
return {
|
||||
arriveTime: data.arriveTime,
|
||||
siteName: data.name,
|
||||
siteId: data.id,
|
||||
};
|
||||
}
|
||||
|
||||
async estimateFreight(weight: number): Promise<FreightEstimateResult> {
|
||||
const data = await this.client.request<XiaofeixiaFreightEstimateData>(
|
||||
XIAOFEIXIA_CMD.ESTIMATE_FREIGHT,
|
||||
{ weight },
|
||||
);
|
||||
|
||||
return { freightPrice: data.freightPrice };
|
||||
}
|
||||
|
||||
buildTrackCallbackResponse(success = true): TrackCallbackResponse {
|
||||
return {
|
||||
code: success ? '100000' : '300000',
|
||||
message: success ? 'success' : 'fail',
|
||||
};
|
||||
}
|
||||
|
||||
private assertShipmentQuery(query: ShipmentQuery): void {
|
||||
if (!query.trackingNumber && !query.outNumber) {
|
||||
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
|
||||
}
|
||||
}
|
||||
|
||||
private formatCoordinate(coordinate?: { lng: number; lat: number }): string | undefined {
|
||||
if (!coordinate) return undefined;
|
||||
return JSON.stringify({ lng: coordinate.lng, lat: coordinate.lat });
|
||||
}
|
||||
|
||||
private mapOrderDetail(data: XiaofeixiaOrderDetail): ShipmentDetail {
|
||||
return {
|
||||
trackingNumber: data.number,
|
||||
toCarrierName: data.toCarrierName,
|
||||
toSiteName: data.toSiteName,
|
||||
toName: data.toName,
|
||||
toMobile: data.toMobile,
|
||||
toAddress: data.toAddress,
|
||||
toAddressDetail: data.toAddressDetail,
|
||||
fromCarrierName: data.fromCarrierName,
|
||||
fromSiteName: data.fromSiteName,
|
||||
fromName: data.fromName,
|
||||
fromMobile: data.fromMobile,
|
||||
fromAddress: data.fromAddress,
|
||||
fromAddressDetail: data.fromAddressDetail,
|
||||
weight: data.weight,
|
||||
payModeName: data.payModeName,
|
||||
freightPrice: data.freightPrice,
|
||||
insuredPrice: data.insuredPrice,
|
||||
collectionPrice: data.collectionPrice,
|
||||
sumPrice: data.sumPrice,
|
||||
goodsName: data.goodsName,
|
||||
remark: data.remark,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createHash, createHmac } from 'crypto';
|
||||
import type { XiaofeixiaSignType } from '../courier.config';
|
||||
|
||||
type SignParams = Record<string, string | number | undefined | null>;
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
/** 按 ASCII 字典序拼接并生成签名 */
|
||||
export function buildXiaofeixiaSign(
|
||||
params: SignParams,
|
||||
apiKey: string,
|
||||
signType: XiaofeixiaSignType = 'MD5',
|
||||
): string {
|
||||
const sortedKeys = Object.keys(params)
|
||||
.filter((key) => key !== 'sign' && !isEmpty(params[key]))
|
||||
.sort();
|
||||
|
||||
const stringA = sortedKeys.map((key) => `${key}=${params[key]}`).join('&');
|
||||
const stringSignTemp = `${stringA}&key=${apiKey}`;
|
||||
|
||||
if (signType === 'HMAC-SHA256') {
|
||||
return createHmac('sha256', apiKey).update(stringSignTemp).digest('hex').toUpperCase();
|
||||
}
|
||||
|
||||
return createHash('md5').update(stringSignTemp).digest('hex').toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface XiaofeixiaApiResponse<T> {
|
||||
cmd: string;
|
||||
code: string;
|
||||
message: string;
|
||||
bizCode?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaCreateOrderData {
|
||||
id: number;
|
||||
number: string;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaTrackNode {
|
||||
trackInfo: string;
|
||||
createTime: string;
|
||||
statusName: string;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaOrderDetail {
|
||||
number: string;
|
||||
toCarrierName?: string;
|
||||
toSiteName?: string;
|
||||
toName: string;
|
||||
toMobile: string;
|
||||
toAddress: string;
|
||||
toAddressDetail?: string;
|
||||
fromCarrierName?: string;
|
||||
fromSiteName?: string;
|
||||
fromName: string;
|
||||
fromMobile: string;
|
||||
fromAddress: string;
|
||||
fromAddressDetail?: string;
|
||||
weight?: number;
|
||||
payModeName?: string;
|
||||
freightPrice?: string;
|
||||
insuredPrice?: number;
|
||||
collectionPrice?: number;
|
||||
sumPrice?: number;
|
||||
goodsName?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaDeliveryCoverageData {
|
||||
arriveTime: string;
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaFreightEstimateData {
|
||||
freightPrice: number;
|
||||
}
|
||||
|
||||
export interface XiaofeixiaCancelOrderData {
|
||||
id: number;
|
||||
number: string;
|
||||
}
|
||||
@@ -16,13 +16,14 @@ import {
|
||||
WECHAT_PROVIDER,
|
||||
OSS_PROVIDER,
|
||||
} from './integrations.constants';
|
||||
import { CourierModule } from './courier/courier.module';
|
||||
import { DELIVERY_QUEUE } from '../jobs/jobs.constants';
|
||||
import type { IWechatProvider } from './wechat/wechat.interface';
|
||||
import type { IPayProvider } from './pay/pay.interface';
|
||||
import type { IOssProvider } from './oss/oss.interface';
|
||||
|
||||
@Module({
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE })],
|
||||
imports: [BullModule.registerQueue({ name: DELIVERY_QUEUE }), CourierModule],
|
||||
providers: [
|
||||
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
|
||||
WechatApiProvider,
|
||||
@@ -62,6 +63,6 @@ import type { IOssProvider } from './oss/oss.interface';
|
||||
SmsMockProvider,
|
||||
DeliveryMockProvider,
|
||||
],
|
||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER],
|
||||
exports: [SMS_PROVIDER, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, CourierModule],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
|
||||
|
||||
export type CouponAllocation = { couponId: string; amount: number };
|
||||
|
||||
@Injectable()
|
||||
export class BenefitService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -85,4 +88,73 @@ export class BenefitService {
|
||||
});
|
||||
return serializeBigInt({ coupon, ledgers });
|
||||
}
|
||||
|
||||
/** 核销扣减券余额(乐观锁),由 redeem 模块调用 */
|
||||
async deductCoupons(
|
||||
tx: Prisma.TransactionClient,
|
||||
allocations: CouponAllocation[],
|
||||
refType: 'STORE',
|
||||
refId: bigint,
|
||||
) {
|
||||
for (const alloc of allocations) {
|
||||
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
const allocAmount = alloc.amount;
|
||||
const updated = await tx.benefitCoupon.updateMany({
|
||||
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
||||
data: {
|
||||
usedAmount: { increment: allocAmount },
|
||||
balance: { decrement: allocAmount },
|
||||
version: { increment: 1 },
|
||||
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) throw new Error('BENEFIT_DEDUCT_CONFLICT');
|
||||
|
||||
const newBalance = Number(coupon.balance) - allocAmount;
|
||||
await tx.commonEvent.create({
|
||||
data: buildBenefitLedgerEvent({
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REDEEM',
|
||||
amount: -allocAmount,
|
||||
balanceAfter: newBalance,
|
||||
refType,
|
||||
refId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 退款作废权益 */
|
||||
async voidCouponsOnRefund(orderId: bigint) {
|
||||
const coupons = await this.prisma.benefitCoupon.findMany({
|
||||
where: { orderId, status: { in: ['ACTIVE', 'USED_UP'] } },
|
||||
});
|
||||
for (const coupon of coupons) {
|
||||
const balance = Number(coupon.balance);
|
||||
if (balance <= 0 && coupon.status === 'USED_UP') continue;
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.benefitCoupon.update({
|
||||
where: { id: coupon.id },
|
||||
data: { status: 'VOID', balance: 0 },
|
||||
});
|
||||
if (balance > 0) {
|
||||
await tx.commonEvent.create({
|
||||
data: buildBenefitLedgerEvent({
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REFUND_VOID',
|
||||
amount: -balance,
|
||||
balanceAfter: 0,
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
remark: '退款作废权益',
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ export class CatalogController {
|
||||
}
|
||||
|
||||
@Get('products')
|
||||
products(@Query('aromaType') aromaType?: string) {
|
||||
return this.catalogService.listProducts(aromaType);
|
||||
products(@Query('aromaType') aromaType?: string, @Query('cityCode') cityCode?: string) {
|
||||
return this.catalogService.listProducts(aromaType, cityCode);
|
||||
}
|
||||
|
||||
@Get('products/:id')
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { CommonProductItem, CommonResource } from '@prisma/client';
|
||||
|
||||
export type ProductMediaDto = {
|
||||
mainImageUrl: string | null;
|
||||
carouselUrls: string[];
|
||||
detailImageUrls: string[];
|
||||
};
|
||||
|
||||
type ProductWithCover = CommonProductItem & {
|
||||
coverResource?: { url: string } | null;
|
||||
};
|
||||
|
||||
function urlsFromResources(resources: CommonResource[], bizType: 'CAROUSEL' | 'DETAIL') {
|
||||
return resources
|
||||
.filter((r) => r.bizType === bizType && r.url)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((r) => r.url);
|
||||
}
|
||||
|
||||
export function mapProductMedia(
|
||||
product: ProductWithCover,
|
||||
extraResources: CommonResource[] = [],
|
||||
): ProductMediaDto {
|
||||
const mainImageUrl = product.coverResource?.url ?? null;
|
||||
const carouselFromDb = urlsFromResources(extraResources, 'CAROUSEL');
|
||||
const detailFromDb = urlsFromResources(extraResources, 'DETAIL');
|
||||
|
||||
const detailFromJson = parseDetailContentImages(product.detailContent);
|
||||
|
||||
const carouselUrls =
|
||||
carouselFromDb.length > 0
|
||||
? carouselFromDb
|
||||
: mainImageUrl
|
||||
? [mainImageUrl]
|
||||
: [];
|
||||
|
||||
const detailImageUrls =
|
||||
detailFromDb.length > 0
|
||||
? detailFromDb
|
||||
: detailFromJson;
|
||||
|
||||
return { mainImageUrl, carouselUrls, detailImageUrls };
|
||||
}
|
||||
|
||||
function parseDetailContentImages(detailContent: unknown): string[] {
|
||||
if (!detailContent || typeof detailContent !== 'object') return [];
|
||||
const record = detailContent as Record<string, unknown>;
|
||||
const images = record.images ?? record.detailImages ?? record.detailImageUrls;
|
||||
if (!Array.isArray(images)) return [];
|
||||
return images.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
||||
}
|
||||
|
||||
export function groupResourcesByProductId(resources: CommonResource[]) {
|
||||
const map = new Map<string, CommonResource[]>();
|
||||
for (const resource of resources) {
|
||||
const key = resource.ownerId.toString();
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(resource);
|
||||
map.set(key, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
@@ -10,24 +11,50 @@ export class CatalogService {
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return serializeBigInt(cities);
|
||||
}
|
||||
|
||||
async listProducts(aromaType?: string) {
|
||||
async listProducts(aromaType?: string, cityCode?: string) {
|
||||
if (cityCode) {
|
||||
const city = await this.prisma.commonCity.findFirst({
|
||||
where: { code: cityCode, status: 'ACTIVE' },
|
||||
});
|
||||
if (!city) throw new BadRequestException('该城市暂未开城');
|
||||
}
|
||||
|
||||
const products = await this.prisma.commonProductItem.findMany({
|
||||
where: { status: 'ON_SALE', ...(aromaType ? { aromaType: aromaType as never } : {}) },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { coverResource: true },
|
||||
});
|
||||
|
||||
const productIds = products.map((p) => p.id);
|
||||
const resources = productIds.length
|
||||
? await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'PRODUCT',
|
||||
ownerId: { in: productIds },
|
||||
status: 'ACTIVE',
|
||||
bizType: { in: ['CAROUSEL', 'DETAIL'] },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
: [];
|
||||
const resourceMap = groupResourcesByProductId(resources);
|
||||
|
||||
return serializeBigInt(
|
||||
products.map((p) => ({
|
||||
...p,
|
||||
benefitAmount: p.benefitAmount ?? p.price,
|
||||
price: Number(p.price),
|
||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
||||
mainImageUrl: p.coverResource?.url ?? null,
|
||||
})),
|
||||
products.map((p) => {
|
||||
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
||||
return {
|
||||
...p,
|
||||
benefitAmount: p.benefitAmount ?? p.price,
|
||||
price: Number(p.price),
|
||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
||||
...media,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,11 +64,23 @@ export class CatalogService {
|
||||
include: { coverResource: true },
|
||||
});
|
||||
if (!product) return null;
|
||||
|
||||
const resources = await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'PRODUCT',
|
||||
ownerId: id,
|
||||
status: 'ACTIVE',
|
||||
bizType: { in: ['CAROUSEL', 'DETAIL'] },
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
const media = mapProductMedia(product, resources);
|
||||
return serializeBigInt({
|
||||
...product,
|
||||
benefitAmount: product.benefitAmount ?? product.price,
|
||||
price: Number(product.price),
|
||||
mainImageUrl: product.coverResource?.url ?? null,
|
||||
...media,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ export class AuthService {
|
||||
? await this.wechatProvider.code2Session(code)
|
||||
: await this.wechatProvider.oauth2AccessToken(code);
|
||||
|
||||
let user = await this.prisma.user.findFirst({
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { wxOpenId: session.openId, status: 1, mergedIntoUserId: null },
|
||||
include: { avatar: true },
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ export class AdminDashboardService {
|
||||
partnersTotal,
|
||||
redeemToday,
|
||||
deliveriesTotal,
|
||||
pendingPayouts,
|
||||
pendingBills,
|
||||
openTickets,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }),
|
||||
this.prisma.user.count({
|
||||
@@ -38,6 +41,9 @@ export class AdminDashboardService {
|
||||
this.prisma.partner.count(),
|
||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
this.prisma.orderDelivery.count(),
|
||||
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
|
||||
this.prisma.partnerBill.count({ where: { status: { in: ['DRAFT', 'CONFIRMED'] } } }),
|
||||
this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -50,6 +56,9 @@ export class AdminDashboardService {
|
||||
partnersTotal,
|
||||
redeemToday,
|
||||
deliveriesTotal,
|
||||
pendingPayouts,
|
||||
pendingBills,
|
||||
openTickets,
|
||||
ordersByStatus: ordersByStatus.map((row) => ({
|
||||
status: row.status,
|
||||
count: row._count.status,
|
||||
|
||||
@@ -45,6 +45,11 @@ export class AdminStoresController {
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
||||
return this.service.updateStoreStatus(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Put(':id/audit')
|
||||
audit(@Param('id') id: string, @Body() body: { approved: boolean; remark?: string }) {
|
||||
return this.service.auditStore(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-accounts')
|
||||
|
||||
@@ -93,6 +93,27 @@ export class AdminStoresService {
|
||||
return serializeBigInt(store);
|
||||
}
|
||||
|
||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
|
||||
const store = await this.prisma.store.findUnique({ where: { id } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
const status = dto.approved ? 'OPEN' : 'PAUSED';
|
||||
const updated = await this.prisma.store.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'STORE_AUDIT',
|
||||
refType: 'STORE',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: dto.approved ? 'APPROVED' : 'REJECTED',
|
||||
remark: dto.remark ?? (dto.approved ? '审核通过' : '审核驳回'),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id },
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminTicketsService } from './admin-tickets.service';
|
||||
import { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||
|
||||
@Controller('admin/tickets')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminTicketsController {
|
||||
constructor(private readonly service: AdminTicketsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: TicketListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(@Param('id') id: string, @Body() body: { remark?: string }) {
|
||||
return this.service.approve(BigInt(id), body.remark);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
reject(@Param('id') id: string, @Body() body: { remark?: string }) {
|
||||
return this.service.reject(BigInt(id), body.remark);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { TicketService } from '../common/ticket.service';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminTicketsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly benefitService: BenefitService,
|
||||
) {}
|
||||
|
||||
list(query: TicketListQueryDto) {
|
||||
return this.ticketService.list(query);
|
||||
}
|
||||
|
||||
detail(id: bigint) {
|
||||
return this.ticketService.detail(id);
|
||||
}
|
||||
|
||||
async approve(id: bigint, remark?: string) {
|
||||
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('工单不存在');
|
||||
if (ticket.status !== 'PENDING' && ticket.status !== 'OPEN') {
|
||||
throw new BadRequestException('工单状态不可审批');
|
||||
}
|
||||
|
||||
if (ticket.ticketType === 'REFUND' && ticket.refType === 'ORDER') {
|
||||
const orderId = ticket.refId;
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'REFUNDED', payStatus: 'REFUNDED' },
|
||||
});
|
||||
await this.benefitService.voidCouponsOnRefund(orderId);
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'WECHAT_REFUND',
|
||||
scene: 'ORDER_REFUND',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
status: 'SUCCESS',
|
||||
amount: 0,
|
||||
},
|
||||
});
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'ORDER_STATUS',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
actorType: 'HQ',
|
||||
status: 'REFUNDED',
|
||||
remark: remark ?? '退款工单审批通过',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (ticket.ticketType === 'RESHIPMENT' && ticket.refType === 'ORDER') {
|
||||
await this.tradeService.applyStatusTransition(
|
||||
ticket.refId,
|
||||
'PENDING_SHIP',
|
||||
'PENDING_SHIP',
|
||||
'HQ',
|
||||
);
|
||||
}
|
||||
|
||||
return this.ticketService.updateStatus(id, {
|
||||
status: 'RESOLVED',
|
||||
remark: remark ?? '审批通过',
|
||||
});
|
||||
}
|
||||
|
||||
reject(id: bigint, remark?: string) {
|
||||
return this.ticketService.updateStatus(id, {
|
||||
status: 'REJECTED',
|
||||
remark: remark ?? '审批驳回',
|
||||
});
|
||||
}
|
||||
|
||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true },
|
||||
});
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { cityId: { in: cities.map((c) => c.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
const orderIds = orders.map((o) => o.id);
|
||||
const tickets = await this.prisma.commonTicket.findMany({
|
||||
where: {
|
||||
ticketType: 'RESHIPMENT',
|
||||
refType: 'ORDER',
|
||||
refId: { in: orderIds },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(tickets);
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,14 @@ import { AdminHqAccountsController } from './admin-hq-accounts.controller';
|
||||
import { AdminHqAccountsService } from './admin-hq-accounts.service';
|
||||
import { AdminProductsController } from './admin-products.controller';
|
||||
import { AdminProductsService } from './admin-products.service';
|
||||
import { AdminTicketsController } from './admin-tickets.controller';
|
||||
import { AdminTicketsService } from './admin-tickets.service';
|
||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, TradeModule],
|
||||
imports: [IamModule, TradeModule, BenefitModule, CommonModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminUsersController,
|
||||
@@ -41,6 +45,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
AdminDeliveriesController,
|
||||
AdminHqAccountsController,
|
||||
AdminProductsController,
|
||||
AdminTicketsController,
|
||||
],
|
||||
providers: [
|
||||
AdminDashboardService,
|
||||
@@ -54,6 +59,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||
AdminDeliveriesService,
|
||||
AdminHqAccountsService,
|
||||
AdminProductsService,
|
||||
AdminTicketsService,
|
||||
SuperAdminGuard,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -29,6 +29,11 @@ export class UserRedeemController {
|
||||
export class ShopRedeemController {
|
||||
constructor(private readonly redeemService: RedeemService) {}
|
||||
|
||||
@Post('preview')
|
||||
preview(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
||||
return this.redeemService.previewRedeem(user.actorId, body.token);
|
||||
}
|
||||
|
||||
@Post('confirm')
|
||||
confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
|
||||
return this.redeemService.confirmRedeem(user.actorId, body);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { SettlementModule } from '../settlement/settlement.module';
|
||||
import { RedeemService } from './redeem.service';
|
||||
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, forwardRef(() => SettlementModule)],
|
||||
imports: [IamModule, BenefitModule, forwardRef(() => SettlementModule)],
|
||||
controllers: [UserRedeemController, ShopRedeemController],
|
||||
providers: [RedeemService],
|
||||
exports: [RedeemService],
|
||||
|
||||
@@ -15,7 +15,15 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { SettlementService } from '../settlement/settlement.service';
|
||||
import { buildBenefitLedgerEvent } from '../../common/event/event.helpers';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
|
||||
type TokenPayload = {
|
||||
userId: string;
|
||||
couponId?: string;
|
||||
amount: number;
|
||||
storeId?: string | null;
|
||||
allocations?: Array<{ couponId: string; amount: number }>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RedeemService {
|
||||
@@ -23,6 +31,7 @@ export class RedeemService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly settlementService: SettlementService,
|
||||
private readonly benefitService: BenefitService,
|
||||
) {}
|
||||
|
||||
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
|
||||
@@ -34,7 +43,7 @@ export class RedeemService {
|
||||
});
|
||||
if (!coupon) throw new NotFoundException('券不存在');
|
||||
const balance = Number(coupon.balance);
|
||||
const check = validateRedeemAmount(balance, body.amount);
|
||||
const check = validateRedeemAmount(balance, body.amount, balance);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
allocations = [{ couponId: coupon.id.toString(), amount: body.amount }];
|
||||
} else {
|
||||
@@ -42,6 +51,7 @@ export class RedeemService {
|
||||
where: { userId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const totalBalance = coupons.reduce((s, c) => s + Number(c.balance), 0);
|
||||
const result = allocateBenefitCoupons(
|
||||
coupons.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
@@ -51,6 +61,8 @@ export class RedeemService {
|
||||
body.amount,
|
||||
);
|
||||
if (!result.ok) throw new BadRequestException(result.message);
|
||||
const check = validateRedeemAmount(totalBalance, body.amount);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
allocations = result.allocations;
|
||||
}
|
||||
|
||||
@@ -70,7 +82,7 @@ export class RedeemService {
|
||||
REDEEM_TOKEN_TTL_SECONDS,
|
||||
);
|
||||
|
||||
return { token, expireAt, amount: body.amount };
|
||||
return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null };
|
||||
}
|
||||
|
||||
async getToken(token: string) {
|
||||
@@ -79,6 +91,40 @@ export class RedeemService {
|
||||
return cached;
|
||||
}
|
||||
|
||||
async previewRedeem(storeAccountId: bigint, token: string) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
include: { store: true },
|
||||
});
|
||||
if (account.store.status !== 'OPEN') {
|
||||
throw new BadRequestException('门店未营业');
|
||||
}
|
||||
|
||||
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
|
||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||
|
||||
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
|
||||
throw new BadRequestException('该核销码仅限指定门店使用');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: BigInt(cached.userId) },
|
||||
select: { id: true, userNo: true, phone: true, nickname: true },
|
||||
});
|
||||
|
||||
const ttl = await this.redis.ttl(`redeem:token:${token}`);
|
||||
|
||||
return serializeBigInt({
|
||||
token,
|
||||
amount: cached.amount,
|
||||
user,
|
||||
boundStoreId: cached.storeId,
|
||||
redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
|
||||
expireInSeconds: ttl > 0 ? ttl : 0,
|
||||
storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
@@ -88,19 +134,16 @@ export class RedeemService {
|
||||
throw new BadRequestException('门店未营业');
|
||||
}
|
||||
|
||||
const cached = await this.redis.getJson<{
|
||||
userId: string;
|
||||
couponId?: string;
|
||||
amount: number;
|
||||
allocations?: Array<{ couponId: string; amount: number }>;
|
||||
}>(`redeem:token:${body.token}`);
|
||||
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.token}`);
|
||||
if (!cached) throw new BadRequestException('核销码无效或已过期');
|
||||
|
||||
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
|
||||
throw new BadRequestException('该核销码仅限指定门店使用');
|
||||
}
|
||||
|
||||
const allocations =
|
||||
cached.allocations ??
|
||||
(cached.couponId
|
||||
? [{ couponId: cached.couponId, amount: cached.amount }]
|
||||
: []);
|
||||
(cached.couponId ? [{ couponId: cached.couponId, amount: cached.amount }] : []);
|
||||
if (allocations.length === 0) {
|
||||
throw new BadRequestException('核销码数据异常');
|
||||
}
|
||||
@@ -115,7 +158,7 @@ export class RedeemService {
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
if (!coupon) throw new BadRequestException('券不存在');
|
||||
const check = validateRedeemAmount(Number(coupon.balance), alloc.amount);
|
||||
const check = validateRedeemAmount(Number(coupon.balance), alloc.amount, Number(coupon.balance));
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
}
|
||||
|
||||
@@ -126,50 +169,30 @@ export class RedeemService {
|
||||
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
|
||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||
|
||||
const record = await this.prisma.$transaction(async (tx) => {
|
||||
for (const alloc of allocations) {
|
||||
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
||||
where: { id: BigInt(alloc.couponId) },
|
||||
});
|
||||
const allocAmount = alloc.amount;
|
||||
const updated = await tx.benefitCoupon.updateMany({
|
||||
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
||||
let record;
|
||||
try {
|
||||
record = await this.prisma.$transaction(async (tx) => {
|
||||
await this.benefitService.deductCoupons(tx, allocations, 'STORE', account.storeId);
|
||||
|
||||
const redeemRecord = await tx.redeemRecord.create({
|
||||
data: {
|
||||
usedAmount: { increment: allocAmount },
|
||||
balance: { decrement: allocAmount },
|
||||
version: { increment: 1 },
|
||||
status: Number(coupon.balance) - allocAmount <= 0 ? 'USED_UP' : 'ACTIVE',
|
||||
redeemNo: generateRedeemNo(),
|
||||
userId: BigInt(cached.userId),
|
||||
couponId: BigInt(allocations[0].couponId),
|
||||
storeId: account.storeId,
|
||||
amount,
|
||||
settleAmount,
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) throw new BadRequestException('核销失败,请重试');
|
||||
|
||||
const newBalance = Number(coupon.balance) - allocAmount;
|
||||
await tx.commonEvent.create({
|
||||
data: buildBenefitLedgerEvent({
|
||||
userId: coupon.userId,
|
||||
couponId: coupon.id,
|
||||
type: 'REDEEM',
|
||||
amount: -allocAmount,
|
||||
balanceAfter: newBalance,
|
||||
refType: 'STORE',
|
||||
refId: account.storeId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const redeemRecord = await tx.redeemRecord.create({
|
||||
data: {
|
||||
redeemNo: generateRedeemNo(),
|
||||
userId: BigInt(cached.userId),
|
||||
couponId: BigInt(allocations[0].couponId),
|
||||
storeId: account.storeId,
|
||||
amount,
|
||||
settleAmount,
|
||||
},
|
||||
return redeemRecord;
|
||||
});
|
||||
|
||||
return redeemRecord;
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') {
|
||||
throw new BadRequestException('核销失败,请重试');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
|
||||
await this.redis.del(`redeem:token:${body.token}`);
|
||||
@@ -187,6 +210,7 @@ export class RedeemService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { payout: true },
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
@Controller('partner/settlement')
|
||||
@@ -15,6 +16,101 @@ export class SettlementController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/payouts')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShopPayoutController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '20',
|
||||
) {
|
||||
return this.settlementService.listShopPayouts(user.actorId, Number(page), Number(pageSize));
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-payouts')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStorePayoutController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.listAdminStorePayouts({
|
||||
page: query.page ? Number(query.page) : 1,
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||
status: query.status,
|
||||
storeId: query.storeId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.settlementService.getAdminStorePayout(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/confirm')
|
||||
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string; batchNo?: string; remark?: string }) {
|
||||
return this.settlementService.confirmStorePayout(BigInt(id), body);
|
||||
}
|
||||
|
||||
@Post('batch-confirm')
|
||||
batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) {
|
||||
return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body);
|
||||
}
|
||||
|
||||
@Post('scan-due')
|
||||
scanDue() {
|
||||
return this.settlementService.scanDueStorePayouts();
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/partner-bills')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminPartnerBillController {
|
||||
constructor(private readonly settlementService: SettlementService) {}
|
||||
|
||||
@Post('generate')
|
||||
generate(@Body() body: { partnerId: string; year: number; month: number }) {
|
||||
return this.settlementService.generatePartnerBill(body);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.listAdminPartnerBills({
|
||||
page: query.page ? Number(query.page) : 1,
|
||||
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||
status: query.status,
|
||||
partnerId: query.partnerId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
export(@Query() query: Record<string, string>) {
|
||||
return this.settlementService.exportPartnerBills({
|
||||
partnerId: query.partnerId,
|
||||
status: query.status,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.settlementService.getAdminPartnerBill(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/confirm')
|
||||
confirm(@Param('id') id: string) {
|
||||
return this.settlementService.confirmPartnerBill(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/mark-paid')
|
||||
markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
|
||||
return this.settlementService.markPartnerBillPaid(BigInt(id), body);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerMeController {
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { PartnerMeController, SettlementController } from './settlement.controller';
|
||||
import {
|
||||
AdminPartnerBillController,
|
||||
AdminStorePayoutController,
|
||||
PartnerMeController,
|
||||
SettlementController,
|
||||
ShopPayoutController,
|
||||
} from './settlement.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule],
|
||||
controllers: [SettlementController, PartnerMeController],
|
||||
controllers: [
|
||||
SettlementController,
|
||||
PartnerMeController,
|
||||
ShopPayoutController,
|
||||
AdminStorePayoutController,
|
||||
AdminPartnerBillController,
|
||||
],
|
||||
providers: [SettlementService],
|
||||
exports: [SettlementService],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
function generateBillNo() {
|
||||
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SettlementService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -29,6 +34,106 @@ export class SettlementService {
|
||||
return serializeBigInt(payout);
|
||||
}
|
||||
|
||||
async listShopPayouts(storeAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
});
|
||||
const where = { storeId: account.storeId };
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storePayout.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { redeemRecord: { select: { redeemNo: true, amount: true } } },
|
||||
}),
|
||||
this.prisma.storePayout.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async listAdminStorePayouts(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
storeId?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StorePayoutWhereInput = {};
|
||||
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storePayout.findMany({
|
||||
where,
|
||||
orderBy: { expectedPayAt: 'asc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityName: true } },
|
||||
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.storePayout.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async getAdminStorePayout(id: bigint) {
|
||||
const payout = await this.prisma.storePayout.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
store: true,
|
||||
redeemRecord: { include: { user: { select: { userNo: true, phone: true } } } },
|
||||
},
|
||||
});
|
||||
if (!payout) throw new NotFoundException('打款记录不存在');
|
||||
return serializeBigInt(payout);
|
||||
}
|
||||
|
||||
async confirmStorePayout(id: bigint, dto: { paymentRef?: string; batchNo?: string; remark?: string }) {
|
||||
const payout = await this.prisma.storePayout.findUnique({ where: { id } });
|
||||
if (!payout) throw new NotFoundException('打款记录不存在');
|
||||
if (payout.status === 'PAID') throw new BadRequestException('已打款');
|
||||
|
||||
const updated = await this.prisma.storePayout.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PAID',
|
||||
paidAt: new Date(),
|
||||
batchNo: dto.batchNo ?? payout.batchNo,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'HQ_OPERATION',
|
||||
refType: 'STORE_PAYOUT',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: 'PAID',
|
||||
param1: dto.paymentRef ?? '',
|
||||
remark: dto.remark ?? '门店 T+1 打款确认',
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async batchConfirmStorePayouts(ids: string[], dto: { batchNo?: string }) {
|
||||
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await this.confirmStorePayout(BigInt(id), { batchNo: dto.batchNo });
|
||||
results.push({ id, ok: true });
|
||||
} catch (e) {
|
||||
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async listPartnerBills(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
@@ -39,4 +144,203 @@ export class SettlementService {
|
||||
});
|
||||
return serializeBigInt(bills);
|
||||
}
|
||||
|
||||
async listAdminPartnerBills(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
partnerId?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.PartnerBillWhereInput = {};
|
||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
}),
|
||||
this.prisma.partnerBill.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async getAdminPartnerBill(id: bigint) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({
|
||||
where: { id },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
return serializeBigInt(bill);
|
||||
}
|
||||
|
||||
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
|
||||
const partnerId = BigInt(body.partnerId);
|
||||
const periodStart = new Date(body.year, body.month - 1, 1);
|
||||
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
|
||||
|
||||
const existing = await this.prisma.partnerBill.findFirst({
|
||||
where: {
|
||||
partnerId,
|
||||
periodStart,
|
||||
status: { not: 'DRAFT' },
|
||||
},
|
||||
});
|
||||
if (existing && existing.status !== 'DRAFT') {
|
||||
throw new BadRequestException('该月账单已确认,不可重复生成');
|
||||
}
|
||||
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId },
|
||||
include: { commissionRule: true },
|
||||
});
|
||||
const cityIds = cities.map((c) => c.id);
|
||||
const defaultOrderRate = cities[0]?.commissionRule?.orderCommissionRate
|
||||
? Number(cities[0].commissionRule.orderCommissionRate)
|
||||
: 0.05;
|
||||
const defaultRedeemRate = cities[0]?.commissionRule?.redeemCommissionRate
|
||||
? Number(cities[0].commissionRule.redeemCommissionRate)
|
||||
: 0.03;
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
cityId: { in: cityIds },
|
||||
payStatus: 'PAID',
|
||||
paidAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
});
|
||||
const orderCommission = orders.reduce(
|
||||
(sum, o) => sum + Number(o.payAmount) * defaultOrderRate,
|
||||
0,
|
||||
);
|
||||
|
||||
const stores = await this.prisma.store.findMany({ where: { partnerId }, select: { id: true } });
|
||||
const storeIds = stores.map((s) => s.id);
|
||||
const redeems = await this.prisma.redeemRecord.findMany({
|
||||
where: {
|
||||
storeId: { in: storeIds },
|
||||
createdAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
});
|
||||
const redeemCommission = redeems.reduce(
|
||||
(sum, r) => sum + Number(r.amount) * defaultRedeemRate,
|
||||
0,
|
||||
);
|
||||
|
||||
const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100;
|
||||
|
||||
const draft = await this.prisma.partnerBill.findFirst({
|
||||
where: { partnerId, periodStart, status: 'DRAFT' },
|
||||
});
|
||||
|
||||
const bill = draft
|
||||
? await this.prisma.partnerBill.update({
|
||||
where: { id: draft.id },
|
||||
data: { orderCommission, redeemCommission, totalAmount, periodEnd },
|
||||
})
|
||||
: await this.prisma.partnerBill.create({
|
||||
data: {
|
||||
billNo: generateBillNo(),
|
||||
partnerId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
orderCommission,
|
||||
redeemCommission,
|
||||
totalAmount,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(bill);
|
||||
}
|
||||
|
||||
async confirmPartnerBill(id: bigint) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (bill.status !== 'DRAFT') throw new BadRequestException('仅草稿可确认');
|
||||
|
||||
const updated = await this.prisma.partnerBill.update({
|
||||
where: { id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'HQ_OPERATION',
|
||||
refType: 'PARTNER_BILL',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: 'CONFIRMED',
|
||||
amount1: Number(updated.totalAmount),
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅已确认账单可标记打款');
|
||||
|
||||
const updated = await this.prisma.partnerBill.update({
|
||||
where: { id },
|
||||
data: { status: 'PAID', paidAt: new Date() },
|
||||
});
|
||||
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
eventType: 'HQ_OPERATION',
|
||||
refType: 'PARTNER_BILL',
|
||||
refId: id,
|
||||
actorType: 'HQ',
|
||||
status: 'PAID',
|
||||
param1: dto.paymentRef ?? '',
|
||||
amount1: Number(updated.totalAmount),
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async exportPartnerBills(query: { partnerId?: string; status?: string }) {
|
||||
const where: Prisma.PartnerBillWhereInput = {};
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||
|
||||
const bills = await this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status';
|
||||
const rows = bills.map((b) =>
|
||||
[
|
||||
b.billNo,
|
||||
b.partner.companyName,
|
||||
b.periodStart.toISOString().slice(0, 10),
|
||||
b.periodEnd.toISOString().slice(0, 10),
|
||||
Number(b.orderCommission),
|
||||
Number(b.redeemCommission),
|
||||
Number(b.totalAmount),
|
||||
b.status,
|
||||
].join(','),
|
||||
);
|
||||
return { csv: [header, ...rows].join('\n'), count: bills.length };
|
||||
}
|
||||
|
||||
async scanDueStorePayouts() {
|
||||
const now = new Date();
|
||||
const due = await this.prisma.storePayout.findMany({
|
||||
where: { status: 'PENDING', expectedPayAt: { lte: now } },
|
||||
take: 100,
|
||||
});
|
||||
return serializeBigInt({ dueCount: due.length, items: due });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,15 @@ export class TradeController {
|
||||
confirmReceive(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.tradeService.confirmReceive(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/refund-requests')
|
||||
refundRequest(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { remark?: string },
|
||||
) {
|
||||
return this.tradeService.createRefundRequest(user.actorId, BigInt(id), body.remark);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/orders')
|
||||
@@ -85,3 +94,14 @@ export class PartnerOrderController {
|
||||
return this.tradeService.advanceDelivery(user.actorId, BigInt(id), body.targetStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/reshipments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerReshipmentController {
|
||||
constructor(private readonly tradeService: TradeService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.tradeService.listPartnerReshipments(user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { TradeController, PartnerOrderController } from './trade.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
|
||||
import { TradeService } from './trade.service';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, IamModule, forwardRef(() => BenefitModule)],
|
||||
controllers: [TradeController, PartnerOrderController],
|
||||
imports: [IntegrationsModule, IamModule, CatalogModule, forwardRef(() => BenefitModule), CommonModule],
|
||||
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
|
||||
providers: [TradeService],
|
||||
exports: [TradeService],
|
||||
})
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
import { loadAppConfig, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
import { TicketService } from '../common/ticket.service';
|
||||
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { IPayProvider } from '../../integrations/pay/pay.interface';
|
||||
import { IDeliveryProvider } from '../../integrations/delivery/delivery.interface';
|
||||
@@ -29,16 +31,16 @@ import type { Request } from 'express';
|
||||
export class TradeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalogService: CatalogService,
|
||||
private readonly benefitService: BenefitService,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly ipGeoService: IpGeoService,
|
||||
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
|
||||
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
||||
) {}
|
||||
|
||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||
const product = await this.prisma.commonProductItem.findUnique({
|
||||
where: { id: BigInt(body.productId) },
|
||||
});
|
||||
const product = await this.catalogService.getProduct(BigInt(body.productId));
|
||||
if (!product || product.status !== 'ON_SALE') {
|
||||
throw new BadRequestException('商品不可购买');
|
||||
}
|
||||
@@ -73,7 +75,7 @@ export class TradeService {
|
||||
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
|
||||
|
||||
return {
|
||||
product: serializeBigInt(product),
|
||||
product,
|
||||
quantity: body.quantity,
|
||||
deliveryType,
|
||||
productAmount,
|
||||
@@ -384,6 +386,47 @@ export class TradeService {
|
||||
return this.getOrder(userId, orderId);
|
||||
}
|
||||
|
||||
async createRefundRequest(userId: bigint, orderId: bigint, remark?: string) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (!['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单不可申请退款');
|
||||
}
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'REFUNDING', payStatus: 'REFUNDING' },
|
||||
});
|
||||
return this.ticketService.create({
|
||||
ticketType: 'REFUND',
|
||||
refType: 'ORDER',
|
||||
refId: orderId.toString(),
|
||||
remark: remark ?? '用户申请退款',
|
||||
});
|
||||
}
|
||||
|
||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true },
|
||||
});
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { cityId: { in: cities.map((c) => c.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
const tickets = await this.prisma.commonTicket.findMany({
|
||||
where: {
|
||||
ticketType: 'RESHIPMENT',
|
||||
refType: 'ORDER',
|
||||
refId: { in: orders.map((o) => o.id) },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(tickets);
|
||||
}
|
||||
|
||||
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
|
||||
@@ -120,8 +120,8 @@ Token 必须含 `actorType` + `actorId`(手册 §六 §1.5)。
|
||||
// packages/domain
|
||||
const benefitAmount = product.benefitAmount ?? product.price;
|
||||
|
||||
// 核销 PRD 上限 ¥500
|
||||
if (amount <= 0 || amount > coupon.balance || amount > 500) throw ...
|
||||
// V3 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
|
||||
if (amount <= 0 || amount > allowedBalance) throw ...
|
||||
|
||||
// 订单 Tab
|
||||
// all | pending_pay | pending_ship | pending_receive | completed
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 杜康好客 · V2 编码手册(完整规格 · 唯一事实源)
|
||||
|
||||
> **V3 交付提示**:当前交付验收以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准。本手册中「核销单次上限 ¥500」等规则已被 V3 替代(直接核销可达总余额 / 单据 cap)。
|
||||
|
||||
> **版本**:**V2**(完整四端 + 微信生态 + 真实第三方)
|
||||
> **联调裁剪版**:见 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)(三端 H5 + Mock,同库同 API 契约)
|
||||
> **用途**:V2 正式编码与 preV1 预留对齐的完整规格
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 杜康好客 · preV1 编码手册(Mock 联调版)
|
||||
|
||||
> **V3 交付提示**:preV1 不再作为交付验收标准。核销规则以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) §3 为准(无 ¥500 上限)。
|
||||
|
||||
> **版本**:preV1(在 V2 完整规格之上的**裁剪实现阶段**)
|
||||
> **完整规格(V2)**:[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) — PRD、DB v3.1、API、任务卡均以 V2 为准
|
||||
> **原则**:**不删表、不删 V2 API 路径、不改 V2 字段语义**;preV1 用 Mock / Feature Flag / Seed 跳过外部依赖,开关关闭即切回 V2 真实流程。
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# 杜康好客 · V3 编码手册(交付业务版)
|
||||
|
||||
> **版本定位**:V3 是基于当前数据库 v3.1 的完整交付版本,目标不是 Mock 联调,而是把「购酒 → 发券 → 到店核销 → 门店打款 → 合伙人结算 → 总部运营」全流程跑通。
|
||||
> **对照文件**:V2 手册定义完整产品蓝图;preV1 手册定义 Mock 联调裁剪;本文件定义 V3 的交付口径、核销新规则、两人分工与业务闭环任务口径。
|
||||
> **数据库事实**:当前 Prisma schema 已是 v3.1,V3 不默认新增大表,优先补齐业务闭环、第三方集成、任务调度、验收测试与运营后台。
|
||||
|
||||
---
|
||||
|
||||
## 1. V3 交付目标
|
||||
|
||||
V3 必须达到可业务验收状态:
|
||||
|
||||
1. C 端用户能登录、选城、浏览商品、下单、支付、查看订单、获得权益、到店核销。
|
||||
2. 门店端能登录、扫码/输码核销、查看核销记录、管理营业状态,并形成待打款记录。
|
||||
3. 合伙人端能登录、录入门店、管理门店、查看辖区订单、处理配送/补发、查看账单与经营数据。
|
||||
4. WebAdmin 能完成开城、商品、门店审核、订单、权益、核销、配送、退款/补发、结算、资源和账号管理。
|
||||
5. 后端能完成真实支付回调、配送状态推进、退款/补发工单、门店 T+1、合伙人 T+30、日志与审计。
|
||||
6. 测试能覆盖主链路、关键边界和生产开关,不再只依赖一条 happy path 冒烟。
|
||||
|
||||
---
|
||||
|
||||
## 2. V3 端与负责人
|
||||
|
||||
| 负责人 | 主责端 | 主责后端/公共范围 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `jacy-dukang` | `apps/h5-user`、`apps/admin-web` | `packages/*`、`iam`、`catalog`、`trade`、`benefit`、`settlement`、`ops`、`callbacks`、`jobs`、`integrations`、Prisma | Tech lead,负责架构、主交易链路、支付退款、后台运营、交付验收 |
|
||||
| `刘景尧` | `apps/h5-shop`、`apps/h5-partner` | `store`、`redeem`,并配合 `settlement`、配送/核销联调 | 负责门店、合伙人、录店、核销、门店体验与辖区履约 |
|
||||
|
||||
协作规则:
|
||||
|
||||
- `apps/*` 只走 HTTP API 与 `packages/shared-types`,禁止 import `server/*` 或其他 app。
|
||||
- 后端跨模块只调用 exported Service,禁止为了赶进度直接写他人领域表。
|
||||
- 涉及 API、枚举、DTO、业务规则变更,必须同步 `packages/shared-types`、`packages/domain` 与本手册。
|
||||
- Prisma 迁移由 `jacy-dukang` 主导;涉及 `store` / `redeem` 表或核销流程时 `刘景尧` 必须 Review。
|
||||
|
||||
---
|
||||
|
||||
## 3. V3 核销规则(已替代 V2 的 ¥500 上限)
|
||||
|
||||
### 3.1 两种核销入口
|
||||
|
||||
| 入口 | 前端表现 | API 入参 | 限制规则 | 券扣减方式 |
|
||||
|---|---|---|---|---|
|
||||
| 直接点核销 | 用户在权益首页点击「去使用」 | `{ amount }`,不带 `couponId` | `0 < amount <= 用户全部 ACTIVE 权益总余额` | 按券创建时间 FIFO 扣减,可跨多张权益 |
|
||||
| 指向单据核销 | 用户在某张权益/核销单点击「立即核销」 | `{ couponId, amount }` | `0 < amount <= 该单据当前可用金额` | 只扣减该单据 |
|
||||
|
||||
### 3.2 后端不变量
|
||||
|
||||
- 核销码只存在 Redis,TTL = 5 分钟。
|
||||
- 生成核销码前必须校验金额,门店确认核销时必须二次校验。
|
||||
- 门店确认时使用券 `version` 乐观锁,避免并发重复扣减。
|
||||
- 核销成功后写入:
|
||||
- `user_redeem_record`
|
||||
- `common_event(BENEFIT_LEDGER, REDEEM)`
|
||||
- `store_payout(PENDING)`
|
||||
- 若核销码绑定门店,确认核销时只能由该门店使用;若未绑定门店,任意 `OPEN` 门店可确认。
|
||||
- 已关闭或暂停门店不可核销。
|
||||
|
||||
### 3.3 前端提示
|
||||
|
||||
- 直接核销:显示「最高可核销 = 当前好客权益总余额」。
|
||||
- 单据核销:显示「最高可核销 = 当前单据可用金额」。
|
||||
- 不再展示「单次最高可核销 ¥500.00」。
|
||||
|
||||
---
|
||||
|
||||
## 4. V3 完整业务闭环
|
||||
|
||||
### 4.1 C 端购酒与权益
|
||||
|
||||
1. 用户打开 H5,完成手机号/微信登录。
|
||||
2. 选择城市,首页展示已开城商品。
|
||||
3. 进入商品详情,选择数量与收货地址。
|
||||
4. 订单预览校验同城 2 瓶、跨城 6 瓶。
|
||||
5. 创建订单,状态 `PENDING_PAY`。
|
||||
6. 发起支付,Mock 环境同步成功,生产环境走微信 JSAPI。
|
||||
7. 支付成功回调幂等更新订单为 `PENDING_SHIP`。
|
||||
8. 根据商品 `benefitAmount ?? price` 发放好客权益。
|
||||
9. 用户在权益页直接核销或指定单据核销。
|
||||
10. 门店确认核销后,用户可评价,权益余额与流水更新。
|
||||
|
||||
### 4.2 门店核销与打款
|
||||
|
||||
1. 门店账号登录。
|
||||
2. 首页扫码或输入核销码。
|
||||
3. 后端校验核销码、门店状态、权益余额、单据金额。
|
||||
4. 核销成功生成记录。
|
||||
5. 系统创建 `store_payout(PENDING)`,预计 T+1 打款。
|
||||
6. 系统按门店绑定关系计算并记录对应合伙人的核销收益,用于合伙人账单与经营统计。
|
||||
7. WebAdmin 财务确认或 Job 自动推进打款状态。
|
||||
8. 门店端可查看核销记录与打款状态。
|
||||
9. 绑定合伙人端可查看辖区门店对应的核销订单、核销金额、门店打款状态与合伙人收益。
|
||||
|
||||
### 4.3 合伙人拓店与履约
|
||||
|
||||
1. 合伙人登录工作台。
|
||||
2. 录入门店资料、门头/环境图、合同资料、银行卡信息。
|
||||
3. V3 由 WebAdmin 审核门店,审核通过后门店才可对 C 端可见并参与核销。
|
||||
4. 合伙人查看辖区订单。
|
||||
5. 配送 Mock 或真实配送推进订单。
|
||||
6. 异常时发起/处理补发、改址拦截、配送异常。
|
||||
7. 合伙人查看月度账单、佣金、经营周报。
|
||||
|
||||
### 4.4 WebAdmin 运营
|
||||
|
||||
1. 管理员登录。
|
||||
2. 配置开城、商品、合伙人、门店分类。
|
||||
3. 审核门店。
|
||||
4. 查看订单与配送。
|
||||
5. 处理退款、补发、客服工单。
|
||||
6. 管理权益、核销、资源、账号。
|
||||
7. 财务确认门店 T+1 和合伙人 T+30 结算。
|
||||
8. 查看运营报表、异常预警、第三方日志。
|
||||
|
||||
---
|
||||
|
||||
## 5. V3 验收用例清单
|
||||
|
||||
### 必过主链路
|
||||
|
||||
1. C 端手机号登录成功。
|
||||
2. 首页展示郑州 4 个上架商品。
|
||||
3. 同城 1 瓶下单失败,2 瓶成功。
|
||||
4. 跨城 5 瓶下单失败,6 瓶成功。
|
||||
5. 支付成功后订单进入待发货,并发放权益。
|
||||
6. 直接核销不带 `couponId`,金额可达到总余额。
|
||||
7. 单据核销带 `couponId`,金额不能超过该单据余额。
|
||||
8. 门店扫码确认核销成功。
|
||||
9. 核销后生成 `store_payout(PENDING)`。
|
||||
10. 门店关闭后不可核销,C 端不可见关闭门店。
|
||||
11. 合伙人录店后进入审核流,审核通过后 C 端可见。
|
||||
12. 配送自动或真实回调推进到完成。
|
||||
13. 退款工单通过后订单/权益/第三方日志一致。
|
||||
14. 门店 T+1 打款状态可确认。
|
||||
15. 合伙人 T+30 账单可生成并确认。
|
||||
|
||||
### 必过后台链路
|
||||
|
||||
1. WebAdmin 登录成功。
|
||||
2. 创建/编辑/上下架商品。
|
||||
3. 审核门店。
|
||||
4. 查询订单与配送单。
|
||||
5. 查询权益券、核销记录、打款记录。
|
||||
6. 处理退款/补发/异常工单。
|
||||
7. 查看第三方日志与运营报表。
|
||||
8. 导出或核对财务数据。
|
||||
|
||||
---
|
||||
|
||||
## 6. 当前已知技术债
|
||||
|
||||
| 优先级 | 技术债 | 处理要求 |
|
||||
|---|---|---|
|
||||
| P0 | 旧文档与规则仍有 ¥500 上限描述 | V3 以后以本手册为准;后续批量清理 V2/preV1 中过时描述 |
|
||||
| P0 | `lint` 多数为 `echo ok` | 交付验收前必须接入有效检查 |
|
||||
| P0 | smoke 覆盖不足 | 按 4.x 业务闭环补齐主流程冒烟 |
|
||||
| P1 | shared-types DTO 不全 | 按接口稳定度分批上提 |
|
||||
| P1 | 跨模块直写 Prisma 表 | 逐步改为 exported Service |
|
||||
| P1 | 真实短信、配送、退款未闭环 | 按 4.1、4.3、4.4 对应业务闭环完成 |
|
||||
| P2 | `admin-web` 与 V2 HQ 小程序形态不一致 | V3 先以 WebAdmin 交付,是否迁小程序另立版本 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 版本冻结规则
|
||||
|
||||
- V3 业务规则以本文件为准。
|
||||
- V2 手册仍作为完整蓝图参考,但与 V3 冲突时,V3 优先。
|
||||
- preV1 手册只作为 Mock 联调历史参考,不再作为交付验收标准。
|
||||
- 未写入本文件的新增需求,不进入 V3 交付范围;如必须加入,先更新本手册与任务表负责人。
|
||||
Reference in New Issue
Block a user