diff --git a/.cursor/agents/backend-lead.md b/.cursor/agents/backend-lead.md index 5166632..3d401ce 100644 --- a/.cursor/agents/backend-lead.md +++ b/.cursor/agents/backend-lead.md @@ -50,7 +50,7 @@ Never scatter `if (process.env.MOCK_PAY)` in trade/benefit services. 1. Align with V2 manual §五 2. Notify table owner: jacy-dukang 或 刘京尧(store/redeem 相关表) 3. `pnpm db:validate` + seed still works -4. PR: jacy-dukang Review;涉及 store/redeem 表时 @刘京尧 +4. PR: jacy-dukang Review;涉及 store/redeem 表时通知刘京尧 ## Forbidden diff --git a/.cursor/agents/boundary-reviewer.md b/.cursor/agents/boundary-reviewer.md index a3d5f75..65a4d81 100644 --- a/.cursor/agents/boundary-reviewer.md +++ b/.cursor/agents/boundary-reviewer.md @@ -25,8 +25,8 @@ Inspect the diff (or named files) for: | 逻辑域 | Git 账号 | Apps | Modules | |--------|----------|------|---------| -| 主责 | jacy-dukang | h5-user, admin-web | iam, trade, benefit, analytics, catalog, settlement, ops | -| 合伙人+门店 | 刘京尧 | h5-partner, h5-shop | store, redeem | +| 主责 | jacy-dukang | h5-user, admin-web, 门店 | iam, trade, benefit, analytics, catalog, settlement, ops | +| 合伙人 | 刘京尧 | h5-partner, h5-shop | store, redeem | | 横切 | jacy-dukang | — | packages, callbacks, jobs, common, integrations | 逻辑 A/B/C/D 边界仍有效;刘京尧 同时负责 B+D,但 **store 与 redeem 模块仍不可互写表**。 diff --git a/.cursor/agents/owner-d-shop-redeem.md b/.cursor/agents/owner-d-shop-redeem.md index 6a697ee..dbef3c1 100644 --- a/.cursor/agents/owner-d-shop-redeem.md +++ b/.cursor/agents/owner-d-shop-redeem.md @@ -36,7 +36,7 @@ User (Owner A) generates token → Shop scans/confirms (you) ## Hard rules - Redis token: `redeem:token:{token}` TTL 300s -- Amount: `0 < amount ≤ min(balance, 500)` +- Amount: direct redeem `0 < amount ≤ total ACTIVE benefit balance`; document redeem `0 < amount ≤ document balance` - Transaction + coupon `version` optimistic lock - **Never** `prisma.order.update` in redeem module diff --git a/.cursor/rules/dukang-core.mdc b/.cursor/rules/dukang-core.mdc index 070ea54..9f44df1 100644 --- a/.cursor/rules/dukang-core.mdc +++ b/.cursor/rules/dukang-core.mdc @@ -41,7 +41,7 @@ alwaysApply: true ## 核心业务常量 - 权益额 = `benefit_amount ?? price` -- 核销:0 < amount ≤ min(balance, **500**) +- 核销:直接核销 `0 < amount ≤ 全部 ACTIVE 权益总余额`;带单据核销 `0 < amount ≤ 该单据可用金额` - 订单 Tab:`all | pending_pay | pending_ship | pending_receive | completed` - API:`/api/v1`,响应 `{ code, message, data }` diff --git a/.cursor/rules/frontend-h5.mdc b/.cursor/rules/frontend-h5.mdc index 0415a19..5ddef82 100644 --- a/.cursor/rules/frontend-h5.mdc +++ b/.cursor/rules/frontend-h5.mdc @@ -24,7 +24,7 @@ src/ | App | 端口 | 负责人 | 样式 | X-Client-App | |-----|------|--------|------|--------------| | h5-user | 5173 | jacy-dukang | shared-ui tokens | USER_H5 | -| h5-shop | 5174 | 刘京尧 | shared-ui tokens | SHOP_H5 | +| h5-shop | 5174 | jacy-dukang | shared-ui tokens | SHOP_H5 | | h5-partner | 5175 | 刘京尧 | shared-ui tokens | PARTNER_H5 | | admin-web | 5175 | jacy-dukang | Ant Design 5 | HQ_WEB | diff --git a/.cursor/rules/git-conventions.mdc b/.cursor/rules/git-conventions.mdc index 0524e85..c2c9067 100644 --- a/.cursor/rules/git-conventions.mdc +++ b/.cursor/rules/git-conventions.mdc @@ -11,7 +11,7 @@ Conventional Commits,scope = 端或模块名: ``` feat(trade): add order preview API -fix(redeem): enforce 500 yuan cap +fix(redeem): validate redeem amount against balance chore(shared-types): add OrderStatus enum ``` diff --git a/.cursor/rules/packages-shared.mdc b/.cursor/rules/packages-shared.mdc index ab73ae1..b3f2557 100644 --- a/.cursor/rules/packages-shared.mdc +++ b/.cursor/rules/packages-shared.mdc @@ -24,13 +24,13 @@ alwaysApply: false ## domain - 纯函数,无 IO(无 Prisma/Redis/HTTP) -- 起购 2/6 瓶、权益 `benefitAmount ?? price`、核销 ¥500 上限在此实现 +- 起购 2/6 瓶、权益 `benefitAmount ?? price`、V3 两路径核销规则在此实现 - 变更必须有单元测试 ```typescript const benefitAmount = product.benefitAmount ?? product.price; // 同城起购 2 瓶 / 跨城 6 瓶 -// 核销上限 ¥500 +// 直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额 ``` ## shared-ui diff --git a/.cursor/skills/dukang-coding/SKILL.md b/.cursor/skills/dukang-coding/SKILL.md index 691858a..c34ef10 100644 --- a/.cursor/skills/dukang-coding/SKILL.md +++ b/.cursor/skills/dukang-coding/SKILL.md @@ -81,14 +81,14 @@ await this.prisma.order.update({ ... }); ## 数据库变更 1. 改 `server/dukang-api/prisma/schema.prisma` 对齐手册 §五 -2. 迁移需 OWNER Review(store/redeem 表 → @刘京尧) +2. 迁移需 OWNER Review(store/redeem 表 → 刘景尧) 3. 初始化 SQL:`server/dukang-api/prisma/init_v3.sql` ## 核心业务(packages/domain) ```typescript const benefitAmount = product.benefitAmount ?? product.price; -// 核销:0 < amount ≤ min(balance, 500) +// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额 // 同城 min 2 瓶 / 跨城 min 6 瓶 // 支付成功 → log_third_party + user_order.pay_status=PAID diff --git a/.cursor/skills/dukang-coding/reference-backend.md b/.cursor/skills/dukang-coding/reference-backend.md index c9e5948..58e3836 100644 --- a/.cursor/skills/dukang-coding/reference-backend.md +++ b/.cursor/skills/dukang-coding/reference-backend.md @@ -36,8 +36,8 @@ Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。 | catalog | `/catalog`, `/admin/cities`, `/admin/products` | jacy-dukang | | trade | `/trade`, `/partner/orders`, `/admin/orders` | jacy-dukang | | benefit | `/benefit` | jacy-dukang | -| store | `/stores`, `/partner/stores`, `/admin/store-audits` | 刘京尧 | -| redeem | `/redeem`, `/shop/redeem` | 刘京尧 | +| store | `/stores`, `/partner/stores`, `/admin/store-audits` | 刘景尧 | +| redeem | `/redeem`, `/shop/redeem` | 刘景尧 | | settlement | `/settlement`, `/partner/settlement`, `/admin/settlement` | jacy-dukang | | ops | `/admin/dashboard`, `/admin/reports` | jacy-dukang | | analytics | `/analytics`, `/promo/touch` | jacy-dukang | @@ -48,8 +48,8 @@ Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。 | 前缀 | 示例表 | 负责人 | |------|--------|--------| | user_ | user_user, user_order, user_benefit_coupon | jacy-dukang | -| store_ | store_store, store_account, store_payout | 刘京尧 / jacy-dukang(settlement) | -| partner_ | partner_partner, partner_bill | 刘京尧 / jacy-dukang(settlement) | +| store_ | store_store, store_account, store_payout | 刘景尧 / jacy-dukang(settlement) | +| partner_ | partner_partner, partner_bill | 刘景尧 / jacy-dukang(settlement) | | hq_ | hq_account | jacy-dukang | | common_ | common_product_item, common_event, common_city | jacy-dukang | | log_ | log_third_party, log_user_analytics | 写入方 Module | diff --git a/.cursor/skills/dukang-coding/reference-frontend.md b/.cursor/skills/dukang-coding/reference-frontend.md index 4195ee8..4dcb79b 100644 --- a/.cursor/skills/dukang-coding/reference-frontend.md +++ b/.cursor/skills/dukang-coding/reference-frontend.md @@ -7,8 +7,8 @@ | App | 目录 | 端口 | 负责人 | X-Client-App | 原型 | |-----|------|------|--------|--------------|------| | h5-user | apps/h5-user | 5173 | jacy-dukang | USER_H5 | pages/user/ | -| h5-shop | apps/h5-shop | 5174 | 刘京尧 | SHOP_H5 | pages/shop/ | -| h5-partner | apps/h5-partner | 5175 | 刘京尧 | PARTNER_H5 | pages/partner/ | +| h5-shop | apps/h5-shop | 5174 | 刘景尧 | SHOP_H5 | pages/shop/ | +| h5-partner | apps/h5-partner | 5175 | 刘景尧 | PARTNER_H5 | pages/partner/ | | admin-web | apps/admin-web | 5175 | jacy-dukang | HQ_WEB | pages/hq/ | > **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。 @@ -51,7 +51,7 @@ H5 三端引用 `@dukang/shared-ui`(`tokens.css`)。admin-web 使用 Ant Des all | pending_pay | pending_ship | pending_receive | completed ``` -## h5-shop 路由(刘京尧) +## h5-shop 路由(刘景尧) | 路由 | Page | 主要 API | |------|------|----------| @@ -63,7 +63,7 @@ all | pending_pay | pending_ship | pending_receive | completed | /status | StatusPage | /shop/store | | /mine | MinePage | /shop/store | -## h5-partner 路由(刘京尧) +## h5-partner 路由(刘景尧) | 路由 | Page | 主要 API | |------|------|----------| diff --git a/.cursor/skills/dukang-task-card/SKILL.md b/.cursor/skills/dukang-task-card/SKILL.md index 6deacb9..7ca7621 100644 --- a/.cursor/skills/dukang-task-card/SKILL.md +++ b/.cursor/skills/dukang-task-card/SKILL.md @@ -31,8 +31,8 @@ description: >- |------|--------|---------| | C 端 FE、h5-user、交易后端 | jacy-dukang | P1-M1-002, M2-* | | admin、catalog、settlement | jacy-dukang | M1-BE-CAT-* | -| 合伙人、store | 刘京尧 | P1-M4-001 | -| 门店核销、h5-shop | 刘京尧 | P1-M3-002 | +| 合伙人、store | 刘景尧 | P1-M4-001 | +| 门店核销、h5-shop | 刘景尧 | P1-M3-002 | | Monorepo/integrations | jacy-dukang | P1-M0-* | ## preV1 任务卡(精简) @@ -47,7 +47,7 @@ description: >- | P1-M2-001 | 起购校验 2/6 瓶 | | P1-M2-002 | Mock 支付发券 | | P1-M2-003 | 5 Tab 含 pending_ship | -| P1-M3-001 | 核销 ¥500 上限 | +| P1-M3-001 | V3 两路径核销金额限制 | | P1-M3-002 | 门店扫码核销 | | P1-M4-001 | 录店 AUTO_APPROVE → C 端可见 | | P1-M5-001 | 配送自动推进到 COMPLETED | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b4357d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main, master, develop] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install + - run: pnpm db:generate + - run: pnpm test + - run: pnpm lint diff --git a/AGENTS.md b/AGENTS.md index deddf75..b6e5b9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,9 @@ | 阶段 | 手册 | 说明 | |------|------|------| -| **当前 preV1** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | 三端 H5 + Mock 联调,同库同 API | -| **目标 V2** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 四端小程序 + 真实第三方(唯一事实源) | +| **当前 V3 交付** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 业务闭环交付验收标准 | +| **preV1 历史** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | Mock 联调裁剪(历史参考) | +| **V2 蓝图** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 完整产品蓝图(与 V3 冲突时 V3 优先) | **禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。 @@ -33,6 +34,7 @@ pnpm dev:partner # :5175 pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序) # 验证 +node scripts/smoke-v3.mjs node scripts/smoke-prev1.mjs pnpm lint && pnpm test ``` @@ -43,27 +45,28 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。 ## 文档优先级(冲突时) -1. `杜康好客-V2编码手册.md` §二 — 业务规则 -2. `杜康好客-preV1编码手册.md` — preV1 裁剪(Mock / Flag) -3. [`conventions.md`](./conventions.md) — 协作与模块边界 -4. V2 手册 §四 §五 §六 — 架构 / DB / API -5. `pages/{user,shop,partner}/` + [`pages/ROUTE_MAP.md`](./pages/ROUTE_MAP.md) — UI 参照 +1. `杜康好客-v3编码手册.md` — V3 交付业务规则与验收 +2. `杜康好客-V2编码手册.md` §二 — 完整蓝图(与 V3 冲突时 V3 优先) +3. `杜康好客-preV1编码手册.md` — preV1 裁剪(历史参考) +4. [`conventions.md`](./conventions.md) — 协作与模块边界 +5. V2 手册 §四 §五 §六 — 架构 / DB / API +6. `pages/{user,shop,partner}/` + [`pages/ROUTE_MAP.md`](./pages/ROUTE_MAP.md) — UI 参照 ## 团队与 OWNER 边界(2 人) | 负责人 | Git 账号 | 职责 | |--------|----------|------| | **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review | -| **刘京尧** | `刘京尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块 | +| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块 | 逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**人员合并**如下: | 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module | |------------|--------|----------|-------------| | **A + C + Lead** | jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | `iam`, `trade`, `benefit`, `analytics`, `catalog`, `settlement`, `ops` | -| **B + D** | 刘京尧 | `apps/h5-partner/`, `apps/h5-shop/` | `store`, `redeem` | +| **B + D** | 刘景尧 | `apps/h5-partner/`, `apps/h5-shop/` | `store`, `redeem` | -**Prisma 迁移**:jacy-dukang 主 Review;若改 `store_*` / 核销相关表,需 `@刘京尧` 共同 Review。 +**Prisma 迁移**:jacy-dukang 主 Review;若改 `store_*` / 核销相关表,需刘景尧共同 Review。 ### 跨模块规则(R1–R8 摘要) @@ -91,8 +94,8 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。 | `owner-a-user-trade` | jacy-dukang | C 端 H5、订单、支付、权益、埋点 | | `owner-c-catalog-ops` | jacy-dukang | admin-web、开城商品、结算、运营 | | `backend-lead` | jacy-dukang | packages、callbacks、jobs、Prisma 横切 | -| `owner-b-partner-store` | 刘京尧 | 合伙人 H5、门店 CRUD/审核 | -| `owner-d-shop-redeem` | 刘京尧 | 门店 H5、核销 | +| `owner-b-partner-store` | 刘景尧 | 合伙人 H5、门店 CRUD/审核 | +| `owner-d-shop-redeem` | 刘景尧 | 门店 H5、核销 | | `boundary-reviewer` | — | PR 前只读审查跨模块违规 | ## Skills(按需 @) @@ -116,7 +119,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。 ``` 权益额 = benefit_amount ?? price 同城起购 2 瓶 / 跨城 6 瓶 -核销:0 < amount ≤ min(balance, 500);Redis 码 5 分钟 +核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 5 分钟 C 端门店仅 status=OPEN 订单 Tab:all | pending_pay | pending_ship | pending_receive | completed ``` @@ -124,7 +127,7 @@ C 端门店仅 status=OPEN ## 提交与 PR - Conventional Commits:`feat(trade):`、`fix(redeem):`;scope = 端或模块 -- 跨模块 PR → jacy-dukang + 刘京尧 共同 Review(若涉及双方模块) +- 跨模块 PR → jacy-dukang + 刘景尧 共同 Review(若涉及双方模块) - 改 API/表 → 同步 V2 手册 §五/§六 + `shared-types` - 不提交 `.env`、`dist/`、`node_modules/` diff --git a/README.md b/README.md index 8eabd17..67a40e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# 杜康好客 preV1 +# 杜康好客 V3 交付版 -三端 H5 + NestJS Mock 联调版。完整规格见 `杜康好客-V2编码手册.md`,preV1 裁剪见 `杜康好客-preV1编码手册.md`。 +四端 H5/WebAdmin + NestJS 单体 API。**V3 交付验收**以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准;V2 为完整蓝图参考;preV1 为 Mock 联调历史参考。 ## 快速启动 @@ -53,9 +53,12 @@ pnpm dev:partner # http://localhost:5175 ## 主链路冒烟 ```bash -node scripts/smoke-prev1.mjs +node scripts/smoke-v3.mjs # V3 交付验收 +node scripts/smoke-prev1.mjs # preV1 历史回归 ``` +验收清单见 [`V3_ACCEPTANCE_CHECKLIST.md`](./V3_ACCEPTANCE_CHECKLIST.md)。 + ## 目录结构 - `apps/h5-user` · `apps/h5-shop` · `apps/h5-partner` — 三端 H5(Vite + React) diff --git a/V3_ACCEPTANCE_CHECKLIST.md b/V3_ACCEPTANCE_CHECKLIST.md new file mode 100644 index 0000000..bdd1607 --- /dev/null +++ b/V3_ACCEPTANCE_CHECKLIST.md @@ -0,0 +1,52 @@ +# V3 验收清单 + +> 业务事实源:[杜康好客-v3编码手册.md](./杜康好客-v3编码手册.md) +> 自动化:`node scripts/smoke-v3.mjs`(Mock 环境) +> 历史回归:`node scripts/smoke-prev1.mjs`(preV1 单链路) + +## 必过主链路 + +| # | 验收项 | Owner | API / 页面 | 自动化 | +|---|--------|-------|------------|--------| +| 1 | C 端手机号登录 | jacy | `POST /auth/login/sms` · h5-user `/login` | smoke-v3 | +| 2 | 首页郑州 4 商品 | jacy | `GET /catalog/products?cityCode=` · h5-user `/` | smoke-v3 | +| 3 | 同城 1 瓶失败 | jacy | `POST /trade/orders/preview` | smoke-v3 | +| 4 | 同城 2 瓶成功 | jacy | `POST /trade/orders` | smoke-v3 | +| 5 | 跨城 5/6 瓶边界 | jacy | preview + create | smoke-v3 | +| 6 | 支付后发权益 | jacy | pay + benefit | smoke-v3 | +| 7 | 直接核销(无 couponId) | jacy + 刘京尧 | `POST /redeem/tokens` | smoke-v3 | +| 8 | 单据核销 cap | jacy + 刘京尧 | `POST /redeem/tokens` + couponId | smoke-v3 | +| 9 | 门店扫码确认 | 刘京尧 | `POST /shop/redeem/confirm` · h5-shop | smoke-v3 | +| 10 | store_payout 生成 | jacy | settlement | smoke-v3 | +| 11 | 关闭门店不可核销 | 刘京尧 | store status + redeem | smoke-v3 | +| 12 | 录店审核后 C 端可见 | 刘京尧 + jacy | admin stores audit · h5-partner | 手动 | +| 13 | 配送推进完成 | jacy | jobs / delivery callback | smoke-v3 | +| 14 | 退款工单一致 | jacy | tickets + benefit void | smoke-v3 | +| 15 | 门店 T+1 打款确认 | jacy | admin store-payouts | smoke-v3 | +| 16 | 合伙人 T+30 账单 | jacy | admin partner-bills | smoke-v3 | + +## 必过后台链路 + +| # | 验收项 | 页面 | +|---|--------|------| +| 1 | WebAdmin 登录 | admin-web `/login` | +| 2 | 商品 CRUD | `/products` | +| 3 | 审核门店 | `/stores` | +| 4 | 订单与配送 | `/orders` · `/deliveries` | +| 5 | 权益/核销/打款 | `/benefit/*` · `/redeem-records` · `/store-payouts` | +| 6 | 退款/补发工单 | `/tickets` | +| 7 | 第三方日志 | `/third-party-logs` | +| 8 | 财务导出 | `/partner-bills` export | + +## 环境矩阵 + +| 环境 | MOCK_SMS | MOCK_PAY | MOCK_DELIVERY_AUTO | 说明 | +|------|----------|----------|-------------------|------| +| 本地开发 | true | true | true | 固定验证码 123456 | +| 测试沙箱 | false | false | false | 微信/配送沙箱 | +| 生产 | false | false | false | 真实第三方 | + +## 合伙人端交接(刘京尧) + +Jacy 侧交付:`/partner/*` 后端 API、shared-types DTO、smoke 断言。 +刘京尧负责:`apps/h5-partner` 页面接入与验收。 diff --git a/agent.md b/agent.md index 6e85a58..ff0be89 100644 --- a/agent.md +++ b/agent.md @@ -81,7 +81,7 @@ | 负责人 | Git 账号 | App(preV1 → V2) | Module | |--------|----------|-------------------|--------| | Jacy | jacy-dukang | h5-user、admin-web → mini-user/mini-hq | iam, trade, benefit, analytics, catalog, settlement, ops | -| 刘京尧 | 刘京尧 | h5-partner、h5-shop → mini-partner | store, redeem | +| 刘景尧 | 刘景尧 | h5-partner、h5-shop → mini-partner | store, redeem | | Jacy(横切) | jacy-dukang | packages/*, callbacks/, jobs/, integrations/ | 基础设施 | **禁止**:Module A 直写 Module B 的 Prisma 表;apps import server 源码。 @@ -93,7 +93,7 @@ ```text 权益发放额 = common_product_item.benefit_amount ?? price 同城起购 2 瓶 / 跨城 6 瓶 -核销:0 < amount ≤ min(balance, 500) +核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额 C 端门店列表仅 status=OPEN 订单 Tab:all | pending_pay | pending_ship | pending_receive | completed 支付回调幂等 → 发券 → common_event(BENEFIT_LEDGER, GRANT) diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 59250e4..68ed792 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -7,8 +7,8 @@ | App | 端口 | X-Client-App | 负责人 | 原型 | |-----|------|--------------|--------|------| | `h5-user` | 5173 | `USER_H5` | jacy-dukang | `pages/user/` | -| `h5-shop` | 5174 | `SHOP_H5` | 刘京尧 | `pages/shop/` | -| `h5-partner` | 5175 | `PARTNER_H5` | 刘京尧 | `pages/partner/` | +| `h5-shop` | 5174 | `SHOP_H5` | 刘景尧 | `pages/shop/` | +| `h5-partner` | 5175 | `PARTNER_H5` | 刘景尧 | `pages/partner/` | | `admin-web` | — | (Admin JWT) | jacy-dukang | preV1 内部 HQ 替代 | V2 目标:`mini-user` / `mini-partner` / `mini-hq` 替换对应 H5(除门店仍 H5)。 @@ -36,13 +36,13 @@ V2 目标:`mini-user` / `mini-partner` / `mini-hq` 替换对应 H5(除门店 **勿改**:门店核销确认 UI(属 h5-shop)、合伙人录店(属 h5-partner) -### h5-shop(刘京尧) +### h5-shop(刘景尧) 主链路:门店登录 → 首页 → 扫码/输入核销 → 确认 → 记录 → 营业状态 **勿改**:C 端出码页面(属 h5-user) -### h5-partner(刘京尧) +### h5-partner(刘景尧) 主链路:登录 → 工作台 → 录店 → 门店列表 → 辖区订单 → Mock 推进配送(preV1) diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 9b68121..268d91a 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -18,6 +18,10 @@ import CitiesPage from './pages/CitiesPage'; import StoreMediaPage from './pages/StoreMediaPage'; import ProductsPage from './pages/ProductsPage'; import ResourcesPage from './pages/ResourcesPage'; +import StorePayoutsPage from './pages/StorePayoutsPage'; +import PartnerBillsPage from './pages/PartnerBillsPage'; +import TicketsPage from './pages/TicketsPage'; +import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; function RequireAuth({ children }: { children: React.ReactNode }) { if (!getToken()) return ; @@ -49,6 +53,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index e9541f8..e6f5e98 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -52,8 +52,12 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/benefit/coupons', label: '权益券' }, { key: '/benefit/ledgers', label: '流水' }, { key: '/redeem-records', label: '核销记录' }, + { key: '/store-payouts', label: '门店打款' }, ], }, + { key: '/partner-bills', icon: , label: '合伙人结算' }, + { key: '/tickets', icon: , label: '工单中心' }, + { key: '/third-party-logs', icon: , label: '第三方日志' }, { key: '/deliveries', icon: , label: '配送单' }, { key: '/hq-accounts', icon: , label: 'HQ账户' }, ]; diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx new file mode 100644 index 0000000..a1d90f9 --- /dev/null +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { Button, Form, Input, Select, Table, Typography, message, Modal, DatePicker } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + id: string; + billNo: string; + orderCommission: number; + redeemCommission: number; + totalAmount: number; + status: string; + periodStart: string; + periodEnd: string; + partner?: { companyName: string }; +}; + +export default function PartnerBillsPage() { + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/partner-bills', + () => { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.partnerId) qs.set('partnerId', filters.partnerId); + return qs; + }, + [filters], + ); + const [genOpen, setGenOpen] = useState(false); + const [genForm] = Form.useForm(); + + async function generateBill(values: { partnerId: string; month: dayjs.Dayjs }) { + await request('/admin/partner-bills/generate', { + method: 'POST', + body: JSON.stringify({ + partnerId: values.partnerId, + year: values.month.year(), + month: values.month.month() + 1, + }), + }); + message.success('账单已生成'); + setGenOpen(false); + reload(); + } + + async function confirmBill(id: string) { + await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' }); + message.success('已确认'); + reload(); + } + + async function markPaid(id: string) { + await request(`/admin/partner-bills/${id}/mark-paid`, { + method: 'POST', + body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), + }); + message.success('已标记打款'); + reload(); + } + + async function exportCsv() { + const result = await request<{ csv: string }>('/admin/partner-bills/export'); + const blob = new Blob([result.csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'partner-bills.csv'; + a.click(); + URL.revokeObjectURL(url); + } + + const columns: ColumnsType = [ + { title: '账单号', dataIndex: 'billNo', width: 180 }, + { title: '合伙人', dataIndex: ['partner', 'companyName'] }, + { title: '订单佣金', dataIndex: 'orderCommission', width: 100, render: (v) => `¥${v}` }, + { title: '核销佣金', dataIndex: 'redeemCommission', width: 100, render: (v) => `¥${v}` }, + { title: '合计', dataIndex: 'totalAmount', width: 100, render: (v) => `¥${v}` }, + { title: '状态', dataIndex: 'status', width: 90 }, + { title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}` }, + { + title: '操作', width: 180, + render: (_, row) => ( + <> + {row.status === 'DRAFT' && } + {row.status === 'CONFIRMED' && } + + ), + }, + ]; + + return ( +
+ 合伙人结算(T+30) +
+ + +
+
{ setFilters(v); setPage(1); }}> + + + +
+ { setPage(p); setPageSize(ps); } }} /> + setGenOpen(false)} footer={null}> +
+ + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/StorePayoutsPage.tsx b/apps/admin-web/src/pages/StorePayoutsPage.tsx new file mode 100644 index 0000000..ec4bf8c --- /dev/null +++ b/apps/admin-web/src/pages/StorePayoutsPage.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { Button, Descriptions, Drawer, Form, Input, Select, Table, Typography, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + id: string; + redeemAmount: number; + payoutAmount: number; + status: string; + expectedPayAt: string; + paidAt?: string; + store?: { name: string; cityName: string }; + redeemRecord?: { redeemNo: string }; +}; + +export default function StorePayoutsPage() { + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/store-payouts', + () => { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.storeId) qs.set('storeId', filters.storeId); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + async function confirmPayout(id: string) { + await request(`/admin/store-payouts/${id}/confirm`, { + method: 'POST', + body: JSON.stringify({ remark: '财务确认打款' }), + }); + message.success('已确认打款'); + reload(); + } + + const columns: ColumnsType = [ + { title: '门店', dataIndex: ['store', 'name'] }, + { title: '城市', dataIndex: ['store', 'cityName'], width: 100 }, + { title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` }, + { title: '打款额', dataIndex: 'payoutAmount', width: 90, render: (v) => `¥${v}` }, + { title: '状态', dataIndex: 'status', width: 90 }, + { title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime }, + { title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') }, + { + title: '操作', width: 140, + render: (_, row) => ( + <> + + {row.status === 'PENDING' && ( + + )} + + ), + }, + ]; + + return ( +
+ 门店打款(T+1) +
{ setFilters(v); setPage(1); }}> + + + + +
{ setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)}> + {detail && ( + + ¥{String(detail.redeemAmount)} + ¥{String(detail.payoutAmount)} + {String(detail.status)} + {fmtTime(String(detail.expectedPayAt))} + + )} + + + ); +} diff --git a/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx new file mode 100644 index 0000000..463fe72 --- /dev/null +++ b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + id: string; + provider: string; + scene: string; + refType: string; + refId: string; + externalNo?: string; + status: string; + createdAt: string; +}; + +export default function ThirdPartyLogsPage() { + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList( + '/common/third-party-logs', + () => { + const qs = new URLSearchParams(); + if (filters.provider) qs.set('provider', filters.provider); + if (filters.refId) qs.set('refId', filters.refId); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + const columns: ColumnsType = [ + { title: 'Provider', dataIndex: 'provider', width: 120 }, + { title: '场景', dataIndex: 'scene', width: 120 }, + { title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` }, + { title: '外部单号', dataIndex: 'externalNo', width: 180 }, + { title: '状态', dataIndex: 'status', width: 90 }, + { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', width: 80, + render: (_, row) => ( + + ), + }, + ]; + + return ( +
+ 第三方日志 +
{ setFilters(v); setPage(1); }}> + + + + +
{ setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)}> + {detail && ( + + {String(detail.provider)} + {String(detail.scene)} + {String(detail.status)} + {String(detail.externalNo ?? '—')} + {fmtTime(String(detail.createdAt))} + + )} + + + ); +} diff --git a/apps/admin-web/src/pages/TicketsPage.tsx b/apps/admin-web/src/pages/TicketsPage.tsx new file mode 100644 index 0000000..30a4f6e --- /dev/null +++ b/apps/admin-web/src/pages/TicketsPage.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + id: string; + ticketNo: string; + ticketType: string; + status: string; + refType: string; + refId: string; + remark?: string; + createdAt: string; +}; + +export default function TicketsPage() { + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/tickets', + () => { + const qs = new URLSearchParams(); + if (filters.ticketType) qs.set('ticketType', filters.ticketType); + if (filters.status) qs.set('status', filters.status); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState | null>(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + async function approve(id: string) { + await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }); + message.success('已审批通过'); + reload(); + setDrawerOpen(false); + } + + async function reject(id: string) { + await request(`/admin/tickets/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ remark: '驳回' }), + }); + message.success('已驳回'); + reload(); + setDrawerOpen(false); + } + + const columns: ColumnsType = [ + { title: '工单号', dataIndex: 'ticketNo', width: 180 }, + { title: '类型', dataIndex: 'ticketType', width: 100 }, + { title: '状态', dataIndex: 'status', width: 90 }, + { title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` }, + { title: '备注', dataIndex: 'remark', ellipsis: true }, + { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', width: 80, + render: (_, row) => ( + + ), + }, + ]; + + return ( +
+ 工单中心 +
{ setFilters(v); setPage(1); }}> + + + + +
{ setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)} + extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? ( + <> + + + + ) : null}> + {detail && ( + + {String(detail.ticketNo)} + {String(detail.ticketType)} + {String(detail.status)} + {String(detail.refType)} #{String(detail.refId)} + {String(detail.remark ?? '—')} + + )} + + + ); +} diff --git a/apps/h5-partner/src/App.tsx b/apps/h5-partner/src/App.tsx index 9f4f4c7..a14e454 100644 --- a/apps/h5-partner/src/App.tsx +++ b/apps/h5-partner/src/App.tsx @@ -22,6 +22,7 @@ function WechatOAuthHandler() { useEffect(() => { if (!isWechatEnv() || !location.search.includes('code=')) return; + if (location.pathname === '/login') return; void handlePartnerWechatCallback() .then((result) => { if (!result || !savePartnerWechatAuth(result)) return; diff --git a/apps/h5-partner/src/pages/LoginPage.tsx b/apps/h5-partner/src/pages/LoginPage.tsx index f1641e4..2f306e8 100644 --- a/apps/h5-partner/src/pages/LoginPage.tsx +++ b/apps/h5-partner/src/pages/LoginPage.tsx @@ -1,6 +1,12 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { request, saveAuth } from '../lib/api'; +import { + authorizePartnerWechat, + handlePartnerWechatCallback, + savePartnerWechatAuth, +} from '../lib/wechat-auth'; +import { isWechatEnv } from '../lib/weixin'; export default function LoginPage() { const navigate = useNavigate(); @@ -9,7 +15,21 @@ export default function LoginPage() { const [phone, setPhone] = useState('13700000001'); const [code, setCode] = useState('123456'); const [loading, setLoading] = useState(false); + const [wechatLoading, setWechatLoading] = useState(false); const [codeCooldown, setCodeCooldown] = useState(0); + const [msg, setMsg] = useState(''); + + useEffect(() => { + if (!isWechatEnv()) return; + void handlePartnerWechatCallback() + .then((result) => { + if (!result) return; + if (savePartnerWechatAuth(result)) { + navigate('/'); + } + }) + .catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败')); + }, [navigate]); async function login() { setLoading(true); @@ -29,6 +49,21 @@ export default function LoginPage() { } } + async function wechatLogin() { + setMsg(''); + if (!isWechatEnv()) { + setMsg('请在微信内打开以使用微信一键登录'); + return; + } + setWechatLoading(true); + try { + await authorizePartnerWechat(); + } catch (e) { + setMsg(e instanceof Error ? e.message : '微信登录失败'); + setWechatLoading(false); + } + } + function sendCode() { if (codeCooldown > 0) return; request('PARTNER_H5', '/partner/auth/sms/send', { @@ -122,10 +157,17 @@ export default function LoginPage() { {!loading && arrow_forward}
其他登录方式
- + {msg &&

{msg}

}