Merge #1 into dev from dev_jacy

支付时检测是否已经微信授权

* dev_jacy: (15 commits)
  v3数据表更改
  fix:更新skill和rules
  fix:需求完善
  agents 和 skills 上传
  fix:提交修改信息
  Merge commit 'a0ee3a21cd01229a555384ad5e1f4b3ccdcdd620' into dev_jacy
  微信SDK接通
  oss1
  web端打通oss资源上传
  admin web 修改订单状态
  手机号从地址栏里获取
  对接微信支付
  oss接通了80端口,还没有接通https,先用
  微信支付
  支付时检测是否已经微信授权

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/1
This commit is contained in:
2026-07-02 17:56:36 +08:00
152 changed files with 11065 additions and 1204 deletions
+64
View File
@@ -0,0 +1,64 @@
---
Dukang Haoke backend lead agent (负责人 jacy-dukang) for packages, callbacks,
jobs, common, integrations, Prisma. Use for M0 infra and shared-types — coordinate
with 刘京尧 when changes affect store/redeem.
name: backend-lead
model: gpt-5.5[context=272k,reasoning=medium,fast=false]
description: >-
---
You are **Backend Lead** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
## Your territory (READ/WRITE)
- `packages/**` (shared-types, domain, shared-ui, shared-utils)
- `server/dukang-api/src/callbacks/**`
- `server/dukang-api/src/jobs/**`
- `server/dukang-api/src/common/**`
- `server/dukang-api/src/integrations/**`
- `server/dukang-api/prisma/**` (migrations — require OWNER Reviews)
- Root: `pnpm-workspace.yaml`, `deploy/`, CI configs
## Coordinate, don't override
Feature logic stays in OWNER modules. You:
- Wire `integrations/*` Mock/Real providers via Nest DI
- Own callback thin layers → delegate to TradeService etc.
- Own BullMQ processors for settlement/trade timeouts
- Review cross-module `schema.prisma` changes
## packages rules
| Package | Content | Change policy |
|---------|---------|---------------|
| shared-types | DTO, enums, ClientApp, JWT payload | Additive preferred; breaking → all OWNERs |
| domain | Pure functions (min qty, benefit, redeem cap) | Must have unit tests |
| shared-ui | Cross-app components | Don't import app-specific code |
## preV1 integration pattern
```typescript
// integrations module exports ISmsProvider, IPayProvider, IDeliveryProvider
// ConfigModule picks mock vs real from AppConfig
```
Never scatter `if (process.env.MOCK_PAY)` in trade/benefit services.
## Prisma migration workflow
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 表时 @刘京尧
## Forbidden
- Implementing full trade/benefit/store features (delegate to Owner A/B/C/D)
- Editing `apps/*` except shared-ui tokens used by all apps
## Before finishing
- `pnpm db:validate` passes
- domain tests pass
- No new cross-module Prisma violations introduced
+54
View File
@@ -0,0 +1,54 @@
---
Read-only reviewer for Dukang Haoke module OWNER violations, cross-app imports,
and preV1/V2 contract drift. Use before merging PRs or after large refactors —
reports issues, does not edit code.
name: boundary-reviewer
model: gpt-5.5[context=272k,reasoning=medium,fast=false]
description: >-
readonly: true
---
You are a **module boundary reviewer** for the Dukang Haoke monorepo. Read-only.
## Review scope
Inspect the diff (or named files) for:
1. **OWNER violations** — edits outside the claimed owner's paths
2. **Cross-module Prisma** — Module A writing Module B's tables directly
3. **Forbidden imports** — apps → server; benefit → trade; store → trade; etc.
4. **Contract drift** — API/DTO changed without shared-types or V2 manual §六
5. **preV1 leaks** — Mock logic outside `integrations/*` or unguarded preV1-only routes
6. **Business rule duplication** — min qty / ¥500 cap / benefit amount not in `packages/domain`
## OWNER map2 人团队)
| 逻辑域 | Git 账号 | Apps | Modules |
|--------|----------|------|---------|
| 主责 | 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 模块仍不可互写表**
## Severity
- **P0 — Must block merge**: cross-module Prisma write; apps import server; secrets committed; auth bypass
- **P1 — Fix before merge**: wrong module dependency; missing shared-types sync; Mock in wrong layer
- **P2 — Suggestion**: scope creep into another owner's app; missing domain test for rule change
## Report format
For each finding:
```
[Px] path:line — issue — fix (which OWNER should do it)
```
End with:
- **OWNER impact**: who must act
- **Safe to merge?** yes/no
- **Cross-owner follow-ups**: list Issue/PR needed for other modules
Do not invent findings. If boundaries are clean, say so plainly.
+55
View File
@@ -0,0 +1,55 @@
---
name: owner-a-user-trade
description: >-
Dukang Haoke Owner A agent (负责人 jacy-dukang) for C-end h5-user and backend
modules iam, trade, benefit, analytics. Use for user login, orders, mock pay,
benefit coupons — not for partner/shop apps or store/redeem modules.
model: inherit
---
You are **Owner A** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
## Your territory (READ/WRITE)
- `apps/h5-user/**`
- `server/dukang-api/src/modules/{iam,trade,benefit,analytics}/**`
- `packages/domain/**` (shared rules — coordinate with Lead on breaking changes)
- `packages/shared-types/**` (only types for your domains; flag cross-owner enum changes)
## Forbidden (escalate to correct OWNER)
| Path / Module | Owner (人) |
|---------------|------------|
| `apps/h5-shop`, `modules/redeem` | 刘京尧 |
| `apps/h5-partner`, `modules/store` | 刘京尧 |
| `apps/admin-web`, `modules/{catalog,settlement,ops}` | jacy-dukang |
| `callbacks/`, `jobs/`, `integrations/` | jacy-dukang |
## Cross-module pattern
Inject exported Services only:
```typescript
// ✅ trade → BenefitService.grantOnOrderPaid()
// ❌ trade → prisma.benefitCoupon.create() if logic belongs in benefit module
// ❌ benefit → TradeService (forbidden dependency direction)
```
## preV1 defaults
- `X-Client-App: USER_H5`
- Mock pay via `integrations/pay`; real benefit grant on pay success
- Order tabs: all | pending_pay | pending_ship | pending_receive | completed
## Session start
1. Read task card if given → `dukang-task-card` skill
2. Confirm changes stay in Owner A paths
3. UI: `pages/user/` + `pages/ROUTE_MAP.md`
4. API/DB: V2 manual §五/§六
## Before finishing
- No direct Prisma writes to store/redeem/settlement tables
- Sync shared-types for new DTOs/enums
- Run relevant lint/test for touched packages
+48
View File
@@ -0,0 +1,48 @@
---
name: owner-b-partner-store
description: >-
Dukang Haoke Owner B agent (负责人 刘京尧) for partner h5-partner and backend
store module. Use for partner login, store onboarding, auto-approve — not for
C-end trade, shop redeem, or admin-web (jacy-dukang).
model: inherit
---
You are **Owner B** on the Dukang Haoke monorepo — **human owner: 刘京尧 (`刘京尧`)**.
## Your territory (READ/WRITE)
- `apps/h5-partner/**`
- `server/dukang-api/src/modules/store/**`
- Partner-facing controllers co-located in store module (e.g. `/partner/stores`)
## Forbidden (escalate)
| Path / Module | Owner (人) |
|---------------|------------|
| `apps/h5-user`, `modules/{iam,trade,benefit,analytics}` | jacy-dukang |
| `apps/h5-shop`, `modules/redeem` | 刘京尧(本人另一模块,勿混写) |
| `apps/admin-web`, `modules/{catalog,settlement,ops}` | jacy-dukang |
## Module rules
- **store** may call `catalog`, `iam`, `common`, `domain`
- **store** must NOT import `trade` or `benefit` modules
- Store audit in preV1: `AUTO_APPROVE_STORE=true` → write `common_event(STORE_AUDIT, APPROVED)`
## preV1 defaults
- `X-Client-App: PARTNER_H5`
- Test partner phone: `13700000001`
- Optional: `POST /partner/orders/:id/mock-advance-delivery` (Flag-guarded)
## Session start
1. UI: `pages/partner/` + `pages/ROUTE_MAP.md`
2. preV1 §6 HQ substitutes for audit flow
3. C-end store visibility: only `store_store.status === 'OPEN'`
## Before finishing
- No order/payment/benefit logic in store module
- No edits to h5-user or redeem apps
- Partner API changes → sync V2 manual §六 + shared-types
+49
View File
@@ -0,0 +1,49 @@
---
name: owner-c-catalog-ops
description: >-
Dukang Haoke Owner C agent (负责人 jacy-dukang) for admin-web and backend
catalog, settlement, ops modules. Use for cities, products, settlement views —
not for partner/shop apps (刘京尧).
model: inherit
---
You are **Owner C** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
## Your territory (READ/WRITE)
- `apps/admin-web/**` (preV1 internal HQ substitute — **not** V2 mini-hq)
- `server/dukang-api/src/modules/{catalog,settlement,ops}/**`
- Admin controllers: `/admin/*` routes in above modules
## Forbidden (escalate)
| Path / Module | Owner (人) |
|---------------|------------|
| `apps/h5-partner`, `modules/store` | 刘京尧 |
| `apps/h5-shop`, `modules/redeem` | 刘京尧 |
| `apps/h5-user`, `modules/{iam,trade,benefit,analytics}` | jacy-dukang |
| `callbacks/`, `jobs/`, `integrations/` | Lead |
## preV1 scope note
- admin-web replaces HQ **mini program** for dev/demo only
- Many HQ features are **Seed-fixed** in preV1 (cities, 4 SKUs)
- Do not assume admin-web UI matches `pages/hq/` 1:1 — V2 will use Taro mini-hq
## Module rules
- **catalog** → iam, domain, common only (no trade/settlement)
- **settlement** → may call trade, redeem, store, catalog services
- **ops** → read-only aggregation; no direct writes to trade/store tables
## Session start
1. Check preV1 §6 for what's Seed vs implemented UI
2. Product/city rules: V2 manual §二 §6
3. Settlement: T+1 store / T+30 partner (M5+)
## Before finishing
- Admin API uses `HqAuthGuard` / AdminAuth
- No C-end or shop/partner app changes
- Schema changes to common_city / common_product_item → Lead + C Review
+53
View File
@@ -0,0 +1,53 @@
---
name: owner-d-shop-redeem
description: >-
Dukang Haoke Owner D agent (负责人 刘京尧) for shop h5-shop and backend redeem
module. Use for store login, redeem confirm, records — not for C-end redeem
token UI (jacy-dukang) or partner store onboarding.
model: inherit
---
You are **Owner D** on the Dukang Haoke monorepo — **human owner: 刘京尧 (`刘京尧`)**.
## Your territory (READ/WRITE)
- `apps/h5-shop/**`
- `server/dukang-api/src/modules/redeem/**`
- Shop routes: `/shop/auth/*`, `/shop/redeem/*`
## Forbidden (escalate)
| Path / Module | Owner (人) |
|---------------|------------|
| `apps/h5-user`C 端出码页) | jacy-dukang |
| `apps/h5-partner`, `modules/store` | 刘京尧(本人另一模块,勿混写) |
| `apps/admin-web`, `modules/{catalog,settlement,ops}` | jacy-dukang |
| `modules/trade`, `modules/benefit` 直写 | jacy-dukang(只 inject Service |
## Redeem flow (you implement shop side)
```
User (Owner A) generates token → Shop scans/confirms (you)
→ RedeemService.confirm()
→ BenefitService.deduct() [inject]
→ SettlementService.createStorePayout() [inject]
```
## Hard rules
- Redis token: `redeem:token:{token}` TTL 300s
- Amount: `0 < amount ≤ min(balance, 500)`
- Transaction + coupon `version` optimistic lock
- **Never** `prisma.order.update` in redeem module
## preV1 defaults
- `X-Client-App: SHOP_H5`
- Test store phone: `13900000001`
- UI: `pages/shop/` + `pages/ROUTE_MAP.md`
## Before finishing
- No changes to h5-user redeem code pages
- No store onboarding (partner app)
- Sync shared-types for redeem DTOs
+42
View File
@@ -0,0 +1,42 @@
---
description: NestJS 模块依赖与 Prisma 写权限 — 后端 OWNER 边界
globs: server/dukang-api/src/**/*.ts
alwaysApply: false
---
# 后端模块规则
## 只写本模块表
Service 内 Prisma 操作限定本 Module OWNER 的 model(见 `server/dukang-api/AGENTS.md`)。
## 禁止依赖(示例)
```typescript
// ❌ redeem.service.ts
await this.prisma.order.update(...)
// ✅ redeem.service.ts
await this.benefitService.deduct(dto)
await this.settlementService.createStorePayout(recordId)
```
```typescript
// ❌ benefit.module.ts imports TradeModule
// ❌ store.service.ts imports TradeService
```
## 模块结构
- `exports: [XxxService]` — 唯一对外能力
- 禁止 export Repository / 裸 Prisma 访问
- Controller 不可被其他 Module import
## 回调与任务
- 微信/配送回调 **仅** `callbacks/` 入口
- BullMQ 消费者在 `jobs/`,调用 OWNER Service
## 响应格式
`{ code: 0, message: 'ok', data }` + 对应 GuardUser/Store/Partner/HQ
+51
View File
@@ -0,0 +1,51 @@
---
description: 杜康好客 NestJS 后端编码规范
globs: server/**/*.ts
alwaysApply: false
---
# 后端规范
## 模块目录
```
server/dukang-api/src/
├── modules/{iam,trade,benefit,catalog,store,redeem,settlement,ops,analytics,health}/
├── common/{guards,decorators,interceptors,filters,prisma,redis}/
├── integrations/{pay,sms,delivery}/
└── jobs/
```
## 新接口流程
1. 在 **OWNER 模块** 建 `dto/`、`*.service.ts`、`*.controller.ts`
2. Service 只写本模块 Prisma 表
3. 跨域 inject 其他 Module 的 exported Service
4. Controller 加对应 Guard`JwtAuthGuard`、`PhoneVerifiedGuard` 等)
5. 响应 `{ code: 0, message: 'ok', data }`
6. 同步 `packages/shared-types`
## Controller 示例
```typescript
@Controller('trade/orders')
@UseGuards(JwtAuthGuard)
export class TradeController {
constructor(private readonly tradeService: TradeService) {}
}
```
## 鉴权路由前缀
| 端 | 前缀 |
|----|------|
| C 端 | `/auth`, `/user`, `/catalog`, `/trade`, `/benefit`, `/redeem`, `/stores` |
| 门店 | `/shop/auth`, `/shop/redeem`, `/shop/store` |
| 合伙人 | `/partner/*` |
| 总部 | `/admin/*` |
## 数据约定
- 埋点 → `log_user_analytics`;业务审计 → `common_event`
- 支付 → `log_third_party` + `user_order.pay_*`(无 payments 表)
- 核销码 → Redis 5min(无 DB 表)
+51
View File
@@ -0,0 +1,51 @@
---
description: 杜康好客全局约束 — Monorepo 布局、文档优先级、2 人 OWNER 边界
alwaysApply: true
---
# 杜康好客 · 核心规则
## Monorepo 布局
- `apps/` — preV1`h5-user`、`h5-shop`、`h5-partner`、`admin-web`
- `packages/` — `shared-types`、`domain`、`shared-ui`
- `server/dukang-api/` — NestJS 单体 API
- `pages/` — UI 原型(**只读参照**
> V2 目标为 Taro 四端(`mini-user` 等),编码以 **当前实际目录** 为准。
## 文档
- 业务事实源:`杜康好客-V2编码手册.md`
- preV1 裁剪:`杜康好客-preV1编码手册.md`
- 协作:`conventions.md` · AI 入口:`AGENTS.md`
**禁止**:编码时修改业务规则;需求变更只改 V2 手册。
## 边界(2 人团队)
| 负责人 | 路径 |
|--------|------|
| jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, `modules/{iam,trade,benefit,analytics,catalog,settlement,ops}/` |
| 刘京尧 | `apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/` |
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
- `apps/*` 禁止 import `server/*` 或其他 app
- Mock 只在 `integrations/*`,不在业务 Service 散落
## 共享契约
- 枚举/DTO → `packages/shared-types`
- 纯规则 → `packages/domain`(无 IO
## 核心业务常量
- 权益额 = `benefit_amount ?? price`
- 核销:0 < amount ≤ min(balance, **500**)
- 订单 Tab`all | pending_pay | pending_ship | pending_receive | completed`
- API`/api/v1`,响应 `{ code, message, data }`
## 提交
- Conventional Commits`feat(trade):` 等
- 不提交 `.env`
+53
View File
@@ -0,0 +1,53 @@
---
description: 杜康好客前端 H5 / Admin 编码规范与 OWNER 边界
globs: apps/**/*.{tsx,ts}
alwaysApply: false
---
# 前端规范
## 目录约定
```
src/
├── components/ # PascalCaseAppToast.tsx
├── pages/ # *Page.tsx
├── layouts/ # *Layout.tsx
├── contexts/ # *Context.tsx
├── lib/ # kebab-caseapi.ts, client-location.ts
├── App.tsx
└── main.tsx
```
## 四 App
| App | 端口 | 负责人 | 样式 | X-Client-App |
|-----|------|--------|------|--------------|
| h5-user | 5173 | jacy-dukang | shared-ui tokens | USER_H5 |
| h5-shop | 5174 | 刘京尧 | shared-ui tokens | SHOP_H5 |
| h5-partner | 5175 | 刘京尧 | shared-ui tokens | PARTNER_H5 |
| admin-web | 5175 | jacy-dukang | Ant Design 5 | HQ_WEB |
> h5-partner 与 admin-web 勿同时 dev。H5 三端用 `@dukang/shared-ui`admin-web 独立 Ant Design。
## 隔离与请求
- 禁止 import 其他 `apps/*` 或 `server/*`
- 类型从 `@dukang/shared-types` import
- Base: `/api/v1`Headers: `Authorization` + `X-Client-App`
- Vite dev 代理 `/api` → `localhost:3000`
## UI 约束
- C 端订单 **5 Tab**(含 pending_ship
- 门店列表仅 `OPEN` 状态
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
## App 归属(勿改他端)
| App | 勿改 |
|-----|------|
| h5-user | shop 核销、partner 录店 |
| h5-shop | user 出码、admin 报表 |
| h5-partner | user 下单、admin 开城 |
| admin-web | 非 V2 小程序规格 |
+32
View File
@@ -0,0 +1,32 @@
---
description: Git 提交与 PR 协作规范
alwaysApply: true
---
# Git 规范
## 提交格式
Conventional Commitsscope = 端或模块名:
```
feat(trade): add order preview API
fix(redeem): enforce 500 yuan cap
chore(shared-types): add OrderStatus enum
```
## 禁止提交
- `.env`、`.env.local` 等敏感配置
- `node_modules/`、`dist/`(已在 .gitignore
## PR 与 Review
- feature 分支 + 小步 PR
- 跨模块改动需相关双 Owner Review
- Prisma 迁移必须多 Owner Review
## Agent 行为
- **仅用户明确要求时** 才执行 git commit / push
- 不 amend 已推送的 commit,不 force push main/master
+43
View File
@@ -0,0 +1,43 @@
---
description: 杜康好客共享包(shared-types / domain / shared-ui)规范
globs: packages/**/*.{ts,tsx}
alwaysApply: false
---
# 共享包规范
## 包职责
| 包 | 用途 | 约束 |
|----|------|------|
| `@dukang/shared-types` | DTO、枚举、错误码、JWT Payload | 前后端唯一契约 |
| `@dukang/domain` | 纯函数业务规则 | **禁止 IO**,含 Vitest 单测 |
| `@dukang/shared-ui` | H5 共享组件 + `tokens.css` | 仅 H5 三端使用 |
## shared-types
- 枚举用 `SCREAMING_SNAKE_CASE`(如 `OrderStatus.PENDING_PAY`
- API Tab 查询用 snake_case`pending_pay`
- **加法优先**:新字段 optional;删除/改名先 deprecated
- 改 API 必须同步 V2 手册 §六 与 shared-types
## domain
- 纯函数,无 IO(无 Prisma/Redis/HTTP
- 起购 2/6 瓶、权益 `benefitAmount ?? price`、核销 ¥500 上限在此实现
- 变更必须有单元测试
```typescript
const benefitAmount = product.benefitAmount ?? product.price;
// 同城起购 2 瓶 / 跨城 6 瓶
// 核销上限 ¥500
```
## shared-ui
- 跨 App 组件;禁止 import 特定 App 代码
- admin-web 使用 Ant Design,不引用 shared-ui
## 破坏性改动
需通知 **jacy-dukang** 与 **刘京尧**(若影响 partner/shop API)并在 PR 说明影响面。Prisma 迁移需双方 Review(涉及 store/redeem 表时)。
+29
View File
@@ -0,0 +1,29 @@
---
description: Prisma schema 变更需对齐 V2 手册 §五 并经表 OWNER Review
globs: server/dukang-api/prisma/**
alwaysApply: false
---
# Prisma / 数据库规则
## 事实源
V2 手册 §五(v3.1,28 表)。schema 与手册保持一致。
## 变更流程
1. 确认受影响 Module OWNER(见 `server/dukang-api/AGENTS.md` 表映射)
2. 修改 `schema.prisma`
3. `pnpm db:validate`
4. PR 需对应 OWNER + Lead Review
## 关键约定
- 无 `payments` / `user_order_item` / `redeem_tokens` 表
- 核销码仅 Redis 5 分钟
- 支付流水 → `log_third_party` + `user_order.pay_*`
- 业务事件 → `common_event`;埋点 → `log_user_analytics`
## preV1
不删表、不改 V2 字段语义。Seed`prisma/seed-v31.ts`
+122
View File
@@ -0,0 +1,122 @@
---
name: dukang-coding
description: >-
Implements 杜康好客 features following V2/preV1 coding manuals, module OWNER
boundaries, and shared-types contracts. Use when coding APIs, pages, Prisma logic,
task cards, or fixing bugs — not for PRD/requirements review (use @dukang-project).
disable-model-invocation: true
---
# 杜康好客 · 编码 Skill
## 何时启用
- 实现/修复 `apps/*``server/dukang-api``packages/*` 功能
- 实现任务卡(`M2-BE-TRD-002``P1-M2-002` 等)
- 用户给出任务卡 ID、说「开始 coding」或 @dukang-coding
- 修复 V1 范围 Bug
**不用于**:写 PRD、评审需求、纯文档问答 → 用 @dukang-project 或直接读手册
## 会话启动(按序)
1. 确认阶段:preV1 → 读 `杜康好客-preV1编码手册.md`;V2 能力 → 读 V2 手册对应 §
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 表 → **只改本 OWNER 路径**
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
5. 编码前过一遍下方 Checklist
**需求变更** → 只改 V2 手册,编码时不改业务规则。
## 编码前 Checklist
```
- [ ] 任务所属 OWNER 与目标路径已确认
- [ ] 已读手册 PRD 对应 § 与 pages/ 原型
- [ ] API 路径、Guard、表名已从 V2 §六/§五 核对
- [ ] 跨模块需求 → 只 inject 对方 exported Service
- [ ] 新枚举/DTO → packages/shared-types
- [ ] 起购/权益/核销规则 → packages/domain(禁止 Controller 硬编码)
- [ ] preV1 Mock → integrations/*,非业务 Service 内散落 if
```
## 后端(OWNER 模块内)
```
modules/{name}/
├── {name}.module.ts # exports: [XxxService] 唯一出口
├── {name}.controller.ts
├── {name}.service.ts # 仅写本模块 Prisma 表
└── dto/
```
WorkflowDTO → Service(本模块表)→ Controller + Guard → 同步 shared-types。
跨模块示例(允许):
```typescript
// trade.service.ts — jacy-dukang
constructor(
private readonly benefitService: BenefitService,
private readonly catalogService: CatalogService,
) {}
await this.benefitService.grantOnOrderPaid(orderId);
```
跨模块示例(禁止):
```typescript
// redeem.service.ts — ❌ 禁止
await this.prisma.order.update({ ... });
```
## 前端(单 App 内)
1. 路由对照 `pages/ROUTE_MAP.md`
2. `src/lib/api.ts` 统一 fetch + `X-Client-App`
3. 类型从 `@dukang/shared-types` import
4. preV1 App`h5-user` / `h5-shop` / `h5-partner` / `admin-web`
5. C 端订单 Tab`all | pending_pay | pending_ship | pending_receive | completed`
## 数据库变更
1.`server/dukang-api/prisma/schema.prisma` 对齐手册 §五
2. 迁移需 OWNER Reviewstore/redeem 表 → @刘京尧
3. 初始化 SQL`server/dukang-api/prisma/init_v3.sql`
## 核心业务(packages/domain
```typescript
const benefitAmount = product.benefitAmount ?? product.price;
// 核销:0 < amount ≤ min(balance, 500)
// 同城 min 2 瓶 / 跨城 min 6 瓶
// 支付成功 → log_third_party + user_order.pay_status=PAID
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
// 埋点 → log_user_analytics;业务审计 → common_event
```
## 核销并发(M3+
- Redis `redeem:token:{token}` EX 300
- 事务 + `user_benefit_coupon.version` 乐观锁
-`user_redeem_record` + `store_payout`(PENDING) + `common_event(BENEFIT_LEDGER, REDEEM)`
## 资源上传
`POST /common/resources/upload-token` → OSS 直传 → `POST /common/resources`
## 完成定义
- 任务卡验收项全部满足
- 无跨模块 Prisma 直写
- `packages/domain` 相关单测通过
- `pnpm lint` 无新增错误
- 改了 API/表 → 同步手册与 shared-types
## 延伸阅读
- 模块/表/路由:[reference-backend.md](reference-backend.md)
- 页面↔API 速查:[reference-frontend.md](reference-frontend.md)
- preV1 Mock`dukang-prev1` skill
- 详细清单:根目录 `skills.md`
@@ -0,0 +1,160 @@
# 后端速查(杜康好客 v3.1
> 完整 DDL/API 以 V2 手册 §五/§六 为准。
## API Base
- Prefix: `/api/v1`
- Response: `{ code: 0, message: 'ok', data }`
- JWT: `actorType` + `actorId` + `clientApp`
- 请求头:`Authorization``X-Client-App`
## clientApp → actorType → 表
| preV1 clientApp | V2 clientApp | actorType | 账号表 |
|-----------------|--------------|-----------|--------|
| USER_H5 | USER_MINI | USER | user_user |
| SHOP_H5 | SHOP_H5 | STORE | store_account |
| PARTNER_H5 | PARTNER_MINI | PARTNER | partner_account |
| HQ_WEB | HQ_MINI | HQ | hq_account |
## Guard 对照
| Guard | 用途 |
|-------|------|
| JwtAuthGuard | 需登录 |
| PhoneVerifiedGuard | C 端需绑定手机(下单/支付) |
| OptionalJwtAuthGuard | 可选登录(bootstrap |
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`
## Module → API 前缀
| Module | 前缀示例 | 负责人 |
|--------|----------|--------|
| iam | `/auth`, `/user` | jacy-dukang |
| 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` | 刘京尧 |
| settlement | `/settlement`, `/partner/settlement`, `/admin/settlement` | jacy-dukang |
| ops | `/admin/dashboard`, `/admin/reports` | jacy-dukang |
| analytics | `/analytics`, `/promo/touch` | jacy-dukang |
| callbacks | `/callbacks/wechat/pay`, `/callbacks/xfx/delivery` | jacy-dukang |
## 表前缀 → Module 写 OWNER
| 前缀 | 示例表 | 负责人 |
|------|--------|--------|
| user_ | user_user, user_order, user_benefit_coupon | jacy-dukang |
| store_ | store_store, store_account, store_payout | 刘京尧 / jacy-dukangsettlement |
| partner_ | partner_partner, partner_bill | 刘京尧 / jacy-dukangsettlement |
| hq_ | hq_account | jacy-dukang |
| common_ | common_product_item, common_event, common_city | jacy-dukang |
| log_ | log_third_party, log_user_analytics | 写入方 Module |
**无以下 V1 表**payments、user_order_item、redeem_tokensDB)。
## Controller 路由(当前实现)
### iam
| 路径 | Controller |
|------|-----------|
| `POST /auth/session/bootstrap` | UserAuthController |
| `POST /auth/sms/send` | UserAuthController |
| `POST /auth/sms/login` | UserAuthController |
| `GET /user/me` | UserAuthController |
| `GET/POST/PUT/DELETE /user/addresses` | UserAddressController |
| `POST /shop/auth/*` | ShopAuthController |
| `POST /partner/auth/*` | PartnerAuthController |
| `POST /admin/auth/login` | AdminAuthController |
### catalog / trade / benefit / redeem
| 路径 | Module |
|------|--------|
| `GET /catalog/*` | catalog |
| `POST/GET /trade/orders` | trade |
| `GET/POST /benefit/*` | benefit |
| `POST /redeem/*` | redeemC 端) |
| `POST /shop/redeem/*` | redeem(门店) |
### store / settlement
| 路径 | Module |
|------|--------|
| `GET /stores` | storeC 端门店列表) |
| `GET/POST /partner/stores` | store |
| `GET /partner/dashboard` | store |
| `GET /shop/store` | store |
| `GET /shop/dashboard` | store |
| `GET /partner/settlement/*` | settlement |
| `GET /partner/me` | settlement |
| `GET /partner/orders` | trade |
### opsadmin-web
| 路径 | 说明 |
|------|------|
| `/admin/dashboard` | 看板 |
| `/admin/users` | C 端用户 |
| `/admin/orders` | 订单 |
| `/admin/stores` | 门店 |
| `/admin/store-accounts` | 门店账号 |
| `/admin/store-media` | 门店媒体 |
| `/admin/partners` | 合伙人 |
| `/admin/partner-accounts` | 合伙人账号 |
| `/admin/cities` | 开城 |
| `/admin/hq-accounts` | 总部账号 |
| `/admin/benefit/coupons` | 权益券 |
| `/admin/benefit/ledgers` | 权益流水 |
| `/admin/redeem-records` | 核销记录 |
| `/admin/deliveries` | 配送 |
### analytics / health
| 路径 | Module |
|------|--------|
| `POST /analytics/*` | analytics |
| `GET /health` | health |
## 关键跨模块调用链
**Mock/真实支付成功**
```
PayProvider → TradeService.handlePaySuccess()
→ BenefitService.grantOnOrderPaid(orderId)
→ OrderDelivery 预创建
→ common_event(ORDER_STATUS)
```
**核销确认**
```
RedeemService.confirm()
→ BenefitService.deduct(couponId)
→ SettlementService.createStorePayout(redeemRecordId)
```
## 禁止依赖(PR 拒绝)
| # | 禁止 |
|---|------|
| F1 | redeem 直写 order |
| F2 | store → trade/benefit |
| F3 | benefit → trade/redeem |
| F4 | trade → redeem |
| F5 | benefit → redeem |
| F6 | catalog → trade/settlement |
| F9 | apps import server 源码 |
## integrationspreV1
| Flag | Provider | 行为 |
|------|----------|------|
| MOCK_SMS=true | sms.mock | 固定码 123456 |
| MOCK_PAY=true | pay.mock | 同步 PAID + 发券 |
| MOCK_DELIVERY_AUTO=true | delivery.mock | BullMQ 自动推进状态 |
@@ -0,0 +1,127 @@
# 前端速查(preV1 三 H5 + admin-web
> 路由事实源:[`pages/ROUTE_MAP.md`](../../../pages/ROUTE_MAP.md)
## 四 App 概览
| 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/ |
| admin-web | apps/admin-web | 5175 | jacy-dukang | HQ_WEB | pages/hq/ |
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
Vite 代理:`/api``localhost:3000`
## 共享包
```typescript
import { OrderStatus, ClientApp } from '@dukang/shared-types';
import { PageHeader } from '@dukang/shared-ui';
```
H5 三端引用 `@dukang/shared-ui``tokens.css`)。admin-web 使用 Ant Design 5,不引用 shared-ui。
## h5-user 路由(jacy-dukang
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /auth/* |
| / | HomePage | /catalog |
| /product/:id | ProductDetailPage | /catalog |
| /order/confirm | OrderConfirmPage | /trade/orders/preview |
| /pay | PayPage | /trade/orders/:id/pay |
| /orders | OrderListPage | /trade/orders?tab= |
| /orders/:id | OrderDetailPage | /trade/orders/:id |
| /stores | StoreListPage | /stores |
| /stores/:id | StoreDetailPage | /stores/:id |
| /benefit | BenefitPage | /benefit |
| /benefit/:id | BenefitDetailPage | /benefit/:id |
| /redeem | RedeemPage | /redeem |
| /redeem/code | RedeemCodePage | /redeem |
| /redeem/success | RedeemSuccessPage | — |
| /addresses | AddressListPage | /user/addresses |
| /mine | MinePage | /user/me |
### 订单 5 Tab
```
all | pending_pay | pending_ship | pending_receive | completed
```
## h5-shop 路由(刘京尧)
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /shop/auth/* |
| / | HomePage | /shop/dashboard |
| /redeem | RedeemConfirmPage | /shop/redeem/* |
| /redeem/success | RedeemSuccessPage | — |
| /records | RecordsPage | /shop/redeem/records |
| /status | StatusPage | /shop/store |
| /mine | MinePage | /shop/store |
## h5-partner 路由(刘京尧)
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /partner/auth/* |
| / | HomePage | /partner/dashboard |
| /stores | StoreListPage | /partner/stores |
| /stores/new | StoreCreatePage | /partner/stores |
| /stores/:id | StoreDetailPage | /partner/stores/:id |
| /orders | OrderListPage | /partner/orders |
| /orders/:id | OrderDetailPage | /partner/orders/:id |
| /center | CenterPage | /partner/me |
| /center/bills | BillsPage | /partner/settlement/* |
| /reshipments | ReshipPage | /partner/after-sales/reshipments |
| /reports/weekly | WeeklyReportPage | /partner/reports/weekly |
| /leaderboard | LeaderboardPage | /partner/dashboard/leaderboard |
| — | Mock 推进配送 | POST /partner/orders/:id/mock-advance-delivery |
## admin-web 路由(jacy-dukang
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /admin/auth/login |
| / | DashboardPage | /admin/dashboard |
| /users | UsersPage | /admin/users |
| /orders | OrdersPage | /admin/orders |
| /stores | StoresPage | /admin/stores |
| /store-accounts | StoreAccountsPage | /admin/store-accounts |
| /store-media | StoreMediaPage | /admin/store-media |
| /partners | PartnersPage | /admin/partners |
| /partner-accounts | PartnerAccountsPage | /admin/partner-accounts |
| /cities | CitiesPage | /admin/cities |
| /hq-accounts | HqAccountsPage | /admin/hq-accounts |
| /benefit/coupons | BenefitCouponsPage | /admin/benefit/coupons |
| /benefit/ledgers | BenefitLedgersPage | /admin/benefit/ledgers |
| /redeem-records | RedeemRecordsPage | /admin/redeem-records |
| /deliveries | DeliveriesPage | /admin/deliveries |
## 端 → API 前缀
| 端 | API 前缀 | NestJS Module |
|----|----------|---------------|
| C 端 | /auth, /user, /catalog, /trade, /benefit, /redeem, /stores | iam, catalog, trade, benefit, redeem, store |
| 门店 | /shop/auth, /shop/redeem, /shop/store | iam, redeem, store |
| 合伙人 | /partner/* | iam, store, trade, settlement |
| 总部 | /admin/* | iam, catalog, store, trade, settlement, ops |
## UI 硬约束
- C 端订单 **5 Tab**(含 pending_ship
- 个人中心无会员等级(V1 不做会员体系)
- 清香型 4 SKU 可购;酱香/浓香灰态
- 门店列表仅 `OPEN` 状态
- 原型只读:`pages/{端}/*/code.html`,勿改原型目录
## preV1 与 V2 UI 差异
| 项 | preV1 | V2 |
|----|-------|-----|
| C/合伙人载体 | H5 | 微信小程序 |
| 总部 | admin-web 内部 | mini-hq 小程序 |
| 支付页 | Mock 同步成功 | 微信 JSAPI |
+94
View File
@@ -0,0 +1,94 @@
---
name: dukang-prev1
description: >-
Guides preV1 Mock integration phase for Dukang Haoke: H5 three-client setup,
MOCK_SMS/MOCK_PAY/MOCK_DELIVERY flags, Seed data, and V2 upgrade path.
Use when working on Mock providers, feature flags, preV1 task cards P1-*,
or smoke tests — not for V2 WeChat/payment production integration.
---
# 杜康好客 · preV1 Mock Skill
## 定位
preV1 = V2 之上的**裁剪实现**:**不删表、不删 API 路径、不改字段语义**。
权威文档:[`杜康好客-preV1编码手册.md`](../../杜康好客-preV1编码手册.md)
## 六条裁剪(必记)
1. **无 HQ 小程序**`admin-web` 内部替代;`/admin/*` 保留,前端不暴露 AdminAuth 给 C/B/D 端
2. **三端均 H5**`USER_H5` / `SHOP_H5` / `PARTNER_H5`
3. **无微信登录** → 仅 SMSwechat 路由返回 501 或 Flag 关闭
4. **验证码 Mock**`123456``MOCK_SMS=true`
5. **配送 Mock** → BullMQ 自动推进或合伙人 `mock-advance-delivery`
6. **支付 Mock**`POST /trade/orders/:id/pay` 同步成功 + **真实发券**
## 环境变量(`.env.example`
```bash
MOCK_SMS=true
MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
```
配置加载:`packages/shared-types/src/config.ts``loadAppConfig()`
## Mock 代码位置(禁止散落)
```
server/dukang-api/src/integrations/
├── sms/sms.interface.ts + sms.mock.provider.ts
├── pay/pay.interface.ts + pay.mock.provider.ts
└── delivery/delivery.interface.ts + delivery.mock.provider.ts
```
业务 Module 只 inject **interface**,由 Nest DI 切换 Mock/Real。
## preV1 必做 vs 跳过
| 必做 | 跳过(V2 补) |
|------|---------------|
| 三端 SMS 登录 | 微信登录/支付/退款 |
| Mock 支付 + 真实发券 | 微信 prepay/回调验签 |
| 5 Tab 订单 | HQ 小程序 UI |
| Redis 核销码 + 门店核销 | 真实小飞侠/物流 |
| 合伙人录店 + AUTO_APPROVE | 总部人工审核 UI |
| 可选埋点 | 推广码 HQ UI、T+30 真实打款 |
## HQ 能力替代(Seed
| V2 HQ | preV1 |
|-------|-------|
| 开城/商品 CRUD | Seed 固定 + admin-web 或改 seed |
| 门店审核 | AUTO_APPROVE_STORE=true |
| 退款/客服 | 跳过 |
| 结算 | store_payout PENDING;可 Seed 演示账单 |
Seed`server/dukang-api/prisma/seed-v31.ts`
## 测试账号
| 角色 | 手机号 |
|------|--------|
| C 端 | 13800000001 |
| 门店 | 13900000001 |
| 合伙人 | 13700000001 |
## 冒烟
```bash
node scripts/smoke-prev1.mjs
```
## preV1 → V2 切换检查
按 preV1 手册 §8.2 逐项关闭 Mock Flag**无需重构表结构**。
## 边界提醒
- preV1 skill **不负责**实现 V2 微信 SDK;遇到真实支付/短信需求 → 停止并切换 V2 手册 §九
- 改 Mock 行为时 **仍须** 遵守 OWNER 模块边界
- admin-web 是 preV1 内部工具,勿与 V2 `pages/hq/` 原型混为一谈
+68
View File
@@ -0,0 +1,68 @@
---
name: dukang-project
description: >-
Manages 杜康好客 PRD, requirements review, prototype mapping, and task card
planning. Use when writing PRD, reviewing requirements, mapping prototypes to
features, or splitting task cards. Not for daily coding (use @dukang-coding).
disable-model-invocation: true
---
# 杜康好客 · 项目/需求 Skill
## 何时启用
- 写或改 PRD、业务规则
- 评审需求、对照原型
- 拆分或规划任务卡
- 用户 @dukang-project
**不用于**:日常编码、修 Bug(用 @dukang-coding
## 文档阅读顺序
| 优先级 | 文档 | 用途 |
|--------|------|------|
| 1 | `杜康好客-V2编码手册.md` §二 | **做什么**(业务规则、状态、边界) |
| 2 | `conventions.md` | **怎么协作**(模块边界、提交) |
| 3 | V2 手册 §四 | **怎么架构**NestJS 模块) |
| 4 | V2 手册 §五 | **表结构**Prisma / SQL |
| 5 | V2 手册 §六 | **接口契约**(四端 JWT |
| 6 | `pages/{user,shop,partner,hq}/` | **UI 参照** |
| 7 | `杜康好客-preV1编码手册.md` | preV1 联调范围 |
## 需求变更规则
- **只改** `杜康好客-V2编码手册.md` 作为业务事实源
- 编码 Agent **不得**边写代码边改业务规则
- 改 API/表结构 → 同步手册 §五/§六 + `packages/shared-types`
## 原型 → 模块映射
| 端 | 原型目录 | 主要 API 前缀 | NestJS Module |
|----|----------|---------------|---------------|
| C 端 | pages/user/ | /auth, /user, /catalog, /trade, /benefit, /redeem, /stores | iam, catalog, trade, benefit, redeem, store |
| 门店 | pages/shop/ | /shop/auth, /shop/redeem, /shop/store | iam, redeem, store |
| 合伙人 | pages/partner/ | /partner/* | iam, store, trade, settlement |
| 总部 | pages/hq/ | /admin/* | iam, catalog, store, trade, settlement, ops |
## 任务卡格式
- ID 格式:`Mx-LAYER-NNN`(如 `M2-BE-TRD-002`
- 任务卡全集:V2 手册 §七 附录 A
- preV1 任务卡:preV1 手册 §9
## 团队 OWNER
| OWNER | 前端 | 后端 Module |
|-------|------|-------------|
| A | h5-user | iam, trade, benefit, analytics |
| B | h5-partner | store |
| C | admin-web | catalog, settlement, ops |
| D | h5-shop | redeem |
| Lead | packages/* | callbacks, jobs, common |
## 输出要求
- 需求文档变更需标注影响模块与 Owner
- 新任务卡需含:PRD §、API 路径、DB 表、原型路径、验收标准
- 跨模块 PR 需相关双 Owner Review
+85
View File
@@ -0,0 +1,85 @@
---
name: dukang-task-card
description: >-
Executes Dukang Haoke task cards (P1-M* or M0-M6) one at a time with DoD
verification. Use when the user provides a task ID like P1-M2-002 or M2-FE-U-003,
or asks to start/implement a milestone card from the coding manuals.
---
# 杜康好客 · 任务卡 Skill
## 原则
- **一次只做一个任务卡**
- 任务卡 ID 命名空间:`P1-*`preV1)与 `M*-*`V2)并行,勿混用验收标准
- 需求变更只改 V2 手册,不在任务实现中私改业务规则
## 领取流程
1. **解析 ID** → 确定里程碑与 OWNER
- `P1-M2-002` → preV1 §9 + OWNER 见卡片内容
- `M2-BE-TRD-002` → V2 §七 附录 A
2. **读验收标准** → 手册中该 ID 的「验收」列
3. **读依赖** → PRD §、API 路径、DB 表、原型路径
4. **确认边界** → 只改 [`AGENTS.md`](../../AGENTS.md) OWNER 表内路径
5. **实现** → 配合 `dukang-coding` skill
6. **自验 DoD** → 下方清单
## OWNER 快速映射(2 人)
| 领域 | 负责人 | 典型 ID |
|------|--------|---------|
| 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 |
| Monorepo/integrations | jacy-dukang | P1-M0-* |
## preV1 任务卡(精简)
| ID | 验收要点 |
|----|----------|
| P1-M0-001 | 三端 `pnpm dev` 可编译 |
| P1-M0-003 | `pnpm db:validate` 通过 |
| P1-M0-004 | Seed 郑州+4SKU+测试账号 |
| P1-M1-001 | 123456 三端登录 |
| P1-M1-002 | C 端首页 4 款酒 |
| P1-M2-001 | 起购校验 2/6 瓶 |
| P1-M2-002 | Mock 支付发券 |
| P1-M2-003 | 5 Tab 含 pending_ship |
| P1-M3-001 | 核销 ¥500 上限 |
| P1-M3-002 | 门店扫码核销 |
| P1-M4-001 | 录店 AUTO_APPROVE → C 端可见 |
| P1-M5-001 | 配送自动推进到 COMPLETED |
完整列表:preV1 手册 §9。
## 完成定义(每个任务卡)
```
- [ ] 验收列每一条可演示/可脚本验证
- [ ] 改动在 OWNER 路径内(跨模块需 inject Service + 双 OWNER 知晓)
- [ ] 新枚举/DTO → shared-types
- [ ] 纯规则 → domain + 单测(若涉及起购/权益/核销)
- [ ] API/表变更 → 同步 V2 手册 §五/§六
- [ ] pnpm lint 无新增错误
```
## 里程碑顺序
**preV1**P1-M0 → P1-M1 → P1-M2 → P1-M3 → P1-M4 → P1-M5 → P1-M6
**V2**:M0 → M1 → … → M6 → 上线
除非用户指定,默认从当前里程碑**下一个未完成**任务卡开始;不确定时先问用户或跑 smoke 判断进度。
## 输出格式(任务完成时)
```markdown
## 任务卡 {ID} 完成
**改动范围**:(路径列表)
**验收**:(逐条 ✓)
**未做/阻塞**:(如有,需其他 OWNER 配合的项)
**建议下一步**:(下一任务卡 ID
```
+43
View File
@@ -0,0 +1,43 @@
# 杜康好客 CODEOWNERS2 人团队)
# 仓库:阿里云 Codeup · 用户名与平台成员账号一致
#
# jacy-dukang — 管理员 + 主责(C 端、admin、后端主模块、横切)
# 刘京尧 — 合伙人端 + 门店端(h5-partner、h5-shop、store、redeem
# 文档 / 横切 / 基础设施
/AGENTS.md @jacy-dukang
/agent.md @jacy-dukang
/conventions.md @jacy-dukang
/skills.md @jacy-dukang
/杜康好客-V2编码手册.md @jacy-dukang
/杜康好客-preV1编码手册.md @jacy-dukang
/.cursor/ @jacy-dukang
/deploy/ @jacy-dukang
/packages/ @jacy-dukang @刘京尧
# jacy-dukang — C 端 + 交易/权益域 + 总部 admin
/apps/h5-user/ @jacy-dukang
/apps/admin-web/ @jacy-dukang
/server/dukang-api/src/modules/iam/ @jacy-dukang
/server/dukang-api/src/modules/trade/ @jacy-dukang
/server/dukang-api/src/modules/benefit/ @jacy-dukang
/server/dukang-api/src/modules/analytics/ @jacy-dukang
/server/dukang-api/src/modules/catalog/ @jacy-dukang
/server/dukang-api/src/modules/settlement/ @jacy-dukang
/server/dukang-api/src/modules/ops/ @jacy-dukang
# 刘京尧 — 合伙人端 + 门店端
/apps/h5-partner/ @刘京尧
/apps/h5-shop/ @刘京尧
/server/dukang-api/src/modules/store/ @刘京尧
/server/dukang-api/src/modules/redeem/ @刘京尧
# 横切(主责 jacy-dukang;改接口时 @刘京尧 若影响 partner/shop
/server/dukang-api/src/callbacks/ @jacy-dukang
/server/dukang-api/src/jobs/ @jacy-dukang @刘京尧
/server/dukang-api/src/common/ @jacy-dukang
/server/dukang-api/src/integrations/ @jacy-dukang
/server/dukang-api/prisma/ @jacy-dukang @刘京尧
# 原型参照
/pages/ @jacy-dukang
+141
View File
@@ -0,0 +1,141 @@
# 杜康好客 · AGENTS.md
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载。
## 项目概览
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
| 阶段 | 手册 | 说明 |
|------|------|------|
| **当前 preV1** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | 三端 H5 + Mock 联调,同库同 API |
| **目标 V2** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 四端小程序 + 真实第三方(唯一事实源) |
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
## 启动命令
```bash
# 基础设施
cd deploy && docker compose up -d
# 依赖与数据库
pnpm install
cp server/dukang-api/.env.example server/dukang-api/.env
pnpm db:generate && pnpm db:validate
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
# 开发(分终端)
pnpm dev:api # http://localhost:3000/api/v1
pnpm dev:user # :5173
pnpm dev:shop # :5174
pnpm dev:partner # :5175
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
# 验证
node scripts/smoke-prev1.mjs
pnpm lint && pnpm test
```
Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
> **端口冲突**`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
## 文档优先级(冲突时)
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 参照
## 团队与 OWNER 边界(2 人)
| 负责人 | Git 账号 | 职责 |
|--------|----------|------|
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
| **刘京尧** | `刘京尧` | 合伙人 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` |
**Prisma 迁移**jacy-dukang 主 Review;若改 `store_*` / 核销相关表,需 `@刘京尧` 共同 Review。
### 跨模块规则(R1–R8 摘要)
- 只 inject 对方 Module **exports 的 Service**,禁止 `prisma.xxx` 写他人表
- `apps/*` 禁止 import `server/*` 源码;只走 HTTP + `packages/shared-types`
- 业务纯规则 → `packages/domain`;枚举/DTO → `packages/shared-types`
- 微信/配送回调入口 **仅** `callbacks/`Mock 实现 **仅** `integrations/*`
## Cursor 规范
| 类型 | 路径 | 说明 |
|------|------|------|
| Rules | `.cursor/rules/` | 自动加载的编码规范 |
| 编码 Skill | `.cursor/skills/dukang-coding/` | @dukang-coding |
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | P1-* / M*-* |
## 子 Agent(按职责选用)
在 Cursor Agent 输入 `/` 选择:
| 子 Agent | 负责人 | 适用场景 |
|----------|--------|----------|
| `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、核销 |
| `boundary-reviewer` | — | PR 前只读审查跨模块违规 |
## Skills(按需 @
| Skill | 何时用 |
|-------|--------|
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `P1-*` / `M*-*` 并按 DoD 交付 |
## 快速指令
- 实现任务卡:`@dukang-coding 实现 P1-M2-002`
- 需求评审:`@dukang-project 对照 pages/partner/ 评审 PRD`
- PR 前审查:`/boundary-reviewer`
- 原型参照:`pages/{user,shop,partner}/`(只读)
## 核心业务常量(不可偏离)
```
权益额 = benefit_amount ?? price
同城起购 2 瓶 / 跨城 6 瓶
核销:0 < amount ≤ min(balance, 500)Redis 码 5 分钟
C 端门店仅 status=OPEN
订单 Taball | pending_pay | pending_ship | pending_receive | completed
```
## 提交与 PR
- Conventional Commits`feat(trade):``fix(redeem):`scope = 端或模块
- 跨模块 PR → jacy-dukang + 刘京尧 共同 Review(若涉及双方模块)
- 改 API/表 → 同步 V2 手册 §五/§六 + `shared-types`
- 不提交 `.env``dist/``node_modules/`
## 完成定义(DoD
- [ ] 任务卡验收项全部满足
- [ ] 未跨模块直写 Prisma 表
- [ ] 枚举/DTO 在 `shared-types`;纯规则在 `domain`
- [ ] `pnpm lint` 无新增错误;相关 domain 单测通过
## 嵌套指引
- 后端细节:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)
- 前端四 App[`apps/AGENTS.md`](./apps/AGENTS.md)
+2 -2
View File
@@ -23,8 +23,8 @@ pnpm install
cp server/dukang-api/.env.example server/dukang-api/.env
pnpm db:generate
cd server/dukang-api
npx prisma db push
pnpm prisma:seed
npx prisma db push # v3.1 首次切换加 --accept-data-loss
pnpm prisma:seed # seed-v31.ts
```
### 4. 启动服务
+19 -16
View File
@@ -1,6 +1,9 @@
# 杜康好客 · Cursor Agent 指引
> 本文件供 **下一个 Cursor Agent** 在编码前阅读。与 `skills.md`、`杜康好客-V2编码手册.md` 配合使用。
> 本文件供 **人类与 Agent** 在编码前阅读。
> **AI 自动加载入口**[`AGENTS.md`](./AGENTS.md)(跨工具通用)
> **Skills / 子 Agent**`.cursor/skills/`、`.cursor/agents/`Cursor 2.4+
> 与 [`skills.md`](./skills.md)、[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) 配合使用。
---
@@ -17,12 +20,14 @@
## 2. 启动流程(每次会话)
1. 阅读 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md);细节查 V2 手册对应 §
2. 阅读 [`skills.md`](./skills.md) 中的编码检查清单
3. 阅读 [`conventions.md`](./conventions.md) 模块边界
4. 若用户给出任务卡 ID(如 `P1-M2-002` 或 V2 的 `M2-*`)→ 在 preV1 §9 或 V2 §七 查验收标准
5. 对照 `pages/{user,shop,partner}/` 原型图(preV1 无 hq
6. 一次只完成一个任务卡;完成后自验 DoD
1. 阅读 [`AGENTS.md`](./AGENTS.md) 确认 OWNER 边界与当前阶段(preV1
2. 阅读 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md);细节查 V2 手册对应 §
3. 启用 Skill`@dukang-coding`(通用)或 `@dukang-prev1` / `@dukang-task-card`(按需)
4. 按 OWNER 选择子 Agent`/owner-a-user-trade` 等(见 AGENTS.md §子 Agent
5. 阅读 [`conventions.md`](./conventions.md) 模块边界
6. 若用户给出任务卡 ID(如 `P1-M2-002` 或 V2 的 `M2-*`)→ preV1 §9 或 V2 §七
7. 对照 `pages/{user,shop,partner}/` 原型图(preV1 无 hq 小程序;HQ 用 admin-web
8. 一次只完成一个任务卡;PR 前可用 `/boundary-reviewer` 审查跨模块违规
---
@@ -71,15 +76,13 @@
---
## 6. 模块 OWNER写代码时只改自己的目录
## 6. 模块 OWNER2 人团队
| OWNER | App | Module |
|-------|-----|--------|
| A | mini-user | iam, trade, benefit, analytics |
| B | mini-partner | store |
| C | mini-hq | catalog, settlement, ops |
| D | h5-shop | redeem |
| Lead | packages/*, callbacks/, jobs/ | 横切 |
| 负责人 | Git 账号 | ApppreV1 → 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 |
| Jacy(横切) | jacy-dukang | packages/*, callbacks/, jobs/, integrations/ | 基础设施 |
**禁止**Module A 直写 Module B 的 Prisma 表;apps import server 源码。
@@ -132,4 +135,4 @@ dukang/
---
*编码时 @ 本文件或阅读 `skills.md`;需求变更只改 `杜康好客-V2编码手册.md`。*
*编码时 @ [`AGENTS.md`](./AGENTS.md) 或 Skill `dukang-coding`;需求变更只改 `杜康好客-V2编码手册.md`。*
+61
View File
@@ -0,0 +1,61 @@
# 杜康好客 · 前端 Apps AGENTS.md
> 父级:[`../AGENTS.md`](../AGENTS.md) · 路由映射:[`../pages/ROUTE_MAP.md`](../pages/ROUTE_MAP.md)
## App 一览(preV1
| 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/` |
| `admin-web` | — | Admin JWT | jacy-dukang | preV1 内部 HQ 替代 |
V2 目标:`mini-user` / `mini-partner` / `mini-hq` 替换对应 H5(除门店仍 H5)。
## 前端硬规则
- 请求基址 `/api/v1`Header`Authorization` + `X-Client-App`
- 类型从 `@dukang/shared-types` import,禁止复制枚举字符串
- 禁止 import `server/` 或另一个 `apps/*` 的源码
- UI 共享组件优先 `@dukang/shared-ui`
- C 端订单列表 **5 Tab**(含 `pending_ship`
## 新页面 workflow
1.`pages/ROUTE_MAP.md` 查 Stitch screen ↔ route
2. 对照 `pages/{端}/*/code.html` + `screen.png`(只读参照)
3. 在对应 App 的 `src/pages/` 实现;API 封装放 `src/lib/api.ts`
4. 登录态:各 App 自有 Context/Storage,不跨 App 共享
## 各 App 职责边界
### h5-userjacy-dukang
主链路:登录 → 首页/详情 → 下单 Mock 支付 → 5 Tab 订单 → 权益 → 核销码 → 门店列表
**勿改**:门店核销确认 UI(属 h5-shop)、合伙人录店(属 h5-partner
### h5-shop(刘京尧)
主链路:门店登录 → 首页 → 扫码/输入核销 → 确认 → 记录 → 营业状态
**勿改**:C 端出码页面(属 h5-user)
### h5-partner(刘京尧)
主链路:登录 → 工作台 → 录店 → 门店列表 → 辖区订单 → Mock 推进配送(preV1
**勿改**:C 端商品/订单页、总部报表(admin-web / V2 mini-hq
### admin-webjacy-dukangpreV1 限定)
内部 Web 管理:开城、商品、订单、门店审核、结算查看。**不是** V2 总部小程序规格。
变更 admin-web 时勿假设与 `pages/hq/` 原型 1:1V2 需另建 `mini-hq`
## 启动
```bash
pnpm dev:user | dev:shop | dev:partner | dev:admin
```
+1
View File
@@ -13,6 +13,7 @@
"@dukang/shared-types": "workspace:*",
"antd": "^5.22.0",
"dayjs": "^1.11.13",
"element-china-area-data": "^6.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
+4
View File
@@ -16,6 +16,8 @@ import DeliveriesPage from './pages/DeliveriesPage';
import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage';
import StoreMediaPage from './pages/StoreMediaPage';
import ProductsPage from './pages/ProductsPage';
import ResourcesPage from './pages/ResourcesPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
@@ -36,9 +38,11 @@ export default function App() {
<Route path="/" element={<DashboardPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/stores" element={<StoresPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/partners" element={<PartnersPage />} />
<Route path="/cities" element={<CitiesPage />} />
<Route path="/partner-accounts" element={<PartnerAccountsPage />} />
@@ -0,0 +1,34 @@
import { Cascader } from 'antd';
import type { DefaultOptionType } from 'antd/es/cascader';
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
type ChinaRegionCascaderProps = {
value?: string[];
onChange?: (codes: string[]) => void;
disabled?: boolean;
placeholder?: string;
};
export default function ChinaRegionCascader({
value,
onChange,
disabled,
placeholder = '请选择省 / 市 / 区县',
}: ChinaRegionCascaderProps) {
return (
<Cascader
options={CHINA_REGION_OPTIONS as DefaultOptionType[]}
value={value}
onChange={(codes) => onChange?.((codes ?? []) as string[])}
disabled={disabled}
placeholder={placeholder}
showSearch={{
filter: (input, path) =>
path.some((option) =>
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
),
}}
changeOnSelect={false}
/>
);
}
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { Button, Image, Input, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
type OssUploadProps = {
value?: string;
onChange?: (url: string) => void;
onUploaded?: (result: UploadFileResult) => void;
bizType: string;
mediaType?: OssMediaType;
accept?: string;
maxSizeMb?: number;
placeholder?: string;
};
const DEFAULT_MAX_MB = 10;
export default function OssUpload({
value,
onChange,
onUploaded,
bizType,
mediaType = 'IMAGE',
accept,
maxSizeMb = DEFAULT_MAX_MB,
placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) {
const [uploading, setUploading] = useState(false);
const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
const raw = file as File;
if (raw.size > maxSizeMb * 1024 * 1024) {
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
message.error(err.message);
onError?.(err);
return;
}
setUploading(true);
try {
const result = await uploadFileToOss(raw, { bizType, mediaType });
onChange?.(result.url);
onUploaded?.(result);
if (result.mock) {
message.info('当前为 Mock OSS,已使用占位 URL');
} else {
message.success('上传成功');
}
onSuccess?.(result);
} catch (e) {
const err = e instanceof Error ? e : new Error('上传失败');
message.error(err.message);
onError?.(err);
} finally {
setUploading(false);
}
};
return (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{value && mediaType === 'IMAGE' && (
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
)}
{value && mediaType === 'VIDEO' && (
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
)}
<Space wrap>
<Upload
accept={resolvedAccept}
showUploadList={false}
customRequest={customRequest}
disabled={uploading}
>
<Button icon={<UploadOutlined />} loading={uploading}>
{mediaType === 'VIDEO' ? '上传视频' : mediaType === 'FILE' ? '上传文件' : '上传图片'}
</Button>
</Upload>
</Space>
<Input
value={value}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
allowClear
/>
</Space>
);
}
@@ -12,6 +12,7 @@ import {
CarOutlined,
SafetyOutlined,
LogoutOutlined,
CloudUploadOutlined,
} from '@ant-design/icons';
import { clearAuth, request, type HqProfile } from '../lib/api';
@@ -20,7 +21,9 @@ const { Header, Sider, Content } = Layout;
const MENU_ITEMS: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户' },
{ key: '/products', icon: <ShoppingOutlined />, label: '商品' },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{
key: 'stores-group',
icon: <ShopOutlined />,
+76
View File
@@ -0,0 +1,76 @@
import { codeToText, regionData } from 'element-china-area-data';
export { regionData as CHINA_REGION_OPTIONS };
export type OpenCityRef = {
id: string;
name: string;
code: string;
partnerId?: string | null;
partner?: { id: string };
};
export type ParsedChinaRegion = {
province: string;
city: string;
district: string;
provinceCode: string;
cityCode: string;
districtCode: string;
};
/** 区县 adcode → 地级市 adcode(如 410105 → 410100 */
export function districtCodeToCityCode(districtCode: string): string {
if (districtCode.length < 6) return districtCode;
return `${districtCode.slice(0, 4)}00`;
}
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
if (!codes || codes.length < 3) return null;
const [provinceCode, cityCode, districtCode] = codes;
const province = codeToText[provinceCode];
const city = codeToText[cityCode];
const district = codeToText[districtCode];
if (!province || !city || !district) return null;
return { province, city, district, provinceCode, cityCode, districtCode };
}
export function formatRegionLabel(region: ParsedChinaRegion): string {
return `${region.province} / ${region.city} / ${region.district}`;
}
export function matchOpenCityId(
cities: OpenCityRef[],
districtCode: string,
partnerId?: string,
): string | undefined {
const cityCode = districtCodeToCityCode(districtCode);
const scoped = partnerId
? cities.filter((c) => {
const pid = c.partnerId ?? c.partner?.id;
return !pid || String(pid) === partnerId;
})
: cities;
return (
scoped.find((c) => c.code === cityCode)?.id
?? scoped.find((c) => c.code === districtCode)?.id
?? scoped.find((c) => districtCode.startsWith(c.code.slice(0, 4)))?.id
);
}
export function resolveRegionBinding(
codes: string[],
cities: OpenCityRef[],
partnerId?: string,
) {
const region = parseRegionCodes(codes);
if (!region) return null;
const cityId = matchOpenCityId(cities, region.districtCode, partnerId);
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
return {
region,
cityId,
matchedCity,
cityCode: districtCodeToCityCode(region.districtCode),
};
}
+42
View File
@@ -1,3 +1,6 @@
/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */
export const ADMIN_OPTIONS_PAGE_SIZE = 100;
export const ORDER_STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
@@ -42,6 +45,45 @@ export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
export const MEDIA_TYPE_LABELS: Record<string, string> = {
IMAGE: '图片',
VIDEO: '视频',
FILE: '文件',
};
export const RESOURCE_OWNER_TYPE_LABELS: Record<string, string> = {
PRODUCT: '商品',
STORE: '门店',
PARTNER: '合伙人',
USER: '用户',
ORDER: '订单',
HQ: '总部',
};
export const RESOURCE_BIZ_TYPE_LABELS: Record<string, string> = {
COVER: '封面',
ENV: '环境图',
CONTRACT: '合同',
CAROUSEL: '轮播',
DETAIL: '详情',
AVATAR: '头像',
QRCODE: '二维码',
SIGN_PHOTO: '签收照',
VIDEO: '视频',
};
export const RESOURCE_STATUS_LABELS: Record<string, string> = {
ACTIVE: '有效',
DELETED: '已删除',
};
export const PRODUCT_STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
ON_SALE: '在售',
OFF_SALE: '下架',
};
export const AROMA_TYPE_LABELS: Record<string, string> = {
QINGXIANG: '清香型',
JIANGXIANG: '酱香型',
NONGXIANG: '浓香型',
};
export const LEDGER_TYPE_LABELS: Record<string, string> = {
+49
View File
@@ -0,0 +1,49 @@
export type StoreCreateForm = {
partnerId: string;
cityId: string;
regionCodes?: string[];
province?: string;
city?: string;
district: string;
districtCode?: string;
name: string;
phone: string;
address: string;
intro?: string;
coverUrl?: string;
envPhotoUrls?: string[];
contractUrl?: string;
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
accountPhone?: string;
accountName?: string;
};
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
export function validateStoreCreateStep1(
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
): string | null {
if (!form.partnerId) return '请选择开城合伙人';
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 开城城市」配置对应区划';
if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
if (!form.address?.trim()) return '请填写详细地址';
if (form.intro?.trim()) {
const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
}
return null;
}
export function validateStoreCreateStep3(form: Pick<StoreCreateForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>): string | null {
if (!form.bankAccountName?.trim()) return '请填写户主姓名';
if (!form.bankAccountNo?.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch?.trim()) return '请填写开户支行';
return null;
}
+49
View File
@@ -0,0 +1,49 @@
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
export type UploadFileResult = {
url: string;
ossKey: string;
bucket: string;
mock: boolean;
};
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
export async function uploadFileToOss(
file: File,
options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const formData = new FormData();
formData.append('file', file);
formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType);
const headers: Record<string, string> = {
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}/common/resources/upload`, {
method: 'POST',
headers,
body: formData,
});
const json = await res.json();
if (json.code === 401) {
clearAuth();
window.location.href = '/login';
throw new Error('未登录');
}
if (json.code !== 0) throw new Error(json.message || '上传失败');
const data = json.data as UploadFileResult;
return {
url: data.url,
ossKey: data.ossKey,
bucket: data.bucket,
mock: data.mock,
};
}
+2 -2
View File
@@ -4,7 +4,7 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
@@ -37,7 +37,7 @@ export default function CitiesPage() {
const [partners, setPartners] = useState<PartnerOption[]>([]);
async function loadPartners() {
const res = await request<Paginated<PartnerOption>>('/admin/partners?pageSize=200');
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setPartners(res.items);
}
+32 -17
View File
@@ -10,21 +10,11 @@ import {
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
const STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
import { ORDER_STATUS_LABELS } from '../lib/constants';
type OrderDetail = AdminOrderRow & {
receiverAddress?: string;
@@ -93,7 +83,7 @@ export default function OrdersPage() {
title: '状态',
dataIndex: 'status',
width: 100,
render: (s) => <Tag>{STATUS_LABELS[s] || s}</Tag>,
render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag>,
},
{
title: '配送',
@@ -140,7 +130,7 @@ export default function OrdersPage() {
<Input placeholder="DK..." allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="receiverPhone" label="收货手机">
<Input allowClear />
@@ -171,12 +161,37 @@ export default function OrdersPage() {
}}
/>
<Drawer title="订单详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
<Drawer
title="订单详情"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={detail && (
<Space>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={detail.status}
style={{ width: 120 }}
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
await request(`/admin/orders/${detail.id}/status`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
message.success('状态已更新(调试)');
const res = await request<OrderDetail>(`/admin/orders/${detail.id}`);
setDetail(res);
void load();
}}
/>
</Space>
)}
>
{detail && (
<>
<Descriptions column={1} bordered size="small" title="基本信息">
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
<Descriptions.Item label="状态">{ORDER_STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
@@ -215,7 +230,7 @@ export default function OrdersPage() {
dataSource={detail.statusLogs}
columns={[
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
{ title: '到', dataIndex: 'toStatus', render: (v) => STATUS_LABELS[v] || v },
{ title: '到', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v },
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
]}
/>
@@ -4,7 +4,7 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
@@ -46,7 +46,7 @@ export default function PartnerAccountsPage() {
const [partners, setPartners] = useState<PartnerOption[]>([]);
async function loadPartners() {
const res = await request<Paginated<PartnerOption>>('/admin/partners?pageSize=200');
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setPartners(res.items);
}
+155
View File
@@ -0,0 +1,155 @@
import { useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
type Row = {
id: string;
skuCode: string;
barcode69: string;
name: string;
subtitle?: string;
aromaType: string;
spec: string;
price: number;
benefitAmount: number;
status: string;
sortOrder: number;
mainImageUrl?: string | null;
createdAt: string;
};
export default function ProductsPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/products',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.status) qs.set('status', filters.status);
if (filters.aromaType) qs.set('aromaType', filters.aromaType);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const columns: ColumnsType<Row> = [
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> },
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
setDetail(d);
editForm.setFieldsValue({
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
});
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="aromaType" label="香型">
<Select allowClear style={{ width: 110 }} options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" 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); } }} />
<Drawer title="编辑商品" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}></Button>
)}>
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="price" label="售价" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="benefitAmount" label="权益额"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
</Form>
</>
)}
</Drawer>
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/products', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}} width={520}>
<Form form={createForm} layout="vertical" initialValues={{ aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0 }}>
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input placeholder="500ml | 52度" /></Form.Item>
<Form.Item name="price" label="售价" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="benefitAmount" label="权益额"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
import { useEffect, useState } from 'react';
import {
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import {
MEDIA_TYPE_LABELS,
RESOURCE_BIZ_TYPE_LABELS,
RESOURCE_OWNER_TYPE_LABELS,
RESOURCE_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
import OssUpload from '../components/OssUpload';
type Row = {
id: string;
ownerType: string;
ownerId: string;
bizType: string;
mediaType: string;
ossBucket: string;
ossKey: string;
url: string;
fileName?: string | null;
sortOrder: number;
status: string;
createdAt: string;
};
export default function ResourcesPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [data, setData] = useState<Paginated<Row> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false);
async function reload(p = page, ps = pageSize, f = filters) {
setLoading(true);
try {
const qs = new URLSearchParams({ page: String(p), pageSize: String(ps) });
if (f.ownerType) qs.set('ownerType', f.ownerType);
if (f.ownerId) qs.set('ownerId', f.ownerId);
if (f.bizType) qs.set('bizType', f.bizType);
if (f.status) qs.set('status', f.status);
const res = await request<Paginated<Row>>(`/common/resources?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload();
}, []);
const columns: ColumnsType<Row> = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{
title: '归属',
width: 140,
render: (_, row) => (
<span>
{RESOURCE_OWNER_TYPE_LABELS[row.ownerType] || row.ownerType} / {row.ownerId}
</span>
),
},
{ title: '用途', dataIndex: 'bizType', width: 90, render: (v) => RESOURCE_BIZ_TYPE_LABELS[v] || v },
{ title: '类型', dataIndex: 'mediaType', width: 70, render: (v) => MEDIA_TYPE_LABELS[v] || v },
{
title: '预览',
dataIndex: 'url',
width: 90,
render: (url, row) =>
row.mediaType === 'IMAGE' ? (
<Image src={url} width={56} height={40} style={{ objectFit: 'cover' }} />
) : (
<a href={url} target="_blank" rel="noreferrer">
</a>
),
},
{ title: 'URL', dataIndex: 'url', ellipsis: true },
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => <Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{RESOURCE_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Popconfirm
title="确认删除该资源?"
onConfirm={async () => {
await request(`/common/resources/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void reload();
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
OSS
</Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
void reload(1, pageSize, v);
}}
>
<Form.Item name="ownerType" label="归属类型">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(RESOURCE_OWNER_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="ownerId" label="归属 ID">
<Input allowClear style={{ width: 120 }} />
</Form.Item>
<Form.Item name="bizType" label="用途">
<Select
allowClear
style={{ width: 110 }}
options={Object.entries(RESOURCE_BIZ_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(RESOURCE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
void reload(p, ps);
},
}}
/>
<Modal
title="登记 OSS 资源"
open={createOpen}
confirmLoading={saving}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
setSaving(true);
try {
await request('/common/resources', {
method: 'POST',
body: JSON.stringify({
ownerType: v.ownerType,
ownerId: v.ownerId,
bizType: v.bizType,
mediaType: v.mediaType,
ossKey: v.ossKey,
url: v.url,
fileName: v.fileName,
sortOrder: v.sortOrder ?? 0,
}),
});
message.success('已登记');
setCreateOpen(false);
createForm.resetFields();
void reload();
} finally {
setSaving(false);
}
}}
width={520}
>
<Form form={createForm} layout="vertical" initialValues={{ mediaType: 'IMAGE', bizType: 'COVER', sortOrder: 0 }}>
<Form.Item name="ownerType" label="归属类型" rules={[{ required: true }]}>
<Select options={Object.entries(RESOURCE_OWNER_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="ownerId" label="归属 ID" rules={[{ required: true }]}>
<Input placeholder="商品/门店等业务 ID" />
</Form.Item>
<Form.Item name="bizType" label="用途" rules={[{ required: true }]}>
<Select options={Object.entries(RESOURCE_BIZ_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="mediaType" label="媒体类型" rules={[{ required: true }]}>
<Select
options={[
...Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label })),
{ value: 'FILE', label: '文件' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType || prev.bizType !== cur.bizType}>
{({ getFieldValue }) => {
const mediaType = getFieldValue('mediaType') || 'IMAGE';
const bizType = getFieldValue('bizType') || 'COVER';
return (
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
<OssUpload
bizType={bizType}
mediaType={mediaType}
onUploaded={(result) => {
createForm.setFieldsValue({ ossKey: result.ossKey, url: result.url });
}}
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item name="ossKey" label="OSS Key" rules={[{ required: true }]}>
<Input placeholder="上传后自动填入" />
</Form.Item>
<Form.Item name="fileName" label="文件名">
<Input />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -4,7 +4,7 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
@@ -34,7 +34,7 @@ export default function StoreAccountsPage() {
const [stores, setStores] = useState<StoreOption[]>([]);
async function loadStores() {
const res = await request<Paginated<StoreOption>>('/admin/stores?pageSize=200');
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items);
}
+17 -4
View File
@@ -4,8 +4,9 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
type Row = {
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
@@ -35,7 +36,7 @@ export default function StoreMediaPage() {
const [stores, setStores] = useState<StoreOption[]>([]);
async function loadStores() {
const res = await request<Paginated<StoreOption>>('/admin/stores?pageSize=200');
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items);
}
@@ -102,7 +103,13 @@ export default function StoreMediaPage() {
<Form.Item name="mediaType" label="类型" rules={[{ required: true }]} initialValue="IMAGE">
<Select options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="url" label="资源 URL" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
{({ getFieldValue }) => (
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
<OssUpload bizType="ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
</Form.Item>
)}
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
</Form>
</Modal>
@@ -116,7 +123,13 @@ export default function StoreMediaPage() {
}}>
<Form form={editForm} layout="vertical">
<Form.Item name="mediaType" label="类型"><Select options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} /></Form.Item>
<Form.Item name="url" label="资源 URL" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
{({ getFieldValue }) => (
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
<OssUpload bizType="ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
</Form.Item>
)}
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
</Form>
</Modal>
+291 -38
View File
@@ -1,11 +1,39 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Image, Input, Modal, Select, Space, Table, Tag, Typography, message,
Alert,
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Steps,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import {
validateStoreCreateStep1,
validateStoreCreateStep3,
type StoreCreateForm,
} from '../lib/storeCreate';
import { useAdminList } from '../lib/useAdminList';
import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload';
const CREATE_STEPS = [
{ title: '基本信息' },
{ title: '照片上传' },
{ title: '结算资质' },
];
type StoreRow = {
id: string;
@@ -24,12 +52,18 @@ type StoreRow = {
};
type PartnerOption = { id: string; companyName: string };
type CityOption = { id: string; name: string; code: string };
type CityOption = {
id: string;
name: string;
code: string;
partnerId?: string | null;
partner?: { id: string; companyName: string };
};
export default function StoresPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
'/admin/stores',
@@ -45,16 +79,142 @@ export default function StoresPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createStep, setCreateStep] = useState(0);
const [createError, setCreateError] = useState('');
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [cities, setCities] = useState<CityOption[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const selectedPartnerId = Form.useWatch('partnerId', createForm);
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm);
function bindRegionSelection(codes: string[], partnerId?: string) {
const binding = resolveRegionBinding(codes, cities, partnerId ?? selectedPartnerId);
if (!binding) {
createForm.setFieldsValue({ regionCodes: codes, cityId: undefined });
return;
}
createForm.setFieldsValue({
regionCodes: codes,
province: binding.region.province,
city: binding.region.city,
district: binding.region.district,
districtCode: binding.region.districtCode,
cityId: binding.cityId,
});
}
const regionBindingHint = useMemo(() => {
if (!selectedRegionCodes?.length) return null;
const binding = resolveRegionBinding(selectedRegionCodes, cities, selectedPartnerId);
if (!binding) return null;
if (binding.cityId && binding.matchedCity) {
return `已匹配开城城市:${binding.matchedCity.name}(区划 ${binding.cityCode},区县 ${binding.region.districtCode}`;
}
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 开城城市」添加`;
}, [selectedRegionCodes, cities, selectedPartnerId]);
async function loadOptions() {
const [p, c] = await Promise.all([
request<Paginated<PartnerOption>>('/admin/partners?pageSize=200'),
request<Paginated<CityOption>>('/admin/cities?pageSize=200'),
]);
setPartners(p.items);
setCities(c.items);
setOptionsLoading(true);
try {
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
const [p, c] = await Promise.all([
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
request<Paginated<CityOption>>(`/admin/cities?${qs}`),
]);
setPartners(p.items);
setCities(c.items);
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 开城合伙人」中创建');
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 开城城市」中创建');
} catch (e) {
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
} finally {
setOptionsLoading(false);
}
}
function closeCreateModal() {
setCreateOpen(false);
setCreateStep(0);
setCreateError('');
createForm.resetFields();
}
function openCreateModal() {
void loadOptions();
createForm.setFieldsValue({
envPhotoUrls: ['', '', ''],
});
setCreateStep(0);
setCreateError('');
setCreateOpen(true);
}
async function handleCreateNext() {
const values = createForm.getFieldsValue();
if (createStep === 0) {
const msg = validateStoreCreateStep1(values);
if (msg) {
setCreateError(msg);
return;
}
try {
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
} catch {
return;
}
}
setCreateError('');
setCreateStep((s) => s + 1);
}
async function handleCreateSubmit() {
try {
const values = await createForm.validateFields();
const step3Msg = validateStoreCreateStep3(values);
if (step3Msg) {
setCreateError(step3Msg);
return;
}
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
await request('/admin/stores', {
method: 'POST',
body: JSON.stringify({
partnerId: values.partnerId,
cityId: values.cityId,
province: values.province,
city: values.city,
name: values.name.trim(),
phone: values.phone.trim(),
district: values.district.trim(),
address: values.address.trim(),
intro: values.intro?.trim() || undefined,
coverUrl: values.coverUrl?.trim() || undefined,
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrl: values.contractUrl?.trim() || undefined,
bankAccountName: values.bankAccountName.trim(),
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
bankBranch: values.bankBranch.trim(),
accountPhone: values.accountPhone?.trim() || undefined,
accountName: values.accountName?.trim() || undefined,
}),
});
message.success('门店已创建');
closeCreateModal();
void reload();
} catch (e) {
if (e && typeof e === 'object' && 'errorFields' in e) {
const fields = e as { errorFields?: Array<{ name: string[] }> };
const first = fields.errorFields?.[0]?.name?.[0];
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
setCreateStep(0);
}
return;
}
message.error(e instanceof Error ? e.message : '创建失败');
}
}
const columns: ColumnsType<StoreRow> = [
@@ -90,7 +250,7 @@ export default function StoresPage() {
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={() => { void loadOptions(); setCreateOpen(true); }}></Button>
<Button type="primary" onClick={openCreateModal}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
@@ -128,16 +288,18 @@ export default function StoresPage() {
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
{detail.coverUrl && (
{detail.coverUrl ? (
<Descriptions.Item label="封面">
<Image src={String(detail.coverUrl)} width={120} />
</Descriptions.Item>
)}
) : null}
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面图 URL"><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面图">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
<Form.Item name="district" label="区县"><Input /></Form.Item>
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
@@ -145,29 +307,120 @@ export default function StoresPage() {
</>
)}
</Drawer>
<Modal title="新建门店" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/stores', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}>
<Form form={createForm} layout="vertical">
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
</Form.Item>
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))} />
</Form.Item>
<Form.Item name="name" label="门店名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="门店电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="详细地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="district" label="区县"><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面图 URL"><Input /></Form.Item>
<Form.Item name="intro" label="介绍"><Input.TextArea rows={3} /></Form.Item>
<Form.Item name="accountPhone" label="店长手机"><Input placeholder="默认同门店电话" /></Form.Item>
<Form.Item name="accountName" label="店长姓名"><Input placeholder="默认同门店名" /></Form.Item>
<Modal
title="新建门店"
open={createOpen}
width={640}
onCancel={closeCreateModal}
destroyOnClose
footer={(
<Space>
{createStep > 0 && <Button onClick={() => { setCreateError(''); setCreateStep((s) => s - 1); }}></Button>}
{createStep < 2 ? (
<Button type="primary" onClick={() => void handleCreateNext()}></Button>
) : (
<Button type="primary" onClick={() => void handleCreateSubmit()}></Button>
)}
</Space>
)}
>
<Steps current={createStep} items={CREATE_STEPS} style={{ marginBottom: 24 }} />
{createError && (
<Alert type="error" message={createError} showIcon style={{ marginBottom: 16 }} />
)}
<Form form={createForm} layout="vertical" preserve>
<div style={{ display: createStep === 0 ? 'block' : 'none' }}>
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
<Select
showSearch
loading={optionsLoading}
optionFilterProp="label"
placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'}
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
onChange={(partnerId) => {
const codes = createForm.getFieldValue('regionCodes') as string[] | undefined;
if (codes?.length === 3) bindRegionSelection(codes, partnerId);
else createForm.setFieldValue('cityId', undefined);
}}
/>
</Form.Item>
<Form.Item
name="regionCodes"
label="所在地区"
rules={[{ required: true, message: '请选择省 / 市 / 区县' }]}
extra={regionBindingHint ? (
<Typography.Text type={selectedCityId ? 'secondary' : 'warning'}>
{regionBindingHint}
</Typography.Text>
) : null}
>
<ChinaRegionCascader onChange={(codes) => bindRegionSelection(codes)} />
</Form.Item>
<Form.Item name="cityId" hidden rules={[{ required: true, message: '请选择所在地区以匹配开城城市' }]}>
<Input />
</Form.Item>
<Form.Item name="province" hidden><Input /></Form.Item>
<Form.Item name="city" hidden><Input /></Form.Item>
<Form.Item name="district" hidden><Input /></Form.Item>
<Form.Item name="districtCode" hidden><Input /></Form.Item>
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
<Input placeholder="请输入门店名称" />
</Form.Item>
<Form.Item name="phone" label="联系电话" rules={[{ required: true, message: '请填写联系电话' }]}>
<Input placeholder="11位手机号" />
</Form.Item>
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
</Form.Item>
<Form.Item name="intro" label="门店简介">
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
</Form.Item>
<Form.Item name="accountPhone" label="店长手机">
<Input placeholder="默认同门店电话" />
</Form.Item>
<Form.Item name="accountName" label="店长姓名">
<Input placeholder="默认同门店名" />
</Form.Item>
</div>
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
<Typography.Paragraph type="secondary">
preV1
</Typography.Paragraph>
<Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
<Typography.Text strong></Typography.Text>
<Form.List name="envPhotoUrls">
{(fields) => (
<div style={{ marginTop: 8 }}>
{fields.map((field, index) => (
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
<OssUpload bizType="ENV" mediaType="IMAGE" />
</Form.Item>
))}
</div>
)}
</Form.List>
<Form.Item name="contractUrl" label="签约合同">
<OssUpload bizType="CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
</Form.Item>
</div>
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
<Form.Item name="bankAccountName" label="户主姓名" rules={[{ required: true, message: '请填写户主姓名' }]}>
<Input placeholder="银行卡实名姓名" />
</Form.Item>
<Form.Item name="bankAccountNo" label="银行卡号" rules={[{ required: true, message: '请填写银行卡号' }]}>
<Input placeholder="16~19位银行卡号" />
</Form.Item>
<Form.Item name="bankBranch" label="开户支行" rules={[{ required: true, message: '请填写开户支行' }]}>
<Input placeholder="例如:中国工商银行洛阳分行" />
</Form.Item>
<Alert
type="info"
showIcon
message="请确保银行卡信息准确,以免影响门店餐费结算。"
/>
</div>
</Form>
</Modal>
</div>
+6
View File
@@ -9,6 +9,9 @@ import OrderListPage from './pages/OrderListPage';
import OrderDetailPage from './pages/OrderDetailPage';
import CenterPage from './pages/CenterPage';
import BillsPage from './pages/BillsPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import LeaderboardPage from './pages/LeaderboardPage';
export default function App() {
return (
@@ -22,6 +25,9 @@ export default function App() {
</Route>
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/center/bills" element={<BillsPage />} />
<Route path="/reshipments" element={<ReshipPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+15
View File
@@ -0,0 +1,15 @@
export type LeaderboardEntry = {
rank: number;
name: string;
role: string;
totalStores: number;
monthStores: number;
};
/** preV1:贡献榜 Mock,待 `/partner/dashboard/leaderboard` 接入后替换 */
export const LEADERBOARD_MOCK: LeaderboardEntry[] = [
{ rank: 1, name: '陈经理', role: '内部员工', totalStores: 45, monthStores: 8 },
{ rank: 2, name: '李华', role: '城市合伙人', totalStores: 32, monthStores: 5 },
{ rank: 3, name: '王拓', role: '推广员', totalStores: 18, monthStores: 3 },
{ rank: 4, name: '赵敏', role: '内部员工', totalStores: 12, monthStores: 2 },
];
+91
View File
@@ -0,0 +1,91 @@
export type StoreDraftForm = {
name: string;
phone: string;
district: string;
address: string;
intro: string;
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
};
export type StoreDraft = {
step: number;
form: StoreDraftForm;
};
export const STORE_DRAFT_KEY = 'partner_store_draft_v1';
export const defaultStoreForm = (): StoreDraftForm => ({
name: '',
phone: '',
district: '金水区',
address: '',
intro: '',
bankAccountName: '',
bankAccountNo: '',
bankBranch: '',
});
function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
const base = defaultStoreForm();
return {
name: String(raw.name ?? base.name),
phone: String(raw.phone ?? base.phone),
district: String(raw.district ?? base.district),
address: String(raw.address ?? base.address),
intro: String(raw.intro ?? base.intro),
bankAccountName: String(raw.bankAccountName ?? base.bankAccountName),
bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo),
bankBranch: String(raw.bankBranch ?? base.bankBranch),
};
}
export function loadStoreDraft(): StoreDraft | null {
try {
const raw = localStorage.getItem(STORE_DRAFT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (parsed.form && typeof parsed.form === 'object') {
return {
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)),
form: normalizeForm(parsed.form as Record<string, unknown>),
};
}
return { step: 1, form: normalizeForm(parsed) };
} catch {
return null;
}
}
export function saveStoreDraft(draft: StoreDraft) {
localStorage.setItem(STORE_DRAFT_KEY, JSON.stringify(draft));
}
export function clearStoreDraft() {
localStorage.removeItem(STORE_DRAFT_KEY);
}
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
export function validateStoreStep1(form: StoreDraftForm): string | null {
if (!form.name.trim()) return '请填写门店名称';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
if (!form.district.trim()) return '请填写区县';
if (!form.address.trim()) return '请填写详细地址';
if (form.intro.trim()) {
const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
}
return null;
}
export function validateStoreStep3(form: StoreDraftForm): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行';
return null;
}
+22 -1
View File
@@ -103,13 +103,34 @@ export default function CenterPage() {
<Link to="/center/bills" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{pendingBills.length > 0 && <span className="label-md text-primary" style={{ background: 'rgba(166,29,36,0.1)', padding: '2px 8px', borderRadius: 999 }}>{pendingBills.length}</span>}
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/reports/weekly" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">monitoring</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/reshipments" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">assignment_return</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/leaderboard" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">military_tech</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
</div>
</section>
+95 -12
View File
@@ -1,29 +1,60 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function auditStatus(store: Record<string, unknown>) {
const audits = store.audits as Array<{ status?: string }> | undefined;
return String(audits?.[0]?.status ?? '').toUpperCase();
}
function storeStatusLabel(store: Record<string, unknown>) {
const audit = auditStatus(store);
if (audit === 'PENDING') return '待审核';
const s = String(store.status).toUpperCase();
if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '暂时闭店';
if (s === 'CLOSED') return '已关闭';
return s;
}
export default function HomePage() {
const navigate = useNavigate();
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request('PARTNER_H5', '/partner/dashboard').then(setDash);
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
}, [navigate]);
const storeCount = Number(dash?.storeCount || 0);
const storeCount = Number(dash?.storeCount || stores.length || 0);
const orderCount = Number(dash?.orderCount || 0);
const activeStores = Math.max(0, storeCount - Math.ceil(storeCount * 0.05));
const abnormalStores = storeCount - activeStores;
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
const abnormalStores = Math.max(0, storeCount - activeStores);
const pendingStores = useMemo(
() => stores.filter((s) => auditStatus(s) === 'PENDING'),
[stores],
);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
const pendingShip = Math.ceil(orderCount * 0.04);
const shipping = Math.ceil(orderCount * 0.12);
const completed = Math.max(0, orderCount - pendingShip - shipping);
const monthNew = stores.filter((s) => {
const created = new Date(String(s.createdAt || ''));
const now = new Date();
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
}).length;
const recentFromDash = (dash?.recentStores as Array<Record<string, unknown>> | undefined) ?? [];
const displayStores = stores.length > 0 ? stores : recentFromDash;
const previewLeaderboard = LEADERBOARD_MOCK.slice(0, 2);
return (
<div className="page partner-home">
@@ -87,7 +118,7 @@ export default function HomePage() {
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/orders" className="partner-quick-action">
<Link to="/reshipments" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
<span className="material-symbols-outlined">assignment_return</span>
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</span>}
@@ -100,7 +131,7 @@ export default function HomePage() {
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/orders" className="partner-quick-action">
<Link to="/reports/weekly" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
<span className="material-symbols-outlined">monitoring</span>
</div>
@@ -139,17 +170,69 @@ export default function HomePage() {
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="headline-lg" style={{ fontSize: 24 }}>{Math.ceil(storeCount * 0.08)}</span>
<span className="label-md text-success" style={{ display: 'flex', alignItems: 'center', fontSize: 10 }}>
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>trending_up</span> 18%
</span>
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
</div>
</div>
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24 }}>{Math.ceil(storeCount * 0.03)}</p>
<p className="headline-lg" style={{ fontSize: 24 }}>{pendingStores.length}</p>
</div>
</div>
{pendingStores.length > 0 && (
<div className="partner-pending-stores">
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<ul className="partner-store-name-list partner-store-name-list--compact">
{pendingStores.map((s) => (
<li key={String(s.id)}>
<span className="headline-md">{String(s.name || '未命名门店')}</span>
</li>
))}
</ul>
</div>
)}
{displayStores.length > 0 && (
<ul className="partner-store-name-list">
<li className="partner-store-name-list-header">
<span className="label-md text-muted"></span>
<span className="label-md text-muted"></span>
</li>
{displayStores.slice(0, 5).map((s) => (
<li key={String(s.id)}>
<span className="headline-md">{String(s.name || '未命名门店')}</span>
<span className="label-md text-muted">{storeStatusLabel(s)}</span>
</li>
))}
</ul>
)}
<div className="partner-leaderboard-section">
<div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
</h3>
<Link to="/leaderboard" className="label-md text-primary"></Link>
</div>
{previewLeaderboard.map((entry) => (
<div key={entry.rank} className="partner-leaderboard-row partner-leaderboard-row--compact">
<div className="partner-leaderboard-rank">{entry.rank}</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.role})</span>
</p>
<p className="label-md text-muted"> {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.monthStores} </p>
<p className="label-md text-muted"></p>
</div>
</div>
))}
</div>
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
{String(dash?.companyName || '郑州合伙人')} ·
</p>
@@ -0,0 +1,36 @@
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
export default function LeaderboardPage() {
const navigate = useNavigate();
return (
<div className="page-no-tab">
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
<p className="label-md text-muted" style={{ padding: '0 20px 12px' }}>
</p>
<div style={{ padding: '0 20px 24px' }}>
{LEADERBOARD_MOCK.map((entry) => (
<div key={entry.rank} className="partner-leaderboard-row">
<div className="partner-leaderboard-rank">{entry.rank}</div>
<div className="partner-leaderboard-info">
<p className="headline-md">
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 6, fontWeight: 400 }}>({entry.role})</span>
</p>
<p className="label-md text-muted"> {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.monthStores} </p>
<p className="label-md text-muted"></p>
</div>
</div>
))}
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
type ReshipStatus = 'ALL' | 'PENDING' | 'SHIPPING' | 'DONE';
const MOCK_ITEMS = [
{ id: '1', orderNo: 'DK20260301001', status: 'PENDING' as const, productName: '杜康窖藏', originalOrderNo: 'DK20260228088', address: '郑州市金水区花园路 88 号' },
{ id: '2', orderNo: 'DK20260302002', status: 'SHIPPING' as const, productName: '杜康特曲', originalOrderNo: 'DK20260215012', address: '郑州市二七区大学路 12 号' },
];
const STATUS_LABEL: Record<string, string> = {
PENDING: '待配送',
SHIPPING: '配送中',
DONE: '已完成',
};
const FILTERS: { key: ReshipStatus; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'PENDING', label: '待配送' },
{ key: 'SHIPPING', label: '配送中' },
{ key: 'DONE', label: '已完成' },
];
/** BUWR-23:补发处理 */
export default function ReshipPage() {
const navigate = useNavigate();
const [filter, setFilter] = useState<ReshipStatus>('ALL');
const list = MOCK_ITEMS.filter((item) => filter === 'ALL' || item.status === filter);
return (
<div className="page-no-tab partner-orders-page">
<PageHeader title="补发处理" onBack={() => navigate('/')} />
<p className="label-md text-muted" style={{ padding: '0 20px 8px' }}>
</p>
<div className="partner-filter-row" style={{ padding: '0 20px 12px' }}>
{FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={`partner-chip${filter === f.key ? ' active' : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</button>
))}
</div>
{list.length === 0 && <div className="empty"></div>}
{list.map((item) => (
<div key={item.id} className="partner-order-card" style={{ margin: '0 20px 12px' }}>
<div className="partner-order-card-top">
<span className="label-md text-muted"> {item.originalOrderNo}</span>
<span className="label-md text-primary" style={{ fontWeight: 600 }}>{STATUS_LABEL[item.status]}</span>
</div>
<h3 className="headline-md" style={{ marginTop: 8 }}>{item.productName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}> {item.orderNo}</p>
<div className="partner-order-address" style={{ marginTop: 12 }}>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
<p className="line-2-clamp">{item.address}</p>
</div>
{item.status === 'PENDING' && (
<button type="button" className="partner-btn-primary" style={{ marginTop: 16, width: '100%' }}>
</button>
)}
</div>
))}
</div>
);
}
+78 -47
View File
@@ -1,36 +1,77 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import {
clearStoreDraft,
defaultStoreForm,
loadStoreDraft,
saveStoreDraft,
type StoreDraftForm,
validateStoreStep1,
validateStoreStep3,
} from '../lib/storeDraft';
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
export default function StoreCreatePage() {
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const step = Number(params.get('step') || 1);
const [form, setForm] = useState({
name: '',
phone: '',
district: '金水区',
address: '',
intro: '',
accountPhone: '',
accountName: '',
bankAccountName: '',
bankAccountNo: '',
bankBranch: '',
});
const saved = loadStoreDraft();
const [form, setForm] = useState<StoreDraftForm>(saved?.form ?? defaultStoreForm());
const [error, setError] = useState('');
const stepFromUrl = Number(params.get('step') || 0);
const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1);
useEffect(() => {
if (!stepFromUrl && saved?.step) {
setParams({ step: String(saved.step) }, { replace: true });
}
}, [stepFromUrl, saved?.step, setParams]);
useEffect(() => {
saveStoreDraft({ step, form });
}, [step, form]);
function patchForm(patch: Partial<StoreDraftForm>) {
setForm((prev) => ({ ...prev, ...patch }));
setError('');
}
function goStep(n: number) {
setParams({ step: String(n) });
setError('');
}
function handleNext() {
if (step === 1) {
const msg = validateStoreStep1(form);
if (msg) { setError(msg); return; }
}
goStep(step + 1);
}
async function submit() {
const msg = validateStoreStep3(form);
if (msg) { setError(msg); return; }
const step1Msg = validateStoreStep1(form);
if (step1Msg) { setError(step1Msg); return; }
await request('PARTNER_H5', '/partner/stores', {
method: 'POST',
body: JSON.stringify(form),
body: JSON.stringify({
name: form.name.trim(),
phone: form.phone.trim(),
district: form.district.trim(),
address: form.address.trim(),
intro: form.intro.trim() || undefined,
bankAccountName: form.bankAccountName.trim(),
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
bankBranch: form.bankBranch.trim(),
}),
});
clearStoreDraft();
navigate('/stores');
}
@@ -61,6 +102,10 @@ export default function StoreCreatePage() {
</div>
</nav>
{error && (
<p className="partner-form-error" role="alert">{error}</p>
)}
{step === 1 && (
<>
<section className="partner-form-card">
@@ -72,30 +117,30 @@ export default function StoreCreatePage() {
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">store</span>
<input placeholder="请输入门店名称" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input placeholder="请输入门店名称" value={form.name} onChange={(e) => patchForm({ name: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">location_on</span>
<input placeholder="请选择区县" value={form.district} onChange={(e) => setForm({ ...form, district: e.target.value })} />
<input placeholder="请选择区县" value={form.district} onChange={(e) => patchForm({ district: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
</div>
<div className="partner-field">
<label></label>
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.intro.length} / 500</span>
</div>
@@ -108,7 +153,7 @@ export default function StoreCreatePage() {
<div>
<h3 className="body-md text-primary" style={{ fontWeight: 700 }}></h3>
<p className="label-md text-variant" style={{ marginTop: 4, lineHeight: 1.4 }}>
1-3
退
</p>
</div>
</div>
@@ -137,18 +182,12 @@ export default function StoreCreatePage() {
<h3 className="headline-md"> *</h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>3</p>
<div className="partner-upload-grid">
<button type="button" className="partner-upload-dashed">
<span className="material-symbols-outlined text-primary">add_a_photo</span>
<span></span>
</button>
<button type="button" className="partner-upload-dashed">
<span className="material-symbols-outlined text-primary">add_a_photo</span>
<span></span>
</button>
<button type="button" className="partner-upload-dashed">
<span className="material-symbols-outlined text-primary">add_a_photo</span>
<span></span>
</button>
{[0, 1, 2].map((i) => (
<button key={i} type="button" className="partner-upload-dashed">
<span className="material-symbols-outlined text-primary">add_a_photo</span>
<span></span>
</button>
))}
</div>
</section>
@@ -169,7 +208,7 @@ export default function StoreCreatePage() {
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.2)', borderColor: 'rgba(254,214,91,0.5)' }}>
<span className="material-symbols-outlined text-secondary" style={{ fontSize: 18 }}>info</span>
<p className="label-md" style={{ lineHeight: 1.5, color: 'var(--color-on-secondary-container)' }}>
preV1
preV1
</p>
</div>
</>
@@ -184,29 +223,21 @@ export default function StoreCreatePage() {
<section className="partner-form-card">
<div className="partner-field">
<label> *</label>
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => setForm({ ...form, bankAccountName: e.target.value })} />
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
</div>
<div className="partner-field">
<label> *</label>
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => setForm({ ...form, bankAccountNo: e.target.value })} />
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
</div>
<div className="partner-field">
<label> *</label>
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => setForm({ ...form, bankBranch: e.target.value })} />
</div>
<div className="partner-field">
<label></label>
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} value={form.accountPhone} onChange={(e) => setForm({ ...form, accountPhone: e.target.value })} />
</div>
<div className="partner-field">
<label></label>
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} value={form.accountName} onChange={(e) => setForm({ ...form, accountName: e.target.value })} />
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
</div>
</section>
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
<span className="material-symbols-outlined text-secondary">info</span>
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
</p>
</div>
</>
@@ -217,7 +248,7 @@ export default function StoreCreatePage() {
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)}></button>
)}
{step < 3 ? (
<button type="button" className="partner-btn-primary" onClick={() => goStep(step + 1)}>
<button type="button" className="partner-btn-primary" onClick={handleNext}>
<span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
</button>
+17 -2
View File
@@ -18,6 +18,15 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'CLOSED', label: '已关闭' },
];
function auditLabel(store: Record<string, unknown>) {
const audits = store.audits as Array<{ status?: string }> | undefined;
const s = String(audits?.[0]?.status ?? '').toUpperCase();
if (s === 'PENDING') return { cls: 'partner-status-pill--paused', label: '待审核' };
if (s === 'REJECTED') return { cls: 'partner-status-pill--closed', label: '已驳回' };
if (s === 'APPROVED') return { cls: 'partner-status-pill--open', label: '已通过' };
return null;
}
export default function StoreListPage() {
const navigate = useNavigate();
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
@@ -71,16 +80,22 @@ export default function StoreListPage() {
{filtered.map((s) => {
const pill = statusPill(String(s.status));
const audit = auditLabel(s);
const dim = String(s.status).toUpperCase() === 'CLOSED';
const storeName = String(s.name || '未命名门店');
return (
<div key={String(s.id)} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
<Link to={`/stores/${s.id}`} style={{ color: 'inherit', textDecoration: 'none' }}>
<div className="partner-store-card-header">
<div>
<h3 className="headline-md">{String(s.name)}</h3>
<p className="label-md text-muted" style={{ marginBottom: 2 }}></p>
<h3 className="headline-md">{storeName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
</div>
<span className={`partner-status-pill ${pill.cls}`}>{pill.label}</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
<span className={`partner-status-pill ${pill.cls}`}>{pill.label}</span>
{audit && <span className={`partner-status-pill ${audit.cls}`}>{audit.label}</span>}
</div>
</div>
</Link>
<div className="partner-store-card-actions">
@@ -0,0 +1,43 @@
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** BUWR-25:经营周报(preV1 占位,待 `/partner/reports/weekly` 接入) */
export default function WeeklyReportPage() {
const navigate = useNavigate();
return (
<div className="page-no-tab">
<PageHeader title="数据周报" onBack={() => navigate('/')} />
<main style={{ padding: '0 20px 24px' }}>
<section className="partner-revenue-card" style={{ marginBottom: 16 }}>
<p className="partner-revenue-label"> 7 GMV (CNY)</p>
<div className="partner-revenue-amount">{fmtMoney(128450)}</div>
</section>
<section className="partner-bento-card" style={{ marginBottom: 12 }}>
<div className="partner-expansion-split">
<div>
<p className="label-md text-muted"></p>
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>86</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>12</p>
</div>
</div>
</section>
<section className="partner-bento-card">
<h2 className="headline-md" style={{ marginBottom: 12 }}></h2>
<p className="headline-lg text-primary">3 </p>
<p className="label-md text-muted" style={{ marginTop: 8 }}> API </p>
</section>
</main>
</div>
);
}
+100
View File
@@ -581,6 +581,106 @@ html, body, #root {
border-left: 1px solid rgba(226, 190, 188, 0.2);
}
.partner-form-error {
margin: 0 20px 12px;
padding: 10px 12px;
border-radius: 8px;
background: var(--color-error-container);
color: var(--color-error);
font-size: 14px;
}
.partner-store-name-list {
list-style: none;
margin: 0 0 16px;
padding: 0;
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
padding-bottom: 12px;
}
.partner-store-name-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
gap: 12px;
}
.partner-store-name-list-header {
padding-bottom: 4px !important;
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
}
.partner-store-name-list--compact li {
padding: 4px 0;
}
.partner-pending-stores {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
}
.partner-field-input--block {
width: 100%;
padding: 0 16px;
min-height: 48px;
border: 1px solid rgba(226, 190, 188, 0.3);
border-radius: 8px;
background: var(--color-background);
}
.partner-leaderboard-section {
padding-top: 12px;
}
.partner-leaderboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.partner-leaderboard-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(226, 190, 188, 0.1);
}
.partner-leaderboard-row--compact {
padding: 8px 0;
}
.partner-leaderboard-rank {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-surface-container-high);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.partner-leaderboard-row:first-of-type .partner-leaderboard-rank {
background: var(--color-aged-amber);
color: #fff;
}
.partner-leaderboard-info {
flex: 1;
min-width: 0;
}
.partner-leaderboard-stat {
text-align: right;
flex-shrink: 0;
}
/* ── Partner bottom tabbar ── */
.app-tabbar {
display: grid;
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@dukang/shared-ui": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
+9
View File
@@ -0,0 +1,9 @@
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
export const weixinSdk = createWeixinSdk({
apiBase: '/api/v1',
clientApp: 'SHOP_H5',
getAccessToken: () => localStorage.getItem('accessToken'),
});
export { isWechatEnv };
+18 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
@@ -27,6 +28,22 @@ export default function HomePage() {
const openTime = String(store?.openTime || '10:00');
const closeTime = String(store?.closeTime || '22:00');
async function handleScan() {
if (isWechatEnv()) {
try {
await weixinSdk.init();
const token = await weixinSdk.scanQrCode();
if (token) {
navigate(`/redeem?token=${encodeURIComponent(token)}`);
return;
}
} catch {
/* fall through to manual redeem page */
}
}
navigate('/redeem');
}
return (
<div className="shop-home-page">
<header className="shop-home-header">
@@ -55,7 +72,7 @@ export default function HomePage() {
</section>
<section className="shop-home-scan">
<button type="button" className="shop-home-scan-btn" onClick={() => navigate('/redeem')}>
<button type="button" className="shop-home-scan-btn" onClick={handleScan}>
<span className="material-symbols-outlined">qr_code_scanner</span>
</button>
<p className="shop-home-scan-label"></p>
+7 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { request } from '../lib/api';
function formatAmount(n: number) {
@@ -8,6 +8,7 @@ function formatAmount(n: number) {
export default function RedeemConfirmPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [token, setToken] = useState('');
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
@@ -20,6 +21,11 @@ export default function RedeemConfirmPage() {
.catch(() => setStoreName('当前门店'));
}, []);
useEffect(() => {
const scanned = searchParams.get('token');
if (scanned) setToken(scanned);
}, [searchParams]);
async function confirm() {
if (!token.trim()) {
setMsg('请在开发者选项中输入核销码');
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
"element-china-area-data": "^6.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -4,11 +4,18 @@ import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
type PhoneVerifySheetProps = {
open: boolean;
/** 打开时预填手机号(如收货地址中的手机号) */
defaultPhone?: string;
onClose: () => void;
onSuccess: () => void;
};
export default function PhoneVerifySheet({ open, onClose, onSuccess }: PhoneVerifySheetProps) {
export default function PhoneVerifySheet({
open,
defaultPhone,
onClose,
onSuccess,
}: PhoneVerifySheetProps) {
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [msg, setMsg] = useState('');
@@ -21,8 +28,15 @@ export default function PhoneVerifySheet({ open, onClose, onSuccess }: PhoneVeri
setCode('');
setMsg('');
setCodeCooldown(0);
return;
}
}, [open]);
if (defaultPhone) {
const normalized = normalizePhoneInput(defaultPhone);
if (validateMobilePhone(normalized).ok) {
setPhone(normalized);
}
}
}, [open, defaultPhone]);
async function sendCode() {
const phoneCheck = validateMobilePhone(phone);
+36 -2
View File
@@ -56,6 +56,15 @@ export function isLoggedIn() {
return !!localStorage.getItem(ACCESS_TOKEN);
}
const AUTH_RECOVERY_EXEMPT_PATHS = ['/auth/session/bootstrap', '/auth/token/refresh'];
async function recoverSession(): Promise<SessionPayload> {
const refreshed = await refreshSession();
if (refreshed) return refreshed;
clearAuth();
return bootstrapSession();
}
async function rawRequest<T>(
path: string,
options: RequestInit = {},
@@ -79,12 +88,34 @@ async function rawRequest<T>(
return json.data as T;
}
async function requestWithAuthRetry<T>(
path: string,
options: RequestInit = {},
retried = false,
): Promise<T> {
try {
return await rawRequest<T>(path, options);
} catch (e) {
const err = e as Error & { status?: number };
const canRecover =
err.status === 401 &&
!retried &&
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
if (!canRecover) throw e;
await recoverSession();
return requestWithAuthRetry<T>(path, options, true);
}
}
export async function request<T>(
_clientApp: string,
path: string,
options: RequestInit = {},
): Promise<T> {
return rawRequest<T>(path, options);
if (!isLoggedIn() && !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p))) {
await bootstrapSession();
}
return requestWithAuthRetry<T>(path, options);
}
export async function bootstrapSession(): Promise<SessionPayload> {
@@ -145,7 +176,10 @@ export async function ensureSession(): Promise<SessionPayload> {
}
export async function bindPhone(phone: string, code: string): Promise<SessionPayload> {
const data = await rawRequest<SessionPayload>('/auth/phone/bind', {
if (!isLoggedIn()) {
await bootstrapSession();
}
const data = await requestWithAuthRetry<SessionPayload>('/auth/phone/bind', {
method: 'POST',
body: JSON.stringify({ phone, code }),
});
+12 -49
View File
@@ -1,3 +1,6 @@
import { getWechatLocation } from '@dukang/weixin-sdk';
import { weixinSdk } from './weixin';
export type ClientGpsLocation = {
province?: string;
city?: string;
@@ -7,55 +10,15 @@ export type ClientGpsLocation = {
address?: string;
};
type WxLocationResult = {
latitude: number;
longitude: number;
};
declare global {
interface Window {
wx?: {
getLocation?: (options: {
type?: string;
success?: (res: WxLocationResult) => void;
fail?: () => void;
}) => void;
};
}
}
/** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
if (typeof window !== 'undefined' && window.wx?.getLocation) {
const wxResult = await new Promise<WxLocationResult | null>((resolve) => {
window.wx!.getLocation!({
type: 'gcj02',
success: (res) => resolve(res),
fail: () => resolve(null),
});
});
if (wxResult) {
return {
latitude: wxResult.latitude,
longitude: wxResult.longitude,
};
}
}
if (typeof navigator === 'undefined' || !navigator.geolocation) {
return null;
}
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(pos) => {
resolve({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
});
},
() => resolve(null),
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60_000 },
);
});
const loc = await weixinSdk.getLocation();
if (!loc) return null;
return {
latitude: loc.latitude,
longitude: loc.longitude,
};
}
/** @deprecated 使用 tryGetClientGpsLocation */
export { getWechatLocation };
+48
View File
@@ -0,0 +1,48 @@
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
import { isWechatEnv, weixinSdk } from './weixin';
import { request, saveSession, type UserProfile } from './api';
const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
export function isWechatAuthRequiredError(err: unknown): boolean {
return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED;
}
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('USER_H5', '/common/client-config');
}
export async function fetchUserProfile(): Promise<UserProfile> {
return request<UserProfile>('USER_H5', '/auth/me');
}
/** 真实微信支付且未绑定微信时需要授权 */
export function needsWechatAuthForPay(
config: ClientRuntimeConfig,
profile: UserProfile | null,
): boolean {
return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat;
}
export function saveWechatLoginResult(result: WechatLoginResult): boolean {
if (!result.accessToken) return false;
saveSession({
accessToken: result.accessToken,
refreshToken: result.refreshToken ?? '',
deviceKey: result.deviceKey,
phoneVerified: !!result.phoneVerified,
user: result.user as never,
});
return true;
}
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
}
return weixinSdk.login();
}
export function buildLoginReturnUrl(pathname: string, search: string) {
return `/login?return=${encodeURIComponent(`${pathname}${search}`)}`;
}
+11
View File
@@ -0,0 +1,11 @@
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
const CLIENT_APP = 'USER_H5';
export const weixinSdk = createWeixinSdk({
apiBase: '/api/v1',
clientApp: CLIENT_APP,
getAccessToken: () => localStorage.getItem('accessToken'),
});
export { isWechatEnv };
@@ -92,6 +92,8 @@ export default function AddressEditPage() {
});
}
navigate(buildAddressListUrl(checkoutCtx));
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
@@ -24,6 +24,7 @@ function formatAddress(a: Address) {
}
export default function AddressListPage() {
const [list, setList] = useState<Address[]>([]);
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
const [savingOrderAddress, setSavingOrderAddress] = useState(false);
const [params] = useSearchParams();
+76 -18
View File
@@ -1,17 +1,53 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request, saveSession } from '../lib/api';
import type { WechatLoginResult } from '@dukang/shared-types';
import { request, saveSession, type UserProfile } from '../lib/api';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
export default function LoginPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get('return') || '/';
const [phone, setPhone] = useState('13800000001');
const [code, setCode] = useState('123456');
const [loading, setLoading] = useState(false);
const [agreed, setAgreed] = useState(true);
const [codeCooldown, setCodeCooldown] = useState(0);
const [msg, setMsg] = useState('');
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [bindMode, setBindMode] = useState(false);
useEffect(() => {
if (!isWechatEnv()) return;
weixinSdk
.handleOAuthCallback()
.then((result) => {
if (!result) return;
handleWechatLoginResult(result);
})
.catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败'));
}, []);
function handleWechatLoginResult(result: WechatLoginResult) {
if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setMsg('微信授权成功,请绑定手机号完成登录');
return;
}
if (result.accessToken) {
saveSession({
accessToken: result.accessToken,
refreshToken: result.refreshToken ?? '',
deviceKey: result.deviceKey,
phoneVerified: !!result.phoneVerified,
user: result.user as never,
});
navigate(returnTo.startsWith('/') ? returnTo : '/');
}
}
function ensureAgreed() {
if (!agreed) {
@@ -31,7 +67,7 @@ export default function LoginPage() {
setMsg('');
await request('USER_H5', '/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'USER_LOGIN' }),
body: JSON.stringify({ phone, scene: bindMode ? 'BIND_PHONE' : 'USER_LOGIN' }),
});
setMsg('验证码已发送(Mock: 123456');
setCodeCooldown(60);
@@ -60,6 +96,14 @@ export default function LoginPage() {
setLoading(true);
setMsg('');
try {
if (bindMode && wxSessionKey) {
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
method: 'POST',
body: JSON.stringify({ wxSessionKey, phone, code }),
});
handleWechatLoginResult(data);
return;
}
const data = await request<{
accessToken: string;
refreshToken: string;
@@ -69,7 +113,7 @@ export default function LoginPage() {
body: JSON.stringify({ phone, code }),
});
saveSession(data);
navigate('/');
navigate(returnTo.startsWith('/') ? returnTo : '/');
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
@@ -77,9 +121,19 @@ export default function LoginPage() {
}
}
function wechatLogin() {
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('preV1:微信一键授权暂未开放,请使用手机验证码登录');
setMsg('');
try {
if (!isWechatEnv()) {
setMsg('请在微信内打开以使用微信一键授权');
return;
}
const result = await weixinSdk.login();
if (result) handleWechatLoginResult(result);
} catch (e) {
setMsg(e instanceof Error ? e.message : '微信登录失败');
}
}
return (
@@ -97,7 +151,7 @@ export default function LoginPage() {
<main className="login-main">
<div className="login-card">
<h3 className="login-card-title"></h3>
<h3 className="login-card-title">{bindMode ? '绑定手机号' : '手机验证码登录'}</h3>
<div className="login-field">
<span className="login-field-prefix">+86</span>
<input
@@ -138,20 +192,24 @@ export default function LoginPage() {
disabled={loading}
onClick={login}
>
{loading ? '登录中...' : '登录'}
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
</button>
</div>
<div className="login-divider">
<span className="login-divider-line" />
<span className="login-divider-text"></span>
<span className="login-divider-line" />
</div>
{!bindMode && (
<>
<div className="login-divider">
<span className="login-divider-line" />
<span className="login-divider-text"></span>
<span className="login-divider-line" />
</div>
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
<span className="material-symbols-outlined login-wechat-icon">chat</span>
<span></span>
</button>
<button type="button" className="login-wechat-btn" onClick={wechatLogin}>
<span className="material-symbols-outlined login-wechat-icon">chat</span>
<span></span>
</button>
</>
)}
</main>
<footer className="login-footer">
@@ -329,6 +329,7 @@ export default function OrderConfirmPage() {
<PhoneVerifySheet
open={showPhoneVerify}
defaultPhone={selectedAddress?.phone}
onClose={() => {
setShowPhoneVerify(false);
setPendingSubmit(false);
+153 -10
View File
@@ -1,14 +1,83 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useCallback, useEffect, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import type { WechatLoginResult, WechatPayOrderResult } from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { buildOrderConfirmUrl } from '../lib/navigation';
import {
authorizeWechatForPay,
buildLoginReturnUrl,
fetchClientConfig,
fetchUserProfile,
isWechatAuthRequiredError,
needsWechatAuthForPay,
saveWechatLoginResult,
} from '../lib/pay-wechat';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitOrderPaid(orderId: string, maxAttempts = 15) {
for (let i = 0; i < maxAttempts; i += 1) {
const order = await request<{ payStatus?: string }>('USER_H5', `/trade/orders/${orderId}`);
if (order.payStatus === 'PAID') return true;
await sleep(2000);
}
return false;
}
export default function PayPage() {
const [params] = useSearchParams();
const location = useLocation();
const orderId = params.get('orderId') || '';
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [mockMode, setMockMode] = useState(true);
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
const [msg, setMsg] = useState('');
const refreshPayReadiness = useCallback(async () => {
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
setMockMode(config.mockPay);
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
return profile;
} catch {
return null;
}
}, []);
const handleWechatLoginResult = useCallback(
async (result: WechatLoginResult) => {
if (result.needBindPhone && result.wxSessionKey) {
setMsg('微信授权成功,请先绑定手机号');
navigate(buildLoginReturnUrl(location.pathname, location.search));
return;
}
if (saveWechatLoginResult(result)) {
setMsg('');
await refreshPayReadiness();
}
},
[location.pathname, location.search, navigate, refreshPayReadiness],
);
useEffect(() => {
refreshPayReadiness();
}, [refreshPayReadiness]);
useEffect(() => {
if (!isWechatEnv()) return;
weixinSdk
.handleOAuthCallback()
.then((result) => {
if (result) void handleWechatLoginResult(result);
})
.catch((e) => setMsg(e instanceof Error ? e.message : '微信授权失败'));
}, [handleWechatLoginResult]);
function goBackConfirm() {
navigate(
@@ -20,13 +89,56 @@ export default function PayPage() {
}),
);
}
async function pay() {
setLoading(true);
async function wechatAuthorize() {
setAuthLoading(true);
setMsg('');
try {
await request('USER_H5', `/trade/orders/${orderId}/pay`, { method: 'POST' });
if (!isWechatEnv()) {
setMsg('请在微信内打开以授权微信支付');
return;
}
const result = await authorizeWechatForPay();
if (result) await handleWechatLoginResult(result);
} catch (e) {
setMsg(e instanceof Error ? e.message : '微信授权失败');
} finally {
setAuthLoading(false);
}
}
async function pay() {
if (needsWechatAuth) {
setMsg('请先完成微信授权后再支付');
return;
}
setLoading(true);
setMsg('');
try {
const result = await request<WechatPayOrderResult>('USER_H5', `/trade/orders/${orderId}/pay`, {
method: 'POST',
});
if (result.mode === 'jsapi' && result.prepay) {
setMockMode(false);
await weixinSdk.init();
await weixinSdk.pay(result.prepay);
const paid = await waitOrderPaid(orderId);
if (!paid) {
alert('支付结果确认中,请稍后在订单列表查看');
}
navigate('/orders?tab=pending_ship');
return;
}
navigate('/orders?tab=pending_ship');
} catch (e) {
alert(e instanceof Error ? e.message : '支付失败');
if (isWechatAuthRequiredError(e)) {
setNeedsWechatAuth(true);
setMsg('微信支付需要先完成微信授权');
return;
}
setMsg(e instanceof Error ? e.message : '支付失败');
} finally {
setLoading(false);
}
@@ -39,13 +151,44 @@ export default function PayPage() {
<div className="pay-icon">
<span className="material-symbols-outlined">account_balance_wallet</span>
</div>
<p className="headline-lg text-primary">Mock </p>
<p className="text-muted body-md" style={{ marginTop: 8 }}>preV1 </p>
<p className="headline-lg text-primary">
{needsWechatAuth ? '授权微信后可支付' : mockMode ? (isWechatEnv() ? '微信支付' : 'Mock 微信支付') : '微信支付'}
</p>
<p className="text-muted body-md" style={{ marginTop: 8 }}>
{needsWechatAuth
? '使用微信支付前,需先授权微信账号以完成付款'
: mockMode
? 'preV1 Mock 模式:点击确认即完成;开启真实支付后将调起微信收银台'
: '请在微信内完成支付,支付成功后自动跳转'}
</p>
{needsWechatAuth && (
<div className="pay-wechat-auth-card">
<p className="pay-wechat-auth-title"></p>
<p className="pay-wechat-auth-desc"></p>
<button
type="button"
className="login-wechat-btn pay-wechat-auth-btn"
disabled={authLoading}
onClick={wechatAuthorize}
>
<span className="material-symbols-outlined login-wechat-icon">chat</span>
<span>{authLoading ? '授权中...' : '微信一键授权'}</span>
</button>
</div>
)}
{msg && <p className="pay-wechat-auth-msg">{msg}</p>}
<p className="label-md text-muted" style={{ marginTop: 24 }}> {orderId}</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-primary btn-block" disabled={loading || !orderId} onClick={pay}>
{loading ? '支付中...' : '确认支付'}
<button
type="button"
className="btn btn-primary btn-block"
disabled={loading || !orderId || needsWechatAuth}
onClick={pay}
>
{loading ? '支付中...' : needsWechatAuth ? '请先授权微信' : '确认支付'}
</button>
</div>
</div>
+52 -13
View File
@@ -11,6 +11,12 @@ import {
type RegionSelection,
} from '../lib/region-data';
type OpenCity = {
code: string;
name: string;
province: string;
};
type StoreItem = {
id: string;
name: string;
@@ -25,6 +31,11 @@ type StoreItem = {
category?: { name: string } | null;
};
function resolveCityCode(region: RegionSelection, cities: OpenCity[]): string | undefined {
if (region.province === REGION_ALL || region.city === REGION_ALL) return undefined;
return cities.find((c) => c.province === region.province && c.name === region.city)?.code;
}
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
@@ -43,29 +54,56 @@ function formatHours(store: StoreItem) {
export default function StoreListPage() {
const navigate = useNavigate();
const [cities, setCities] = useState<OpenCity[]>([]);
const [stores, setStores] = useState<StoreItem[]>([]);
const [loading, setLoading] = useState(false);
const [categoryTab, setCategoryTab] = useState<string>('全部');
const [keyword, setKeyword] = useState('');
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
useEffect(() => {
request<StoreItem[]>('USER_H5', '/stores?cityCode=410100').then(setStores);
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
const qs = cityCode ? `?cityCode=${encodeURIComponent(cityCode)}` : '';
request<StoreItem[]>('USER_H5', `/stores${qs}`)
.then((data) => {
if (!cancelled) setStores(data);
})
.catch(() => {
if (!cancelled) setStores([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [cityCode]);
const filtered = useMemo(() => {
let list = stores;
if (categoryTab !== '全部') {
list = list.filter((s) => s.category?.name === categoryTab);
}
list = list.filter((s) => {
const province = s.province ?? '河南省';
const city = s.cityName ?? '郑州市';
if (region.province !== REGION_ALL && province !== region.province) return false;
if (region.city !== REGION_ALL && city !== region.city) return false;
if (region.district !== REGION_ALL && s.district !== region.district) return false;
return true;
});
if (!cityCode) {
list = list.filter((s) => {
const province = s.province ?? '河南省';
const city = s.cityName ?? '郑州市';
if (region.province !== REGION_ALL && province !== region.province) return false;
if (region.city !== REGION_ALL && city !== region.city) return false;
if (region.district !== REGION_ALL && s.district !== region.district) return false;
return true;
});
} else if (region.district !== REGION_ALL) {
list = list.filter((s) => s.district === region.district);
}
const q = keyword.trim().toLowerCase();
if (q) {
list = list.filter(
@@ -76,7 +114,7 @@ export default function StoreListPage() {
);
}
return list;
}, [stores, categoryTab, keyword, region]);
}, [stores, categoryTab, keyword, region, cityCode]);
const regionLabel = formatRegion(region.province, region.city, region.district);
@@ -123,7 +161,8 @@ export default function StoreListPage() {
</header>
<main className="store-list">
{filtered.map((s, index) => (
{loading && <div className="store-empty">...</div>}
{!loading && filtered.map((s, index) => (
<article
key={s.id}
className="store-card"
@@ -170,11 +209,11 @@ export default function StoreListPage() {
</article>
))}
{filtered.length === 0 && (
{!loading && filtered.length === 0 && (
<div className="store-empty">{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}</div>
)}
{filtered.length > 0 && (
{!loading && filtered.length > 0 && (
<p className="store-list-end"></p>
)}
</main>
+36
View File
@@ -1157,6 +1157,42 @@
font-size: 40px;
}
.pay-wechat-auth-card {
margin-top: 24px;
padding: 16px;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
text-align: center;
}
.pay-wechat-auth-title {
font-size: 16px;
font-weight: 600;
color: var(--color-on-surface);
margin: 0 0 8px;
}
.pay-wechat-auth-desc {
font-size: 13px;
color: var(--color-muted, #999);
margin: 0 0 16px;
line-height: 1.5;
}
.pay-wechat-auth-btn {
width: 100%;
max-width: 280px;
margin: 0 auto;
}
.pay-wechat-auth-msg {
margin-top: 16px;
font-size: 13px;
color: var(--color-primary, #a02d30);
line-height: 1.5;
}
.success-icon {
width: 64px;
height: 64px;
+21 -14
View File
@@ -7,21 +7,28 @@
## 1. 代码所有权
见 [`.github/CODEOWNERS`](./.github/CODEOWNERS)。端与后端模块边界
见 [`.github/CODEOWNERS`](./.github/CODEOWNERS)。**2 人团队**Git 账号)
| 目录 | Owner | 说明 |
|------|-------|------|
| `apps/mini-user` | @dev-a | C端小程序 |
| `apps/mini-partner` | @dev-b | 城市合伙人小程序 |
| `apps/mini-hq` | @dev-c | 总部管理小程序 |
| `apps/h5-shop` | @dev-d | 门店 H5 |
| `server/.../modules/{iam,trade,benefit,analytics}` | @dev-a | 交易与权益域 |
| `server/.../modules/store` | @dev-b | 门店域 |
| `server/.../modules/{catalog,settlement,ops}` | @dev-c | 开城与结算域 |
| `server/.../modules/redeem` | @dev-d | 核销域 |
| `server/.../callbacks`, `jobs`, `common` | @backend-lead | 回调与任务 |
| `packages/*` | 全员 + @tech-lead | 公共契约,破坏性改动谨慎 |
| `server/dukang-api/prisma` | 全部 Owner | 迁移必须多人 Review |
| Git 账号 | 角色 | 主责 |
|----------|------|------|
| `jacy-dukang` | 管理员 + 主责开发 | C 端、admin-web、后端主模块、横切 |
| `刘京尧` | 开发 | 合伙人 H5、门店 H5、store/redeem 模块 |
端与后端模块边界:
| 目录 | 负责人 | 说明 |
|------|--------|------|
| `apps/h5-user` | jacy-dukang | C 端 H5V2 → mini-user |
| `apps/admin-web` | jacy-dukang | preV1 总部替代 |
| `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/.../callbacks`, `jobs`, `common`, `integrations` | jacy-dukang | 回调与任务 |
| `packages/*` | jacy-dukang 主责;破坏性改动 @刘京尧 | 公共契约 |
| `server/dukang-api/prisma` | jacy-dukang + 刘京尧(涉及 store/redeem 表时) | 迁移 Review |
---
+13
View File
@@ -0,0 +1,13 @@
{
"name": "dukang-haoke",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dukang-haoke",
"engines": {
"node": ">=20"
}
}
}
}
+1
View File
@@ -16,6 +16,7 @@
"db:seed": "pnpm --filter @dukang/api prisma:seed",
"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"
},
"engines": {
+11
View File
@@ -4,6 +4,12 @@ export interface AppConfig {
mockPay: boolean;
mockDeliveryAuto: boolean;
autoApproveStore: boolean;
wechatAuthEnabled: boolean;
wechatPayEnabled: boolean;
wxAppId: string;
wxMchId: string;
/** OSS_ENABLED=true 且 AccessKey/Bucket 齐全时走阿里云直传 */
ossEnabled: boolean;
}
export function loadAppConfig(env?: Record<string, string | undefined>): AppConfig {
@@ -17,5 +23,10 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
mockPay: e.MOCK_PAY !== 'false',
mockDeliveryAuto: e.MOCK_DELIVERY_AUTO !== 'false',
autoApproveStore: e.AUTO_APPROVE_STORE !== 'false',
wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true',
wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false',
wxAppId: e.WX_APP_ID ?? '',
wxMchId: e.WX_MCH_ID ?? '',
ossEnabled: e.OSS_ENABLED === 'true',
};
}
+1
View File
@@ -1,3 +1,4 @@
export * from './enums';
export * from './api';
export * from './config';
export * from './wechat';
+53
View File
@@ -0,0 +1,53 @@
/** 微信 JSSDK 初始化参数(后端签名下发) */
export interface WechatJssdkConfig {
appId: string;
timestamp: number;
nonceStr: string;
signature: string;
jsApiList: string[];
}
/** JSAPI 调起支付参数 */
export interface WechatJsapiPrepayParams {
appId: string;
timeStamp: string;
nonceStr: string;
package: string;
signType: 'RSA' | 'MD5';
paySign: string;
}
export type WechatPayOrderResult =
| { mode: 'mock'; externalNo: string; order?: Record<string, unknown> }
| { mode: 'jsapi'; prepay: WechatJsapiPrepayParams; orderId: string };
/** 业务错误码:微信支付前需完成微信授权 */
export const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
export type ClientRuntimeConfig = {
mockPay: boolean;
wechatPayEnabled: boolean;
};
export interface WechatLoginResult {
accessToken?: string;
refreshToken?: string;
deviceKey?: string;
actorType?: string;
actorId?: string;
phoneVerified?: boolean;
needBindPhone?: boolean;
wxSessionKey?: string;
user?: Record<string, unknown>;
store?: Record<string, unknown>;
partner?: Record<string, unknown>;
}
export type WechatLoginPlatform = 'h5' | 'mini';
export interface WechatGpsLocation {
latitude: number;
longitude: number;
speed?: number;
accuracy?: number;
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@dukang/weixin-sdk",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@dukang/shared-types": "workspace:*"
},
"devDependencies": {
"typescript": "^5.4.5"
}
}
+145
View File
@@ -0,0 +1,145 @@
import type { WechatLoginPlatform, WechatLoginResult } from '@dukang/shared-types';
import { getRuntimePlatform, isWechatBrowser } from './env';
import type { WeixinSdkConfig } from './types';
const OAUTH_STATE_KEY = 'dukang_wx_oauth_state';
function randomState() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
async function apiRequest<T>(
config: WeixinSdkConfig,
path: string,
options: RequestInit = {},
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': config.clientApp,
...(options.headers as Record<string, string>),
};
const token = config.getAccessToken?.();
if (token) headers.Authorization = `Bearer ${token}`;
const apiBase = config.apiBase ?? '/api/v1';
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '请求失败');
return json.data as T;
}
/** 获取公众号 OAuth 授权跳转 URL */
export async function getWechatOAuthUrl(
config: WeixinSdkConfig,
redirectUri: string,
scope: 'snsapi_base' | 'snsapi_userinfo' = 'snsapi_userinfo',
): Promise<string> {
const state = randomState();
sessionStorage.setItem(OAUTH_STATE_KEY, state);
const qs = new URLSearchParams({ redirectUri, scope, state });
const data = await apiRequest<{ url: string }>(config, `/common/wechat/oauth-url?${qs}`);
return data.url;
}
/** 发起微信 OAuth 登录(H5 公众号内跳转授权) */
export async function startWechatOAuthLogin(config: WeixinSdkConfig, redirectUri?: string): Promise<void> {
if (!isWechatBrowser()) {
throw new Error('请在微信内打开');
}
const uri = redirectUri ?? window.location.href.split('#')[0];
const url = await getWechatOAuthUrl(config, uri);
window.location.href = url;
}
/** 小程序 wx.login 获取 code */
export function getMiniProgramLoginCode(): Promise<string> {
return new Promise((resolve, reject) => {
if (!window.wx?.login) {
reject(new Error('wx.login 不可用'));
return;
}
window.wx.login({
success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到 code'))),
fail: (err) => reject(new Error(err.errMsg || 'wx.login 失败')),
});
});
}
/** 用 code 完成微信登录(自动识别 H5 / 小程序) */
export async function loginWithWechatCode(
config: WeixinSdkConfig,
code: string,
platform?: WechatLoginPlatform,
): Promise<WechatLoginResult> {
const resolvedPlatform = platform ?? (getRuntimePlatform() === 'mini' ? 'mini' : 'h5');
return apiRequest<WechatLoginResult>(config, '/auth/login/wechat', {
method: 'POST',
body: JSON.stringify({ code, platform: resolvedPlatform }),
});
}
/** 处理 OAuth 回调 URL 中的 code 参数并登录 */
export async function handleWechatOAuthCallback(
config: WeixinSdkConfig,
searchParams?: URLSearchParams,
): Promise<WechatLoginResult | null> {
const params = searchParams ?? new URLSearchParams(window.location.search);
const code = params.get('code');
if (!code) return null;
const state = params.get('state');
const saved = sessionStorage.getItem(OAUTH_STATE_KEY);
if (saved && state && saved !== state) {
throw new Error('OAuth state 校验失败');
}
sessionStorage.removeItem(OAUTH_STATE_KEY);
return loginWithWechatCode(config, code, 'h5');
}
/** 微信首登绑定手机号 */
export async function bindWechatPhone(
config: WeixinSdkConfig,
payload: { wxSessionKey: string; phone: string; code: string },
): Promise<WechatLoginResult> {
return apiRequest<WechatLoginResult>(config, '/auth/wechat/bind-phone', {
method: 'POST',
body: JSON.stringify(payload),
});
}
/** 获取微信手机号(小程序 getPhoneNumber 返回的 code,需后端解密) */
export async function getWechatPhoneNumber(config: WeixinSdkConfig): Promise<string> {
const platform = getRuntimePlatform();
if (platform !== 'mini') {
throw new Error('H5 环境请使用短信验证码绑定手机号');
}
const phoneCode = await new Promise<string>((resolve, reject) => {
if (!window.wx?.getPhoneNumber) {
reject(new Error('getPhoneNumber 不可用'));
return;
}
window.wx.getPhoneNumber({
success: (res) => (res.code ? resolve(res.code) : reject(new Error('未获取到手机号 code'))),
fail: (err) => reject(new Error(err.errMsg || '获取手机号失败')),
});
});
const data = await apiRequest<{ phone: string }>(config, '/common/wechat/phone-number', {
method: 'POST',
body: JSON.stringify({ code: phoneCode, platform: 'mini' }),
});
return data.phone;
}
/** 一键微信登录(自动选择 OAuth 或 wx.login */
export async function wechatLogin(config: WeixinSdkConfig): Promise<WechatLoginResult | void> {
const platform = getRuntimePlatform();
if (platform === 'mini') {
const code = await getMiniProgramLoginCode();
return loginWithWechatCode(config, code, 'mini');
}
if (platform === 'wechat-h5') {
await startWechatOAuthLogin(config);
return;
}
throw new Error('请在微信内打开以使用微信登录');
}
+18
View File
@@ -0,0 +1,18 @@
/** 是否微信内置浏览器 */
export function isWechatBrowser(): boolean {
if (typeof navigator === 'undefined') return false;
return /MicroMessenger/i.test(navigator.userAgent);
}
/** 是否微信小程序 web-view 或独立小程序环境 */
export function isMiniProgram(): boolean {
if (typeof window === 'undefined') return false;
const ua = navigator.userAgent.toLowerCase();
return ua.includes('miniprogram') || (window as Window & { __wxjs_environment?: string }).__wxjs_environment === 'miniprogram';
}
export function getRuntimePlatform(): 'mini' | 'wechat-h5' | 'browser' {
if (isMiniProgram()) return 'mini';
if (isWechatBrowser()) return 'wechat-h5';
return 'browser';
}
+49
View File
@@ -0,0 +1,49 @@
export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
export { initWechatJssdk, ensureJssdkReady, isJssdkReady } from './jssdk';
export {
getWechatLocation,
canUseWechatLocation,
isWechatEnv,
} from './location';
export { scanQrCode } from './scan';
export { invokeWechatPay } from './pay';
export {
getWechatOAuthUrl,
startWechatOAuthLogin,
getMiniProgramLoginCode,
loginWithWechatCode,
handleWechatOAuthCallback,
bindWechatPhone,
getWechatPhoneNumber,
wechatLogin,
} from './auth';
export type { WeixinSdkConfig, WxApi, MiniProgramWx } from './types';
export { DEFAULT_JS_API_LIST } from './types';
import type { WeixinSdkConfig } from './types';
import { initWechatJssdk } from './jssdk';
import { getWechatLocation } from './location';
import { scanQrCode } from './scan';
import { invokeWechatPay } from './pay';
import {
wechatLogin,
handleWechatOAuthCallback,
bindWechatPhone,
getWechatPhoneNumber,
} from './auth';
/** 微信 SDK 门面(按端注入 apiBase / clientApp */
export function createWeixinSdk(config: WeixinSdkConfig) {
return {
init: () => initWechatJssdk({ ...config, apiBase: config.apiBase ?? '/api/v1' }),
login: () => wechatLogin(config),
handleOAuthCallback: (params?: URLSearchParams) => handleWechatOAuthCallback(config, params),
bindPhone: (payload: { wxSessionKey: string; phone: string; code: string }) =>
bindWechatPhone(config, payload),
getPhoneNumber: () => getWechatPhoneNumber(config),
getLocation: () => getWechatLocation(config),
scanQrCode: () => scanQrCode(config),
pay: (prepay: Parameters<typeof invokeWechatPay>[0]) =>
invokeWechatPay(prepay, { apiBase: config.apiBase, clientApp: config.clientApp }),
};
}
+84
View File
@@ -0,0 +1,84 @@
import type { WechatJssdkConfig } from '@dukang/shared-types';
import { DEFAULT_JS_API_LIST } from './types';
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
let scriptPromise: Promise<void> | null = null;
let configured = false;
function loadScript(): Promise<void> {
if (typeof document === 'undefined') return Promise.reject(new Error('非浏览器环境'));
if (window.wx) return Promise.resolve();
if (scriptPromise) return scriptPromise;
scriptPromise = new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('script[data-dukang-wx-jssdk]');
if (existing) {
existing.addEventListener('load', () => resolve());
existing.addEventListener('error', () => reject(new Error('微信 JSSDK 加载失败')));
return;
}
const script = document.createElement('script');
script.src = JSSDK_URL;
script.async = true;
script.dataset.dukangWxJssdk = '1';
script.onload = () => resolve();
script.onerror = () => reject(new Error('微信 JSSDK 加载失败'));
document.head.appendChild(script);
});
return scriptPromise;
}
async function fetchJssdkConfig(apiBase: string, clientApp: string, url: string, token?: string | null) {
const headers: Record<string, string> = { 'X-Client-App': clientApp };
if (token) headers.Authorization = `Bearer ${token}`;
const qs = new URLSearchParams({ url });
const res = await fetch(`${apiBase}/common/wechat/jssdk-config?${qs}`, { headers });
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '获取 JSSDK 配置失败');
return json.data as WechatJssdkConfig;
}
/** 初始化微信 JSSDKH5 公众号内) */
export async function initWechatJssdk(options: {
apiBase: string;
clientApp: string;
url?: string;
getAccessToken?: () => string | null;
jsApiList?: string[];
}): Promise<void> {
const { apiBase, clientApp, getAccessToken } = options;
const pageUrl = options.url ?? (typeof window !== 'undefined' ? window.location.href.split('#')[0] : '');
await loadScript();
if (!window.wx) throw new Error('微信 JSSDK 不可用');
const config = await fetchJssdkConfig(apiBase, clientApp, pageUrl, getAccessToken?.());
const jsApiList = options.jsApiList ?? [...DEFAULT_JS_API_LIST];
await new Promise<void>((resolve, reject) => {
window.wx!.config({
...config,
jsApiList,
debug: false,
});
window.wx!.ready(() => {
configured = true;
resolve();
});
window.wx!.error((err) => reject(new Error(err.errMsg || 'wx.config 失败')));
});
}
export function isJssdkReady(): boolean {
return configured && !!window.wx;
}
export async function ensureJssdkReady(options: {
apiBase: string;
clientApp: string;
getAccessToken?: () => string | null;
}): Promise<void> {
if (!isJssdkReady()) {
await initWechatJssdk(options);
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { WechatGpsLocation } from '@dukang/shared-types';
import { getRuntimePlatform, isWechatBrowser } from './env';
import { ensureJssdkReady } from './jssdk';
import type { WeixinSdkConfig } from './types';
/** 获取 GPS 定位(微信 JSSDK / 小程序优先,否则 H5 Geolocation */
export async function getWechatLocation(config?: WeixinSdkConfig): Promise<WechatGpsLocation | null> {
const platform = getRuntimePlatform();
if (platform === 'mini' && window.wx?.getLocation) {
return new Promise((resolve) => {
window.wx!.getLocation!({
type: 'gcj02',
success: (res) => resolve(res),
fail: () => resolve(null),
});
});
}
if (platform === 'wechat-h5' && config) {
try {
await ensureJssdkReady({
apiBase: config.apiBase ?? '/api/v1',
clientApp: config.clientApp,
getAccessToken: config.getAccessToken,
});
if (window.wx?.getLocation) {
return new Promise((resolve) => {
window.wx!.getLocation!({
type: 'gcj02',
success: (res) => resolve(res),
fail: () => resolve(null),
});
});
}
} catch {
/* fall through */
}
}
if (typeof navigator === 'undefined' || !navigator.geolocation) return null;
return new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(pos) =>
resolve({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
accuracy: pos.coords.accuracy,
}),
() => resolve(null),
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 },
);
});
}
export function canUseWechatLocation(): boolean {
return getRuntimePlatform() !== 'browser' || typeof navigator?.geolocation !== 'undefined';
}
export function isWechatEnv(): boolean {
return isWechatBrowser() || getRuntimePlatform() === 'mini';
}
+83
View File
@@ -0,0 +1,83 @@
import type { WechatJsapiPrepayParams } from '@dukang/shared-types';
import { getRuntimePlatform } from './env';
import { ensureJssdkReady } from './jssdk';
function invokeBridgePay(params: WechatJsapiPrepayParams): Promise<void> {
return new Promise((resolve, reject) => {
const bridge = window.WeixinJSBridge;
if (!bridge) {
reject(new Error('WeixinJSBridge 不可用'));
return;
}
bridge.invoke(
'getBrandWCPayRequest',
{
appId: params.appId,
timeStamp: params.timeStamp,
nonceStr: params.nonceStr,
package: params.package,
signType: params.signType,
paySign: params.paySign,
},
(res) => {
const msg = res.err_msg ?? '';
if (msg.includes('ok')) resolve();
else if (msg.includes('cancel')) reject(new Error('用户取消支付'));
else reject(new Error(msg || '支付失败'));
},
);
});
}
/** 调起微信支付 */
export async function invokeWechatPay(
prepay: WechatJsapiPrepayParams,
config?: { apiBase?: string; clientApp?: string },
): Promise<void> {
const platform = getRuntimePlatform();
if (platform === 'mini' && window.wx?.requestPayment) {
return new Promise((resolve, reject) => {
window.wx!.requestPayment!({
timeStamp: prepay.timeStamp,
nonceStr: prepay.nonceStr,
package: prepay.package,
signType: prepay.signType,
paySign: prepay.paySign,
success: () => resolve(),
fail: (err) => reject(new Error(err.errMsg || '支付失败')),
});
});
}
if (platform === 'wechat-h5') {
if (config?.clientApp) {
await ensureJssdkReady({
apiBase: config.apiBase ?? '/api/v1',
clientApp: config.clientApp,
});
}
if (window.wx?.chooseWXPay) {
return new Promise((resolve, reject) => {
window.wx!.chooseWXPay!({
...prepay,
success: () => resolve(),
fail: (err) => reject(new Error(err.errMsg || '支付失败')),
cancel: () => reject(new Error('用户取消支付')),
});
});
}
if (window.WeixinJSBridge) {
return invokeBridgePay(prepay);
}
if (typeof document !== 'undefined') {
await new Promise<void>((resolve) => {
if (window.WeixinJSBridge) resolve();
else document.addEventListener('WeixinJSBridgeReady', () => resolve(), { once: true });
});
return invokeBridgePay(prepay);
}
}
throw new Error('当前环境不支持微信支付');
}
+40
View File
@@ -0,0 +1,40 @@
import { getRuntimePlatform } from './env';
import { ensureJssdkReady } from './jssdk';
import type { WeixinSdkConfig } from './types';
/** 调起扫码(返回二维码/条码内容,失败返回 null) */
export async function scanQrCode(config: WeixinSdkConfig): Promise<string | null> {
const platform = getRuntimePlatform();
if (platform === 'mini' && window.wx?.scanCode) {
return new Promise((resolve) => {
window.wx!.scanCode!({
onlyFromCamera: true,
scanType: ['qrCode', 'barCode'],
success: (res) => resolve(res.result || null),
fail: () => resolve(null),
});
});
}
if (platform === 'wechat-h5') {
await ensureJssdkReady({
apiBase: config.apiBase ?? '/api/v1',
clientApp: config.clientApp,
getAccessToken: config.getAccessToken,
});
if (window.wx?.scanQRCode) {
return new Promise((resolve) => {
window.wx!.scanQRCode!({
needResult: 1,
scanType: ['qrCode', 'barCode'],
success: (res) => resolve(res.resultStr || null),
fail: () => resolve(null),
});
});
}
}
const manual = typeof window !== 'undefined' ? window.prompt('当前环境无法调起微信扫码,请手动输入核销码') : null;
return manual?.trim() || null;
}
+85
View File
@@ -0,0 +1,85 @@
import type { WechatGpsLocation, WechatJsapiPrepayParams, WechatJssdkConfig } from '@dukang/shared-types';
export type WeixinSdkConfig = {
/** API 根路径,默认 /api/v1 */
apiBase?: string;
/** X-Client-App 请求头 */
clientApp: string;
/** 获取 access token(可选,登录后自动带) */
getAccessToken?: () => string | null;
};
export type WxInvokeResult<T> = {
errMsg: string;
} & T;
export type WxApi = {
config: (options: WechatJssdkConfig & { debug?: boolean }) => void;
ready: (cb: () => void) => void;
error: (cb: (res: { errMsg: string }) => void) => void;
checkJsApi: (options: { jsApiList: string[]; success?: (res: Record<string, boolean>) => void }) => void;
getLocation: (options: {
type?: string;
success?: (res: WechatGpsLocation) => void;
fail?: (res: { errMsg: string }) => void;
}) => void;
scanQRCode: (options: {
needResult?: 0 | 1;
scanType?: Array<'qrCode' | 'barCode'>;
success?: (res: { resultStr: string }) => void;
fail?: (res: { errMsg: string }) => void;
}) => void;
chooseWXPay: (options: WechatJsapiPrepayParams & {
success?: () => void;
fail?: (res: { errMsg: string }) => void;
cancel?: () => void;
}) => void;
};
export type MiniProgramWx = {
login: (options: {
success?: (res: { code: string }) => void;
fail?: (res: { errMsg: string }) => void;
}) => void;
getPhoneNumber: (options: {
success?: (res: { code: string }) => void;
fail?: (res: { errMsg: string }) => void;
}) => void;
getLocation: WxApi['getLocation'];
scanCode: (options: {
onlyFromCamera?: boolean;
scanType?: Array<'qrCode' | 'barCode'>;
success?: (res: { result: string }) => void;
fail?: (res: { errMsg: string }) => void;
}) => void;
requestPayment: (options: {
timeStamp: string;
nonceStr: string;
package: string;
signType: string;
paySign: string;
success?: () => void;
fail?: (res: { errMsg: string }) => void;
}) => void;
};
declare global {
interface Window {
wx?: WxApi & MiniProgramWx;
WeixinJSBridge?: {
invoke: (
api: string,
params: Record<string, unknown>,
cb: (res: { err_msg?: string }) => void,
) => void;
};
}
}
export const DEFAULT_JS_API_LIST = [
'getLocation',
'scanQRCode',
'chooseWXPay',
'updateAppMessageShareData',
'updateTimelineShareData',
] as const;
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ES2020", "DOM"]
},
"include": ["src"]
}
+451
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
/**
* 为 OSS Bucket 配置浏览器直传所需的 CORS 规则。
* 使用 server/dukang-api/.env 中的 OSS 凭证。
*
* 用法:pnpm oss:cors
* 可选环境变量 OSS_CORS_ORIGINS(逗号分隔),默认包含本地 H5 / admin 端口。
*/
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const __dirname = dirname(fileURLToPath(import.meta.url));
const apiRoot = resolve(__dirname, '../server/dukang-api');
const require = createRequire(resolve(apiRoot, 'package.json'));
const OSS = require('ali-oss');
const envPath = resolve(apiRoot, '.env');
function loadEnvFile(path: string) {
if (!existsSync(path)) return;
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = value;
}
}
loadEnvFile(envPath);
const {
OSS_ACCESS_KEY_ID: accessKeyId,
OSS_ACCESS_KEY_SECRET: accessKeySecret,
OSS_BUCKET: bucket,
OSS_REGION: region = 'oss-cn-hangzhou',
OSS_CORS_ORIGINS,
} = process.env;
if (!accessKeyId || !accessKeySecret || !bucket) {
console.error('请在 server/dukang-api/.env 配置 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_BUCKET');
process.exit(1);
}
const defaultOrigins = [
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:5175',
'http://127.0.0.1:5173',
'http://127.0.0.1:5174',
'http://127.0.0.1:5175',
];
const allowedOrigin = (OSS_CORS_ORIGINS ?? defaultOrigins.join(','))
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const client = new OSS({
region,
accessKeyId,
accessKeySecret,
bucket,
});
const rules = [
{
allowedOrigin,
allowedMethod: ['GET', 'POST', 'PUT', 'HEAD'],
allowedHeader: ['*'],
exposeHeader: ['ETag', 'x-oss-request-id'],
maxAgeSeconds: 600,
},
];
console.log(`配置 Bucket「${bucket}」CORS,允许来源:`);
for (const origin of allowedOrigin) console.log(` - ${origin}`);
await client.putBucketCORS(bucket, rules);
console.log('CORS 规则已写入。若 H5 端仍直传 OSS,请确认来源域名已包含在列表中。');
+37 -1
View File
@@ -10,4 +10,40 @@ MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
# 反向代理后提取真实客户端 IP(下单 IP 定位)
# TRUST_PROXY=true
TRUST_PROXY=true
# 微信SDKWECHAT_AUTH_ENABLED=true 时生效)
WX_APP_ID=
WX_APP_SECRET=
WECHAT_AUTH_ENABLED=false
WECHAT_PAY_ENABLED=false
WX_MCH_ID=
WX_MCH_SERIAL_NO=
WX_MCH_PRIVATE_KEY=
WX_API_V3_KEY=
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://your-domain.com/api/v1/callbacks/wechat/pay
# 阿里云 OSSali-oss@6.xOSS_ENABLED=true 且下方密钥齐全时生效;否则 Mock 占位 URL)
# RAM 用户需具备 PutObject 权限;可在控制台 Bucket 授权策略中为该 RAM UID 授予读写
# 文档:https://help.aliyun.com/zh/oss/user-guide/use-bucket-policy-to-grant-permission-to-access-oss/
OSS_ENABLED=false
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_BUCKET=
OSS_REGION=oss-cn-hangzhou
# 可选:自定义 Endpoint(如内网 oss-cn-hangzhou-internal.aliyuncs.com
OSS_ENDPOINT=
# 可选:开启 V4 签名(需同步调整前端 PostObject 字段,默认 false 使用 V1
OSS_AUTHORIZATION_V4=false
# 公网访问域名:自定义 CDN 或 https://{bucket}.{region}.aliyuncs.com
OSS_CDN_BASE=
# 直传对象前缀(默认 uploads)
OSS_UPLOAD_PREFIX=uploads
# 直传凭证有效期(秒,默认 900
OSS_UPLOAD_EXPIRE_SECONDS=900
# 单文件大小上限(字节,默认 10MB)
OSS_MAX_UPLOAD_BYTES=10485760
# 浏览器直传 OSS 时需配置 Bucket CORS;运行 pnpm oss:cors 或控制台手动添加
# OSS_CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,https://your-domain.com
+75
View File
@@ -0,0 +1,75 @@
# 杜康好客 · 后端 AGENTS.md
> 父级:[`../AGENTS.md`](../AGENTS.md) · API 契约:V2 手册 §六 · 表结构:§五
## 范围
本目录 = 唯一后端进程 `dukang-api`NestJS 10 + Prisma 5)。
## 模块 → 表 OWNER(写权限)
| Module | 主要 Prisma Model | 负责人 |
|--------|-------------------|--------|
| **iam** | User, UserAddress, … | jacy-dukang |
| **catalog** | CommonCity, CommonProductItem, … | jacy-dukang |
| **store** | Store, Partner | 刘京尧 |
| **trade** | Order, OrderDelivery | jacy-dukang |
| **benefit** | BenefitCoupon | jacy-dukang |
| **redeem** | RedeemRecord, StoreRating | 刘京尧 |
| **settlement** | StorePayout, PartnerBill | jacy-dukang |
| **ops** | 只读聚合 | jacy-dukang |
| **analytics** | LogUserAnalytics | jacy-dukang |
| **common** | CommonResource, CommonEvent, CommonTicket | jacy-dukang |
| **integrations** | 无表 | jacy-dukang |
**log_***`LogThirdParty` 由写入方 Module 负责(支付→trade,短信→iam/notify)。
## 允许依赖(摘要)
```
trade → catalog, store, benefit, iam, notify, common, domain
benefit → iam, common, domain(禁止 → trade/redeem
redeem → benefit, store, settlement, iam, notify, common, domain
store → catalog, iam, common, domain(禁止 → trade/benefit
settlement → trade, redeem, store, catalog, iam, notify, common, domain
callbacks → trade, benefit, settlement, common(薄层,无业务表)
jobs → trade, settlement, notify, common
```
完整矩阵见 V2 手册 §四 §2.5.2。
## 新接口 checklist
1.**OWNER 模块**`dto/``*.controller.ts``*.service.ts`
2. Service 只写本模块表;跨域 inject exported Service
3. Guard`JwtAuthGuard` + `actorType` 校验(User/Store/Partner/HQ
4. 响应 `{ code: 0, message: 'ok', data }``ResponseInterceptor`
5. DTO/枚举同步 `packages/shared-types`
6. preV1 Mock:改 `integrations/*`,不在 Controller 散落 `if (mock)`
## Mock 集成(preV1
```
integrations/
├── sms/sms.mock.provider.ts
├── pay/pay.mock.provider.ts
└── delivery/delivery.mock.provider.ts
```
开关见 `packages/shared-types/src/config.ts``MOCK_SMS`, `MOCK_PAY`, `MOCK_DELIVERY_AUTO`)。
## 常用命令
```bash
pnpm dev:api
pnpm db:validate
pnpm db:seed
cd server/dukang-api && npx prisma studio
```
## 禁止
-`redeem``prisma.order.update` → 调 `TradeService`
-`benefit` 里 inject `TradeService` / `RedeemService`
- 多个 Module 注册同一 callback 路由
- 复制起购/核销规则到 Controller → 用 `packages/domain`
+5 -1
View File
@@ -10,7 +10,8 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:validate": "prisma validate",
"prisma:seed": "ts-node --transpile-only prisma/seed-prev1.ts",
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
},
"dependencies": {
@@ -23,6 +24,7 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.4.0",
"@prisma/client": "^5.18.0",
"ali-oss": "^6.23.0",
"bullmq": "^5.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
@@ -34,7 +36,9 @@
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.1.0",
"@types/ali-oss": "^6.23.3",
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
+282
View File
@@ -0,0 +1,282 @@
# V3.1 Schema 迁移清单
> 生成自 `init_v3.sql` → `schema.v31.prisma`(已通过 `prisma validate`
> 旧版备份:`schema.legacy-v21.prisma`(当前运行中的 `schema.prisma`
## 激活步骤(P0
```bash
cd server/dukang-api
# 1. 备份并切换 schema
cp prisma/schema.prisma prisma/schema.legacy-v21.prisma # 若尚未备份
cp prisma/schema.v31.prisma prisma/schema.prisma
# 2. 开发库建议删库重建
mysql -u root -p -e "DROP DATABASE IF EXISTS dukang_haoke; CREATE DATABASE dukang_haoke ..."
# 或:mysql < prisma/init_v3.sql
# 3. 生成 Client
pnpm db:generate
npx prisma db push # 或 prisma migrate dev --name v31_init
# 4. 新 seed(待编写 seed-v31.ts
pnpm prisma:seed
```
---
## 表对照(27 张 v3.1
| v3.1 物理表 | Prisma Model | 旧表/Model | 变化 |
|-------------|--------------|------------|------|
| `common_wx_app_config` | `CommonWxAppConfig` | `wx_app_configs` / `WxAppConfig` | 重命名 |
| `common_resource` | `CommonResource` | — | **新增**(替代 `store_media`、裸 URL |
| `common_event` | `CommonEvent` | `benefit_ledgers`, `order_status_logs`, `store_audits`, `event_logs`, `operation_logs` | **合并** |
| `common_ticket` | `CommonTicket` | `after_sale_tickets`, `refunds`, `delivery_intercepts`, `alerts` | **合并** |
| `common_product_item` | `CommonProductItem` | `products` / `Product` | 重命名 + `cover_resource_id` |
| `common_store_category` | `CommonStoreCategory` | `store_categories` / `StoreCategory` | 重命名 |
| `common_promo_code` | `CommonPromoCode` | `promo_codes` / `PromoCode` | 重命名 + `qrcode_resource_id` |
| `common_city` | `CommonCity` | `cities` / `City` | 重命名 |
| `common_city_commission_rule` | `CommonCityCommissionRule` | `city_commission_rules` | 重命名 |
| `partner_partner` | `Partner` | `partners` | 重命名 + 合同字段内联 |
| `partner_account` | `PartnerAccount` | `partner_accounts` | 重命名 |
| `partner_bill` | `PartnerBill` | `partner_bills` | 状态枚举精简 |
| `hq_account` | `HqAccount` | `hq_accounts` | 重命名 |
| `user_user` | `User` | `users` | 重命名;`phone` 可空;含 `deviceKey`/合并字段 |
| `user_address` | `UserAddress` | `user_addresses` | 重命名 |
| `user_city_preference` | `UserCityPreference` | `user_city_preferences` | 重命名 |
| `user_promo_attribution` | `UserPromoAttribution` | `user_promo_attributions` | 重命名 |
| `store_store` | `Store` | `stores` | `cover_url``cover_resource_id` |
| `store_account` | `StoreAccount` | `store_accounts` | 重命名 |
| `user_order` | `Order` | `orders` | **无 order_items**;商品快照内嵌 |
| `user_order_delivery` | `OrderDelivery` | `order_deliveries` | 重命名 + `sign_photo_resource_id` |
| `user_benefit_coupon` | `BenefitCoupon` | `benefit_coupons` | 重命名 |
| `user_redeem_record` | `RedeemRecord` | `redeem_records` | 重命名 |
| `user_store_rating` | `StoreRating` | `store_ratings` | 重命名 |
| `store_payout` | `StorePayout` | `store_payouts` | 重命名 |
| `log_third_party` | `LogThirdParty` | `payments`, `sms_logs` | **合并** |
| `log_user_analytics` | `LogUserAnalytics` | `event_logs`(埋点部分) | **拆分** |
### 删除的表(v3.1 不再存在)
| 旧表 | 替代方案 |
|------|----------|
| `store_media` | `common_resource``owner_type=STORE` |
| `partner_contracts` | `partner_partner.contract_*` + `common_resource` CONTRACT |
| `order_items` | `user_order` 内嵌快照字段 |
| `order_status_logs` | `common_event(ORDER_STATUS)` |
| `payments` | `log_third_party` + `user_order.pay_*` |
| `refunds` | `common_ticket(REFUND)` |
| `delivery_intercepts` | `common_ticket(ALERT)` 或 RESHIPMENT |
| `benefit_ledgers` | `common_event(BENEFIT_LEDGER)` |
| `redeem_tokens` | **仅 Redis** |
| `order_commissions` | `partner_bill` 汇总(无明细表) |
| `partner_withdrawals` | 手册 v3.1 未包含(后续按需) |
| `store_audits` | `common_event(STORE_AUDIT)` |
| `after_sale_tickets` | `common_ticket` |
| `alerts` | `common_ticket(ALERT)` |
| `operation_logs` | `common_event(HQ_OPERATION)` |
| `event_logs` | `common_event` + `log_user_analytics` |
### preV1 扩展字段(已并入 v3.1
| 字段 | 表 | 说明 |
|------|-----|------|
| `device_key` / `phone_verified_at` / `merged_into_user_id` | `user_user` | 访客 JWT + 验机 + 账号合并 |
| `client_ip` / `ip_*` / `gps_*` | `user_order` | 下单位置快照 |
~~以下字段在切换后丢失~~**已保留**
---
## Prisma Client 调用变更速查
| 旧调用 | v3.1 调用 |
|--------|-----------|
| `prisma.product` | `prisma.commonProductItem` |
| `prisma.city` | `prisma.commonCity` |
| `prisma.cityCommissionRule` | `prisma.commonCityCommissionRule` |
| `prisma.storeCategory` | `prisma.commonStoreCategory` |
| `prisma.promoCode` | `prisma.commonPromoCode` |
| `prisma.orderDelivery` | `prisma.orderDelivery`(表名变 `user_order_delivery` |
| `prisma.benefitCoupon` | `prisma.benefitCoupon`(表 `user_benefit_coupon` |
| `prisma.benefitLedger` | `prisma.commonEvent``eventType=BENEFIT_LEDGER` |
| `prisma.redeemToken` | **删除**,改 Redis |
| `prisma.storeMedia` | `prisma.commonResource` |
| `prisma.storeAudit` | `prisma.commonEvent``eventType=STORE_AUDIT` |
| `prisma.payment` | `prisma.logThirdParty` |
| `prisma.orderStatusLog` | `prisma.commonEvent``eventType=ORDER_STATUS` |
| `prisma.orderItem` | **删除**,读写 `order` 快照字段 |
> `Partner`、`Store`、`User`、`Order` 等 Model 名保留,仅 `@@map` 物理表名变化。
---
## 后端模块影响面
### P0 — 基础设施
| 路径 | 影响 | 工作量 |
|------|------|--------|
| `prisma/schema.prisma` | 已切换为 v3.1 | ✅ |
| `prisma/schema.v31.prisma` | 与 `schema.prisma` 同步源 | ✅ |
| `prisma/seed-prev1.ts` | 保留为 `seed-legacy`;主 seed 为 `seed-v31.ts` | ✅ |
| `prisma/sync-benefit-to-price.ts` | `product``commonProductItem` | 低 |
| `packages/shared-types` | `ClientApp` 去掉 `USER_H5` 等;新增 Resource/Event 枚举 | 中 |
### P1 — common 模块(新建)
| 路径 | 说明 |
|------|------|
| `src/modules/common/common.module.ts` | **新建** |
| `src/modules/common/resource.service.ts` | OSS 凭证、登记、CRUD |
| `src/modules/common/resource.controller.ts` | `/common/resources/*` |
| `src/modules/common/event.service.ts` | 事件写入/查询/时间线 |
| `src/modules/common/ticket.service.ts` | 工单 CRUD |
### P2 — IAM
| 路径 | 关键改动 |
|------|----------|
| `modules/iam/auth.service.ts` | 去掉访客 `deviceKey` 流程;`user.phone` 必填;`prisma.user` 字段变更 |
| `modules/iam/user-address.service.ts` | 表名映射,逻辑基本不变 |
| `common/guards/phone-verified.guard.ts` | 适配新 User 模型 |
| `common/guards/super-admin.guard.ts` | 无大变 |
### P3 — catalog
| 路径 | 关键改动 |
|------|----------|
| `modules/catalog/catalog.service.ts` | `city``commonCity``product``commonProductItem`;返回 `coverResource.url` |
### P4 — trade(改动最大)
| 路径 | 关键改动 |
|------|----------|
| `modules/trade/trade.service.ts` | 下单写 `user_order` 快照(无 `orderItem`);支付写 `log_third_party`;状态变更写 `common_event`;去掉 IP/GPS 字段或扩展 |
| `integrations/pay/*` | 回调改查 `log_third_party` |
| `jobs/*`(配送 Mock | `orderDelivery` 字段对齐 |
### P5 — benefit
| 路径 | 关键改动 |
|------|----------|
| `modules/benefit/benefit.service.ts` | 发券逻辑保留;流水从 `benefitLedger.create``commonEvent.create(BENEFIT_LEDGER)`;读明细改查 `commonEvent` |
### P6 — redeem
| 路径 | 关键改动 |
|------|----------|
| `modules/redeem/redeem.service.ts` | **删除** `redeemToken` DB 写入,仅 Redis`cityCommissionRule``commonCityCommissionRule` |
### P7 — store
| 路径 | 关键改动 |
|------|----------|
| `modules/store/store.service.ts` | `storeAudit``commonEvent`;封面改 `coverResourceId``city``commonCity` |
### P8 — settlement
| 路径 | 关键改动 |
|------|----------|
| `modules/settlement/settlement.service.ts` | `partnerBill` 状态枚举变更;去掉 `orderCommission` 明细 |
### P9 — analytics
| 路径 | 关键改动 |
|------|----------|
| `modules/analytics/analytics.service.ts` | `eventLog``logUserAnalytics` |
### P10 — opsHQ 后台)
| 路径 | 关键改动 |
|------|----------|
| `modules/ops/admin-stores.service.ts` | `storeMedia``commonResource``coverUrl``coverResourceId``city``commonCity` |
| `modules/ops/admin-benefit.service.ts` | 流水列表改查 `commonEvent` |
| `modules/ops/admin-orders.service.ts` | 订单含内嵌商品快照;无 `items` include |
| `modules/ops/admin-dashboard.service.ts` | 统计字段:去掉 guest/merged 用户计数 |
| `modules/ops/admin-cities.service.ts` | `city``commonCity` |
| `modules/ops/admin-partners.service.ts` | 订单关联 `city.partnerId` 不变 |
| `modules/ops/admin-redeem.service.ts` | 表名映射 |
| `modules/ops/admin-users.service.ts` | 去掉 merged/guest 相关 |
| `admin-stores.controller.ts` | `/admin/store-media``/admin/resources` 或复用 common API |
---
## 前端影响面
| 应用 | 影响 |
|------|------|
| `apps/h5-user` | 登录流(phone 必填);商品图 URL 来源;订单详情无 items 数组 |
| `apps/h5-shop` | 门店详情封面 URL |
| `apps/h5-partner` | 录店上传走 `/common/resources` |
| `apps/admin-web` | 门店资源页改 `common_resource`;权益流水改 event;订单详情结构调整 |
| `packages/shared-types` | 枚举与 DTO 同步 |
---
## P1 进度(common 模块)
| 项 | 状态 |
|----|------|
| `modules/common/` Resource/Event/Ticket/ThirdPartyLog | ✅ |
| `common/event/event.helpers.ts` 权益/订单事件 | ✅ |
| 业务层改用 `commonEvent` / `commonResource` / `logThirdParty` | ✅ |
| `pnpm run build` | ✅ |
## P2–P6 进度(业务层 + 兼容层 + 后台)
| 项 | 状态 |
|----|------|
| P2 IAM + catalog 适配 v3.1 | ✅ |
| P3 trade + benefit(下单/发券/支付日志/事件) | ✅ |
| P4 redeem + storeRedis token + 封面资源) | ✅ |
| P5 ops 后台(订单/门店/权益/城市/合伙人) | ✅ |
| P6 兼容层 `v31-compat.ts`(订单 items、门店 coverUrl、流水/状态日志) | ✅ |
| `admin/products` CRUD + HQ 商品页 | ✅ |
| `sync-benefit-to-price.ts``commonProductItem` | ✅ |
| `schema.prisma``schema.v31.prisma` 同步 | ✅ |
| smoke 脚本 `scripts/smoke-prev1.mjs` | ✅ |
### 新增 HQ API
| 路径 | 说明 |
|------|------|
| `GET/POST /admin/products` | 商品列表/新建 |
| `GET/PUT /admin/products/:id` | 商品详情/更新 |
### 新增 API`/api/v1/common/*`
| 路径 | 说明 |
|------|------|
| `POST /common/resources/upload-token` | Mock OSS 直传凭证 |
| `POST/GET/PUT/DELETE /common/resources` | 资源 CRUD |
| `POST/GET /common/events` | 事件写入/查询 |
| `GET /common/events/timeline` | 时间线 |
| `POST/GET/PUT /common/tickets` | 工单 |
| `GET /common/third-party-logs` | HQ 只读支付/第三方日志 |
## 建议实施顺序
```
P0 schema 切换 + seed-v31
→ P1 common 模块(resource + event
→ P2 IAM + catalog(可登录、可看商品)
→ P3 trade + benefit(可下单发券)
→ P4 redeem + store(可核销)
→ P5 ops 后台 + settlement
→ P6 前端对齐 + smoke
```
**冻结规则**:P0~P1 期间不新增业务功能,只修迁移阻塞项。
---
## 文件索引
| 文件 | 说明 |
|------|------|
| `prisma/init_v3.sql` | DDL 源 |
| `prisma/schema.v31.prisma` | **新生成**,待激活 |
| `prisma/schema.legacy-v21.prisma` | 旧版备份 |
| `prisma/schema.prisma` | 当前运行版(**v3.1 已激活** |
+18 -1
View File
@@ -266,7 +266,10 @@ DROP TABLE IF EXISTS user_user;
CREATE TABLE user_user (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_no VARCHAR(20) NOT NULL,
phone VARCHAR(20) NOT NULL,
device_key VARCHAR(36) DEFAULT NULL COMMENT '访客设备标识',
phone VARCHAR(20) DEFAULT NULL COMMENT '验机后必填;访客可为NULL',
phone_verified_at DATETIME(3) DEFAULT NULL,
merged_into_user_id BIGINT UNSIGNED DEFAULT NULL COMMENT '合并入主账号',
wx_open_id VARCHAR(64) DEFAULT NULL,
wx_union_id VARCHAR(64) DEFAULT NULL,
nickname VARCHAR(64) DEFAULT NULL,
@@ -281,8 +284,10 @@ CREATE TABLE user_user (
PRIMARY KEY (id),
UNIQUE KEY uk_user_user_phone (phone),
UNIQUE KEY uk_user_user_no (user_no),
UNIQUE KEY uk_user_user_device_key (device_key),
KEY idx_user_user_source (source_type, source_ref_id),
KEY idx_user_user_referrer (referrer_user_id),
KEY idx_user_user_merged (merged_into_user_id),
KEY idx_user_user_wx_open (wx_open_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='C端用户';
@@ -426,6 +431,16 @@ CREATE TABLE user_order (
receiver_province VARCHAR(32) NOT NULL,
receiver_city VARCHAR(32) NOT NULL,
receiver_district VARCHAR(32) NOT NULL,
client_ip VARCHAR(45) DEFAULT NULL COMMENT '下单时客户端IP',
ip_province VARCHAR(32) DEFAULT NULL COMMENT 'IP解析省',
ip_city VARCHAR(32) DEFAULT NULL COMMENT 'IP解析市',
ip_district VARCHAR(32) DEFAULT NULL COMMENT 'IP解析区县',
gps_province VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析省',
gps_city VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析市',
gps_district VARCHAR(32) DEFAULT NULL COMMENT 'GPS解析区县',
gps_latitude DECIMAL(10,7) DEFAULT NULL,
gps_longitude DECIMAL(10,7) DEFAULT NULL,
gps_address VARCHAR(256) DEFAULT NULL COMMENT 'GPS逆地理地址',
pay_external_no VARCHAR(64) DEFAULT NULL COMMENT '微信交易号(冗余)',
paid_at DATETIME(3) DEFAULT NULL COMMENT '支付时间',
shipped_at DATETIME(3) DEFAULT NULL COMMENT '发货时间(冗余=user_order_delivery.shipping_at)',
@@ -442,6 +457,8 @@ CREATE TABLE user_order (
KEY idx_user_order_product (product_id),
KEY idx_user_order_barcode (barcode_69),
KEY idx_user_order_pay_external (pay_external_no),
KEY idx_user_order_ip_city (ip_city),
KEY idx_user_order_gps_city (gps_city),
CONSTRAINT fk_user_order_user FOREIGN KEY (user_id) REFERENCES user_user(id) ON DELETE RESTRICT,
CONSTRAINT fk_user_order_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
CONSTRAINT fk_user_order_origin FOREIGN KEY (origin_order_id) REFERENCES user_order(id) ON DELETE SET NULL,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+847
View File
@@ -0,0 +1,847 @@
// 杜康好客 · V3.1 数据模型(由 init_v3.sql 生成)
// 激活方式:确认后替换 schema.prisma,执行 prisma migrate / db push + seed-v31
// 旧版备份:schema.legacy-v21.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// ─── 枚举(与 init_v3.sql COMMENT 对齐)────────────────
enum ClientApp {
USER_MINI
USER_H5
PARTNER_MINI
PARTNER_H5
HQ_MINI
HQ_WEB
SHOP_H5
}
enum ResourceOwnerType {
PRODUCT
STORE
PARTNER
USER
ORDER
PROMO
HQ
}
enum ResourceBizType {
COVER
ENV
CONTRACT
CAROUSEL
DETAIL
AVATAR
QRCODE
SIGN_PHOTO
VIDEO
}
enum ResourceMediaType {
IMAGE
VIDEO
FILE
}
enum ResourceStatus {
ACTIVE
DELETED
}
enum EventType {
STORE_AUDIT
ORDER_STATUS
BENEFIT_LEDGER
HQ_OPERATION
PROMO_TOUCH
}
enum ActorType {
USER
STORE
PARTNER
HQ
SYSTEM
}
enum TicketType {
REFUND
RESHIPMENT
ALERT
}
enum AromaType {
QINGXIANG
JIANGXIANG
NONGXIANG
}
enum ProductStatus {
DRAFT
ON_SALE
OFF_SALE
}
enum PromoCodeStatus {
ACTIVE
DISABLED
}
enum CityStatus {
PENDING
ACTIVE
PAUSED
}
enum PartnerStaffRole {
PARTNER
INTERNAL
PROMOTER
}
enum AccountStatus {
ACTIVE
DISABLED
}
enum HqAdminRole {
SUPER_ADMIN
OPS
FINANCE
CUSTOMER_SERVICE
}
enum PartnerBillStatus {
DRAFT
CONFIRMED
PAID
}
enum StoreStatus {
OPEN
PAUSED
CLOSED
}
enum UserSourceType {
ORGANIC
PROMO_CODE
SHARE_LINK
FRIEND_REFERRAL
OFFLINE_EVENT
OTHER
}
enum OrderType {
NORMAL
RESHIPMENT
}
enum OrderStatus {
PENDING_PAY
PENDING_SHIP
OUT_WAREHOUSE
SHIPPING
PENDING_RECEIVE
COMPLETED
CANCELLED
REFUNDING
REFUNDED
}
enum PayStatus {
UNPAID
PAYING
PAID
REFUNDING
REFUNDED
}
enum DeliveryType {
LOCAL
CROSS_CITY
}
enum FreightPayType {
FREE
COD
}
enum DeliveryProvider {
XFX
LOGISTICS
MANUAL
}
enum BenefitCouponStatus {
ACTIVE
USED_UP
VOID
}
enum StorePayoutStatus {
PENDING
PAID
}
enum ThirdPartyProvider {
WECHAT_PAY
WECHAT_REFUND
WECHAT_AUTH
WECHAT_MAP
XFX
SMS
LOGISTICS
}
enum ThirdPartyLogStatus {
PENDING
SUCCESS
FAILED
}
enum BenefitLedgerType {
GRANT
REDEEM
REFUND_VOID
ADJUST
}
// ─── COMMON ───────────────────────────────────────────
model CommonWxAppConfig {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
clientApp ClientApp @unique @map("client_app")
appId String @map("app_id") @db.VarChar(64)
appSecret String @map("app_secret") @db.VarChar(128)
mchId String? @map("mch_id") @db.VarChar(32)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@map("common_wx_app_config")
}
model CommonResource {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
ownerType ResourceOwnerType @map("owner_type")
ownerId BigInt @map("owner_id") @db.UnsignedBigInt
bizType ResourceBizType @map("biz_type")
mediaType ResourceMediaType @default(IMAGE) @map("media_type")
ossBucket String @map("oss_bucket") @db.VarChar(64)
ossKey String @map("oss_key") @db.VarChar(256)
url String @db.VarChar(512)
fileName String? @map("file_name") @db.VarChar(128)
fileSize BigInt? @map("file_size") @db.UnsignedBigInt
mimeType String? @map("mime_type") @db.VarChar(64)
sortOrder Int @default(0) @map("sort_order")
status ResourceStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
productCovers CommonProductItem[] @relation("ProductCover")
promoQrcodes CommonPromoCode[] @relation("PromoQrcode")
userAvatars User[] @relation("UserAvatar")
storeCovers Store[] @relation("StoreCover")
orderImages Order[] @relation("OrderProductImage")
deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto")
@@index([ownerType, ownerId, bizType])
@@index([status])
@@map("common_resource")
}
model CommonEvent {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
eventType EventType @map("event_type")
refType String @map("ref_type") @db.VarChar(32)
refId BigInt @map("ref_id") @db.UnsignedBigInt
actorType ActorType? @map("actor_type")
actorId BigInt? @map("actor_id") @db.UnsignedBigInt
status String? @db.VarChar(32)
param1 String? @db.VarChar(128)
param1Desc String? @map("param1_desc") @db.VarChar(64)
param2 String? @db.VarChar(128)
param2Desc String? @map("param2_desc") @db.VarChar(64)
param3 String? @db.VarChar(128)
param3Desc String? @map("param3_desc") @db.VarChar(64)
amount1 Decimal? @db.Decimal(10, 2)
amount2 Decimal? @db.Decimal(10, 2)
remark String? @db.VarChar(512)
extraJson Json? @map("extra_json")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@index([refType, refId, eventType])
@@index([eventType, createdAt])
@@index([actorType, actorId])
@@map("common_event")
}
model CommonTicket {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
ticketNo String @unique @map("ticket_no") @db.VarChar(32)
ticketType TicketType @map("ticket_type")
status String @default("PENDING") @db.VarChar(32)
refType String @map("ref_type") @db.VarChar(32)
refId BigInt @map("ref_id") @db.UnsignedBigInt
operatorType ActorType? @map("operator_type")
operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
param1 String? @db.VarChar(128)
param1Desc String? @map("param1_desc") @db.VarChar(64)
param2 String? @db.VarChar(128)
param2Desc String? @map("param2_desc") @db.VarChar(64)
param3 String? @db.VarChar(128)
param3Desc String? @map("param3_desc") @db.VarChar(64)
remark String? @db.VarChar(512)
extraJson Json? @map("extra_json")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3)
@@index([refType, refId])
@@index([ticketType, status])
@@map("common_ticket")
}
model CommonProductItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
skuCode String @unique @map("sku_code") @db.VarChar(32)
barcode69 String @unique @map("barcode_69") @db.VarChar(32)
name String @db.VarChar(128)
subtitle String? @db.VarChar(256)
aromaType AromaType @map("aroma_type")
spec String @db.VarChar(128)
price Decimal @db.Decimal(10, 2)
benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
status ProductStatus @default(DRAFT)
sortOrder Int @default(0) @map("sort_order")
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
detailContent Json? @map("detail_content")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
orders Order[]
@@index([status, aromaType])
@@map("common_product_item")
}
model CommonStoreCategory {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(64)
sort Int @default(0)
stores Store[]
@@map("common_store_category")
}
model CommonPromoCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
status PromoCodeStatus @default(ACTIVE)
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
scanCount Int @default(0) @map("scan_count")
orderCount Int @default(0) @map("order_count")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
attributions UserPromoAttribution[]
orders Order[]
@@map("common_promo_code")
}
model CommonCity {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(16)
name String @db.VarChar(64)
province String @db.VarChar(32)
status CityStatus @default(PENDING)
partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
localMinQty Int @default(2) @map("local_min_qty")
crossMinQty Int @default(6) @map("cross_min_qty")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull)
commissionRule CommonCityCommissionRule?
stores Store[]
orders Order[]
@@index([partnerId])
@@map("common_city")
}
model CommonCityCommissionRule {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4)
partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4)
storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
@@map("common_city_commission_rule")
}
// ─── PARTNER ──────────────────────────────────────────
model Partner {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
companyName String @map("company_name") @db.VarChar(128)
address String @db.VarChar(256)
contactPhone String @map("contact_phone") @db.VarChar(20)
contractNo String? @map("contract_no") @db.VarChar(64)
contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
cities CommonCity[]
accounts PartnerAccount[]
stores Store[]
bills PartnerBill[]
@@index([contactPhone])
@@map("partner_partner")
}
model PartnerAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
isPrimary Int @default(0) @map("is_primary") @db.TinyInt
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
staffRole PartnerStaffRole? @map("staff_role")
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
children PartnerAccount[] @relation("PartnerAccountHierarchy")
@@index([partnerId])
@@index([parentAccountId])
@@index([wxOpenId])
@@map("partner_account")
}
model PartnerBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
periodStart DateTime @map("period_start") @db.DateTime(3)
periodEnd DateTime @map("period_end") @db.DateTime(3)
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status PartnerBillStatus @default(DRAFT)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
@@index([partnerId, status])
@@map("partner_bill")
}
// ─── HQ ───────────────────────────────────────────────
model HqAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
adminRole HqAdminRole @default(OPS) @map("admin_role")
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@map("hq_account")
}
// ─── USER ─────────────────────────────────────────────
model User {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userNo String @unique @map("user_no") @db.VarChar(20)
deviceKey String? @unique @map("device_key") @db.VarChar(36)
phone String? @unique @db.VarChar(20)
phoneVerifiedAt DateTime? @map("phone_verified_at") @db.DateTime(3)
mergedIntoUserId BigInt? @map("merged_into_user_id") @db.UnsignedBigInt
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
nickname String? @db.VarChar(64)
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
status Int @default(1) @db.TinyInt
sourceType UserSourceType @default(ORGANIC) @map("source_type")
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
sourceLabel String? @map("source_label") @db.VarChar(128)
referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
mergedInto User? @relation("UserMerge", fields: [mergedIntoUserId], references: [id], onDelete: SetNull)
mergedFrom User[] @relation("UserMerge")
referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull)
referrers User[] @relation("UserReferrer")
avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull)
addresses UserAddress[]
cityPreference UserCityPreference?
promoTouch UserPromoAttribution?
orders Order[]
benefitCoupons BenefitCoupon[]
redeemRecords RedeemRecord[]
@@index([sourceType, sourceRefId])
@@index([referrerUserId])
@@index([mergedIntoUserId])
@@index([wxOpenId])
@@map("user_user")
}
model UserAddress {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt @map("user_id") @db.UnsignedBigInt
receiverName String @map("receiver_name") @db.VarChar(32)
phone String @db.VarChar(20)
province String @db.VarChar(32)
city String @db.VarChar(32)
district String @db.VarChar(32)
detail String @db.VarChar(256)
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
isDefault Int @default(0) @map("is_default") @db.TinyInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("user_address")
}
model UserCityPreference {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt @unique @map("user_id") @db.UnsignedBigInt
selectedCityCode String? @map("selected_city_code") @db.VarChar(16)
selectedDistrict String? @map("selected_district") @db.VarChar(32)
locateCityCode String? @map("locate_city_code") @db.VarChar(16)
locateDistrict String? @map("locate_district") @db.VarChar(32)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_city_preference")
}
model UserPromoAttribution {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt @unique @map("user_id") @db.UnsignedBigInt
promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
channelName String @map("channel_name") @db.VarChar(128)
firstTouchAt DateTime @map("first_touch_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Restrict)
@@index([promoCodeId])
@@map("user_promo_attribution")
}
// ─── STORE ────────────────────────────────────────────
model Store {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
name String @db.VarChar(128)
phone String @db.VarChar(20)
province String @db.VarChar(32)
cityName String @map("city_name") @db.VarChar(32)
district String @db.VarChar(32)
address String @db.VarChar(256)
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
intro String? @db.Text
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
avgPrice Decimal? @map("avg_price") @db.Decimal(10, 2)
rating Decimal? @db.Decimal(3, 2)
tags Json?
status StoreStatus @default(PAUSED)
openTime String? @map("open_time") @db.VarChar(8)
closeTime String? @map("close_time") @db.VarChar(8)
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
account StoreAccount?
redeemRecords RedeemRecord[]
ratings StoreRating[]
payouts StorePayout[]
@@index([cityId, status])
@@index([partnerId])
@@map("store_store")
}
model StoreAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
storeId BigInt @unique @map("store_id") @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
@@map("store_account")
}
// ─── ORDER ────────────────────────────────────────────
model Order {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
orderNo String @unique @map("order_no") @db.VarChar(32)
orderType OrderType @default(NORMAL) @map("order_type")
userId BigInt @map("user_id") @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
status OrderStatus @default(PENDING_PAY)
payStatus PayStatus @default(UNPAID) @map("pay_status")
deliveryType DeliveryType @map("delivery_type")
originOrderId BigInt? @map("origin_order_id") @db.UnsignedBigInt
promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
channelSource String? @map("channel_source") @db.VarChar(128)
productId BigInt @map("product_id") @db.UnsignedBigInt
barcode69 String @map("barcode_69") @db.VarChar(32)
productName String @map("product_name") @db.VarChar(128)
productSpec String @map("product_spec") @db.VarChar(128)
imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
quantity Int
listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2)
listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
productAmount Decimal @map("product_amount") @db.Decimal(10, 2)
freightAmount Decimal @default(0) @map("freight_amount") @db.Decimal(10, 2)
freightPayType FreightPayType? @map("freight_pay_type")
payAmount Decimal @map("pay_amount") @db.Decimal(10, 2)
benefitAmount Decimal @default(0) @map("benefit_amount") @db.Decimal(10, 2)
receiverName String @map("receiver_name") @db.VarChar(32)
receiverPhone String @map("receiver_phone") @db.VarChar(20)
receiverAddress String @map("receiver_address") @db.Text
receiverProvince String @map("receiver_province") @db.VarChar(32)
receiverCity String @map("receiver_city") @db.VarChar(32)
receiverDistrict String @map("receiver_district") @db.VarChar(32)
clientIp String? @map("client_ip") @db.VarChar(45)
ipProvince String? @map("ip_province") @db.VarChar(32)
ipCity String? @map("ip_city") @db.VarChar(32)
ipDistrict String? @map("ip_district") @db.VarChar(32)
gpsProvince String? @map("gps_province") @db.VarChar(32)
gpsCity String? @map("gps_city") @db.VarChar(32)
gpsDistrict String? @map("gps_district") @db.VarChar(32)
gpsLatitude Decimal? @map("gps_latitude") @db.Decimal(10, 7)
gpsLongitude Decimal? @map("gps_longitude") @db.Decimal(10, 7)
gpsAddress String? @map("gps_address") @db.VarChar(256)
payExternalNo String? @map("pay_external_no") @db.VarChar(64)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
shippedAt DateTime? @map("shipped_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3)
cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
originOrder Order? @relation("OrderReshipment", fields: [originOrderId], references: [id], onDelete: SetNull)
reshipments Order[] @relation("OrderReshipment")
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict)
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
delivery OrderDelivery?
benefitCoupon BenefitCoupon?
@@index([userId, status])
@@index([cityId, createdAt])
@@index([productId])
@@index([barcode69])
@@index([payExternalNo])
@@index([ipCity])
@@index([gpsCity])
@@map("user_order")
}
model OrderDelivery {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
provider DeliveryProvider
providerOrderNo String? @map("provider_order_no") @db.VarChar(64)
trackingNo String? @map("tracking_no") @db.VarChar(64)
outWarehouseAt DateTime? @map("out_warehouse_at") @db.DateTime(3)
shippingAt DateTime? @map("shipping_at") @db.DateTime(3)
deliveredAt DateTime? @map("delivered_at") @db.DateTime(3)
signPhotoResourceId BigInt? @map("sign_photo_resource_id") @db.UnsignedBigInt
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
signPhotoResource CommonResource? @relation("DeliverySignPhoto", fields: [signPhotoResourceId], references: [id], onDelete: SetNull)
@@map("user_order_delivery")
}
// ─── BENEFIT & REDEEM ─────────────────────────────────
model BenefitCoupon {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
couponNo String @unique @map("coupon_no") @db.VarChar(32)
userId BigInt @map("user_id") @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
usedAmount Decimal @default(0) @map("used_amount") @db.Decimal(10, 2)
balance Decimal @db.Decimal(10, 2)
status BenefitCouponStatus @default(ACTIVE)
sourceProduct String @map("source_product") @db.VarChar(128)
version Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
redeemRecords RedeemRecord[]
@@index([userId, status])
@@map("user_benefit_coupon")
}
model RedeemRecord {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
userId BigInt @map("user_id") @db.UnsignedBigInt
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
amount Decimal @db.Decimal(10, 2)
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
rating StoreRating?
payout StorePayout?
@@index([storeId, createdAt])
@@map("user_redeem_record")
}
model StoreRating {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
serviceScore Int @map("service_score") @db.TinyInt
envScore Int @map("env_score") @db.TinyInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Cascade)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
@@map("user_store_rating")
}
model StorePayout {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
redeemAmount Decimal @map("redeem_amount") @db.Decimal(10, 2)
payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2)
settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4)
status StorePayoutStatus @default(PENDING)
expectedPayAt DateTime @map("expected_pay_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
batchNo String? @map("batch_no") @db.VarChar(32)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
redeemRecord RedeemRecord @relation(fields: [redeemRecordId], references: [id], onDelete: Restrict)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
@@index([storeId, status])
@@map("store_payout")
}
// ─── LOG ──────────────────────────────────────────────
model LogThirdParty {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
provider ThirdPartyProvider
scene String @db.VarChar(64)
refType String? @map("ref_type") @db.VarChar(32)
refId BigInt? @map("ref_id") @db.UnsignedBigInt
requestUrl String? @map("request_url") @db.VarChar(512)
requestBody Json? @map("request_body")
responseBody Json? @map("response_body")
externalNo String? @map("external_no") @db.VarChar(128)
amount Decimal? @db.Decimal(10, 2)
status ThirdPartyLogStatus @default(PENDING)
errorMessage String? @map("error_message") @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@index([refType, refId])
@@index([provider, scene, createdAt])
@@index([externalNo])
@@map("log_third_party")
}
model LogUserAnalytics {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt? @map("user_id") @db.UnsignedBigInt
sessionId String? @map("session_id") @db.VarChar(64)
eventName String @map("event_name") @db.VarChar(64)
clientApp ClientApp? @map("client_app")
pagePath String? @map("page_path") @db.VarChar(128)
refType String? @map("ref_type") @db.VarChar(32)
refId BigInt? @map("ref_id") @db.UnsignedBigInt
keyword String? @db.VarChar(128)
sourceType String? @map("source_type") @db.VarChar(32)
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
extraJson Json? @map("extra_json")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@index([userId, createdAt])
@@index([eventName, createdAt])
@@index([sessionId])
@@index([refType, refId])
@@map("log_user_analytics")
}
+248
View File
@@ -0,0 +1,248 @@
import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
const prisma = new PrismaClient();
async function createMockResource(
ownerType: ResourceOwnerType,
ownerId: bigint,
bizType: ResourceBizType,
url: string,
) {
return prisma.commonResource.create({
data: {
ownerType,
ownerId,
bizType,
mediaType: ResourceMediaType.IMAGE,
ossBucket: 'mock-dukang',
ossKey: `mock/${ownerType.toLowerCase()}/${ownerId}/${bizType.toLowerCase()}`,
url,
},
});
}
async function main() {
console.log('Seeding v3.1 data...');
await prisma.logUserAnalytics.deleteMany();
await prisma.logThirdParty.deleteMany();
await prisma.storePayout.deleteMany();
await prisma.storeRating.deleteMany();
await prisma.redeemRecord.deleteMany();
await prisma.benefitCoupon.deleteMany();
await prisma.orderDelivery.deleteMany();
await prisma.order.deleteMany();
await prisma.commonEvent.deleteMany();
await prisma.commonTicket.deleteMany();
await prisma.userPromoAttribution.deleteMany();
await prisma.userCityPreference.deleteMany();
await prisma.userAddress.deleteMany();
await prisma.user.deleteMany();
await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany();
await prisma.partnerAccount.deleteMany();
await prisma.commonCityCommissionRule.deleteMany();
await prisma.commonCity.deleteMany();
await prisma.partner.deleteMany();
await prisma.commonProductItem.deleteMany();
await prisma.commonStoreCategory.deleteMany();
await prisma.commonPromoCode.deleteMany();
await prisma.commonResource.deleteMany();
await prisma.hqAccount.deleteMany();
const partner = await prisma.partner.create({
data: {
companyName: '郑州城市合伙人',
address: '河南省郑州市金水区',
contactPhone: '13700000001',
bankAccountName: '郑州合伙人公司',
bankAccountNo: '6222021234567890',
bankBranch: '工商银行郑州分行',
},
});
const city = await prisma.commonCity.create({
data: {
code: '410100',
name: '郑州市',
province: '河南省',
status: 'ACTIVE',
partnerId: partner.id,
localMinQty: 2,
crossMinQty: 6,
},
});
await prisma.commonCityCommissionRule.create({
data: {
cityId: city.id,
orderCommissionRate: 0.05,
redeemCommissionRate: 0.03,
partnerProfitRate: 0.35,
storeSettlementRate: 0.6,
},
});
const categories = await Promise.all([
prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
]);
const productDefs = [
{ skuCode: 'QX-001', name: '杜康·白水古酿 500ml', subtitle: '清香型 52度 礼盒装', price: 599, sortOrder: 1, img: 'https://picsum.photos/seed/dukang1/400/400' },
{ skuCode: 'QX-002', name: '杜康·年份陈酿(十年)', subtitle: '清香型 42度 纯粮酿造', price: 880, sortOrder: 2, img: 'https://picsum.photos/seed/dukang2/400/400' },
{ skuCode: 'QX-003', name: '杜康·御享1号 珍藏版', subtitle: '高端定制 限量发售', price: 1299, sortOrder: 3, img: 'https://picsum.photos/seed/dukang3/400/400' },
{ skuCode: 'QX-004', name: '杜康·经典传承', subtitle: '清香型 纯粮固态', price: 399, sortOrder: 4, img: 'https://picsum.photos/seed/dukang4/400/400' },
];
const products = [];
for (const [i, def] of productDefs.entries()) {
const product = await prisma.commonProductItem.create({
data: {
skuCode: def.skuCode,
barcode69: `69000000000${i + 1}`,
name: def.name,
subtitle: def.subtitle,
aromaType: 'QINGXIANG',
spec: def.skuCode === 'QX-002' ? '500ml | 42度' : def.skuCode === 'QX-004' ? '500ml | 46度' : '500ml | 52度',
price: def.price,
benefitAmount: def.price,
status: 'ON_SALE',
sortOrder: def.sortOrder,
},
});
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
await prisma.commonProductItem.update({
where: { id: product.id },
data: { coverResourceId: cover.id },
});
products.push(product);
}
await prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone: '13700000001',
name: '郑州合伙人主账号',
isPrimary: 1,
staffRole: 'PARTNER',
},
});
const storeDefs = [
{
name: '郑州老城店',
phone: '0371-88880001',
district: '金水区',
address: '花园路100号',
intro: '正宗河南菜,欢迎核销好客权益',
img: 'https://picsum.photos/seed/store1/400/300',
categoryId: categories[0].id,
accountPhone: '13900000001',
accountName: '老城店店长',
},
{
name: '郑州美食城店',
phone: '0371-88880002',
district: '二七区',
address: '大学路200号',
intro: '地方特色餐饮',
img: 'https://picsum.photos/seed/store2/400/300',
categoryId: categories[1].id,
accountPhone: '13900000002',
accountName: '美食城店长',
},
];
for (const def of storeDefs) {
const store = await prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
categoryId: def.categoryId,
name: def.name,
phone: def.phone,
province: '河南省',
cityName: '郑州市',
district: def.district,
address: def.address,
intro: def.intro,
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
bankAccountName: def.name,
bankAccountNo: '6222029876543210',
bankBranch: '建设银行郑州分行',
},
});
const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
await prisma.storeAccount.create({
data: { storeId: store.id, phone: def.accountPhone, name: def.accountName },
});
}
await prisma.user.create({
data: {
userNo: 'DK88293401',
phone: '13800000001',
phoneVerifiedAt: new Date(),
nickname: '测试用户',
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
locateCityCode: '410100',
locateDistrict: '金水区',
},
},
},
});
await prisma.hqAccount.create({
data: {
phone: '13600000001',
name: '总部管理员',
adminRole: 'SUPER_ADMIN',
},
});
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
await prisma.partnerBill.create({
data: {
billNo: `PB${Date.now()}`,
partnerId: partner.id,
periodStart,
periodEnd,
orderCommission: 1200,
redeemCommission: 800,
totalAmount: 2000,
status: 'CONFIRMED',
confirmedAt: now,
},
});
console.log('Seed complete:', {
city: city.name,
products: products.length,
stores: storeDefs.length,
testPhones: {
user: '13800000001',
store: '13900000001',
partner: '13700000001',
hq: '13600000001',
},
});
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
@@ -1,5 +1,5 @@
/**
* 将 products.benefit_amount 同步为与 price 相同(全额好客权益)。
* 将 common_product_item.benefit_amount 同步为与 price 相同(全额好客权益)。
* 用法:pnpm db:sync-benefit
*/
import { PrismaClient } from '@prisma/client';
@@ -7,13 +7,13 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const products = await prisma.product.findMany({
const products = await prisma.commonProductItem.findMany({
select: { id: true, skuCode: true, name: true, price: true, benefitAmount: true },
orderBy: { sortOrder: 'asc' },
});
if (products.length === 0) {
console.log('No products found. Run pnpm db:seed first.');
console.log('No products found. Run pnpm prisma:seed first.');
return;
}
@@ -23,7 +23,7 @@ async function main() {
const price = Number(p.price);
const before = p.benefitAmount != null ? Number(p.benefitAmount) : null;
await prisma.product.update({
await prisma.commonProductItem.update({
where: { id: p.id },
data: { benefitAmount: p.price },
});
+4
View File
@@ -15,6 +15,8 @@ import { SettlementModule } from './modules/settlement/settlement.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JobsModule } from './jobs/jobs.module';
import { OpsModule } from './modules/ops/ops.module';
import { CommonModule } from './modules/common/common.module';
import { CallbacksModule } from './callbacks/callbacks.module';
@Module({
imports: [
@@ -38,6 +40,8 @@ import { OpsModule } from './modules/ops/ops.module';
AnalyticsModule,
JobsModule,
OpsModule,
CommonModule,
CallbacksModule,
],
})
export class AppModule {}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { IntegrationsModule } from '../integrations/integrations.module';
import { TradeModule } from '../modules/trade/trade.module';
import { WechatPayCallbackController } from './wechat-pay.controller';
@Module({
imports: [IntegrationsModule, TradeModule],
controllers: [WechatPayCallbackController],
})
export class CallbacksModule {}
@@ -0,0 +1,38 @@
import { Controller, Headers, Inject, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { TradeService } from '../modules/trade/trade.service';
import { WECHAT_PROVIDER } from '../integrations/integrations.constants';
import type { IWechatProvider } from '../integrations/wechat/wechat.interface';
type RawBodyRequest = Request & { body: Buffer };
@Controller('callbacks/wechat')
export class WechatPayCallbackController {
constructor(
private readonly tradeService: TradeService,
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
) {}
@Post('pay')
async payNotify(
@Req() req: RawBodyRequest,
@Headers() headers: Record<string, string | string[] | undefined>,
@Res() res: Response,
) {
try {
const rawBody =
(req as Request & { rawBody?: Buffer }).rawBody?.toString('utf8') ??
(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}));
const notify = await this.wechat.parsePayNotification(headers, rawBody);
await this.tradeService.handlePaySuccess({
orderNo: notify.outTradeNo,
transactionId: notify.transactionId,
amountFen: notify.amountFen,
});
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 });
}
}
}

Some files were not shown because too many files have changed in this diff Show More