Merge pull request 'Dev jacy v3.5.6 版本迭代' (#38) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/38
This commit was merged in pull request #38.
This commit is contained in:
@@ -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`
|
||||
@@ -1,33 +1,11 @@
|
||||
---
|
||||
description: Git 提交与 PR 协作规范
|
||||
description: 杜康提交 scope 与发版默认(生产)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git 规范
|
||||
# Git / 发版(杜康专属)
|
||||
|
||||
## 提交格式
|
||||
|
||||
Conventional Commits,scope = 端或模块名:
|
||||
|
||||
```
|
||||
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 Commits,scope = 端或模块:`feat(trade):`、`fix(redeem):`
|
||||
- 只说「发布/发版」→ 生产(`main` + `deploy-prod.sh`);明确说「发测试」才 staging。流程见 `/dukang-release`
|
||||
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
|
||||
- Prisma 迁移须 Review
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ description: >-
|
||||
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
|
||||
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
|
||||
or @杜康发布 / @dukang-release.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 杜康发布
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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-v1:MySQL :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 人)
|
||||
## OWNER(2026-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-v3(PRD、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` | 8190–8194 | `*-test.dukanghaoke.com` |
|
||||
| **production 生产** | `main` | `/opt/dukang` | 8090–8094 | `*.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}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,12 +289,19 @@ 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={() => setInfoFullscreen((v) => !v)}>
|
||||
{infoFullscreen ? '退出全屏' : '全屏查看'}
|
||||
</Button>
|
||||
{detail?.status === 'PENDING' ? (
|
||||
<>
|
||||
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
||||
<Button
|
||||
danger
|
||||
@@ -303,8 +313,9 @@ function InfoChangeAuditPanel({
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{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,
|
||||
}),
|
||||
|
||||
@@ -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,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);
|
||||
@@ -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 已启用"
|
||||
@@ -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 已启用"
|
||||
@@ -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)"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
# 杜康好客 H5 三端 — ai-mirror.xyz
|
||||
# API 内部端口 8090;H5 分别 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;
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
# 证书申请期间 — lingshivip.cn HTTP(api 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;
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
# 杜康好客 — lingshivip.cn 五端 + API
|
||||
# API 8090;H5 8091/8092/8093;admin-web 8094
|
||||
|
||||
map $host $dukang_lingshi_port {
|
||||
user.lingshivip.cn 8091;
|
||||
shop.lingshivip.cn 8092;
|
||||
partner.lingshivip.cn 8093;
|
||||
admin.lingshivip.cn 8094;
|
||||
}
|
||||
|
||||
# HTTP → HTTPS(H5 三端)
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
# 杜康好客 — runxian.top(HTTP)
|
||||
# 三端 H5 统一入口 user.runxian.top/{user,shop,partner}/(微信网页授权单域名)
|
||||
# shop/partner 子域名 301 到 user.runxian.top 路径;webadmin → 8094;dkapi → 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;
|
||||
}
|
||||
}
|
||||
@@ -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 统一部署完成"
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
# 服务器机器相关(可合并到 .env.production 的参考项)
|
||||
PORT=8090
|
||||
DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
+8
-22
@@ -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
|
||||
|
||||
+10
-1
@@ -24,6 +24,8 @@
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -88,6 +90,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 +110,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 |
|
||||
|
||||
|
||||
@@ -60,9 +60,11 @@
|
||||
| 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) | ✅ 已交付 |
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 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 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 |
|
||||
|
||||
@@ -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
|
||||
- [ ] 门店审核对比可全屏;图片放大可预览
|
||||
@@ -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(
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+111
-44
@@ -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: {}
|
||||
|
||||
@@ -43,6 +43,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`;
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
@@ -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(` `);
|
||||
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 })
|
||||
|
||||
@@ -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(','),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user