6 Commits

Author SHA1 Message Date
jacy 7304c7a8e1 chore(ops): 增加生产库备份/回滚与按 isTest 清理脚本
只入库通用运维能力,不提交写死手机号的一次性清理脚本与 dump。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 22:03:35 +08:00
jacy a3f17cbf94 v3.5.7版本迭代
CI / verify (pull_request) Waiting to run
门店端UI修改
2026-08-23 19:26:12 +08:00
jacy 114d77ca48 修改订单导出时间错误
CI / verify (pull_request) Waiting to run
2026-08-23 17:33:20 +08:00
jacy 7e12eb3ca6 v3.5.6版本迭代
CI / verify (pull_request) Waiting to run
2026-08-23 16:41:22 +08:00
jacy 608e3e5ec5 整理发布脚本 2026-08-23 14:37:36 +08:00
jacy d98b0aefb0 整理上下文 2026-08-23 14:37:29 +08:00
67 changed files with 2983 additions and 1580 deletions
-55
View File
@@ -1,55 +0,0 @@
---
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` 等),编码以 **当前实际目录** 为准。
## 文档
- **V3.0 业务事实源**`杜康好客-v3-PRD.md`
- V3 实现验收:`杜康好客-v3编码手册.md`
- V2 蓝图:`杜康好客-V2编码手册.md`(与 V3 冲突时 V3 PRD 优先)
- preV1 裁剪:`杜康好客-preV1编码手册.md`
- 协作:`conventions.md` · AI 入口:`AGENTS.md`
**禁止**:编码时修改业务规则;需求变更只改 `杜康好客-v3-PRD.md`。
## 边界(2 人团队)
> **临时(2026-07**:刘京尧任务由 `jacy-dukang` 代管,B+D 路径可改。
| 负责人 | 路径 |
|--------|------|
| jacy-dukang | `apps/*`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, 全部 `modules/*` |
| ~~刘京尧~~ | ~~`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 ≤ 全部 ACTIVE 权益总余额`;带单据核销 `0 < amount ≤ 该单据可用金额`Redis 码 3 分钟
- 订单 Tab(V3.0):`待付款 | 已付款 | 已完成`
- API`/api/v1`,响应 `{ code, message, data }`
## 提交
- Conventional Commits`feat(trade):` 等
- 不提交 `.env`
+6 -28
View File
@@ -1,33 +1,11 @@
---
description: Git 提交与 PR 协作规范
description: 杜康提交 scope 与发版默认(生产)
alwaysApply: true
---
# Git 规范
# Git / 发版(杜康专属)
## 提交格式
Conventional Commitsscope = 端或模块名:
```
feat(trade): add order preview API
fix(redeem): validate redeem amount against balance
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/dev
- 发版:`dev`→测试(staging)`main`→生产;用户只说「发布/发版」默认发**生产**(见 dukang-release skill);明确说「发测试」才发 staging
- Conventional Commitsscope = 端或模块:`feat(trade):`、`fix(redeem):`
- 只说「发布/发版」→ 生产(`main` + `deploy-prod.sh`);明确说「发测试」才 staging。流程见 `/dukang-release`
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
- Prisma 迁移须 Review
+1 -1
View File
@@ -21,7 +21,7 @@ disable-model-invocation: true
## 会话启动(按序)
1. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`preV1 → preV1 手册;V2 蓝图 → V2 手册
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER **只改本 OWNER 路径**
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 段 + 对应 `apps/``server/dukang-api/AGENTS.md`**只改本 OWNER 路径**
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
5. 编码前过一遍下方 Checklist
+1
View File
@@ -5,6 +5,7 @@ description: >-
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.
disable-model-invocation: true
---
# 杜康好客 · preV1 Mock Skill
+1
View File
@@ -4,6 +4,7 @@ description: >-
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
or @杜康发布 / @dukang-release.
disable-model-invocation: true
---
# 杜康发布
+1
View File
@@ -4,6 +4,7 @@ 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.
disable-model-invocation: true
---
# 杜康好客 · 任务卡 Skill
+1
View File
@@ -25,3 +25,4 @@ server/dukang-api/assets/fonts/*.ttf
server/dukang-api/assets/fonts/*.ttc
server/dukang-api/assets/fonts/*.otf
deploy/auto-release.env
deploy/backups/
+26 -150
View File
@@ -1,133 +1,37 @@
# 杜康好客 · AGENTS.md
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载
> AI 编码入口。人类读 [`agent.md`](./agent.md)。启动、端口、测试账号见 [`README.md`](./README.md)
## 项目概览
杜康酒业 O2O:**购酒 → 发券 → 门店核销**。Monorepo + NestJS 单体(`/api/v1``{ code, message, data }`)。
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
| 路径 | 说明 |
|------|------|
| `apps/` | `h5-user` · `h5-shop` · `h5-partner` · `admin-web` · `mini-user` |
| `packages/` | `shared-types`(枚举/DTO)· `domain`(纯规则,无 IO)· `shared-ui` |
| `server/dukang-api/` | 唯一 API |
| `pages/` | UI 原型,只读 |
| 阶段 | 手册 | 说明 |
|------|------|------|
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
| ~~V2 / preV1~~ | 历史参考(**已压缩**V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
前端:[`apps/AGENTS.md`](./apps/AGENTS.md)。后端模块/表 OWNER[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)。
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
## 文档(冲突时)
## 启动命令
```bash
# 基础设施
cd deploy && docker compose up -d # dukang-v1MySQL :6016 Redis :6017
# 依赖与数据库
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:3010/api/v1
pnpm dev:user # :5173
pnpm dev:shop # :5174
pnpm dev:partner # :5175
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
# 验证
node scripts/smoke-v3.mjs
node scripts/smoke-prev1.mjs
pnpm lint && pnpm test
```
Mock 验证码:`999888`。测试账号见 [`README.md`](./README.md)。
> **端口冲突**`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
## 文档优先级(冲突时)
1. `杜康好客-v3-PRD.md`**唯一需求事实源**
2. `杜康好客-v3-现状对照.md` — 实现进度审计
3. `杜康好客-v3编码手册.md` — 实现验收、核销、分工
4. `conventions.md` — 协作与模块边界
5. V2 手册 §四 §五 §六 — 仅作架构/DB/API **参考**(与 V3 冲突时忽略)
1. `杜康好客-v3-PRD.md`**唯一需求事实源**(编码时禁止改业务规则;需求变更只改它)
2. `杜康好客-v3-现状对照.md` — 进度
3. `杜康好客-v3编码手册.md` — 验收、核销、分工
4. `conventions.md` — 协作边界
5. V2 手册 §四–§六 — 架构/DB/API **参考**(与 V3 冲突则忽略)
6. `pages/{user,shop,partner,hq}/` — UI 参照
> **V2 / preV1 手册不再作需求依据。**
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 过时文档;`apps/*` import `server/*`;跨模块 `prisma` 写他人表。V2 / preV1 手册不再作需求依据。
## 团队与 OWNER 边界(2 人
## OWNER2026-07 起暂由 jacy 代管全部
| 负责人 | Git 账号 | 职责 |
|--------|----------|------|
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管** |
`jacy-dukang` 可改 `apps/*``packages/*`、全部后端模块。逻辑边界仍按 A/B/C/D,便于子 Agent 隔离。Prisma 迁移由 jacy 主导。
> **临时分工(2026-07**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。
- 跨模块只 inject 对方 **exported Service**
- Mock 只在 `integrations/*`;微信/配送回调只进 `callbacks/`
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**当前人员合并**如下:
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|------------|--------|----------|-------------|
| **A + C + Lead + B + D** | jacy-dukang | `apps/*`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | 全部后端模块 |
| ~~B + D~~ | ~~刘景尧~~ | — | 恢复分工前由 jacy 代管 |
**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 |
| V3.0 需求 Skill | `.cursor/skills/dukang-v3/` | @dukang-v3PRD、Wave、REQ/ACC |
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
## 子 Agent(按职责选用)
在 Cursor Agent 输入 `/` 选择:
| 子 Agent | 负责人 | 适用场景 |
|----------|--------|----------|
| `v3-delivery-lead` | jacy-dukang | V3.0 三波交付统筹、DLV 拆分、跨 Owner 协调 |
| `v3-req-reviewer` | — | V3.0 需求只读审查、REQ/ACC 缺口分析 |
| `owner-a-user-trade` | jacy-dukang | C 端、订单、支付、权益、埋点 |
| `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-v3` | V3.0 PRD、现状对照、Wave/REQ/ACC |
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令
- 现状审计:`@dukang-v3` + [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
- 实现 DLV 任务:`@dukang-coding 实现 DLV-W1-M2`
- 波次规划:`/v3-delivery-lead 拆分 Wave 2 任务`
- 需求缺口审查:`/v3-req-reviewer 审查门店提现`
- PR 前审查:`/boundary-reviewer`
- 原型参照:`pages/{user,shop,partner}/`(只读)
## 核心业务常量(不可偏离)
## 不可偏离
```
权益额 = benefit_amount ?? price
@@ -137,38 +41,10 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成
```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」
**微信小程序 open-type**`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`
**微信小程序页面标题**weapp 只用原生 `navigationBarTitleText`,不要再画一层与导航栏重复的 `SubPageHeader` title;见 `.cursor/rules/mini-user-weapp-nav-title.mdc`
微信:iOS H5 登录后禁止仅 SPA 跳转再扫码`packages/weixin-sdk/GOTCHAS.md`)。小程序 `open-type` 按钮祖先禁止 `stopPropagation`weapp 标题只用 `navigationBarTitleText``h5-partner``admin-web` 同端口 5175,勿同时开
## 环境与发版
## 按需加载
| 环境 | 分支 | 目录 | 端口 | 域名 |
|------|------|------|------|------|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3010` | — |
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
- 日常发布 → `deploy/deploy-staging.sh``dev`
- 生产发布 → `deploy/deploy-prod.sh``main`,验收后)
- Staging:正式微信 + 全 Mock;配置见 `.env.staging.example`
- 详情:`@dukang-release` / `.cursor/skills/dukang-release/SKILL.md`
## 提交与 PR
- Conventional Commits`feat(trade):``fix(redeem):`scope = 端或模块
- 跨模块 PR → jacy-dukang + 刘景尧 共同 Review(若涉及双方模块)
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `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)
- 编码 `/dukang-coding` · V3 需求 `/dukang-v3` · 发版 `/dukang-release`(只说「发版」= 生产)
- 子 Agent`/owner-a-user-trade` 等(见 `.cursor/agents/`
- DoD:验收项满足;不跨模块直写表;类型在 `shared-types`、规则在 `domain`;相关 lint/单测过
@@ -23,6 +23,11 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
cityName?: string;
address?: string;
partnerAccount?: { companyName?: string };
primaryAccount?: {
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} | null;
}
| undefined;
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
@@ -46,6 +51,15 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
</Descriptions.Item>
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="收款户名">
{store?.primaryAccount?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="收款账号">
{store?.primaryAccount?.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{store?.primaryAccount?.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item>
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
@@ -174,11 +174,13 @@ function PackageDetailCard({
pkg,
change,
changes,
imageSize = 72,
}: {
title?: string;
pkg: StorePackageItemDto | StorePackageViewDto;
change?: keyof typeof CHANGE_LABELS;
changes?: FieldChange[];
imageSize?: number;
}) {
const images = normalizeStorePackageImageUrls(pkg);
const meta = change ? CHANGE_LABELS[change] : null;
@@ -217,7 +219,7 @@ function PackageDetailCard({
<Image.PreviewGroup>
<Space wrap size={8}>
{images.map((url) => (
<Image key={url} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={url} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -265,6 +267,7 @@ export type StorePackageAuditPanelProps = {
requestId: string;
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
showActions?: boolean;
fullscreen?: boolean;
onAudited?: () => void;
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
};
@@ -273,6 +276,7 @@ export type StorePackageAuditPanelProps = {
export default function StorePackageAuditPanel({
requestId,
showActions = true,
fullscreen = false,
onAudited,
onDetailLoaded,
}: StorePackageAuditPanelProps) {
@@ -389,6 +393,7 @@ export default function StorePackageAuditPanel({
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
imageSize={fullscreen ? 160 : 72}
/>
))
) : (
@@ -407,6 +412,7 @@ export default function StorePackageAuditPanel({
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
changes={changesByKey.get(packageKey(pkg, index))}
imageSize={fullscreen ? 160 : 72}
/>
))
) : (
+2 -2
View File
@@ -196,10 +196,10 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/benefit/ledgers': 'benefit',
'/redeem-records': 'benefit',
'/redeem-pending': 'benefit',
'/redeem/debug': 'benefit',
'/redeem/debug': 'benefit_debug',
'deliveries-group': 'deliveries',
'/deliveries': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries_debug',
'/tickets': 'tickets',
'/tickets/support': 'tech_support',
'dev-plan-group': 'dev_plan',
+101 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import {
Button,
Form,
Image,
Input,
Modal,
Popconfirm,
@@ -18,6 +19,7 @@ import {
DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS,
type DevPlanTaskDto,
type DevPlanTaskExportFormat,
type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto,
type DevPlanVersionDto,
@@ -25,7 +27,9 @@ import {
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { downloadBase64File } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
value: v,
@@ -56,6 +60,8 @@ export default function DevPlanTasksPage() {
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
const [saving, setSaving] = useState(false);
const [dispatching, setDispatching] = useState(false);
const [exportFormat, setExportFormat] = useState<DevPlanTaskExportFormat>('xlsx');
const [exporting, setExporting] = useState(false);
const [form] = Form.useForm();
const [dispatchForm] = Form.useForm<{ supplement?: string }>();
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
@@ -88,24 +94,30 @@ export default function DevPlanTasksPage() {
function openCreate() {
setEditing(null);
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined, attachmentUrls: [''] });
setModalOpen(true);
}
function openEdit(row: DevPlanTaskDto) {
setEditing(row);
form.setFieldsValue({ content: row.content, type: row.type, status: row.status });
form.setFieldsValue({
content: row.content,
type: row.type,
status: row.status,
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
});
setModalOpen(true);
}
async function save() {
const values = await form.validateFields();
const attachmentUrls = (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean);
setSaving(true);
try {
if (editing) {
await request(`/admin/dev-plan/tasks/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(values),
body: JSON.stringify({ ...values, attachmentUrls }),
});
message.success('已更新');
} else {
@@ -115,6 +127,7 @@ export default function DevPlanTasksPage() {
content: values.content,
type: values.type,
supportTicketId: values.supportTicketId || undefined,
attachmentUrls,
}),
});
message.success('已创建');
@@ -199,6 +212,34 @@ export default function DevPlanTasksPage() {
}
}
async function exportTasks(scope: 'filter' | 'selected') {
setExporting(true);
try {
const result = await request<{
filename: string;
mimeType: string;
contentBase64: string;
count: number;
}>('/admin/dev-plan/tasks/export', {
method: 'POST',
body: JSON.stringify({
scope,
format: exportFormat,
ids: scope === 'selected' ? selectedRowKeys : undefined,
status: filters.status || undefined,
type: filters.type || undefined,
keyword: filters.keyword || undefined,
}),
});
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
message.success(`已导出 ${result.count} 条任务`);
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败');
} finally {
setExporting(false);
}
}
const columns: ColumnsType<DevPlanTaskDto> = [
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
{
@@ -216,6 +257,22 @@ export default function DevPlanTasksPage() {
),
},
{ title: '内容', dataIndex: 'content', ellipsis: true },
{
title: '附件',
width: 100,
render: (_, row) =>
row.attachmentUrls?.length ? (
<Image.PreviewGroup>
<Space size={4}>
{row.attachmentUrls.slice(0, 3).map((url) => (
<Image key={url} src={url} width={32} height={32} style={{ objectFit: 'cover' }} />
))}
</Space>
</Image.PreviewGroup>
) : (
'—'
),
},
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
@@ -243,7 +300,24 @@ export default function DevPlanTasksPage() {
<Typography.Title level={4} style={{ margin: 0 }}>
·
</Typography.Title>
<Space>
<Space wrap>
<Select
value={exportFormat}
style={{ width: 120 }}
onChange={setExportFormat}
options={[
{ value: 'markdown', label: 'Markdown' },
{ value: 'docx', label: 'Word' },
{ value: 'xlsx', label: 'Excel' },
{ value: 'pdf', label: 'PDF' },
]}
/>
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
</Button>
<Button loading={exporting} onClick={() => void exportTasks('filter')}>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
</Button>
@@ -333,6 +407,29 @@ export default function DevPlanTasksPage() {
/>
</Form.Item>
)}
<Form.Item label="附件图片">
<Form.List name="attachmentUrls">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType="SUPPORT_TICKET_ATTACHMENT" mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 ? (
<Button type="link" danger onClick={() => remove(field.name)}>
</Button>
) : null}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block>
</Button>
</>
)}
</Form.List>
</Form.Item>
</Form>
</Modal>
@@ -44,6 +44,13 @@ type BillRow = {
settlementMethod: string;
status: string;
paidAt?: string | null;
paymentRef?: string | null;
fulfillmentProvider?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
};
type BillItem = {
@@ -140,14 +147,30 @@ export default function LogisticsBillsPage() {
}, [summaryPeriod]);
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认结算?',
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认结算',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
await request('/admin/logistics-bills/batch-confirm', {
method: 'POST',
@@ -217,6 +240,7 @@ export default function LogisticsBillsPage() {
}
const summary = data?.summary;
const providerBank = (r: BillRow) => r.fulfillmentProvider;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
@@ -248,6 +272,18 @@ export default function LogisticsBillsPage() {
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '收款户名',
width: 100,
ellipsis: true,
render: (_, r) => providerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
ellipsis: true,
render: (_, r) => providerBank(r)?.bankAccountNo || '—',
},
{
title: '状态',
dataIndex: 'status',
@@ -550,7 +586,31 @@ export default function LogisticsBillsPage() {
<Descriptions.Item label="结算时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{providerBank(detail) ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="户名">
{providerBank(detail)?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户银行">
{providerBank(detail)?.bankName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户支行">
{providerBank(detail)?.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{providerBank(detail)?.bankAccountNo || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Table
rowKey="id"
size="small"
+113 -7
View File
@@ -3,6 +3,8 @@ import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
Modal,
@@ -31,8 +33,22 @@ type Row = {
periodStart: string;
periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
paymentRef?: string | null;
paidAt?: string | null;
partner?: {
companyName?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
partnerAccount?: {
companyName?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
};
type PartnerOption = { id: string; companyName: string; phone?: string };
@@ -77,6 +93,8 @@ export default function PartnerBillsPage() {
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -127,17 +145,30 @@ export default function PartnerBillsPage() {
}
function markPaid(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认打款?',
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}`,
content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { method: 'POST', body });
} else {
await request('/admin/partner-bills/batch-mark-paid', {
method: 'POST',
@@ -182,6 +213,12 @@ export default function PartnerBillsPage() {
}
}
async function openDetail(id: string) {
const d = await request<Row>(`/admin/partner-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
@@ -201,6 +238,7 @@ export default function PartnerBillsPage() {
const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
@@ -238,6 +276,18 @@ export default function PartnerBillsPage() {
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '收款户名',
width: 100,
ellipsis: true,
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
ellipsis: true,
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
},
{
title: '状态',
dataIndex: 'status',
@@ -259,6 +309,9 @@ export default function PartnerBillsPage() {
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
@@ -465,6 +518,59 @@ export default function PartnerBillsPage() {
</Button>
</Form>
</Modal>
<Drawer
title="合伙人账单明细"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={560}
>
{detail && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="合伙人">{partnerName(detail)}</Descriptions.Item>
<Descriptions.Item label="账期">
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="订单佣金">
¥{Number(detail.orderCommission).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="核销佣金">
¥{Number(detail.redeemCommission).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="应付合计">
¥{Number(detail.totalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLORS[detail.status] || 'default'}>
{STATUS_LABELS[detail.status] || detail.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{partnerBank(detail)?.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{partnerBank(detail)?.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{partnerBank(detail)?.bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
</div>
);
}
+75 -5
View File
@@ -44,6 +44,11 @@ type Row = {
settlementRate?: number | null;
payoutCount?: number | null;
store?: { id: string; name: string; cityName: string; phone?: string | null };
bankAccount?: {
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} | null;
};
type StoreOption = { id: string; name: string; phone: string };
@@ -55,6 +60,11 @@ const STATUS_COLORS: Record<string, string> = {
REJECTED: 'red',
};
function settlementStatusColor(kind: StoreSettlementKind, status: string) {
if (status === 'PAID') return kind === 'WITHDRAW' ? 'cyan' : 'green';
return STATUS_COLORS[status] || 'default';
}
const KIND_COLORS: Record<StoreSettlementKind, string> = {
T1_BILL: 'blue',
WITHDRAW: 'purple',
@@ -118,14 +128,30 @@ export default function StoreBillsPage() {
}, []);
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认打款?',
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
content: (
<div>
<div>
{ids.length} T+1
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
setBatchLoading(true);
try {
@@ -247,7 +273,9 @@ export default function StoreBillsPage() {
dataIndex: 'kind',
width: 100,
render: (k: StoreSettlementKind) => (
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
<Tag color={KIND_COLORS[k]} style={{ minWidth: 72, textAlign: 'center' }}>
{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}
</Tag>
),
},
{
@@ -271,6 +299,20 @@ export default function StoreBillsPage() {
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{
title: '收款户名',
width: 100,
ellipsis: true,
render: (_, row) =>
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—',
},
{
title: '收款账号',
width: 140,
ellipsis: true,
render: (_, row) =>
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—',
},
{
title: '笔数',
width: 80,
@@ -288,7 +330,9 @@ export default function StoreBillsPage() {
dataIndex: 'status',
width: 100,
render: (s: string, row) => (
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
<Tag color={settlementStatusColor(row.kind, s)}>
{storeSettlementStatusLabel(row.kind, s)}
</Tag>
),
},
{
@@ -339,7 +383,8 @@ export default function StoreBillsPage() {
</Typography.Title>
<Typography.Text type="secondary">
T+1 沿
T+1 沿
= T+1 =
</Typography.Text>
{overdueSummary && overdueSummary.overdueCount > 0 ? (
<Typography.Text type="danger">
@@ -503,7 +548,32 @@ export default function StoreBillsPage() {
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{(detail.storeAccount as {
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
} | null) ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{(detail.storeAccount as { bankAccountName?: string }).bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{(detail.storeAccount as { bankAccountNo?: string }).bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{(detail.storeAccount as { bankBranch?: string }).bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
@@ -55,10 +55,12 @@ function InfoChangeImageDiff({
field,
live,
proposed,
imageSize = 72,
}: {
field: string;
live: unknown;
proposed: unknown;
imageSize?: number;
}) {
const liveUrls =
field === 'coverUrl'
@@ -83,7 +85,7 @@ function InfoChangeImageDiff({
<Image.PreviewGroup>
<Space wrap size={8}>
{liveUrls.map((url) => (
<Image key={`live-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={`live-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -99,7 +101,7 @@ function InfoChangeImageDiff({
<Image.PreviewGroup>
<Space wrap size={8}>
{proposedUrls.map((url) => (
<Image key={`new-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={`new-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -128,6 +130,7 @@ function InfoChangeAuditPanel({
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
const [infoFullscreen, setInfoFullscreen] = useState(false);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -286,25 +289,33 @@ function InfoChangeAuditPanel({
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
width={680}
width={infoFullscreen ? '100%' : 680}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onClose={() => {
setDetailOpen(false);
setInfoFullscreen(false);
}}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : null
<Space>
<Button onClick={() => setInfoFullscreen((v) => !v)}>
{infoFullscreen ? '退出全屏' : '全屏查看'}
</Button>
{detail?.status === 'PENDING' ? (
<>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</>
) : null}
</Space>
}
>
{detailLoading ? (
@@ -328,7 +339,12 @@ function InfoChangeAuditPanel({
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
>
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
<InfoChangeImageDiff
field={d.field}
live={d.live}
proposed={d.proposed}
imageSize={infoFullscreen ? 160 : 72}
/>
) : (
<span>
<Typography.Text delete type="secondary">
@@ -388,6 +404,7 @@ export default function StorePackageAuditsPage() {
const [detailOpen, setDetailOpen] = useState(false);
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
const [packageFullscreen, setPackageFullscreen] = useState(false);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -554,14 +571,23 @@ export default function StorePackageAuditsPage() {
<Drawer
title={drawerTitle}
width={880}
width={packageFullscreen ? '100%' : 880}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onClose={() => {
setDetailOpen(false);
setPackageFullscreen(false);
}}
destroyOnClose
extra={
<Button onClick={() => setPackageFullscreen((v) => !v)}>
{packageFullscreen ? '退出全屏' : '全屏查看'}
</Button>
}
>
{activeRequestId ? (
<StorePackageAuditPanel
requestId={activeRequestId}
fullscreen={packageFullscreen}
onAudited={() => {
setDetailOpen(false);
void reload(page, status);
@@ -218,8 +218,13 @@ export default function SupportTicketsPage() {
setReviewOpen(false);
const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500);
const ticketUrls = (detail!.attachmentUrls ?? []).filter(Boolean);
tasksForm.setFieldsValue({
tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }],
tasks: [{
content: summary || detail!.title,
type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType),
attachmentUrls: ticketUrls,
}],
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
});
@@ -364,7 +369,11 @@ export default function SupportTicketsPage() {
body: JSON.stringify({
decision: 'APPROVE',
note: note?.trim() || undefined,
tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })),
tasks: values.tasks.map((t) => ({
content: t.content.trim(),
type: t.type,
attachmentUrls: t.attachmentUrls?.length ? t.attachmentUrls : detail!.attachmentUrls,
})),
dispatchToWecom: !!values.dispatchToWecom,
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
}),
+49 -2
View File
@@ -37,6 +37,13 @@ type Row = {
wineryAmount: number;
status: string;
paidAt?: string | null;
paymentRef?: string | null;
wineryBank?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
};
type BillItem = {
@@ -127,14 +134,30 @@ export default function WineryBillsPage() {
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
title: '确认打款?',
content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
content: (
<div>
<div>
{ids.length}
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}
</div>
<Input
placeholder="打款凭证号(可选)"
style={{ marginTop: 8 }}
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
</div>
),
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST', body });
} else {
await request('/admin/winery-bills/batch-confirm', {
method: 'POST',
@@ -399,7 +422,31 @@ export default function WineryBillsPage() {
{displayWineryStatus(detail.status, detail.wineryAmount).label}
</Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{detail.wineryBank ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{detail.wineryBank.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户银行">
{detail.wineryBank.bankName || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户支行">
{detail.wineryBank.bankBranch || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{detail.wineryBank.bankAccountNo || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
+1 -1
View File
@@ -463,7 +463,7 @@ export default function HomePage() {
<div className="shop-home-stat">
<p className="shop-home-stat-label"></p>
<p className="shop-home-stat-label"></p>
<p className="shop-home-stat-value">
+1 -3
View File
@@ -196,9 +196,7 @@ export default function RecordsPage() {
</div>
<p className="shop-record-time">
:{' '}
{new Date(String(r.createdAt))
.toLocaleString('zh-CN', { hour12: false })
.slice(0, 16)}
{new Date(String(r.createdAt)).toLocaleString('zh-CN', { hour12: false })}
</p>
<p className="shop-record-channel">
<span
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const crypto = require('crypto');
const path = process.argv[2] || '/opt/dukang/server/dukang-api/.env.production';
function stripQuotes(s) {
const t = s.trim();
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1);
}
return t;
}
function parseEnvValue(text, key) {
const re = new RegExp(`^${key}=(.*)$`, 'm');
const m = text.match(re);
if (!m) return null;
return stripQuotes(m[1]);
}
function tryKey(name, key) {
const literalN = (key.match(/\\n/g) || []).length;
const realN = (key.match(/\n/g) || []).length;
const hasBegin = /BEGIN (RSA )?PRIVATE KEY/.test(key);
try {
crypto.createPrivateKey(key);
console.log(`${name}: OK begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length}`);
return true;
} catch (e) {
console.log(
`${name}: FAIL ${e.message} begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length} head=${JSON.stringify(key.slice(0, 48))}`,
);
return false;
}
}
const text = fs.readFileSync(path, 'utf8');
const raw = parseEnvValue(text, 'WX_MCH_PRIVATE_KEY');
if (!raw) {
console.log('MISSING WX_MCH_PRIVATE_KEY in', path);
process.exit(1);
}
console.log('file=', path);
console.log('MOCK_PAY=', parseEnvValue(text, 'MOCK_PAY'));
console.log('WX_MINI_APP_ID=', parseEnvValue(text, 'WX_MINI_APP_ID'));
console.log('WX_APP_ID=', parseEnvValue(text, 'WX_APP_ID'));
console.log('WX_MCH_ID=', parseEnvValue(text, 'WX_MCH_ID'));
console.log('WX_PAY_NOTIFY_URL=', parseEnvValue(text, 'WX_PAY_NOTIFY_URL'));
const variants = {
as_is: raw,
unescape_n: raw.replace(/\\n/g, '\n'),
unescape_twice: raw.replace(/\\\\n/g, '\\n').replace(/\\n/g, '\n'),
strip_cr_unesc: raw.replace(/\\n/g, '\n').replace(/\r/g, ''),
// dotenv style sometimes leaves surrounding quotes in process.env
quoted_unesc: stripQuotes(raw).replace(/\\n/g, '\n'),
};
let ok = false;
for (const [name, key] of Object.entries(variants)) {
if (tryKey(name, key)) ok = true;
}
// also simulate dotenv load
try {
const dotenv = require('dotenv');
const parsed = dotenv.parse(text);
const fromDotenv = parsed.WX_MCH_PRIVATE_KEY || '';
console.log('dotenv_raw_literalN=', (fromDotenv.match(/\\n/g) || []).length, 'realN=', (fromDotenv.match(/\n/g) || []).length);
tryKey('dotenv_as_is', fromDotenv);
tryKey('dotenv_unescape', fromDotenv.replace(/\\n/g, '\n'));
} catch (e) {
console.log('dotenv skip', e.message);
}
process.exit(ok ? 0 : 2);
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# backup-prod-db.sh — 将线上(production)数据库 mysqldump 落盘到 deploy/backups/
#
# 与 sync-prod-db-to-local.sh 共用 SSH 隧道;仅导出,不覆盖本地库。
#
# 用法:
# bash deploy/backup-prod-db.sh # 交互确认
# bash deploy/backup-prod-db.sh --yes # 跳过确认
# bash deploy/backup-prod-db.sh --dry-run # 只打印计划
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
LOCAL_CONTAINER="dukang-v1-mysql"
TUNNEL_PORT=6019
REMOTE_ENV_FILE="/opt/dukang/server/dukang-api/.env.production"
HOST_ALIAS="host.docker.internal"
ASSUME_YES=0
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--yes|-y) ASSUME_YES=1 ;;
--dry-run) DRY_RUN=1 ;;
--help|-h) sed -n '3,12p' "$0"; exit 0 ;;
--tunnel-port=*) TUNNEL_PORT="${arg#*=}" ;;
--container=*) LOCAL_CONTAINER="${arg#*=}" ;;
--host-alias=*) HOST_ALIAS="${arg#*=}" ;;
*) echo "未知参数: $arg" >&2; exit 2 ;;
esac
done
if [[ -f deploy.env ]]; then
# shellcheck disable=SC1091
source deploy.env
fi
DEPLOY_HOST="${DEPLOY_HOST:?请在 deploy.env 配置 DEPLOY_HOST}"
DEPLOY_USER="${DEPLOY_USER:-root}"
DEPLOY_PORT="${DEPLOY_PORT:-22}"
SSH=(ssh -o StrictHostKeyChecking=accept-new -p "$DEPLOY_PORT")
if [[ -n "${DEPLOY_SSH_KEY:-}" ]]; then SSH+=(-i "$DEPLOY_SSH_KEY"); fi
TARGET="$DEPLOY_USER@$DEPLOY_HOST"
echo "==> 读取线上数据库配置 ($TARGET:$REMOTE_ENV_FILE)"
PARSED="$("${SSH[@]}" "$TARGET" bash -s <<'NODEEOF'
cat > /tmp/_parse_db.js <<'JSEOF'
const fs=require("fs");
const l=fs.readFileSync("/opt/dukang/server/dukang-api/.env.production","utf8").match(/^DATABASE_URL=(.*)$/m)[1].trim();
let u=l;
if((u[0]==='"'&&u[u.length-1]==='"')||(u[0]==="'"&&u[u.length-1]==="'"))u=u.slice(1,-1);
const U=new URL(u);
const b=s=>Buffer.from(s).toString("base64");
const path=U.pathname.replace(/^\//,"").split("?")[0];
process.stdout.write([b(decodeURIComponent(U.username)),b(U.hostname),U.port||"3306",b(path),b(decodeURIComponent(U.password))].join("|")+"\n");
JSEOF
node /tmp/_parse_db.js; rm -f /tmp/_parse_db.js
NODEEOF
)"
if [[ -z "$PARSED" ]]; then
echo "无法读取远端 DATABASE_URL,中止。" >&2; exit 1
fi
IFS='|' read -r _U _H _P _DB _PASS <<< "$PARSED"
REMOTE_USER="$(echo "$_U" | base64 -d)"
REMOTE_HOST="$(echo "$_H" | base64 -d)"
REMOTE_PORT="$_P"
REMOTE_DB="$(echo "$_DB" | base64 -d)"
REMOTE_PASS="$(echo "$_PASS" | base64 -d)"
mkdir -p backups
OUT="backups/prod-${REMOTE_DB}-$(date +%Y%m%d-%H%M%S).sql"
echo
echo "备份计划:"
echo " 源(线上): $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB"
echo " 目标文件: $OUT"
echo " 隧道: $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
if [[ $DRY_RUN -eq 1 ]]; then
echo "(dry-run) 已结束,未做任何修改。"
exit 0
fi
if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then
echo "本地容器 $LOCAL_CONTAINER 未运行。请先执行: cd deploy && docker compose up -d" >&2
exit 1
fi
if [[ $ASSUME_YES -ne 1 ]]; then
read -r -p "确认备份【线上 $REMOTE_DB】到 $OUT ? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "已取消。"; exit 0; }
fi
CTL="/tmp/backup-prod-db-$$.sock"
echo "==> 建立 SSH 隧道"
"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET"
ready=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysqladmin -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" ping >/dev/null 2>&1; then
ready=1; break
fi
sleep 1
done
if [[ $ready -ne 1 ]]; then
echo "隧道未就绪,中止。" >&2
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
exit 1
fi
echo "==> mysqldump 导出中..."
docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysqldump -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" \
--lock-tables=0 --add-drop-table --skip-triggers \
--skip-routines --skip-events --column-statistics=0 --no-tablespaces --set-gtid-purged=OFF \
--databases "$REMOTE_DB" > "$OUT"
RC=$?
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
if [[ $RC -ne 0 ]]; then
echo "备份失败 (mysqldump 退出码 $RC)。" >&2
exit 1
fi
echo "==> 备份完成 ✅ $OUT ($(wc -c < "$OUT") bytes)"
echo " 提示:请在阿里云 RDS 控制台再创建一次手动快照,作为整库回滚保险。"
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env bash
# 为 api.lingshivip.cn 申请 DNS 证书并启用 HTTPS
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SSL_DIR="/etc/nginx/ssl/api.lingshivip.cn"
NGINX_CONF="/etc/nginx/conf.d/dukang-lingshivip.conf"
mkdir -p "$SSL_DIR" /var/www/certbot/.well-known/acme-challenge
echo "==> 1. 发起 DNS 验证(需先在 DNSPod 添加 TXT 记录)"
~/.acme.sh/acme.sh --issue -d api.lingshivip.cn --dns \
--yes-I-know-dns-manual-mode-enough-go-ahead-please \
--server letsencrypt || true
echo ""
echo "请在 DNSPod 添加上述 TXT 记录后,执行:"
echo " $0 --renew"
echo ""
if [[ "${1:-}" != "--renew" ]]; then
exit 0
fi
echo "==> 2. 继续 DNS 验证并签发证书"
~/.acme.sh/acme.sh --renew -d api.lingshivip.cn --yes-I-know-dns-manual-mode-enough-go-ahead-please
echo "==> 3. 安装证书到 nginx 目录"
install -m 644 ~/.acme.sh/api.lingshivip.cn_ecc/fullchain.cer "$SSL_DIR/fullchain.cer"
install -m 600 ~/.acme.sh/api.lingshivip.cn_ecc/api.lingshivip.cn.key "$SSL_DIR/api.lingshivip.cn.key"
echo "==> 4. 更新 nginx 并重载"
cp "$SCRIPT_DIR/nginx-lingshivip.conf" "$NGINX_CONF"
python3 - <<'PY'
from pathlib import Path
p = Path("/etc/nginx/conf.d/dukang-lingshivip.conf")
text = p.read_text()
old = """ location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
# API 独立域名 HTTPS"""
new = """ location / {
return 301 https://$host$request_uri;
}
}
# API 独立域名 HTTPS"""
if old not in text:
raise SystemExit("nginx api http redirect patch failed")
p.write_text(text.replace(old, new, 1))
PY
nginx -t
systemctl reload nginx
echo "==> 5. 健康检查"
curl -sf -o /dev/null -w "api-https:%{http_code}\n" https://api.lingshivip.cn/api/v1/health
echo "==> api.lingshivip.cn HTTPS 已启用"
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
# runxian.top 裸域名申请 Let's Encrypt 并启用 HTTPS
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DOMAIN=runxian.top
EMAIL="${CERTBOT_EMAIL:-admin@runxian.top}"
echo "==> 检查 DNS: $DOMAIN"
ip="$(dig +short "$DOMAIN" A @223.5.5.5 | tail -1)"
if [[ -z "$ip" ]]; then
echo "ERROR: $DOMAIN 无 A 记录,请先在 DNS 添加指向本机公网 IP(当前服务器: $(curl -sf ifconfig.me || echo unknown)"
exit 1
fi
echo " $DOMAIN -> $ip"
mkdir -p /var/www/certbot /var/log/nginx/dukang
# 若尚未有证书,先部署仅 HTTP 的配置以便 ACME 校验
if [[ ! -f /etc/letsencrypt/live/runxian.top/fullchain.pem ]]; then
echo "==> 临时 HTTP 配置(用于 ACME..."
cat > /etc/nginx/conf.d/dukang-runxian-apex.conf <<'EOF'
server {
listen 80;
server_name runxian.top;
root /opt/dukang-haoke/public;
index index.html;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location ~ ^/MP_verify_.*\.txt$ {
default_type text/plain;
access_log off;
}
location / {
try_files $uri $uri/ =404;
}
}
EOF
nginx -t
systemctl reload nginx
fi
echo "==> 申请证书..."
certbot certonly --webroot -w /var/www/certbot \
--cert-name runxian.top \
-d runxian.top \
--non-interactive --agree-tos -m "$EMAIL"
echo "==> 切换 HTTPS 配置..."
install -m 644 "$SCRIPT_DIR/nginx-runxian-apex.conf" /etc/nginx/conf.d/dukang-runxian-apex.conf
nginx -t
systemctl reload nginx
echo "==> 验证..."
code="$(curl -sf -o /dev/null -w '%{http_code}' "https://$DOMAIN/MP_verify_ayPJ4CQqbUcec3jX.txt" || echo fail)"
echo " https://$DOMAIN/MP_verify_ayPJ4CQqbUcec3jX.txt -> $code"
echo "==> runxian.top HTTPS 已启用"
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# 为杜康 runxian.top 申请 Let's Encrypt 并切换 HTTPS 配置
# 三端 H5 统一入口 user.runxian.top/{user,shop,partner}/
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DOMAINS=(
user.runxian.top
shop.runxian.top
partner.runxian.top
webadmin.runxian.top
dkapi.runxian.top
)
echo "==> 检查 DNS 解析到本机..."
for d in "${DOMAINS[@]}"; do
ip="$(dig +short "$d" @223.5.5.5 | tail -1)"
if [[ -z "$ip" ]]; then
echo "ERROR: $d 无 A 记录,请先在 DNS 添加指向本机公网 IP"
exit 1
fi
echo " $d -> $ip"
done
mkdir -p /var/www/certbot /var/log/nginx/dukang
echo "==> 申请证书..."
certbot certonly --webroot -w /var/www/certbot \
--cert-name user.runxian.top \
-d user.runxian.top \
-d shop.runxian.top \
-d partner.runxian.top \
-d webadmin.runxian.top \
-d dkapi.runxian.top \
--non-interactive --agree-tos -m admin@runxian.top || {
echo "certbot 失败,请确认 DNS 已生效且 80 端口可从公网访问"
exit 1
}
echo "==> 切换 nginx HTTPS 配置..."
install -m 644 "$SCRIPT_DIR/nginx-runxian-dukang-ssl.conf" /etc/nginx/conf.d/dukang-runxian.conf
nginx -t
systemctl reload nginx
echo "==> 验证..."
for url in \
https://user.runxian.top/user/ \
https://user.runxian.top/shop/ \
https://user.runxian.top/partner/ \
https://shop.runxian.top/ \
https://webadmin.runxian.top/ \
https://dkapi.runxian.top/api/v1/health; do
code="$(curl -sf -o /dev/null -w '%{http_code}' "$url" || echo fail)"
echo " $url -> $code"
done
echo "==> runxian.top 杜康域名 HTTPS 已启用(H5 统一入口 user.runxian.top"
-33
View File
@@ -1,33 +0,0 @@
map $host $dukang_h5_port {
user.ai-mirror.xyz 8091;
shop.ai-mirror.xyz 8092;
partner.ai-mirror.xyz 8093;
}
# HTTP — 证书申请期间先提供 HTTP 服务
server {
listen 80;
server_name user.ai-mirror.xyz shop.ai-mirror.xyz partner.ai-mirror.xyz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:$dukang_h5_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-54
View File
@@ -1,54 +0,0 @@
# 杜康好客 H5 三端 — ai-mirror.xyz
# API 内部端口 8090H5 分别 8091/8092/8093
map $host $dukang_h5_port {
user.ai-mirror.xyz 8091;
shop.ai-mirror.xyz 8092;
partner.ai-mirror.xyz 8093;
}
# HTTP → HTTPS
server {
listen 80;
server_name user.ai-mirror.xyz shop.ai-mirror.xyz partner.ai-mirror.xyz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name user.ai-mirror.xyz shop.ai-mirror.xyz partner.ai-mirror.xyz;
access_log /var/log/nginx/dukang/access.log main;
error_log /var/log/nginx/dukang/error.log warn;
ssl_certificate /etc/letsencrypt/live/user.ai-mirror.xyz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.ai-mirror.xyz/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:$dukang_h5_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-105
View File
@@ -1,105 +0,0 @@
# 证书申请期间 — lingshivip.cn HTTPapi HTTPS 证书就绪后换 nginx-lingshivip.conf
map $host $dukang_lingshi_port {
user.lingshivip.cn 8091;
shop.lingshivip.cn 8092;
partner.lingshivip.cn 8093;
admin.lingshivip.cn 8094;
}
server {
listen 80;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 80;
server_name api.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name admin.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
access_log /var/log/nginx/dukang/lingshi.access.log main;
error_log /var/log/nginx/dukang/lingshi.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.lingshivip.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.lingshivip.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:$dukang_lingshi_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-135
View File
@@ -1,135 +0,0 @@
# 杜康好客 — lingshivip.cn 五端 + API
# API 8090H5 8091/8092/8093admin-web 8094
map $host $dukang_lingshi_port {
user.lingshivip.cn 8091;
shop.lingshivip.cn 8092;
partner.lingshivip.cn 8093;
admin.lingshivip.cn 8094;
}
# HTTP → HTTPSH5 三端)
server {
listen 80;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
# API:证书就绪前 HTTP 直连;就绪后改走 HTTPS server
server {
listen 80;
server_name api.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
# admin 待 DNS + 证书就绪前先走 HTTP
server {
listen 80;
server_name admin.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# API 独立域名 HTTPS(证书:/etc/nginx/ssl/api.lingshivip.cn/
server {
listen 443 ssl;
server_name api.lingshivip.cn;
access_log /var/log/nginx/dukang/lingshi-api.access.log main;
error_log /var/log/nginx/dukang/lingshi-api.error.log warn;
ssl_certificate /etc/nginx/ssl/api.lingshivip.cn/fullchain.cer;
ssl_certificate_key /etc/nginx/ssl/api.lingshivip.cn/api.lingshivip.cn.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 443 ssl;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
access_log /var/log/nginx/dukang/lingshi.access.log main;
error_log /var/log/nginx/dukang/lingshi.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.lingshivip.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.lingshivip.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:$dukang_lingshi_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-53
View File
@@ -1,53 +0,0 @@
# runxian.top 裸域名 — 静态公共资源(微信域名校验等)
# HTTP: 保留 MP_verify + ACME;其余跳转 HTTPS
# HTTPS: deploy/enable-runxian-apex-ssl.sh 申请证书后启用
server {
listen 80;
server_name runxian.top;
access_log /var/log/nginx/dukang/runxian-apex.access.log main;
error_log /var/log/nginx/dukang/runxian-apex.error.log warn;
root /opt/dukang-haoke/public;
index index.html;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location ~ ^/MP_verify_.*\.txt$ {
default_type text/plain;
access_log off;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name runxian.top;
access_log /var/log/nginx/dukang/runxian-apex.access.log main;
error_log /var/log/nginx/dukang/runxian-apex.error.log warn;
ssl_certificate /etc/letsencrypt/live/runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
root /opt/dukang-haoke/public;
index index.html;
location ~ ^/MP_verify_.*\.txt$ {
default_type text/plain;
access_log off;
}
location / {
try_files $uri $uri/ =404;
}
}
-218
View File
@@ -1,218 +0,0 @@
# 杜康好客 — runxian.top HTTPS
# 执行: deploy/enable-runxian-dukang-ssl.sh
# 三端 H5 统一入口 user.runxian.top/{user,shop,partner}/
server {
listen 80;
server_name user.runxian.top shop.runxian.top partner.runxian.top m.runxian.top;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://user.runxian.top$request_uri;
}
}
server {
listen 443 ssl;
server_name m.runxian.top;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://user.runxian.top$request_uri;
}
server {
listen 443 ssl;
server_name user.runxian.top;
access_log /var/log/nginx/dukang/runxian-h5.access.log main;
error_log /var/log/nginx/dukang/runxian-h5.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location ~ ^/MP_verify_.*\.txt$ {
root /opt/dukang-haoke/apps/mini-user/dist;
default_type text/plain;
access_log off;
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location = / {
return 302 /user/;
}
# Taro 路由页是 /pages/xxx/index;补全短路径,避免 UniversalRouter 404 白屏
location ~ ^/user/pages/([^/]+)/?$ {
return 302 /user/pages/$1/index$is_args$args;
}
location /user/ {
rewrite ^/user/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
# serve 若仍发出绝对 Location:/pages/...,补回 /user 前缀
proxy_redirect / /user/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /shop/ {
rewrite ^/shop/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8092;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /partner/ {
rewrite ^/partner/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8093;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 旧 C 端根路径书签(无 /user 前缀)→ /user/...
location / {
return 302 /user$request_uri;
}
}
server {
listen 443 ssl;
server_name shop.runxian.top;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://user.runxian.top/shop$request_uri;
}
server {
listen 443 ssl;
server_name partner.runxian.top;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://user.runxian.top/partner$request_uri;
}
server {
listen 80;
server_name dkapi.runxian.top;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
include /opt/dukang-haoke/deploy/nginx-deploy-webhook.conf;
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name dkapi.runxian.top;
access_log /var/log/nginx/dukang/runxian-api.access.log main;
error_log /var/log/nginx/dukang/runxian-api.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
include /opt/dukang-haoke/deploy/nginx-deploy-webhook.conf;
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name webadmin.runxian.top;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name webadmin.runxian.top;
access_log /var/log/nginx/dukang/runxian-admin.access.log main;
error_log /var/log/nginx/dukang/runxian-admin.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-154
View File
@@ -1,154 +0,0 @@
# 杜康好客 — runxian.topHTTP
# 三端 H5 统一入口 user.runxian.top/{user,shop,partner}/(微信网页授权单域名)
# shop/partner 子域名 301 到 user.runxian.top 路径;webadmin → 8094dkapi → 8090
server {
listen 80;
server_name user.runxian.top;
access_log /var/log/nginx/dukang/runxian-h5.access.log main;
error_log /var/log/nginx/dukang/runxian-h5.error.log warn;
client_max_body_size 20m;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location ~ ^/MP_verify_.*\.txt$ {
root /opt/dukang-haoke/apps/mini-user/dist;
default_type text/plain;
access_log off;
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location = / {
return 302 /user/;
}
location ~ ^/user/pages/([^/]+)/?$ {
return 302 /user/pages/$1/index$is_args$args;
}
location /user/ {
rewrite ^/user/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
proxy_redirect / /user/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /shop/ {
rewrite ^/shop/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8092;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /partner/ {
rewrite ^/partner/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8093;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
return 302 /user$request_uri;
}
}
server {
listen 80;
server_name shop.runxian.top;
return 301 http://user.runxian.top/shop$request_uri;
}
server {
listen 80;
server_name partner.runxian.top;
return 301 http://user.runxian.top/partner$request_uri;
}
server {
listen 80;
server_name m.runxian.top;
return 301 http://user.runxian.top$request_uri;
}
server {
listen 80;
server_name dkapi.runxian.top;
access_log /var/log/nginx/dukang/runxian-api.access.log main;
error_log /var/log/nginx/dukang/runxian-api.error.log warn;
client_max_body_size 20m;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name webadmin.runxian.top;
access_log /var/log/nginx/dukang/runxian-admin.access.log main;
error_log /var/log/nginx/dukang/runxian-admin.error.log warn;
client_max_body_size 20m;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env bash
# 续部署:user.runxian.top 统一路径入口 + nginx 切换
set -euo pipefail
APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
H5_BASE="https://user.runxian.top"
cd "$APP_ROOT"
echo "==> 1. PM2 重启(H5 dist 已含 /user|shop|partner/ 路径)"
pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner dukang-admin-web
pm2 save
echo "==> 2. 安装 nginx HTTPS 配置"
install -m 644 "$APP_ROOT/deploy/nginx-runxian-dukang-ssl.conf" /etc/nginx/conf.d/dukang-runxian.conf
nginx -t
systemctl reload nginx
echo "==> 3. 更新 USER_H5_URL"
ENV_FILE="$APP_ROOT/server/dukang-api/.env.production"
if grep -q '^USER_H5_URL=' "$ENV_FILE"; then
sed -i "s|^USER_H5_URL=.*|USER_H5_URL=${H5_BASE}/user|" "$ENV_FILE"
else
echo "USER_H5_URL=${H5_BASE}/user" >> "$ENV_FILE"
fi
pm2 restart dukang-api
echo "==> 4. 验证"
for url in \
"${H5_BASE}/user/" \
"${H5_BASE}/shop/" \
"${H5_BASE}/partner/" \
"https://shop.runxian.top/" \
"https://dkapi.runxian.top/api/v1/health"; do
code="$(curl -sk -o /dev/null -w '%{http_code}' "$url" || echo fail)"
echo " $url -> $code"
done
echo "==> user.runxian.top 统一部署完成"
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
APP_ROOT="/opt/dukang-haoke"
DEPLOY_DIR="$APP_ROOT/deploy"
echo "==> install api deps only"
cd "$APP_ROOT"
export NODE_OPTIONS="--max-old-space-size=1024"
pnpm install --filter @dukang/api... --filter @dukang/domain --filter @dukang/shared-types
echo "==> env"
cat > "$APP_ROOT/server/dukang-api/.env.production" <<'ENV'
DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke"
REDIS_URL="redis://localhost:6379"
JWT_SECRET="gky4CNZgwdrYoDIDy4AWBiJRa4WX2ax8STe3TEG93Ht"
JWT_EXPIRES_IN="7d"
PORT=8090
MOCK_SMS=true
MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
ENV
echo "==> prisma"
cd "$APP_ROOT/server/dukang-api"
pnpm prisma:generate
npx prisma db push --accept-data-loss
pnpm prisma:seed
echo "==> serve"
npm install -g serve
echo "==> pm2"
mkdir -p /var/log/nginx/dukang /var/www/certbot
pm2 delete dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner 2>/dev/null || true
pm2 start "$DEPLOY_DIR/ecosystem.config.cjs"
pm2 save
echo "==> ssl"
if [ ! -f /etc/letsencrypt/live/user.ai-mirror.xyz/fullchain.pem ]; then
certbot certonly --webroot -w /var/www/certbot \
-d user.ai-mirror.xyz -d shop.ai-mirror.xyz -d partner.ai-mirror.xyz \
--non-interactive --agree-tos -m admin@ai-mirror.xyz
fi
echo "==> nginx"
cp "$DEPLOY_DIR/nginx-ai-mirror.conf" /etc/nginx/conf.d/dukang-ai-mirror.conf
nginx -t
systemctl reload nginx
echo "==> DONE"
pm2 list
ss -tlnp | grep -E '809[0-3]' || true
curl -s -o /dev/null -w "user:%{http_code}\n" http://127.0.0.1:8091/
curl -s -o /dev/null -w "api:%{http_code}\n" http://127.0.0.1:8090/api/v1/health 2>/dev/null || curl -s -o /dev/null -w "api:%{http_code}\n" http://127.0.0.1:8090/
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
APP_ROOT="/opt/dukang-haoke"
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "==> 1. 安装 MySQL(如未安装)"
if ! command -v mysqld &>/dev/null; then
dnf install -y mysql-server
systemctl enable --now mysqld
sleep 3
fi
echo "==> 2. 配置 MySQL 数据库"
mysql -uroot -e "CREATE DATABASE IF NOT EXISTS dukang_haoke CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" 2>/dev/null || true
mysql -uroot -e "CREATE USER IF NOT EXISTS 'dukang'@'localhost' IDENTIFIED BY 'Dukang2026!';" 2>/dev/null || \
mysql -uroot -e "ALTER USER 'dukang'@'localhost' IDENTIFIED BY 'Dukang2026!';" 2>/dev/null || true
mysql -uroot -e "GRANT ALL PRIVILEGES ON dukang_haoke.* TO 'dukang'@'localhost'; FLUSH PRIVILEGES;" 2>/dev/null || true
echo "==> 3. 启用 pnpm"
corepack enable
corepack prepare pnpm@11.2.2 --activate
echo "==> 4. 安装依赖并构建"
cd "$APP_ROOT"
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
pnpm build
echo "==> 5. 写入 API 生产环境变量"
cat > "$APP_ROOT/server/dukang-api/.env.production" <<'ENV'
DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke"
REDIS_URL="redis://localhost:6379"
JWT_SECRET="gky4CNZgwdrYoDIDy4AWBiJRa4WX2ax8STe3TEG93Ht"
JWT_EXPIRES_IN="7d"
PORT=8090
MOCK_SMS=true
MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
ENV
echo "==> 6. 初始化数据库"
cd "$APP_ROOT/server/dukang-api"
pnpm prisma:generate
npx prisma db push --accept-data-loss
pnpm prisma:seed
echo "==> 7. 安装 serve(静态服务)"
npm install -g serve
echo "==> 8. 启动 PM2 进程"
mkdir -p /var/log/nginx/dukang /var/www/certbot
pm2 delete dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner 2>/dev/null || true
pm2 start "$DEPLOY_DIR/ecosystem.config.cjs"
pm2 save
echo "==> 9. 申请 SSL 证书"
if [ ! -f /etc/letsencrypt/live/user.ai-mirror.xyz/fullchain.pem ]; then
certbot certonly --webroot -w /var/www/certbot \
-d user.ai-mirror.xyz \
-d shop.ai-mirror.xyz \
-d partner.ai-mirror.xyz \
--non-interactive --agree-tos -m admin@ai-mirror.xyz || \
certbot certonly --nginx \
-d user.ai-mirror.xyz \
-d shop.ai-mirror.xyz \
-d partner.ai-mirror.xyz \
--non-interactive --agree-tos -m admin@ai-mirror.xyz
fi
echo "==> 10. 配置 Nginx"
cp "$DEPLOY_DIR/nginx-ai-mirror.conf" /etc/nginx/conf.d/dukang-ai-mirror.conf
nginx -t
systemctl reload nginx
echo "==> 部署完成"
pm2 list
ss -tlnp | grep -E '809[0-3]' || true
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# restore-prod-db.sh — 将本地 mysqldump 恢复到线上(production)数据库
#
# 仅在用户明确要求恢复生产库时使用。
#
# 用法:
# bash deploy/restore-prod-db.sh backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql
# bash deploy/restore-prod-db.sh --yes backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql
# bash deploy/restore-prod-db.sh --dry-run backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
LOCAL_CONTAINER="dukang-v1-mysql"
TUNNEL_PORT=6021
HOST_ALIAS="host.docker.internal"
ASSUME_YES=0
DRY_RUN=0
DUMP=""
for arg in "$@"; do
case "$arg" in
--yes|-y) ASSUME_YES=1 ;;
--dry-run) DRY_RUN=1 ;;
--help|-h) sed -n '3,14p' "$0"; exit 0 ;;
--tunnel-port=*) TUNNEL_PORT="${arg#*=}" ;;
--container=*) LOCAL_CONTAINER="${arg#*=}" ;;
--host-alias=*) HOST_ALIAS="${arg#*=}" ;;
-*) echo "未知参数: $arg" >&2; exit 2 ;;
*) DUMP="$arg" ;;
esac
done
if [[ -z "$DUMP" ]]; then
echo "请指定 dump 文件,例如: bash deploy/restore-prod-db.sh backups/prod-dukang_prod-20260823-202044.sql" >&2
exit 2
fi
if [[ ! -f "$DUMP" ]]; then
echo "文件不存在: $DUMP" >&2
exit 1
fi
if [[ -f deploy.env ]]; then
# shellcheck disable=SC1091
source deploy.env
fi
DEPLOY_HOST="${DEPLOY_HOST:?请在 deploy.env 配置 DEPLOY_HOST}"
DEPLOY_USER="${DEPLOY_USER:-root}"
DEPLOY_PORT="${DEPLOY_PORT:-22}"
SSH=(ssh -o StrictHostKeyChecking=accept-new -p "$DEPLOY_PORT")
if [[ -n "${DEPLOY_SSH_KEY:-}" ]]; then SSH+=(-i "$DEPLOY_SSH_KEY"); fi
TARGET="$DEPLOY_USER@$DEPLOY_HOST"
echo "==> 读取线上数据库配置 ($TARGET)"
PARSED="$("${SSH[@]}" "$TARGET" bash -s <<'NODEEOF'
cat > /tmp/_parse_db.js <<'JSEOF'
const fs=require("fs");
const l=fs.readFileSync("/opt/dukang/server/dukang-api/.env.production","utf8").match(/^DATABASE_URL=(.*)$/m)[1].trim();
let u=l;
if((u[0]==='"'&&u[u.length-1]==='"')||(u[0]==="'"&&u[u.length-1]==="'"))u=u.slice(1,-1);
const U=new URL(u);
const b=s=>Buffer.from(s).toString("base64");
const path=U.pathname.replace(/^\//,"").split("?")[0];
process.stdout.write([b(decodeURIComponent(U.username)),b(U.hostname),U.port||"3306",b(path),b(decodeURIComponent(U.password))].join("|")+"\n");
JSEOF
node /tmp/_parse_db.js; rm -f /tmp/_parse_db.js
NODEEOF
)"
if [[ -z "$PARSED" ]]; then
echo "无法读取远端 DATABASE_URL,中止。" >&2; exit 1
fi
IFS='|' read -r _U _H _P _DB _PASS <<< "$PARSED"
REMOTE_USER="$(echo "$_U" | base64 -d)"
REMOTE_HOST="$(echo "$_H" | base64 -d)"
REMOTE_PORT="$_P"
REMOTE_DB="$(echo "$_DB" | base64 -d)"
REMOTE_PASS="$(echo "$_PASS" | base64 -d)"
echo
echo "恢复计划:"
echo " 源文件: $DUMP ($(wc -c < "$DUMP") bytes)"
echo " 目标(线上): $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB"
echo " 隧道: $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
if [[ $DRY_RUN -eq 1 ]]; then
echo "(dry-run) 已结束,未做任何修改。"
exit 0
fi
if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then
echo "本地容器 $LOCAL_CONTAINER 未运行。" >&2
exit 1
fi
if [[ $ASSUME_YES -ne 1 ]]; then
read -r -p "确认用该 dump 覆盖【线上 $REMOTE_DB】? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "已取消。"; exit 0; }
fi
CTL="/tmp/restore-prod-db-$$.sock"
echo "==> 建立 SSH 隧道"
"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET"
cleanup_tunnel() {
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
}
trap cleanup_tunnel EXIT
ready=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysqladmin -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" ping >/dev/null 2>&1; then
ready=1; break
fi
sleep 1
done
if [[ $ready -ne 1 ]]; then
echo "隧道未就绪,中止。" >&2
exit 1
fi
echo "==> 导入 dump 到线上 $REMOTE_DB ..."
docker exec -i -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysql -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" \
--default-character-set=utf8mb4 < "$DUMP"
echo "==> 恢复完成 ✅ 线上 $REMOTE_DB 已回滚到 $DUMP"
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# run-prod-cleanup.sh — 经 SSH 隧道连接生产库,执行测试数据清理
#
# 用法:
# bash deploy/run-prod-cleanup.sh # dry-run
# bash deploy/run-prod-cleanup.sh --apply --yes # 写库(默认带 --force 处理已打款测试核销)
# bash deploy/run-prod-cleanup.sh --inventory # 仅盘点
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$SCRIPT_DIR"
LOCAL_CONTAINER="dukang-v1-mysql"
TUNNEL_PORT=6020
DOCKER_HOST_ALIAS="host.docker.internal"
CLIENT_HOST="127.0.0.1"
APPLY=0
ASSUME_YES=0
FORCE=1
INVENTORY=0
SCRIPT_NAME=""
EXTRA_ARGS=()
for arg in "$@"; do
case "$arg" in
--apply) APPLY=1 ;;
--yes|-y) ASSUME_YES=1 ;;
--no-force) FORCE=0 ;;
--force) FORCE=1 ;;
--inventory) INVENTORY=1 ;;
--script=*) SCRIPT_NAME="${arg#*=}" ;;
--help|-h)
sed -n '3,12p' "$0"
exit 0
;;
*) EXTRA_ARGS+=("$arg") ;;
esac
done
if [[ -f deploy.env ]]; then
# shellcheck disable=SC1091
source deploy.env
fi
DEPLOY_HOST="${DEPLOY_HOST:?请在 deploy.env 配置 DEPLOY_HOST}"
DEPLOY_USER="${DEPLOY_USER:-root}"
DEPLOY_PORT="${DEPLOY_PORT:-22}"
SSH=(ssh -o StrictHostKeyChecking=accept-new -p "$DEPLOY_PORT")
if [[ -n "${DEPLOY_SSH_KEY:-}" ]]; then SSH+=(-i "$DEPLOY_SSH_KEY"); fi
TARGET="$DEPLOY_USER@$DEPLOY_HOST"
if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then
echo "本地容器 $LOCAL_CONTAINER 未运行。" >&2
exit 1
fi
PARSED="$("${SSH[@]}" "$TARGET" bash -s <<'NODEEOF'
cat > /tmp/_parse_db.js <<'JSEOF'
const fs=require("fs");
const l=fs.readFileSync("/opt/dukang/server/dukang-api/.env.production","utf8").match(/^DATABASE_URL=(.*)$/m)[1].trim();
let u=l;
if((u[0]==='"'&&u[u.length-1]==='"')||(u[0]==="'"&&u[u.length-1]==="'"))u=u.slice(1,-1);
const U=new URL(u);
const b=s=>Buffer.from(s).toString("base64");
const path=U.pathname.replace(/^\//,"").split("?")[0];
process.stdout.write([b(decodeURIComponent(U.username)),b(U.hostname),U.port||"3306",b(path),b(decodeURIComponent(U.password))].join("|")+"\n");
JSEOF
node /tmp/_parse_db.js; rm -f /tmp/_parse_db.js
NODEEOF
)"
IFS='|' read -r _U _H _P _DB _PASS <<< "$PARSED"
REMOTE_USER="$(echo "$_U" | base64 -d)"
REMOTE_HOST="$(echo "$_H" | base64 -d)"
REMOTE_PORT="$_P"
REMOTE_DB="$(echo "$_DB" | base64 -d)"
REMOTE_PASS="$(echo "$_PASS" | base64 -d)"
CTL="/tmp/run-prod-cleanup-$$.sock"
echo "==> 建立 SSH 隧道 $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET"
cleanup_tunnel() {
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
}
trap cleanup_tunnel EXIT
ready=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysqladmin -h "$DOCKER_HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" ping >/dev/null 2>&1; then
ready=1; break
fi
sleep 1
done
if [[ $ready -ne 1 ]]; then
echo "隧道未就绪,中止。" >&2
exit 1
fi
ENC_PASS="$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$REMOTE_PASS")"
export DATABASE_URL="mysql://${REMOTE_USER}:${ENC_PASS}@${CLIENT_HOST}:${TUNNEL_PORT}/${REMOTE_DB}?charset=utf8mb4"
export FORCE_DATABASE_URL=1
cd "$REPO_ROOT/server/dukang-api"
CMD=(pnpm)
if [[ -n "$SCRIPT_NAME" ]]; then
CMD+=(ts-node --transpile-only "scripts/${SCRIPT_NAME}.ts")
[[ $FORCE -eq 1 ]] && CMD+=(--force)
[[ $APPLY -eq 1 ]] && CMD+=(--apply)
[[ $ASSUME_YES -eq 1 ]] && CMD+=(--yes)
[[ ${#EXTRA_ARGS[@]} -gt 0 ]] && CMD+=("${EXTRA_ARGS[@]}")
elif [[ $INVENTORY -eq 1 ]]; then
CMD+=(inventory:test-data -- "${EXTRA_ARGS[@]}")
else
CMD+=(cleanup:test-data --)
[[ $FORCE -eq 1 ]] && CMD+=(--force)
[[ $APPLY -eq 1 ]] && CMD+=(--apply)
[[ $ASSUME_YES -eq 1 ]] && CMD+=(--yes)
CMD+=("${EXTRA_ARGS[@]}")
fi
echo "==> 执行: DATABASE_URL=*** ${CMD[*]}"
"${CMD[@]}"
-4
View File
@@ -1,4 +0,0 @@
# 服务器机器相关(可合并到 .env.production 的参考项)
PORT=8090
DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke"
REDIS_URL="redis://localhost:6379"
+8 -22
View File
@@ -2,7 +2,7 @@
# 在服务器上执行:安装 Webhook 自动发版
set -euo pipefail
APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
APP_ROOT="${APP_ROOT:-/opt/dukang}"
DEPLOY_DIR="$APP_ROOT/deploy"
ENV_FILE="$DEPLOY_DIR/auto-release.env"
LOG_DIR="/var/log/dukang"
@@ -30,28 +30,14 @@ HOOK_CONF="$DEPLOY_DIR/nginx-deploy-webhook.conf"
rm -f /etc/nginx/conf.d/dukang-deploy-webhook.conf
MARKER="include $HOOK_CONF;"
for conf in \
/etc/nginx/sites-available/dukang \
/etc/nginx/conf.d/dukang-runxian.conf \
/etc/nginx/conf.d/dukang-runxian-ssl.conf
do
if [[ ! -f "$conf" ]]; then
continue
NGINX_SITE="/etc/nginx/sites-available/dukang"
if [[ -f "$NGINX_SITE" ]]; then
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$NGINX_SITE"
if grep -q 'server_name api.dukanghaoke.com' "$NGINX_SITE" && ! grep -qF "$MARKER" "$NGINX_SITE"; then
sed -i "/server_name api.dukanghaoke.com;/a\\ $MARKER" "$NGINX_SITE"
echo " 已 patch $NGINX_SITE (api.dukanghaoke.com)"
fi
# 清理旧错误 include
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$conf"
if grep -q 'server_name api.dukanghaoke.com' "$conf"; then
if ! grep -qF "$MARKER" "$conf"; then
sed -i "/server_name api.dukanghaoke.com;/a\\ $MARKER" "$conf"
echo " 已 patch $conf (api.dukanghaoke.com)"
fi
elif grep -q 'server_name dkapi.runxian.top' "$conf"; then
if ! grep -qF "$MARKER" "$conf"; then
sed -i "/server_name dkapi.runxian.top;/a\\ $MARKER" "$conf"
echo " 已 patch $conf (dkapi.runxian.top)"
fi
fi
done
fi
nginx -t
systemctl reload nginx
+14
View File
@@ -0,0 +1,14 @@
# 测试数据清理(通用)
按 HQ 白名单 + `isTest` 打标清理,**不写死手机号/订单号**。dump 落在 `deploy/backups/`(已 gitignore)。
## 流程
1. 备份:`bash deploy/backup-prod-db.sh --yes`;建议同时在 RDS 控制台打手动快照
2. 同步到本地演练:`bash deploy/sync-prod-db-to-local.sh --yes`
3. 盘点(默认本地 `.env`):`pnpm --dir server/dukang-api inventory:test-data`
4. 本地 dry-run / apply`pnpm --dir server/dukang-api cleanup:test-data`,确认后加 `--apply --yes`
5. 生产须口头允许后再执行:`bash deploy/run-prod-cleanup.sh`dry-run)→ `--apply --yes`
6. 回滚:`bash deploy/restore-prod-db.sh --yes backups/prod-<库名>-<时间>.sql`
已打款且混有测试流水时需 `--force`。有交叉风险(测试合伙人下仍有正式门店等)会中止。
+11 -1
View File
@@ -24,6 +24,9 @@
| 3.4.16 | 08-09 | 联系电话分离;取消自动 ST;套餐 UI;合伙人列表/入驻客服门槛;HQ 门店列表表格 | [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) |
| 3.5.3 | 08-20 | 门店照片/企微审核/分享;核销金额序列化与提现补建;测试流水计结算;核销浮动提示;权益金额门店图标 | [`v3.5.3`](./杜康好客-v3.5.3-开发文档.md) |
| 3.5.4 | 08-21 | 商品 SPU+SKU 规格;DK 码/独立 69 码/规格主图;下单兼容回落;发票品类;权益文案与门店详情/收银台 UI;客户端版本号 | [`v3.5.4`](./杜康好客-v3.5.4-开发文档.md) |
| 3.5.5 | 08-23 | HQ 订单监控导出 Excel/PDF + 列表页布局优化 | [`v3.5.5`](./杜康好客-v3.5.5-开发文档.md) |
| 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) |
| 3.5.7 | 08-23 | 门店端核销记录时间显示秒;首页「今日核销金额」文案 | [`v3.5.7`](./杜康好客-v3.5.7-开发文档.md) |
---
@@ -88,6 +91,13 @@
**FIN-001~003**:白名单未出账提现 · 单店日限 ¥5000 · T+0 审完预警。
合伙人 **T+30** 独立月账/打款。
**HQ 财务打款信息**`admin-web` 财务四页,v3.5.6):
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
- 核销详情展示核销门店主账户收款信息
### 3.4~3.8 Wave 能力
- **3.4** 门店一号多店、主账号、店员子账号(W2)
@@ -101,7 +111,7 @@
| § | 主题 | 文档 |
|---|------|------|
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
| 3.10 | 开发计划/任务/版本/技术支持联动 | v3.4.11 |
| 3.10 | 开发计划/任务/版本/技术支持联动;任务导出 md/docx/xlsx/pdf;任务附件 | v3.4.11 / v3.5.6 |
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
| 3.12 | **登录手机号 ≠ 对外联系电话**;体验优化 | v3.4.16 |
+4
View File
@@ -60,9 +60,13 @@
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | ✅ 生产 |
| 3.5.4 | [`商品规格 SPU+SKU + 发票/文案 UI`](./杜康好客-v3.5.4-开发文档.md) | ✅ 生产(weapp 版本号需单独上传) |
| 3.5.5 | [`HQ 订单导出 + 订单监控页优化`](./杜康好客-v3.5.5-开发文档.md) | 🔶 开发完成,待发版 |
| 3.5.6 | [`HQ 任务导出/财务银行信息/工单图片/运营去调试/审核全屏`](./杜康好客-v3.5.6-开发文档.md) | ✅ 已交付 |
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
| 日期 | 说明 |
|------|------|
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
| 2026-08-23 | HQ 订单监控:筛选/勾选导出 Excel(xlsx)与 PDF`POST /admin/orders/export` |
| 2026-08-21 | v3.5.4 发生产:规格表/回填 SKU/DK 码/主图列/发票品类;H5+API 200 |
| 2026-08-20 | v3.5.3 增补:Decimal 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 |
+129
View File
@@ -0,0 +1,129 @@
# 杜康好客 · v3.5.6 开发文档
> **2026-08-23** · admin-web / API
> **主题**:HQ 运营与财务体验增强(任务导出、核销/账单银行信息、工单图片、权限、审核全屏)
---
## 1. 版本目标
1. **开发计划** 任务列表支持导出 Markdown / DOCX / Excel / PDF。
2. **好客权益 · 核销详情** 展示核销门店收款账户(户名/账号/开户行)。
3. **门店账单** 保留「已打款」「已结算」文案,用颜色与类型标签区分;页头说明两类终态含义。
4. **财务四页**(门店/合伙人/酒厂/物流)统一展示收款账户与打款凭证;确认打款可填凭证号。
5. **技术支持 → 开发任务** 审批创建任务时带入工单图片;新建任务支持上传图片。
6. **运营权限** 去掉调试模块(核销调试、小飞侠联调),仅超级管理员可见。
7. **门店审核对比** 支持全屏查看详情。
**不做**:C 端 / 合伙人 / 门店端改动;不升小程序版本号。
---
## 2. 功能清单
### 2.1 开发计划任务导出
| # | 项 | 说明 |
|---|----|------|
| 1 | API | `POST /api/v1/admin/dev-plan/tasks/export` |
| 2 | 范围 | `scope=filter` 按筛选;`scope=selected` 仅勾选 `ids` |
| 3 | 格式 | `markdown` / `docx` / `xlsx` / `pdf` |
| 4 | 列 | 任务编号、类型、状态、内容、关联工单、创建人、创建时间、完成时间、附件 |
| 5 | 权限 | `dev_plan` |
### 2.2 核销详情 · 门店收款账户
| # | 项 | 说明 |
|---|----|------|
| 6 | API | `GET /admin/redeem-records/:id` 增加 `store.primaryAccount` |
| 7 | UI | `RedeemRecordDetailDescriptions` 增加「收款账户」区块 |
### 2.3 门店账单状态区分
| 类型 | 终态 | 颜色 | 含义 |
|------|------|------|------|
| T+1 出账 | 已打款 | 绿 | HQ 确认已对公打款 |
| 手动提现 | 已结算 | 青 | HQ 审核通过并完成结算 |
两类单据终态枚举均为 `PAID`,故文案不同;本版不改文案,仅强化视觉区分。
### 2.4 财务四页 · 银行卡打款信息
| 页 | 收款账户来源 | 打款凭证 |
|----|-------------|----------|
| 门店账单 | 主 `StoreAccount` | `StoreBill.paymentRef`T+1);提现沿用 `StoreWithdrawRequest` |
| 合伙人账单 | `PartnerAccount` | `PartnerBill.paymentRef` |
| 酒厂账单 | `WINERY_BANK_*` 系统配置 | `WineryBill.paymentRef` |
| 物流对账 | `FulfillmentProvider` | `LogisticsBill.paymentRef`(挂账模式) |
列表 / 详情 / 导出均展示户名、账号、开户行(及酒厂开户银行);确认打款弹窗可选填凭证号。
### 2.5 工单 → 任务图片
| # | 项 | 说明 |
|---|----|------|
| 8 | DB | `dev_plan_task.attachment_urls` JSON |
| 9 | 审批创建 | 默认拷贝工单 `attachmentUrls` |
| 10 | 新建任务 | `DevPlanTasksPage` 支持 `OssUpload` |
### 2.6 运营去掉调试模块
| 菜单 | 路由 | 新权限键 |
|------|------|----------|
| 核销调试 | `/redeem/debug` | `benefit_debug` |
| 小飞侠联调 | `/deliveries/xiaofeixia` | `deliveries_debug` |
`SUPER_ADMIN` 默认拥有;调试 API 同步鉴权。
### 2.7 门店审核全屏
`StorePackageAuditsPage` 套餐对比 / 信息变更 Drawer 支持「全屏查看」切换 `width=100%`;全屏时图片放大。
---
## 3. Prisma 迁移
- `store_bill.payment_ref`
- `partner_bill.payment_ref`
- `winery_bill.payment_ref`
- `logistics_bill.payment_ref`
- `dev_plan_task.attachment_urls`
脚本:`prisma/migrate-finance-payment-ref-v356.sql``prisma/migrate-dev-plan-attachments-v356.sql`
---
## 4. 发版步骤
| 步骤 | 动作 |
|------|------|
| 1 | `pnpm install`(新增 `docx` |
| 2 | 执行 Prisma 迁移 |
| 3 | `pnpm build`API + admin-web |
| 4 | 发 `dukang-api` + `admin-web` |
---
## 5. 关键路径
| 域 | 路径 |
|----|------|
| 任务导出 | `server/.../dev-plan/admin-dev-plan-export.util.ts` |
| 核销详情 | `admin-redeem.service.ts` + `RedeemRecordDetailDescriptions.tsx` |
| 门店账单 | `StoreBillsPage.tsx` + `settlement.service.ts` |
| 合伙人/酒厂/物流 | `PartnerBillsPage` / `WineryBillsPage` / `LogisticsBillsPage` |
| 工单图片 | `dev-plan.service.ts` + `SupportTicketsPage.tsx` + `DevPlanTasksPage.tsx` |
| 权限 | `hq-permissions.ts` + `AdminLayout.tsx` |
| 审核全屏 | `StorePackageAuditsPage.tsx` + `StorePackageAuditPanel.tsx` |
---
## 6. 验收清单
- [ ] 开发计划:勾选/筛选导出 md/docx/xlsx/pdf,中文正常
- [ ] 核销详情:有主账户时展示户名/账号/开户行
- [ ] 门店账单:T+1「已打款」绿色、提现「已结算」青色;类型 Tag 醒目
- [ ] 财务四页:列表/详情/导出含收款账户;确认打款可填凭证;已打款后可见凭证
- [ ] 工单审批创建任务:图片带入;新建任务可上传图
- [ ] 运营账号无「核销调试」「小飞侠联调」菜单;直链 403
- [ ] 门店审核对比可全屏;图片放大可预览
+50
View File
@@ -0,0 +1,50 @@
# 杜康好客 · v3.5.7 开发文档
> **2026-08-23** · h5-shop
> **主题**:门店端核销页两处 UI 修复(记录时间显示秒、首页统计文案)
---
## 1. 版本目标
1. **核销记录** 列表「核销时间」显示到秒(不再截断到分钟)。
2. **门店首页** 统计卡片「今日到账金额」改为「今日核销金额」,与 `todayAmount`(当日券面核销额合计)语义一致。
**不做**:后端 API / 结算逻辑;提现页时间格式;小程序版本号;`pages/shop/` 原型。
---
## 2. 功能清单
| 工单 | 项 | 说明 |
|------|----|------|
| DPT-20260821-935 | 核销记录时间 | `RecordsPage.tsx` 去掉 `.slice(0, 16)`,保留 `toLocaleString('zh-CN', { hour12: false })` |
| DPT-20260821-796 | 首页统计文案 | `HomePage.tsx` 标签「今日到账金额」→「今日核销金额」 |
---
## 3. 关键路径
| 域 | 路径 |
|----|------|
| 核销记录 | `apps/h5-shop/src/pages/RecordsPage.tsx` |
| 门店首页 | `apps/h5-shop/src/pages/HomePage.tsx` |
---
## 4. 发版步骤
| 步骤 | 动作 |
|------|------|
| 1 | `pnpm build`h5-shop |
| 2 | 发 `h5-shop` 静态资源 |
无 Prisma 迁移、无 API 变更。
---
## 5. 验收清单
- [ ] 门店首页统计区显示「今日核销金额」,金额仍为当日券面合计
- [ ] 核销记录页每条「核销时间」含秒(如 `2026/8/23 18:48:30`
- [ ] 打款时间、金额列、筛选与 Tab 行为无回归
+23 -1
View File
@@ -92,6 +92,8 @@ export interface DevPlanTaskDto {
completedAt?: string | null;
attachmentUrls?: string[] | null;
}
@@ -148,6 +150,8 @@ export interface CreateDevPlanTaskInput {
supportTicketId?: string;
attachmentUrls?: string[];
}
@@ -160,6 +164,8 @@ export interface UpdateDevPlanTaskInput {
status?: DevPlanTaskStatusDto;
attachmentUrls?: string[];
}
@@ -228,7 +234,23 @@ export interface DevPlanLinkedTaskSummary {
/** 技术支持工单类型 → 开发任务类型 */
export type DevPlanTaskExportFormat = 'markdown' | 'docx' | 'xlsx' | 'pdf';
export interface DevPlanTaskExportRequest {
scope: 'filter' | 'selected';
format: DevPlanTaskExportFormat;
ids?: string[];
status?: string;
type?: string;
keyword?: string;
}
export interface DevPlanTaskExportResponse {
filename: string;
mimeType: string;
contentBase64: string;
count: number;
}
export function mapSupportTicketTypeToDevPlanTask(
+15 -2
View File
@@ -10,7 +10,9 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'partners', label: '开城管理', group: '业务' },
{ key: 'finance', label: '财务', group: '业务' },
{ key: 'benefit', label: '好客权益', group: '业务' },
{ key: 'benefit_debug', label: '核销调试', group: '业务' },
{ key: 'deliveries', label: '配送单', group: '业务' },
{ key: 'deliveries_debug', label: '小飞侠联调', group: '业务' },
{ key: 'tickets', label: '工单中心', group: '业务' },
{ key: 'tech_support', label: '技术支持', group: '业务' },
{ key: 'invoices', label: '发票管理', group: '业务' },
@@ -41,6 +43,11 @@ export const HQ_PERMISSION_CATALOG = [
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
/** 危险操作:非超管角色默认不含,需在权限分配中显式勾选;超管默认拥有 */
export const HQ_DEBUG_PERMISSION_KEYS = [
'benefit_debug',
'deliveries_debug',
] as const satisfies readonly HqPermissionKey[];
export const HQ_DANGEROUS_PERMISSION_KEYS = [
'users_delete',
'orders_delete',
@@ -51,8 +58,14 @@ export function isHqDangerousPermission(key: string): boolean {
return (HQ_DANGEROUS_PERMISSION_KEYS as readonly string[]).includes(key);
}
export function isHqDebugPermission(key: string): boolean {
return (HQ_DEBUG_PERMISSION_KEYS as readonly string[]).includes(key);
}
export function hqBasePermissionKeys(): HqPermissionKey[] {
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter((k) => !isHqDangerousPermission(k));
return HQ_PERMISSION_CATALOG.map((p) => p.key).filter(
(k) => !isHqDangerousPermission(k) && !isHqDebugPermission(k),
);
}
/** 系统配置 registry group → 权限 key */
@@ -100,7 +113,7 @@ export const HQ_ADMIN_ROLES = [
] as const;
export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
SUPER_ADMIN: hqBasePermissionKeys(),
SUPER_ADMIN: [...hqBasePermissionKeys(), ...HQ_DEBUG_PERMISSION_KEYS],
OPS: [
'dashboard',
'users',
@@ -96,6 +96,7 @@ export interface RejectSupportTicketRequest {
export interface CreateDevPlanTaskFromTicketInput {
content: string;
type: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION';
attachmentUrls?: string[];
}
export interface ReviewSupportTicketRequest {
+111 -44
View File
@@ -68,13 +68,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
version: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
apps/h5-partner:
dependencies:
@@ -120,13 +120,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
version: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
apps/h5-shop:
dependencies:
@@ -160,13 +160,13 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
typescript:
specifier: ^5.4.5
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
version: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
apps/mini-user:
dependencies:
@@ -193,7 +193,7 @@ importers:
version: 4.2.0(@swc/helpers@0.5.23)
'@tarojs/plugin-framework-react':
specifier: 4.2.0
version: 4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))
version: 4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))
'@tarojs/plugin-platform-h5':
specifier: 4.2.0
version: 4.2.0(@swc/helpers@0.5.23)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)))(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))
@@ -242,13 +242,13 @@ importers:
version: 4.2.0(@swc/helpers@0.5.23)
'@tarojs/vite-runner':
specifier: 4.2.0
version: 4.2.0(@swc/helpers@0.5.23)(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
version: 4.2.0(@swc/helpers@0.5.23)(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
'@types/react':
specifier: ^18.3.3
version: 18.3.31
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
version: 4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
babel-preset-taro:
specifier: 4.2.0
version: 4.2.0(@babel/core@7.29.7)(@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7))(@babel/preset-react@7.29.7(@babel/core@7.29.7))(@swc/helpers@0.5.23)
@@ -257,7 +257,7 @@ importers:
version: 5.9.3
vite:
specifier: ^5.4.0
version: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
version: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
packages/client-logging:
devDependencies:
@@ -272,7 +272,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^1.6.0
version: 1.6.1(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
version: 1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
packages/shared-types:
devDependencies:
@@ -358,6 +358,9 @@ importers:
class-validator:
specifier: ^0.14.1
version: 0.14.4
docx:
specifier: ^9.5.0
version: 9.7.1
dotenv:
specifier: ^17.4.2
version: 17.4.2
@@ -388,7 +391,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^10.4.0
version: 10.4.9
version: 10.4.9(@swc/core@1.3.96(@swc/helpers@0.5.23))
'@nestjs/schematics':
specifier: ^10.1.0
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
@@ -415,7 +418,7 @@ importers:
version: 5.22.0
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.19.43)(typescript@5.9.3)
version: 10.9.2(@swc/core@1.3.96(@swc/helpers@0.5.23))(@types/node@20.19.43)(typescript@5.9.3)
typescript:
specifier: ^5.4.5
version: 5.9.3
@@ -2634,6 +2637,9 @@ packages:
'@types/node@22.20.0':
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
'@types/node@25.9.5':
resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==}
'@types/pdfkit@0.17.6':
resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==}
@@ -3650,6 +3656,10 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
docx@9.7.1:
resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==}
engines: {node: '>=10'}
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
@@ -4280,6 +4290,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
hashery@1.5.1:
resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==}
engines: {node: '>=20'}
@@ -4934,6 +4947,9 @@ packages:
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
engines: {node: '>=4'}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -5015,6 +5031,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.16:
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
engines: {node: ^18 || >=20}
hasBin: true
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -6532,6 +6553,9 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
unescape@1.0.1:
resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==}
engines: {node: '>=0.10.0'}
@@ -6813,10 +6837,17 @@ packages:
utf-8-validate:
optional: true
xml-js@1.6.11:
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
hasBin: true
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xml@1.0.1:
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
@@ -8258,7 +8289,7 @@ snapshots:
bullmq: 5.79.2
tslib: 2.8.1
'@nestjs/cli@10.4.9':
'@nestjs/cli@10.4.9(@swc/core@1.3.96(@swc/helpers@0.5.23))':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
'@angular-devkit/schematics': 17.3.11(chokidar@3.6.0)
@@ -8268,7 +8299,7 @@ snapshots:
chokidar: 3.6.0
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1)
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23)))
glob: 10.4.5
inquirer: 8.2.6
node-emoji: 1.11.0
@@ -8277,8 +8308,10 @@ snapshots:
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.7.2
webpack: 5.97.1
webpack: 5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))
webpack-node-externals: 3.0.0
optionalDependencies:
'@swc/core': 1.3.96(@swc/helpers@0.5.23)
transitivePeerDependencies:
- '@minify-html/node'
- '@swc/css'
@@ -9084,7 +9117,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tarojs/plugin-framework-react@4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))':
'@tarojs/plugin-framework-react@4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/runtime@4.2.0)(@tarojs/shared@4.2.0)(@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)))(react@18.3.1)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))':
dependencies:
'@tarojs/helper': 4.2.0(@swc/helpers@0.5.23)
'@tarojs/runtime': 4.2.0
@@ -9094,9 +9127,9 @@ snapshots:
lodash: 4.18.1
tslib: 2.8.1
optionalDependencies:
'@vitejs/plugin-react': 4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
'@vitejs/plugin-react': 4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
react: 18.3.1
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
webpack: 5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)
'@tarojs/plugin-platform-h5@4.2.0(@swc/helpers@0.5.23)(@tarojs/taro@4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)))(@tarojs/helper@4.2.0(@swc/helpers@0.5.23))(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)))(@types/react@18.3.31)(postcss@8.5.15)(react@18.3.1)(rollup@3.30.0)(solid-js@1.9.14)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15))':
@@ -9217,7 +9250,7 @@ snapshots:
rollup: 3.30.0
webpack: 5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)
'@tarojs/vite-runner@4.2.0(@swc/helpers@0.5.23)(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))':
'@tarojs/vite-runner@4.2.0(@swc/helpers@0.5.23)(@tarojs/runtime@4.2.0)(@types/babel__core@7.20.5)(postcss@8.5.15)(rollup@3.30.0)(terser@5.48.0)(typescript@5.9.3)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@babel/core': 7.29.7
@@ -9230,7 +9263,7 @@ snapshots:
'@tarojs/runner-utils': 4.2.0(@swc/helpers@0.5.23)
'@tarojs/runtime': 4.2.0
'@tarojs/shared': 4.2.0
'@vitejs/plugin-legacy': 4.1.1(terser@5.48.0)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
'@vitejs/plugin-legacy': 4.1.1(terser@5.48.0)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
acorn-walk: 8.3.5
autoprefixer: 10.5.2(postcss@8.5.15)
babel-plugin-transform-taroapi: 4.2.0(@babel/core@7.29.7)
@@ -9253,8 +9286,8 @@ snapshots:
regenerator-runtime: 0.11.1
sax: 1.2.4
stylelint: 16.26.1(typescript@5.9.3)
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite-plugin-static-copy: 0.17.1(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
vite-plugin-static-copy: 0.17.1(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))
transitivePeerDependencies:
- '@swc/helpers'
- '@types/babel__core'
@@ -9400,6 +9433,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@25.9.5':
dependencies:
undici-types: 7.24.6
'@types/pdfkit@0.17.6':
dependencies:
'@types/node': 20.19.43
@@ -9548,7 +9585,7 @@ snapshots:
'@typescript-eslint/types': 8.62.1
eslint-visitor-keys: 5.0.1
'@vitejs/plugin-legacy@4.1.1(terser@5.48.0)(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))':
'@vitejs/plugin-legacy@4.1.1(terser@5.48.0)(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/preset-env': 7.29.7(@babel/core@7.29.7)
@@ -9558,11 +9595,11 @@ snapshots:
regenerator-runtime: 0.13.11
systemjs: 6.15.1
terser: 5.48.0
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0))':
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@@ -9570,7 +9607,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
transitivePeerDependencies:
- supports-color
@@ -10670,6 +10707,15 @@ snapshots:
dependencies:
esutils: 2.0.3
docx@9.7.1:
dependencies:
'@types/node': 25.9.5
hash.js: 1.1.7
jszip: 3.10.1
nanoid: 5.1.16
xml: 1.0.1
xml-js: 1.6.11
dot-case@3.0.4:
dependencies:
no-case: 3.0.4
@@ -11205,7 +11251,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1):
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))):
dependencies:
'@babel/code-frame': 7.29.7
chalk: 4.1.2
@@ -11220,7 +11266,7 @@ snapshots:
semver: 7.8.5
tapable: 2.3.3
typescript: 5.7.2
webpack: 5.97.1
webpack: 5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))
form-data@4.0.6:
dependencies:
@@ -11474,6 +11520,11 @@ snapshots:
dependencies:
has-symbols: 1.1.0
hash.js@1.1.7:
dependencies:
inherits: 2.0.4
minimalistic-assert: 1.0.1
hashery@1.5.1:
dependencies:
hookified: 1.15.1
@@ -12085,6 +12136,8 @@ snapshots:
mimic-response@1.0.1: {}
minimalistic-assert@1.0.1: {}
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.7
@@ -12172,6 +12225,8 @@ snapshots:
nanoid@3.3.15: {}
nanoid@5.1.16: {}
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
@@ -13589,13 +13644,15 @@ snapshots:
postcss: 8.5.15
optional: true
terser-webpack-plugin@5.6.1(webpack@5.97.1):
terser-webpack-plugin@5.6.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.48.0
webpack: 5.97.1
webpack: 5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))
optionalDependencies:
'@swc/core': 1.3.96(@swc/helpers@0.5.23)
terser@5.48.0:
dependencies:
@@ -13681,7 +13738,7 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3):
ts-node@10.9.2(@swc/core@1.3.96(@swc/helpers@0.5.23))(@types/node@20.19.43)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
@@ -13698,6 +13755,8 @@ snapshots:
typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
optionalDependencies:
'@swc/core': 1.3.96(@swc/helpers@0.5.23)
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
@@ -13779,6 +13838,8 @@ snapshots:
undici-types@6.21.0: {}
undici-types@7.24.6: {}
unescape@1.0.1:
dependencies:
extend-shallow: 2.0.1
@@ -13892,13 +13953,13 @@ snapshots:
vary@1.1.2: {}
vite-node@1.6.1(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0):
vite-node@1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -13910,26 +13971,26 @@ snapshots:
- supports-color
- terser
vite-plugin-static-copy@0.17.1(vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)):
vite-plugin-static-copy@0.17.1(vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)):
dependencies:
chokidar: 3.6.0
fast-glob: 3.3.3
fs-extra: 11.3.6
picocolors: 1.1.1
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
vite@5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0):
vite@5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.15
rollup: 4.62.2
optionalDependencies:
'@types/node': 22.20.0
'@types/node': 25.9.5
fsevents: 2.3.3
sass: 1.101.0
terser: 5.48.0
vitest@1.6.1(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0):
vitest@1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0):
dependencies:
'@vitest/expect': 1.6.1
'@vitest/runner': 1.6.1
@@ -13948,11 +14009,11 @@ snapshots:
strip-literal: 2.1.1
tinybench: 2.9.0
tinypool: 0.8.4
vite: 5.4.21(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite-node: 1.6.1(@types/node@22.20.0)(sass@1.101.0)(terser@5.48.0)
vite: 5.4.21(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
vite-node: 1.6.1(@types/node@25.9.5)(sass@1.101.0)(terser@5.48.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.0
'@types/node': 25.9.5
transitivePeerDependencies:
- less
- lightningcss
@@ -13983,7 +14044,7 @@ snapshots:
webpack-sources@3.5.0: {}
webpack@5.97.1:
webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23)):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9
@@ -14005,7 +14066,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.3.3
terser-webpack-plugin: 5.6.1(webpack@5.97.1)
terser-webpack-plugin: 5.6.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23)))
watchpack: 2.5.2
webpack-sources: 3.5.0
transitivePeerDependencies:
@@ -14129,11 +14190,17 @@ snapshots:
ws@8.21.1: {}
xml-js@1.6.11:
dependencies:
sax: 1.6.0
xml2js@0.6.2:
dependencies:
sax: 1.6.0
xmlbuilder: 11.0.1
xml@1.0.1: {}
xmlbuilder@11.0.1: {}
xmlchars@2.2.0: {}
+4 -1
View File
@@ -22,7 +22,9 @@
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts"
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts",
"inventory:test-data": "ts-node --transpile-only scripts/inventory-test-data.ts",
"cleanup:test-data": "ts-node --transpile-only scripts/cleanup-prod-test-data.ts"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.6.0",
@@ -43,6 +45,7 @@
"bullmq": "^5.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"docx": "^9.5.0",
"dotenv": "^17.4.2",
"exceljs": "^4.4.0",
"express": "^4.21.0",
@@ -0,0 +1,2 @@
-- v3.5.6: 开发计划任务附件
ALTER TABLE `dev_plan_task` ADD COLUMN `attachment_urls` JSON NULL AFTER `support_ticket_id`;
@@ -0,0 +1,5 @@
-- v3.5.6: 财务账单打款凭证
ALTER TABLE `store_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
ALTER TABLE `partner_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
ALTER TABLE `winery_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
ALTER TABLE `logistics_bill` ADD COLUMN `payment_ref` VARCHAR(128) NULL AFTER `status`;
+5
View File
@@ -733,6 +733,7 @@ model DevPlanTask {
status DevPlanTaskStatus @default(TODO)
creatorHqAccountId BigInt @map("creator_hq_account_id") @db.UnsignedBigInt
supportTicketId BigInt? @map("support_ticket_id") @db.UnsignedBigInt
attachmentUrls Json? @map("attachment_urls")
lastDispatchedAt DateTime? @map("last_dispatched_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3)
@@ -1073,6 +1074,7 @@ model LogisticsBill {
settlementMethod LogisticsSettlementMethod @map("settlement_method")
pricingSnapshotJson String? @map("pricing_snapshot_json") @db.Text
status FinancePayStatus @default(UNPAID)
paymentRef String? @map("payment_ref") @db.VarChar(128)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@ -1215,6 +1217,7 @@ model PartnerBill {
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status PartnerBillStatus @default(PENDING_REVIEW)
paymentRef String? @map("payment_ref") @db.VarChar(128)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
sentAt DateTime? @map("sent_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
@@ -1863,6 +1866,7 @@ model StoreBill {
settlementRate Decimal @map("settlement_rate") @db.Decimal(5, 4)
payoutAmount Decimal @map("payout_amount") @db.Decimal(10, 2)
status FinancePayStatus @default(UNPAID)
paymentRef String? @map("payment_ref") @db.VarChar(128)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@ -1946,6 +1950,7 @@ model WineryBill {
wineryRate Decimal @map("winery_rate") @db.Decimal(5, 4)
wineryAmount Decimal @map("winery_amount") @db.Decimal(10, 2)
status FinancePayStatus @default(UNPAID)
paymentRef String? @map("payment_ref") @db.VarChar(128)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@ -0,0 +1,448 @@
/**
* 生产/本地测试数据清理脚本
*
* 默认 dry-run;加 --apply --yes 才写库。
*
* 用法:
* cd server/dukang-api
* pnpm inventory:test-data
* pnpm cleanup:test-data
* pnpm cleanup:test-data -- --apply --yes
* APP_ENV=production pnpm cleanup:test-data -- --apply --yes
*/
import '../src/load-env';
import { Prisma, PrismaClient } from '@prisma/client';
import {
collectTestDataScope,
countTestData,
detectCrossRisks,
} from './test-data-cleanup.shared';
const prisma = new PrismaClient();
const apply = process.argv.includes('--apply');
const assumeYes = process.argv.includes('--yes');
const skipRiskBlock = process.argv.includes('--force');
const jsonMode = process.argv.includes('--json');
type DeleteStats = Record<string, number>;
function bump(stats: DeleteStats, key: string, n: number) {
stats[key] = (stats[key] ?? 0) + n;
}
async function recalcStoreBill(prismaTx: Prisma.TransactionClient, billId: bigint) {
const payouts = await prismaTx.storePayout.findMany({
where: { storeBillId: billId },
select: { redeemAmount: true, payoutAmount: true },
});
if (!payouts.length) {
await prismaTx.storeBill.delete({ where: { id: billId } });
return 'deleted';
}
const redeemAmount = payouts.reduce((s, p) => s + Number(p.redeemAmount), 0);
const payoutAmount = payouts.reduce((s, p) => s + Number(p.payoutAmount), 0);
const bill = await prismaTx.storeBill.findUniqueOrThrow({ where: { id: billId } });
const rate = Number(bill.settlementRate);
await prismaTx.storeBill.update({
where: { id: billId },
data: {
redeemCount: payouts.length,
redeemAmount,
payoutAmount,
settlementRate: rate,
},
});
return 'updated';
}
async function recalcWineryBill(prismaTx: Prisma.TransactionClient, billId: bigint) {
const items = await prismaTx.wineryBillItem.findMany({ where: { wineryBillId: billId } });
if (!items.length) {
await prismaTx.wineryBill.delete({ where: { id: billId } });
return 'deleted';
}
const orderAmount = items.reduce((s, i) => s + Number(i.payAmount), 0);
const wineryAmount = items.reduce((s, i) => s + Number(i.wineryAmount), 0);
await prismaTx.wineryBill.update({
where: { id: billId },
data: {
orderCount: items.length,
orderAmount,
wineryAmount,
},
});
return 'updated';
}
async function recalcLogisticsBill(prismaTx: Prisma.TransactionClient, billId: bigint) {
const items = await prismaTx.logisticsBillItem.findMany({ where: { logisticsBillId: billId } });
if (!items.length) {
await prismaTx.logisticsBill.delete({ where: { id: billId } });
return 'deleted';
}
const bottleCount = items.reduce((s, i) => s + i.quantity, 0);
const logisticsAmount = items.reduce((s, i) => s + Number(i.logisticsAmount), 0);
await prismaTx.logisticsBill.update({
where: { id: billId },
data: {
orderCount: items.length,
bottleCount,
logisticsAmount,
},
});
return 'updated';
}
async function runCleanup(scope: Awaited<ReturnType<typeof collectTestDataScope>>) {
const stats: DeleteStats = {};
const deletableLimitedProductIds = scope.limitedProductIds.filter(
(id) => !scope.limitedProductsWithFormalOrders.some((p) => p.productId === id),
);
const {
testUserIds,
deletableTestStoreAccountIds,
promotedTestStoreAccountIds,
deletableTestPartnerIds,
promotedTestPartnerIds,
deletableTestStoreIds,
promotedTestStoreIds,
testOrderIds,
testRedeemIds,
} = scope;
await prisma.$transaction(
async (tx) => {
// --- analytics / logs ---
if (testUserIds.length) {
bump(stats, 'logUserAnalytics', (await tx.logUserAnalytics.deleteMany({ where: { userId: { in: testUserIds } } })).count);
bump(stats, 'logPromoEvent(user)', (await tx.logPromoEvent.deleteMany({ where: { userId: { in: testUserIds } } })).count);
}
if (deletableTestStoreIds.length) {
bump(stats, 'logStoreAnalytics', (await tx.logStoreAnalytics.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count);
}
if (deletableTestStoreAccountIds.length) {
bump(stats, 'logStoreAnalytics(acct)', (await tx.logStoreAnalytics.deleteMany({ where: { storeAccountId: { in: deletableTestStoreAccountIds } } })).count);
}
if (deletableTestPartnerIds.length) {
bump(stats, 'logPartnerAnalytics', (await tx.logPartnerAnalytics.deleteMany({ where: { partnerAccountId: { in: deletableTestPartnerIds } } })).count);
}
if (testOrderIds.length) {
bump(stats, 'logPromoEvent(order)', (await tx.logPromoEvent.deleteMany({ where: { orderId: { in: testOrderIds } } })).count);
const wineryItems = await tx.wineryBillItem.findMany({
where: { orderId: { in: testOrderIds } },
select: { wineryBillId: true },
});
const wineryBillIds = [...new Set(wineryItems.map((i) => i.wineryBillId))];
bump(stats, 'wineryBillItem', (await tx.wineryBillItem.deleteMany({ where: { orderId: { in: testOrderIds } } })).count);
const logisticsItems = await tx.logisticsBillItem.findMany({
where: { orderId: { in: testOrderIds } },
select: { logisticsBillId: true },
});
const logisticsBillIds = [...new Set(logisticsItems.map((i) => i.logisticsBillId))];
bump(stats, 'logisticsBillItem', (await tx.logisticsBillItem.deleteMany({ where: { orderId: { in: testOrderIds } } })).count);
for (const billId of wineryBillIds) {
const action = await recalcWineryBill(tx, billId);
if (action === 'deleted') bump(stats, 'wineryBill', 1);
else bump(stats, 'wineryBillRecalc', 1);
}
for (const billId of logisticsBillIds) {
const action = await recalcLogisticsBill(tx, billId);
if (action === 'deleted') bump(stats, 'logisticsBill', 1);
else bump(stats, 'logisticsBillRecalc', 1);
}
}
// --- redeem chain ---
if (testRedeemIds.length) {
const payouts = await tx.storePayout.findMany({
where: { redeemRecordId: { in: testRedeemIds } },
select: { id: true, storeBillId: true },
});
const payoutIds = payouts.map((p) => p.id);
const billIds = [...new Set(payouts.map((p) => p.storeBillId).filter((id): id is bigint => id != null))];
if (payoutIds.length) {
bump(stats, 'storeWithdrawPayoutItem', (await tx.storeWithdrawPayoutItem.deleteMany({ where: { storePayoutId: { in: payoutIds } } })).count);
bump(stats, 'storePayout', (await tx.storePayout.deleteMany({ where: { id: { in: payoutIds } } })).count);
}
for (const billId of billIds) {
const action = await recalcStoreBill(tx, billId);
if (action === 'deleted') bump(stats, 'storeBill', 1);
else bump(stats, 'storeBillRecalc', 1);
}
bump(stats, 'storeRating', (await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: testRedeemIds } } })).count);
bump(stats, 'redeemPendingRecord', (await tx.redeemPendingRecord.deleteMany({ where: { redeemRecordId: { in: testRedeemIds } } })).count);
bump(stats, 'redeemRecordAllocation', (await tx.redeemRecordAllocation.deleteMany({ where: { redeemRecordId: { in: testRedeemIds } } })).count);
bump(stats, 'redeemRecord', (await tx.redeemRecord.deleteMany({ where: { id: { in: testRedeemIds } } })).count);
}
if (deletableTestStoreIds.length || deletableTestStoreAccountIds.length) {
bump(
stats,
'redeemPendingRecord(store)',
(
await tx.redeemPendingRecord.deleteMany({
where: {
OR: [
...(deletableTestStoreIds.length ? [{ storeId: { in: deletableTestStoreIds } }] : []),
...(deletableTestStoreAccountIds.length ? [{ storeAccountId: { in: deletableTestStoreAccountIds } }] : []),
],
},
})
).count,
);
}
if (deletableTestStoreIds.length || deletableTestStoreAccountIds.length) {
bump(
stats,
'storeWithdrawRequest',
(
await tx.storeWithdrawRequest.deleteMany({
where: {
OR: [
...(deletableTestStoreIds.length ? [{ storeId: { in: deletableTestStoreIds } }] : []),
...(deletableTestStoreAccountIds.length ? [{ storeAccountId: { in: deletableTestStoreAccountIds } }] : []),
],
},
})
).count,
);
}
// --- orders / coupons / invoices ---
if (testOrderIds.length) {
bump(stats, 'userInvoice', (await tx.userInvoice.deleteMany({ where: { orderId: { in: testOrderIds } } })).count);
}
if (testUserIds.length) {
bump(stats, 'userInvoice(user)', (await tx.userInvoice.deleteMany({ where: { userId: { in: testUserIds } } })).count);
bump(stats, 'benefitCoupon', (await tx.benefitCoupon.deleteMany({ where: { userId: { in: testUserIds } } })).count);
}
if (testOrderIds.length) {
bump(stats, 'benefitCoupon(order)', (await tx.benefitCoupon.deleteMany({ where: { orderId: { in: testOrderIds } } })).count);
bump(stats, 'orderDelivery', (await tx.orderDelivery.deleteMany({ where: { orderId: { in: testOrderIds } } })).count);
bump(stats, 'order', (await tx.order.deleteMany({ where: { id: { in: testOrderIds } } })).count);
}
// --- promote mixed entities before deleting pure test ones ---
if (promotedTestStoreIds.length) {
bump(
stats,
'store.promoted',
(
await tx.store.updateMany({
where: { id: { in: promotedTestStoreIds } },
data: { isTest: false, visibilityWhitelistEnabled: false },
})
).count,
);
}
if (promotedTestStoreAccountIds.length) {
bump(
stats,
'storeAccount.promoted',
(
await tx.storeAccount.updateMany({
where: { id: { in: promotedTestStoreAccountIds } },
data: { isTest: false },
})
).count,
);
}
if (promotedTestPartnerIds.length) {
bump(
stats,
'partnerAccount.promoted',
(
await tx.partnerAccount.updateMany({
where: { id: { in: promotedTestPartnerIds } },
data: { isTest: false },
})
).count,
);
}
// --- store subtree (deletable only) ---
if (deletableTestStoreIds.length) {
bump(stats, 'storeVisibilityPhone', (await tx.storeVisibilityPhone.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count);
bump(stats, 'storePackage', (await tx.storePackage.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count);
bump(stats, 'storePackageChangeRequest', (await tx.storePackageChangeRequest.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count);
bump(stats, 'storeInfoChangeRequest', (await tx.storeInfoChangeRequest.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count);
bump(stats, 'storeAccountStore', (await tx.storeAccountStore.deleteMany({ where: { storeId: { in: deletableTestStoreIds } } })).count);
bump(stats, 'store', (await tx.store.deleteMany({ where: { id: { in: deletableTestStoreIds } } })).count);
}
if (deletableTestStoreAccountIds.length) {
await tx.storeAccount.updateMany({
where: { parentAccountId: { in: deletableTestStoreAccountIds } },
data: { parentAccountId: null },
});
bump(stats, 'storeAccountStore(acct)', (await tx.storeAccountStore.deleteMany({ where: { storeAccountId: { in: deletableTestStoreAccountIds } } })).count);
bump(stats, 'storeAccount', (await tx.storeAccount.deleteMany({ where: { id: { in: deletableTestStoreAccountIds } } })).count);
}
// --- partners (deletable only) ---
if (deletableTestPartnerIds.length) {
bump(stats, 'partnerBill', (await tx.partnerBill.deleteMany({ where: { partnerAccountId: { in: deletableTestPartnerIds } } })).count);
await tx.partnerAccount.updateMany({
where: { parentAccountId: { in: deletableTestPartnerIds } },
data: { parentAccountId: null },
});
await tx.partnerAccount.updateMany({
where: { id: { in: deletableTestPartnerIds } },
data: { managedWarehouseId: null },
});
await tx.cityWarehouse.updateMany({
where: { partnerAccountId: { in: deletableTestPartnerIds } },
data: { partnerAccountId: null },
});
bump(stats, 'partnerAccount', (await tx.partnerAccount.deleteMany({ where: { id: { in: deletableTestPartnerIds } } })).count);
}
// --- users ---
if (testUserIds.length) {
await tx.user.updateMany({
where: { mergedIntoUserId: { in: testUserIds } },
data: { mergedIntoUserId: null },
});
await tx.user.updateMany({
where: { referrerUserId: { in: testUserIds } },
data: { referrerUserId: null },
});
bump(stats, 'userPromoAttribution', (await tx.userPromoAttribution.deleteMany({ where: { userId: { in: testUserIds } } })).count);
bump(stats, 'commonPromoCode(owner)', (await tx.commonPromoCode.updateMany({ where: { ownerUserId: { in: testUserIds } }, data: { ownerUserId: null } })).count);
bump(stats, 'user', (await tx.user.deleteMany({ where: { id: { in: testUserIds } } })).count);
}
// --- limited products ---
if (deletableLimitedProductIds.length) {
bump(
stats,
'commonProductVisibilityPhone',
(await tx.commonProductVisibilityPhone.deleteMany({ where: { productId: { in: deletableLimitedProductIds } } })).count,
);
bump(stats, 'commonProductItem', (await tx.commonProductItem.deleteMany({ where: { id: { in: deletableLimitedProductIds } } })).count);
}
// --- whitelist + visibility flags ---
bump(stats, 'commonTestWhitelistPhone', (await tx.commonTestWhitelistPhone.deleteMany()).count);
bump(
stats,
'store.visibilityOff',
(
await tx.store.updateMany({
where: { visibilityWhitelistEnabled: true },
data: { visibilityWhitelistEnabled: false },
})
).count,
);
bump(
stats,
'product.visibilityOff',
(
await tx.commonProductItem.updateMany({
where: { visibilityWhitelistEnabled: true },
data: { visibilityWhitelistEnabled: false },
})
).count,
);
},
{ timeout: 600_000 },
);
return stats;
}
async function main() {
const before = await countTestData(prisma);
const scope = await collectTestDataScope(prisma);
const risks = await detectCrossRisks(prisma, scope);
const blockingRisks = risks.filter((r) => {
if (r.code === 'LIMITED_PRODUCT_FORMAL_ORDERS' || r.code === 'TEST_STORE_FORMAL_REDEEMS') {
return false;
}
if (r.code === 'TEST_PARTNER_FORMAL_STORES') return true;
if (skipRiskBlock) return false;
return (
r.code === 'PAID_WINERY_BILL_TEST_ORDERS' ||
r.code === 'PAID_LOGISTICS_BILL_TEST_ORDERS' ||
r.code === 'PAID_STORE_PAYOUT_TEST_REDEEMS'
);
});
const plan = {
mode: apply ? 'apply' : 'dry-run',
before,
scope: {
testUsers: scope.testUserIds.length,
testStoreAccounts: scope.testStoreAccountIds.length,
deletableTestStoreAccounts: scope.deletableTestStoreAccountIds.length,
promotedTestStoreAccounts: scope.promotedTestStoreAccountIds.length,
testPartners: scope.testPartnerIds.length,
deletableTestPartners: scope.deletableTestPartnerIds.length,
promotedTestPartners: scope.promotedTestPartnerIds.length,
testStores: scope.testStoreIds.length,
deletableTestStores: scope.deletableTestStoreIds.length,
promotedTestStores: scope.promotedTestStoreIds.length,
testOrders: scope.testOrderIds.length,
testRedeems: scope.testRedeemIds.length,
whitelistPhones: scope.whitelistPhones.length,
limitedProducts: scope.limitedProductIds.length,
deletableLimitedProducts:
scope.limitedProductIds.length - scope.limitedProductsWithFormalOrders.length,
},
crossRisks: risks,
blockingRisks: blockingRisks.map((r) => r.code),
};
if (jsonMode) {
console.log(JSON.stringify(plan, null, 2));
} else {
console.log('=== 杜康好客 · 测试数据清理 ===');
console.log(`模式: ${plan.mode}`);
console.log(`库: ${(process.env.DATABASE_URL ?? '').replace(/:([^:@/]+)@/, ':***@')}`);
console.log('待处理:', plan.scope);
if (risks.length) {
console.log('交叉风险:');
for (const r of risks) console.log(` [${r.code}] ${r.message}`);
}
}
if (blockingRisks.length) {
console.error('存在阻塞性交叉风险,已中止。请查看 inventory 报告或加 --force(已打款账单类风险)。');
process.exit(2);
}
if (!apply) {
console.log('\n(dry-run) 未修改数据库。确认后加: --apply --yes');
return;
}
if (!assumeYes) {
console.error('写库需同时传 --apply --yes');
process.exit(2);
}
const stats = await runCleanup(scope);
const after = await countTestData(prisma);
const result = { stats, after };
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log('\n删除统计:', stats);
console.log('清理后计数:', after);
console.log('完成 ✅');
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,189 @@
/**
* 只读盘点:白名单 / isTest / 限测商品 / 交叉风险
*
* 用法:
* cd server/dukang-api
* pnpm inventory:test-data
* pnpm inventory:test-data -- --json > inventory.json
*/
import '../src/load-env';
import { PrismaClient } from '@prisma/client';
import { writeFileSync } from 'fs';
import { resolve } from 'path';
import {
SEED_TEST_PHONES,
collectTestDataScope,
countTestData,
detectCrossRisks,
} from './test-data-cleanup.shared';
const prisma = new PrismaClient();
const jsonMode = process.argv.includes('--json');
const outArg = process.argv.find((a) => a.startsWith('--out='));
const outPath = outArg ? outArg.slice('--out='.length) : '';
async function main() {
const counts = await countTestData(prisma);
const scope = await collectTestDataScope(prisma);
const risks = await detectCrossRisks(prisma, scope);
const [users, storeAccounts, partners, stores, orders, redeems, limitedProducts] =
await Promise.all([
prisma.user.findMany({
where: { isTest: true },
select: { id: true, userNo: true, phone: true, nickname: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
prisma.storeAccount.findMany({
where: { isTest: true },
select: { id: true, phone: true, name: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
prisma.partnerAccount.findMany({
where: { isTest: true },
select: { id: true, phone: true, name: true, companyName: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
prisma.store.findMany({
where: { isTest: true },
select: { id: true, name: true, phone: true, cityName: true, status: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
prisma.order.findMany({
where: { id: { in: scope.testOrderIds } },
select: { id: true, orderNo: true, userId: true, payAmount: true, status: true, isTest: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 200,
}),
prisma.redeemRecord.findMany({
where: { id: { in: scope.testRedeemIds } },
select: { id: true, redeemNo: true, storeId: true, userId: true, amount: true, isTest: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 200,
}),
prisma.commonProductItem.findMany({
where: { visibilityWhitelistEnabled: true },
select: { id: true, name: true, skuCode: true, status: true },
orderBy: { sortOrder: 'asc' },
}),
]);
const whitelistLinked = await Promise.all(
scope.whitelistPhones.map(async (row) => {
const phone = row.phone;
const [user, storeAccount, partner, store] = await Promise.all([
prisma.user.findFirst({ where: { phone }, select: { id: true, userNo: true, isTest: true } }),
prisma.storeAccount.findFirst({ where: { phone }, select: { id: true, name: true, isTest: true } }),
prisma.partnerAccount.findFirst({ where: { phone }, select: { id: true, name: true, isTest: true } }),
prisma.store.findFirst({ where: { phone }, select: { id: true, name: true, isTest: true } }),
]);
return { ...row, user, storeAccount, partner, store };
}),
);
const report = {
generatedAt: new Date().toISOString(),
databaseUrlHost: (process.env.DATABASE_URL ?? '').replace(/:([^:@/]+)@/, ':***@'),
seedTestPhones: SEED_TEST_PHONES,
counts,
whitelist: whitelistLinked,
testUsers: users,
testStoreAccounts: storeAccounts,
testPartners: partners,
testStores: stores,
testOrders: orders,
testRedeems: redeems,
limitedProducts,
limitedProductsWithFormalOrders: scope.limitedProductsWithFormalOrders,
crossRisks: risks,
scopeSummary: {
testUserIds: scope.testUserIds.map(String),
testStoreAccountIds: scope.testStoreAccountIds.map(String),
deletableTestStoreAccountIds: scope.deletableTestStoreAccountIds.map(String),
promotedTestStoreAccountIds: scope.promotedTestStoreAccountIds.map(String),
testPartnerIds: scope.testPartnerIds.map(String),
deletableTestPartnerIds: scope.deletableTestPartnerIds.map(String),
promotedTestPartnerIds: scope.promotedTestPartnerIds.map(String),
testStoreIds: scope.testStoreIds.map(String),
deletableTestStoreIds: scope.deletableTestStoreIds.map(String),
promotedTestStoreIds: scope.promotedTestStoreIds.map(String),
testOrderIds: scope.testOrderIds.map(String),
testRedeemIds: scope.testRedeemIds.map(String),
limitedProductIds: scope.limitedProductIds.map(String),
deletableLimitedProductIds: scope.limitedProductIds
.filter((id) => !scope.limitedProductsWithFormalOrders.some((p) => p.productId === id))
.map(String),
},
};
const serialized = JSON.stringify(
report,
(_key, value) => (typeof value === 'bigint' ? value.toString() : value),
2,
);
if (outPath) {
const abs = resolve(outPath);
writeFileSync(abs, serialized, 'utf8');
console.log(`已写入 ${abs}`);
}
if (jsonMode) {
console.log(serialized);
return;
}
console.log('=== 杜康好客 · 测试数据盘点 ===');
console.log(`时间: ${report.generatedAt}`);
console.log(`库: ${report.databaseUrlHost}`);
console.log('');
console.log('【计数】');
console.log(` 白名单手机号: ${counts.whitelistCount}`);
console.log(` 测试用户: ${counts.testUsers}`);
console.log(` 测试门店账号: ${counts.testStoreAccounts}`);
console.log(` 测试合伙人: ${counts.testPartners}`);
console.log(` 测试门店: ${counts.testStores}`);
console.log(` 测试订单(isTest): ${counts.testOrders} / 待删订单(含测试用户): ${scope.testOrderIds.length}`);
console.log(` 测试核销(isTest): ${counts.testRedeems} / 待删核销: ${scope.testRedeemIds.length}`);
console.log(` 限测商品: ${counts.limitedProducts}`);
console.log(` 仍开「仅白名单可见」的门店: ${counts.visibilityStores}`);
console.log('');
console.log('【白名单手机号】');
for (const row of whitelistLinked) {
const flags = [
row.user ? `user#${row.user.id}${row.user.isTest ? '(test)' : ''}` : null,
row.storeAccount ? `storeAcct#${row.storeAccount.id}` : null,
row.partner ? `partner#${row.partner.id}` : null,
row.store ? `store#${row.store.id}` : null,
]
.filter(Boolean)
.join(', ');
console.log(` ${row.phone} ${row.note ?? ''} ${flags || '(无关联账号)'}`);
}
console.log('');
console.log('【限测商品】');
for (const p of limitedProducts) {
const blocked = scope.limitedProductsWithFormalOrders.find((x) => x.productId === p.id);
console.log(
` #${p.id} ${p.name} (${p.skuCode})${blocked ? ` — 跳过删除,正式订单 ${blocked.formalOrderCount}` : ' — 将删除'}`,
);
}
console.log('');
console.log('【交叉风险】');
if (!risks.length) {
console.log(' (无)');
} else {
for (const risk of risks) {
console.log(` [${risk.code}] ${risk.message} (${risk.details.length} 条)`);
}
}
console.log('');
console.log('确认后执行: pnpm cleanup:test-data -- --apply --yes');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,332 @@
import { Prisma, PrismaClient } from '@prisma/client';
export const SEED_TEST_PHONES = [
'13800000001',
'13700000001',
'13700000002',
'13910000001',
'13910000002',
] as const;
export type TestDataScope = {
whitelistPhones: { id: bigint; phone: string; note: string | null }[];
testUserIds: bigint[];
testStoreAccountIds: bigint[];
deletableTestStoreAccountIds: bigint[];
promotedTestStoreAccountIds: bigint[];
testPartnerIds: bigint[];
deletableTestPartnerIds: bigint[];
promotedTestPartnerIds: bigint[];
testStoreIds: bigint[];
deletableTestStoreIds: bigint[];
promotedTestStoreIds: bigint[];
testOrderIds: bigint[];
testRedeemIds: bigint[];
limitedProductIds: bigint[];
limitedProductsWithFormalOrders: { productId: bigint; name: string; formalOrderCount: number }[];
};
export type CrossRisk = {
code: string;
message: string;
details: unknown[];
};
export async function collectTestDataScope(prisma: PrismaClient): Promise<TestDataScope> {
const whitelistPhones = await prisma.commonTestWhitelistPhone.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, phone: true, note: true },
});
const [testUsers, testStoreAccounts, testPartners, testStores] = await Promise.all([
prisma.user.findMany({ where: { isTest: true }, select: { id: true } }),
prisma.storeAccount.findMany({ where: { isTest: true }, select: { id: true } }),
prisma.partnerAccount.findMany({ where: { isTest: true }, select: { id: true } }),
prisma.store.findMany({ where: { isTest: true }, select: { id: true } }),
]);
const testUserIds = testUsers.map((r) => r.id);
const testStoreAccountIds = testStoreAccounts.map((r) => r.id);
const testPartnerIds = testPartners.map((r) => r.id);
const testStoreIds = testStores.map((r) => r.id);
const testOrders = await prisma.order.findMany({
where: {
OR: [
{ isTest: true },
...(testUserIds.length ? [{ userId: { in: testUserIds } }] : []),
],
},
select: { id: true },
});
let testOrderIds = testOrders.map((r) => r.id);
if (testOrderIds.length) {
const linkedReshipments = await prisma.order.findMany({
where: { originOrderId: { in: testOrderIds } },
select: { id: true },
});
testOrderIds = [...new Set([...testOrderIds, ...linkedReshipments.map((r) => r.id)])];
}
const testRedeems = await prisma.redeemRecord.findMany({
where: {
OR: [
{ isTest: true },
...(testUserIds.length ? [{ userId: { in: testUserIds } }] : []),
],
},
select: { id: true },
});
const testRedeemIds = testRedeems.map((r) => r.id);
const storesWithFormalRedeems = testStoreIds.length
? await prisma.redeemRecord.findMany({
where: {
storeId: { in: testStoreIds },
isTest: false,
...(testUserIds.length ? { userId: { notIn: testUserIds } } : {}),
},
select: { storeId: true },
distinct: ['storeId'],
})
: [];
const promotedTestStoreIds = storesWithFormalRedeems.map((r) => r.storeId);
const promotedSet = new Set(promotedTestStoreIds.map(String));
const deletableTestStoreIds = testStoreIds.filter((id) => !promotedSet.has(id.toString()));
const partnerStores = testPartnerIds.length
? await prisma.store.findMany({
where: { partnerAccountId: { in: testPartnerIds } },
select: { id: true, partnerAccountId: true, isTest: true },
})
: [];
const deletableTestPartnerIds: bigint[] = [];
const promotedTestPartnerIds: bigint[] = [];
for (const partnerId of testPartnerIds) {
const stores = partnerStores.filter((s) => s.partnerAccountId === partnerId);
const hasRetainedStore = stores.some((s) => !deletableTestStoreIds.some((id) => id === s.id));
if (hasRetainedStore || stores.length === 0) promotedTestPartnerIds.push(partnerId);
else deletableTestPartnerIds.push(partnerId);
}
const accountBindings = testStoreAccountIds.length
? await prisma.storeAccountStore.findMany({
where: { storeAccountId: { in: testStoreAccountIds } },
select: { storeAccountId: true, storeId: true },
})
: [];
const deletableTestStoreAccountIds: bigint[] = [];
const promotedTestStoreAccountIds: bigint[] = [];
for (const accountId of testStoreAccountIds) {
const bindings = accountBindings.filter((b) => b.storeAccountId === accountId);
const hasRetainedStore = bindings.some((b) => !deletableTestStoreIds.some((id) => id === b.storeId));
if (hasRetainedStore || bindings.length === 0) promotedTestStoreAccountIds.push(accountId);
else deletableTestStoreAccountIds.push(accountId);
}
const limitedProducts = await prisma.commonProductItem.findMany({
where: { visibilityWhitelistEnabled: true },
select: { id: true, name: true },
});
const limitedProductIds = limitedProducts.map((p) => p.id);
const limitedProductsWithFormalOrders: TestDataScope['limitedProductsWithFormalOrders'] = [];
for (const product of limitedProducts) {
const formalOrderCount = await prisma.order.count({
where: {
productId: product.id,
isTest: false,
...(testUserIds.length ? { userId: { notIn: testUserIds } } : {}),
},
});
if (formalOrderCount > 0) {
limitedProductsWithFormalOrders.push({
productId: product.id,
name: product.name,
formalOrderCount,
});
}
}
return {
whitelistPhones,
testUserIds,
testStoreAccountIds,
deletableTestStoreAccountIds,
promotedTestStoreAccountIds,
testPartnerIds,
deletableTestPartnerIds,
promotedTestPartnerIds,
testStoreIds,
deletableTestStoreIds,
promotedTestStoreIds,
testOrderIds,
testRedeemIds,
limitedProductIds,
limitedProductsWithFormalOrders,
};
}
export async function detectCrossRisks(
prisma: PrismaClient,
scope: TestDataScope,
): Promise<CrossRisk[]> {
const risks: CrossRisk[] = [];
if (scope.testPartnerIds.length) {
const formalStoresUnderTestPartner = await prisma.store.findMany({
where: {
partnerAccountId: { in: scope.testPartnerIds },
isTest: false,
},
select: { id: true, name: true, phone: true, partnerAccountId: true },
});
if (formalStoresUnderTestPartner.length) {
risks.push({
code: 'TEST_PARTNER_FORMAL_STORES',
message: '测试合伙人名下仍有正式门店',
details: formalStoresUnderTestPartner,
});
}
}
if (scope.testStoreIds.length) {
const formalRedeemsAtTestStore = await prisma.redeemRecord.findMany({
where: {
storeId: { in: scope.testStoreIds },
isTest: false,
...(scope.testUserIds.length ? { userId: { notIn: scope.testUserIds } } : {}),
},
select: { id: true, redeemNo: true, storeId: true, userId: true, amount: true },
take: 50,
});
if (formalRedeemsAtTestStore.length) {
risks.push({
code: 'TEST_STORE_FORMAL_REDEEMS',
message: '测试门店上有正式用户核销;这些门店将改为取消测试标记并保留,不删除',
details: formalRedeemsAtTestStore,
});
}
}
if (scope.testOrderIds.length) {
const paidWineryItems = await prisma.wineryBillItem.findMany({
where: {
orderId: { in: scope.testOrderIds },
wineryBill: { status: 'PAID' },
},
select: {
id: true,
orderNo: true,
wineryBill: { select: { billNo: true, status: true } },
},
take: 50,
});
if (paidWineryItems.length) {
risks.push({
code: 'PAID_WINERY_BILL_TEST_ORDERS',
message: '酒厂账单已打款且含测试订单',
details: paidWineryItems,
});
}
const paidLogisticsItems = await prisma.logisticsBillItem.findMany({
where: {
orderId: { in: scope.testOrderIds },
logisticsBill: { status: 'PAID' },
},
select: {
id: true,
orderNo: true,
logisticsBill: { select: { billNo: true, status: true } },
},
take: 50,
});
if (paidLogisticsItems.length) {
risks.push({
code: 'PAID_LOGISTICS_BILL_TEST_ORDERS',
message: '物流账单已打款且含测试订单',
details: paidLogisticsItems,
});
}
}
if (scope.testRedeemIds.length) {
const paidPayouts = await prisma.storePayout.findMany({
where: {
redeemRecordId: { in: scope.testRedeemIds },
status: 'PAID',
},
select: {
id: true,
redeemRecordId: true,
storeBill: { select: { billNo: true, status: true } },
},
take: 50,
});
if (paidPayouts.length) {
risks.push({
code: 'PAID_STORE_PAYOUT_TEST_REDEEMS',
message: '门店打款已支付且含测试核销',
details: paidPayouts,
});
}
}
if (scope.limitedProductsWithFormalOrders.length) {
risks.push({
code: 'LIMITED_PRODUCT_FORMAL_ORDERS',
message: '限测商品已有正式订单,将跳过删除',
details: scope.limitedProductsWithFormalOrders,
});
}
return risks;
}
export async function countTestData(prisma: PrismaClient) {
const [
whitelistCount,
testUsers,
testStoreAccounts,
testPartners,
testStores,
testOrders,
testRedeems,
limitedProducts,
visibilityStores,
visibilityProducts,
] = await Promise.all([
prisma.commonTestWhitelistPhone.count(),
prisma.user.count({ where: { isTest: true } }),
prisma.storeAccount.count({ where: { isTest: true } }),
prisma.partnerAccount.count({ where: { isTest: true } }),
prisma.store.count({ where: { isTest: true } }),
prisma.order.count({ where: { isTest: true } }),
prisma.redeemRecord.count({ where: { isTest: true } }),
prisma.commonProductItem.count({ where: { visibilityWhitelistEnabled: true } }),
prisma.store.count({ where: { visibilityWhitelistEnabled: true } }),
prisma.commonProductItem.count({ where: { visibilityWhitelistEnabled: true } }),
]);
return {
whitelistCount,
testUsers,
testStoreAccounts,
testPartners,
testStores,
testOrders,
testRedeems,
limitedProducts,
visibilityStores,
visibilityProducts,
};
}
export function idsEmpty(ids: bigint[]): boolean {
return ids.length === 0;
}
export function inIds(ids: bigint[]): Prisma.BigIntFilter | undefined {
return ids.length ? { in: ids } : undefined;
}
@@ -0,0 +1,86 @@
import type { PrismaClient } from '@prisma/client';
export type StoreBankAccountSnapshot = {
bankAccountName: string | null;
bankAccountNo: string | null;
bankBranch: string | null;
};
export async function loadStorePrimaryBank(
prisma: PrismaClient,
storeId: bigint,
): Promise<StoreBankAccountSnapshot | null> {
const binding = await prisma.storeAccountStore.findFirst({
where: { storeId, storeAccount: { isPrimary: 1 } },
include: {
storeAccount: {
select: { bankAccountName: true, bankAccountNo: true, bankBranch: true },
},
},
});
if (binding) return binding.storeAccount;
const fallback = await prisma.storeAccountStore.findFirst({
where: { storeId },
orderBy: { storeAccountId: 'asc' },
include: {
storeAccount: {
select: { bankAccountName: true, bankAccountNo: true, bankBranch: true },
},
},
});
return fallback?.storeAccount ?? null;
}
export async function loadStorePrimaryBanksMap(
prisma: PrismaClient,
storeIds: bigint[],
): Promise<Map<string, StoreBankAccountSnapshot>> {
const map = new Map<string, StoreBankAccountSnapshot>();
if (!storeIds.length) return map;
const bindings = await prisma.storeAccountStore.findMany({
where: { storeId: { in: storeIds } },
include: {
storeAccount: {
select: {
isPrimary: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
},
},
orderBy: [{ storeAccount: { isPrimary: 'desc' } }, { storeAccountId: 'asc' }],
});
for (const binding of bindings) {
const key = String(binding.storeId);
if (map.has(key)) continue;
map.set(key, {
bankAccountName: binding.storeAccount.bankAccountName,
bankAccountNo: binding.storeAccount.bankAccountNo,
bankBranch: binding.storeAccount.bankBranch,
});
}
return map;
}
export async function loadWineryBankConfig(
prisma: PrismaClient,
): Promise<StoreBankAccountSnapshot & { bankName?: string | null }> {
const keys = [
'WINERY_BANK_ACCOUNT_NAME',
'WINERY_BANK_NAME',
'WINERY_BANK_BRANCH',
'WINERY_BANK_ACCOUNT_NO',
];
const rows = await prisma.systemConfig.findMany({
where: { configKey: { in: keys } },
select: { configKey: true, value: true },
});
const map = new Map(rows.map((r) => [r.configKey, r.value]));
return {
bankAccountName: map.get('WINERY_BANK_ACCOUNT_NAME') ?? null,
bankName: map.get('WINERY_BANK_NAME') ?? null,
bankBranch: map.get('WINERY_BANK_BRANCH') ?? null,
bankAccountNo: map.get('WINERY_BANK_ACCOUNT_NO') ?? null,
};
}
+5
View File
@@ -25,6 +25,8 @@ function resolveAppEnv(): 'local' | 'staging' | 'production' {
}
const appEnv = resolveAppEnv();
const preserveDatabaseUrl =
process.env.FORCE_DATABASE_URL === '1' ? process.env.DATABASE_URL : undefined;
const layers =
appEnv === 'staging'
@@ -45,3 +47,6 @@ if (!process.env.NODE_ENV) {
if (!process.env.APP_ENV) {
process.env.APP_ENV = appEnv;
}
if (preserveDatabaseUrl) {
process.env.DATABASE_URL = preserveDatabaseUrl;
}
@@ -0,0 +1,270 @@
import {
DEV_PLAN_TASK_STATUS_LABELS,
DEV_PLAN_TASK_TYPE_LABELS,
type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto,
} from '@dukang/shared-types';
import ExcelJS from 'exceljs';
import * as fs from 'fs';
import * as path from 'path';
import PDFDocument from 'pdfkit';
import {
Document,
HeadingLevel,
ImageRun,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from 'docx';
const EXPORT_HEADERS = [
'任务编号',
'类型',
'状态',
'内容',
'关联工单',
'创建人',
'创建时间',
'完成时间',
'附件',
] as const;
export type DevPlanTaskExportRow = {
taskNo: string;
type: string;
status: string;
content: string;
supportTicketNo: string;
creatorName: string;
createdAt: string;
completedAt: string;
attachmentUrls: string[];
};
export type DevPlanExportFormat = 'markdown' | 'docx' | 'xlsx' | 'pdf';
export function mapTaskToExportRow(task: {
taskNo: string;
type: DevPlanTaskTypeDto;
status: DevPlanTaskStatusDto;
content: string;
supportTicketNo?: string | null;
creatorName?: string | null;
createdAt: string;
completedAt?: string | null;
attachmentUrls?: string[] | null;
}): DevPlanTaskExportRow {
return {
taskNo: task.taskNo,
type: DEV_PLAN_TASK_TYPE_LABELS[task.type] ?? task.type,
status: DEV_PLAN_TASK_STATUS_LABELS[task.status] ?? task.status,
content: task.content,
supportTicketNo: task.supportTicketNo ?? '',
creatorName: task.creatorName ?? '',
createdAt: task.createdAt.slice(0, 19).replace('T', ' '),
completedAt: task.completedAt ? task.completedAt.slice(0, 19).replace('T', ' ') : '',
attachmentUrls: task.attachmentUrls ?? [],
};
}
function rowToCells(row: DevPlanTaskExportRow): string[] {
return [
row.taskNo,
row.type,
row.status,
row.content,
row.supportTicketNo,
row.creatorName,
row.createdAt,
row.completedAt,
row.attachmentUrls.join(' '),
];
}
function resolvePdfFontPath(): string {
const candidates = [
process.env.EXPORT_PDF_FONT_PATH,
path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'),
path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'),
path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'),
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
'C:\\Windows\\Fonts\\simhei.ttf',
'C:\\Windows\\Fonts\\msyh.ttc',
].filter(Boolean) as string[];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/');
}
export function buildDevPlanMarkdown(rows: DevPlanTaskExportRow[]): Buffer {
const lines: string[] = ['# 开发计划任务导出', ''];
rows.forEach((row, index) => {
lines.push(`## ${index + 1}. ${row.taskNo} [${row.type}] ${row.status}`);
lines.push('');
lines.push(row.content);
lines.push('');
if (row.supportTicketNo) lines.push(`- 关联工单:${row.supportTicketNo}`);
if (row.creatorName) lines.push(`- 创建人:${row.creatorName}`);
lines.push(`- 创建时间:${row.createdAt}`);
if (row.completedAt) lines.push(`- 完成时间:${row.completedAt}`);
if (row.attachmentUrls.length) {
lines.push('- 附件:');
for (const url of row.attachmentUrls) {
lines.push(` ![附件](${url})`);
lines.push(` ${url}`);
}
}
lines.push('');
});
return Buffer.from(lines.join('\n'), 'utf8');
}
async function fetchImageBuffer(url: string): Promise<Buffer | null> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return null;
const buf = Buffer.from(await res.arrayBuffer());
return buf.length > 0 && buf.length < 5 * 1024 * 1024 ? buf : null;
} catch {
return null;
}
}
export async function buildDevPlanDocx(rows: DevPlanTaskExportRow[]): Promise<Buffer> {
const children: (Paragraph | Table)[] = [
new Paragraph({ text: '开发计划任务导出', heading: HeadingLevel.HEADING_1 }),
];
for (const row of rows) {
children.push(
new Paragraph({
text: `${row.taskNo} [${row.type}] ${row.status}`,
heading: HeadingLevel.HEADING_2,
}),
new Paragraph({ children: [new TextRun(row.content)] }),
new Paragraph({
children: [
new TextRun(`创建人:${row.creatorName || '—'} 创建:${row.createdAt}`),
],
}),
);
if (row.supportTicketNo) {
children.push(new Paragraph({ children: [new TextRun(`关联工单:${row.supportTicketNo}`)] }));
}
if (row.completedAt) {
children.push(new Paragraph({ children: [new TextRun(`完成时间:${row.completedAt}`)] }));
}
for (const url of row.attachmentUrls) {
const img = await fetchImageBuffer(url);
if (img) {
children.push(
new Paragraph({
children: [
new ImageRun({
data: img,
transformation: { width: 320, height: 240 },
type: 'png',
}),
],
}),
);
} else {
children.push(new Paragraph({ children: [new TextRun(url)] }));
}
}
children.push(new Paragraph({ text: '' }));
}
const doc = new Document({
sections: [{ children }],
});
return Packer.toBuffer(doc);
}
export async function buildDevPlanXlsx(rows: DevPlanTaskExportRow[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('开发任务');
sheet.addRow([...EXPORT_HEADERS]);
for (const row of rows) {
sheet.addRow(rowToCells(row));
}
sheet.columns.forEach((col, i) => {
col.width = i === 3 || i === 8 ? 40 : 16;
});
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
export async function buildDevPlanPdf(rows: DevPlanTaskExportRow[]): Promise<Buffer> {
const fontPath = resolvePdfFontPath();
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 24, bufferPages: true });
doc.on('data', (chunk) => chunks.push(chunk as Buffer));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.registerFont('zh', fontPath);
doc.font('zh');
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const colWidths = [72, 48, 48, 180, 72, 56, 78, 78, 120];
const scale = pageWidth / colWidths.reduce((s, w) => s + w, 0);
const widths = colWidths.map((w) => w * scale);
const rowHeight = 36;
let y = doc.page.margins.top;
const drawRow = (cells: string[], isHeader = false) => {
let x = doc.page.margins.left;
const height = isHeader ? 24 : rowHeight;
if (y + height > doc.page.height - doc.page.margins.bottom) {
doc.addPage({ size: 'A4', layout: 'landscape', margin: 24 });
y = doc.page.margins.top;
}
doc.fontSize(isHeader ? 8 : 7);
cells.forEach((cell, index) => {
doc.rect(x, y, widths[index], height).stroke('#dddddd');
doc.text(cell || '', x + 2, y + 4, {
width: widths[index] - 4,
height: height - 6,
lineBreak: true,
});
x += widths[index];
});
y += height;
};
drawRow([...EXPORT_HEADERS], true);
for (const row of rows) {
drawRow(rowToCells(row));
}
doc.end();
});
}
export function buildDevPlanExportFilename(format: DevPlanExportFormat, count: number): string {
const stamp = new Date().toISOString().slice(0, 10);
const ext = format === 'markdown' ? 'md' : format === 'docx' ? 'docx' : format;
return `开发任务导出_${stamp}_${count}条.${ext}`;
}
export function devPlanExportMimeType(format: DevPlanExportFormat): string {
switch (format) {
case 'markdown':
return 'text/markdown;charset=utf-8';
case 'docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
case 'xlsx':
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
case 'pdf':
return 'application/pdf';
default:
return 'application/octet-stream';
}
}
@@ -31,6 +31,7 @@ import {
UpdateDevPlanTaskDto,
UpdateDevPlanVersionDto,
DevPlanTaskBatchUpdateDto,
DevPlanTaskExportDto,
} from './dto/dev-plan.dto';
@Controller('admin/dev-plan')
@@ -98,6 +99,11 @@ export class AdminDevPlanController {
return this.service.batchUpdateTasks(body);
}
@Post('tasks/export')
exportTasks(@Body() body: DevPlanTaskExportDto) {
return this.service.exportTasks(body);
}
@Get('versions')
listVersions(
@Query('status') status?: string,
@@ -59,6 +59,26 @@ import type {
UpdateDevPlanVersionDto,
} from './dto/dev-plan.dto';
import {
buildDevPlanDocx,
buildDevPlanExportFilename,
buildDevPlanMarkdown,
buildDevPlanPdf,
buildDevPlanXlsx,
devPlanExportMimeType,
mapTaskToExportRow,
type DevPlanExportFormat,
} from './admin-dev-plan-export.util';
function parseAttachmentUrls(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((u) => String(u).trim()).filter(Boolean);
}
function attachmentUrlsInput(urls?: string[]): Prisma.InputJsonValue | undefined {
const list = (urls ?? []).map((u) => u.trim()).filter(Boolean);
return list.length ? (list as unknown as Prisma.InputJsonValue) : undefined;
}
@@ -132,6 +152,8 @@ export class DevPlanService {
completedAt: Date | null;
attachmentUrls?: unknown;
},
extras?: { creatorName?: string | null; supportTicketNo?: string | null },
@@ -164,6 +186,8 @@ export class DevPlanService {
completedAt: row.completedAt?.toISOString() ?? null,
attachmentUrls: parseAttachmentUrls(row.attachmentUrls),
};
}
@@ -378,6 +402,8 @@ export class DevPlanService {
supportTicketId: dto.supportTicketId ? BigInt(dto.supportTicketId) : null,
attachmentUrls: attachmentUrlsInput(dto.attachmentUrls),
},
});
@@ -400,6 +426,12 @@ export class DevPlanService {
if (!tasks.length) throw new BadRequestException('至少创建 1 条开发任务');
const ticket = await this.prisma.commonSupportTicket.findUnique({
where: { id: ticketId },
select: { attachmentUrls: true },
});
const ticketUrls = parseAttachmentUrls(ticket?.attachmentUrls);
const created = await this.prisma.$transaction(
tasks.map((t) =>
@@ -418,6 +450,10 @@ export class DevPlanService {
supportTicketId: ticketId,
attachmentUrls: attachmentUrlsInput(
t.attachmentUrls?.length ? t.attachmentUrls : ticketUrls,
),
},
}),
@@ -464,6 +500,10 @@ export class DevPlanService {
}
if (dto.attachmentUrls !== undefined) {
data.attachmentUrls = attachmentUrlsInput(dto.attachmentUrls);
}
await this.prisma.devPlanTask.update({ where: { id }, data });
@@ -1157,6 +1197,114 @@ export class DevPlanService {
}
private buildTaskExportWhere(query: {
status?: string;
type?: string;
keyword?: string;
}): Prisma.DevPlanTaskWhereInput {
const where: Prisma.DevPlanTaskWhereInput = {};
if (query.status) where.status = query.status as DevPlanTaskStatus;
if (query.type) where.type = query.type as DevPlanTaskType;
if (query.keyword?.trim()) {
where.OR = [
{ taskNo: { contains: query.keyword.trim() } },
{ content: { contains: query.keyword.trim() } },
];
}
return where;
}
async exportTasks(dto: {
scope: 'filter' | 'selected';
format: DevPlanExportFormat;
ids?: string[];
status?: string;
type?: string;
keyword?: string;
}) {
let rows: Array<{
id: bigint;
taskNo: string;
content: string;
type: DevPlanTaskType;
status: DevPlanTaskStatus;
creatorHqAccountId: bigint;
supportTicketId: bigint | null;
attachmentUrls: unknown;
createdAt: Date;
completedAt: Date | null;
}>;
if (dto.scope === 'selected') {
if (!dto.ids?.length) throw new BadRequestException('请先勾选要导出的任务');
const ids = dto.ids.map(BigInt);
rows = await this.prisma.devPlanTask.findMany({
where: { id: { in: ids } },
orderBy: { createdAt: 'desc' },
});
if (rows.length !== ids.length) throw new BadRequestException('部分任务不存在');
} else {
rows = await this.prisma.devPlanTask.findMany({
where: this.buildTaskExportWhere(dto),
orderBy: { createdAt: 'desc' },
});
}
if (!rows.length) throw new BadRequestException('没有可导出的任务');
const creatorIds = rows.map((r) => r.creatorHqAccountId);
const ticketIds = rows.map((r) => r.supportTicketId).filter((id): id is bigint => id != null);
const [names, tickets] = await Promise.all([
this.loadHqNames(creatorIds),
ticketIds.length
? this.prisma.commonSupportTicket.findMany({
where: { id: { in: ticketIds } },
select: { id: true, ticketNo: true },
})
: Promise.resolve([]),
]);
const ticketMap = new Map(tickets.map((t) => [String(t.id), t.ticketNo] as [string, string]));
const exportRows = rows.map((r) =>
mapTaskToExportRow({
taskNo: r.taskNo,
type: r.type,
status: r.status,
content: r.content,
supportTicketNo: r.supportTicketId != null ? ticketMap.get(String(r.supportTicketId)) ?? null : null,
creatorName: names.get(String(r.creatorHqAccountId)) ?? null,
createdAt: r.createdAt.toISOString(),
completedAt: r.completedAt?.toISOString() ?? null,
attachmentUrls: parseAttachmentUrls(r.attachmentUrls),
}),
);
let buffer: Buffer;
switch (dto.format) {
case 'markdown':
buffer = buildDevPlanMarkdown(exportRows);
break;
case 'docx':
buffer = await buildDevPlanDocx(exportRows);
break;
case 'xlsx':
buffer = await buildDevPlanXlsx(exportRows);
break;
case 'pdf':
buffer = await buildDevPlanPdf(exportRows);
break;
default:
throw new BadRequestException('不支持的导出格式');
}
return {
filename: buildDevPlanExportFilename(dto.format, exportRows.length),
mimeType: devPlanExportMimeType(dto.format),
contentBase64: buffer.toString('base64'),
count: exportRows.length,
};
}
}
@@ -37,6 +37,11 @@ export class CreateDevPlanTaskDto {
@IsOptional()
@IsString()
supportTicketId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
attachmentUrls?: string[];
}
export class UpdateDevPlanTaskDto {
@@ -52,6 +57,11 @@ export class UpdateDevPlanTaskDto {
@IsOptional()
@IsIn(DEV_PLAN_TASK_STATUSES)
status?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
attachmentUrls?: string[];
}
export class CreateDevPlanVersionDto {
@@ -131,6 +141,11 @@ export class DevPlanTaskFromTicketDto {
@IsIn(DEV_PLAN_TASK_TYPES)
type!: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION';
@IsOptional()
@IsArray()
@IsString({ each: true })
attachmentUrls?: string[];
}
export class ReviewSupportTicketDto {
@@ -177,6 +192,32 @@ export class DevPlanTaskBatchUpdateDto {
versionId?: string | null;
}
export class DevPlanTaskExportDto {
@IsIn(['filter', 'selected'])
scope!: 'filter' | 'selected';
@IsIn(['markdown', 'docx', 'xlsx', 'pdf'])
format!: 'markdown' | 'docx' | 'xlsx' | 'pdf';
@ValidateIf((o: DevPlanTaskExportDto) => o.scope === 'selected')
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
ids?: string[];
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
type?: string;
@IsOptional()
@IsString()
keyword?: string;
}
export class BatchReviewPreviewDto {
@IsArray()
@IsString({ each: true })
@@ -48,9 +48,14 @@ function formatBenefitBrief(order: {
return '';
}
/** 导出给运营看的时间:按上海时区,避免 toISOString() 落成 UTC(少 8 小时) */
function formatShanghaiDateTime(value: Date): string {
return value.toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' });
}
export function formatExportDateTime(value?: Date | null): string {
if (!value) return '';
return value.toISOString().slice(0, 19).replace('T', ' ');
return formatShanghaiDateTime(value);
}
export function mapOrderToExportRow(order: {
@@ -206,6 +211,6 @@ export async function buildOrdersPdf(rows: OrderExportRow[]): Promise<Buffer> {
}
export function buildExportFilename(format: 'xlsx' | 'pdf', count: number): string {
const stamp = new Date().toISOString().slice(0, 10);
const stamp = formatShanghaiDateTime(new Date()).slice(0, 10);
return `订单导出_${stamp}_${count}条.${format}`;
}
@@ -1,5 +1,9 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
@@ -13,7 +17,8 @@ import {
} from './dto/admin-mutate.dto';
@Controller('admin/redeem/debug')
@UseGuards(HqAuthGuard)
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('benefit_debug')
export class AdminRedeemDebugController {
constructor(private readonly service: AdminRedeemDebugService) {}
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import type { DeliveryProvider } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { loadStorePrimaryBank } from '../../common/store/store-bank.util';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto';
import type { UpdateDeliveryDto } from './dto/admin-mutate.dto';
@@ -113,8 +114,13 @@ export class AdminRedeemService {
},
});
if (!record) throw new NotFoundException('核销记录不存在');
const primaryAccount = await loadStorePrimaryBank(this.prisma, record.storeId);
return serializeBigInt({
...record,
store: {
...record.store,
primaryAccount,
},
allocations: record.allocations.map((a) => ({
couponId: a.couponId,
amount: Number(a.amount),
@@ -1,5 +1,9 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import {
XiaofeixiaBatchShipmentQueryDto,
@@ -11,7 +15,8 @@ import {
/** HQ 小飞侠接口联调(仅管理端,勿对 C 端暴露) */
@Controller('admin/courier/xiaofeixia')
@UseGuards(HqAuthGuard)
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('deliveries_debug')
export class AdminXiaofeixiaController {
constructor(private readonly service: AdminXiaofeixiaService) {}
@@ -302,8 +302,8 @@ export class AdminStoreBillController {
refType: 'STORE_BILL',
refIdParam: 'id',
})
confirm(@Param('id') id: string) {
return this.settlementService.confirmStoreBill(BigInt(id));
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
return this.settlementService.confirmStoreBill(BigInt(id), body);
}
}
@@ -485,8 +485,8 @@ export class AdminWineryBillController {
refType: 'WINERY_BILL',
refIdParam: 'id',
})
confirm(@Param('id') id: string) {
return this.settlementService.confirmWineryBill(BigInt(id));
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
return this.settlementService.confirmWineryBill(BigInt(id), body);
}
}
@@ -565,8 +565,8 @@ export class AdminLogisticsBillController {
refType: 'LOGISTICS_BILL',
refIdParam: 'id',
})
confirm(@Param('id') id: string) {
return this.settlementService.confirmLogisticsBill(BigInt(id));
confirm(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
return this.settlementService.confirmLogisticsBill(BigInt(id), body);
}
}
@@ -22,6 +22,11 @@ import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import {
loadStorePrimaryBank,
loadStorePrimaryBanksMap,
loadWineryBankConfig,
} from '../../common/store/store-bank.util';
function generateBillNo(prefix: string) {
return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
@@ -1043,11 +1048,20 @@ export class SettlementService {
const total = rows.length;
const slice = rows.slice((page - 1) * pageSize, page * pageSize);
const storeIds = [...new Set(rows.filter((r) => r.kind === 'T1_BILL').map((r) => r.storeId))];
const bankMap = await loadStorePrimaryBanksMap(this.prisma, storeIds);
const redeemAmount = bills.reduce((s, b) => s + Number(b.redeemAmount), 0);
const payoutAmount = rows.reduce((s, r) => s + r.amount, 0);
return serializeBigInt({
items: slice,
items: slice.map((r) => ({
...r,
bankAccount:
r.kind === 'T1_BILL'
? bankMap.get(String(r.storeId)) ?? null
: null,
})),
total,
page,
pageSize,
@@ -1112,10 +1126,11 @@ export class SettlementService {
},
});
if (!bill) throw new NotFoundException('门店对账单不存在');
return serializeBigInt(bill);
const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId);
return serializeBigInt({ ...bill, storeAccount });
}
async confirmStoreBill(id: bigint) {
async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) {
const bill = await this.prisma.storeBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('门店对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
@@ -1124,7 +1139,11 @@ export class SettlementService {
const updated = await this.prisma.$transaction(async (tx) => {
const b = await tx.storeBill.update({
where: { id },
data: { status: 'PAID', paidAt },
data: {
status: 'PAID',
paidAt,
paymentRef: dto.paymentRef?.trim() || null,
},
});
await tx.storePayout.updateMany({
where: { storeBillId: id, status: 'PENDING' },
@@ -1157,12 +1176,32 @@ export class SettlementService {
const where = this.buildStoreBillWhere(query);
const bills = await this.prisma.storeBill.findMany({
where,
include: { store: { select: { name: true, phone: true, cityName: true } } },
include: { store: { select: { id: true, name: true, phone: true, cityName: true } } },
orderBy: { billDate: 'desc' },
});
const header = ['账单号', '账单日', '门店', '城市', '核销笔数', '核销金额', '结算比例', '应付金额', '状态', '打款时间'].join(',');
const rows = bills.map((b) =>
[
const bankMap = await loadStorePrimaryBanksMap(
this.prisma,
bills.map((b) => b.storeId),
);
const header = [
'账单号',
'账单日',
'门店',
'城市',
'核销笔数',
'核销金额',
'结算比例',
'应付金额',
'状态',
'打款时间',
'打款凭证',
'收款户名',
'收款账号',
'开户行',
].join(',');
const rows = bills.map((b) => {
const bank = bankMap.get(String(b.storeId));
return [
csvEscape(b.billNo),
b.billDate.toISOString().slice(0, 10),
csvEscape(b.store.name),
@@ -1173,8 +1212,12 @@ export class SettlementService {
Number(b.payoutAmount),
b.status,
b.paidAt ? b.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '',
].join(','),
);
csvEscape(b.paymentRef ?? ''),
csvEscape(bank?.bankAccountName ?? ''),
csvEscape(bank?.bankAccountNo ?? ''),
csvEscape(bank?.bankBranch ?? ''),
].join(',');
});
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };
}
@@ -1262,7 +1305,15 @@ export class SettlementService {
take: pageSize,
include: {
partnerAccount: {
select: { id: true, companyName: true, name: true, phone: true },
select: {
id: true,
companyName: true,
name: true,
phone: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
},
},
}),
@@ -1553,14 +1604,19 @@ export class SettlementService {
return serializeBigInt(updated);
}
async markPartnerBillPaid(id: bigint, _dto: { paymentRef?: string } = {}) {
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string } = {}) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可标记打款');
const updated = await this.prisma.partnerBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date(), rejectReason: null },
data: {
status: 'PAID',
paidAt: new Date(),
rejectReason: null,
paymentRef: dto.paymentRef?.trim() || null,
},
});
return serializeBigInt(updated);
@@ -1588,7 +1644,17 @@ export class SettlementService {
const where = this.buildPartnerBillWhere(query);
const bills = await this.prisma.partnerBill.findMany({
where,
include: { partnerAccount: { select: { companyName: true, phone: true } } },
include: {
partnerAccount: {
select: {
companyName: true,
phone: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
},
},
orderBy: { periodStart: 'desc' },
});
@@ -1605,6 +1671,10 @@ export class SettlementService {
'发送时间',
'确认时间',
'打款时间',
'打款凭证',
'收款户名',
'收款账号',
'开户行',
'驳回理由',
].join(',');
const rows = bills.map((b) =>
@@ -1621,6 +1691,10 @@ export class SettlementService {
b.sentAt ? b.sentAt.toISOString().slice(0, 10) : '',
b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '',
b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '',
csvEscape(b.paymentRef ?? ''),
csvEscape(b.partnerAccount.bankAccountName ?? ''),
csvEscape(b.partnerAccount.bankAccountNo ?? ''),
csvEscape(b.partnerAccount.bankBranch ?? ''),
csvEscape(b.rejectReason ?? ''),
].join(','),
);
@@ -1752,10 +1826,11 @@ export class SettlementService {
include: { items: { orderBy: { paidAt: 'desc' } } },
});
if (!bill) throw new NotFoundException('酒厂对账单不存在');
return serializeBigInt(bill);
const wineryBank = await loadWineryBankConfig(this.prisma);
return serializeBigInt({ ...bill, wineryBank });
}
async confirmWineryBill(id: bigint) {
async confirmWineryBill(id: bigint, dto: { paymentRef?: string } = {}) {
const bill = await this.prisma.wineryBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('酒厂对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
@@ -1764,7 +1839,11 @@ export class SettlementService {
}
const updated = await this.prisma.wineryBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
data: {
status: 'PAID',
paidAt: new Date(),
paymentRef: dto.paymentRef?.trim() || null,
},
});
return serializeBigInt(updated);
}
@@ -1795,6 +1874,7 @@ export class SettlementService {
include: { items: true },
orderBy: { billDate: 'desc' },
});
const wineryBank = await loadWineryBankConfig(this.prisma);
const header = [
'账单号',
@@ -1806,6 +1886,11 @@ export class SettlementService {
'应付',
'支付时间',
'账单状态',
'打款凭证',
'收款户名',
'开户银行',
'开户支行',
'收款账号',
].join(',');
const rows: string[] = [];
const statusLabel = (b: { status: string; wineryAmount: Prisma.Decimal | number }) => {
@@ -1814,6 +1899,13 @@ export class SettlementService {
if (b.status === 'UNPAID') return '未打款';
return b.status;
};
const bankCols = (b: { paymentRef?: string | null }) => [
csvEscape(b.paymentRef ?? ''),
csvEscape(wineryBank.bankAccountName ?? ''),
csvEscape(wineryBank.bankName ?? ''),
csvEscape(wineryBank.bankBranch ?? ''),
csvEscape(wineryBank.bankAccountNo ?? ''),
];
for (const b of bills) {
if (b.items.length === 0) {
rows.push(
@@ -1827,6 +1919,7 @@ export class SettlementService {
Number(b.wineryAmount),
'',
statusLabel(b),
...bankCols(b),
].join(','),
);
continue;
@@ -1843,6 +1936,7 @@ export class SettlementService {
Number(item.wineryAmount),
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
statusLabel(b),
...bankCols(b),
].join(','),
);
}
@@ -2275,11 +2369,17 @@ export class SettlementService {
});
}
async confirmLogisticsBill(id: bigint) {
async confirmLogisticsBill(id: bigint, dto: { paymentRef?: string } = {}) {
const bill = await this.prisma.logisticsBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('物流对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未结算账单可确认');
const paidData = {
status: 'PAID' as const,
paidAt: new Date(),
paymentRef: dto.paymentRef?.trim() || null,
};
if (bill.settlementMethod === 'PREPAID') {
const deducted = await this.prisma.$transaction(async (tx) => {
const result = await this.fulfillmentProviderService.deductPrepaid(
@@ -2295,7 +2395,7 @@ export class SettlementService {
}
return tx.logisticsBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
data: paidData,
});
});
return serializeBigInt(deducted);
@@ -2303,7 +2403,7 @@ export class SettlementService {
const updated = await this.prisma.logisticsBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
data: paidData,
});
return serializeBigInt(updated);
}
@@ -2331,7 +2431,16 @@ export class SettlementService {
const bills = await this.prisma.logisticsBill.findMany({
where,
include: {
fulfillmentProvider: { select: { code: true, name: true } },
fulfillmentProvider: {
select: {
code: true,
name: true,
bankAccountName: true,
bankName: true,
bankBranch: true,
bankAccountNo: true,
},
},
items: true,
},
orderBy: { periodStart: 'desc' },
@@ -2349,15 +2458,28 @@ export class SettlementService {
'物流费',
'发货时间',
'账单状态',
'打款凭证',
'收款户名',
'开户银行',
'开户支行',
'收款账号',
].join(',');
const rows: string[] = [];
for (const b of bills) {
const p = b.fulfillmentProvider;
const bankCols = [
csvEscape(b.paymentRef ?? ''),
csvEscape(p.bankAccountName ?? ''),
csvEscape(p.bankName ?? ''),
csvEscape(p.bankBranch ?? ''),
csvEscape(p.bankAccountNo ?? ''),
];
if (b.items.length === 0) {
rows.push(
[
csvEscape(b.billNo),
csvEscape(b.fulfillmentProvider.code),
csvEscape(b.fulfillmentProvider.name),
csvEscape(p.code),
csvEscape(p.name),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
b.settlementMethod,
@@ -2366,6 +2488,7 @@ export class SettlementService {
Number(b.logisticsAmount),
'',
b.status,
...bankCols,
].join(','),
);
continue;
@@ -2374,8 +2497,8 @@ export class SettlementService {
rows.push(
[
csvEscape(b.billNo),
csvEscape(b.fulfillmentProvider.code),
csvEscape(b.fulfillmentProvider.name),
csvEscape(p.code),
csvEscape(p.name),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
b.settlementMethod,
@@ -2384,6 +2507,7 @@ export class SettlementService {
Number(item.logisticsAmount),
item.shippedAt.toISOString().slice(0, 19).replace('T', ' '),
b.status,
...bankCols,
].join(','),
);
}