From d98b0aefb0814ce3dcb034094f1dbe8795cd85da Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 23 Aug 2026 14:37:29 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=95=B4=E7=90=86=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/dukang-core.mdc | 55 ------------------------ .cursor/rules/git-conventions.mdc | 34 +++------------ .cursor/skills/dukang-coding/SKILL.md | 2 +- .cursor/skills/dukang-prev1/SKILL.md | 1 + .cursor/skills/dukang-release/SKILL.md | 1 + .cursor/skills/dukang-task-card/SKILL.md | 1 + 6 files changed, 10 insertions(+), 84 deletions(-) delete mode 100644 .cursor/rules/dukang-core.mdc diff --git a/.cursor/rules/dukang-core.mdc b/.cursor/rules/dukang-core.mdc deleted file mode 100644 index cd16226..0000000 --- a/.cursor/rules/dukang-core.mdc +++ /dev/null @@ -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` diff --git a/.cursor/rules/git-conventions.mdc b/.cursor/rules/git-conventions.mdc index a788ac3..e469d7e 100644 --- a/.cursor/rules/git-conventions.mdc +++ b/.cursor/rules/git-conventions.mdc @@ -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 diff --git a/.cursor/skills/dukang-coding/SKILL.md b/.cursor/skills/dukang-coding/SKILL.md index c3af638..b2b2417 100644 --- a/.cursor/skills/dukang-coding/SKILL.md +++ b/.cursor/skills/dukang-coding/SKILL.md @@ -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 diff --git a/.cursor/skills/dukang-prev1/SKILL.md b/.cursor/skills/dukang-prev1/SKILL.md index 3ab1915..4e39dcd 100644 --- a/.cursor/skills/dukang-prev1/SKILL.md +++ b/.cursor/skills/dukang-prev1/SKILL.md @@ -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 diff --git a/.cursor/skills/dukang-release/SKILL.md b/.cursor/skills/dukang-release/SKILL.md index 673d8a7..855c54c 100644 --- a/.cursor/skills/dukang-release/SKILL.md +++ b/.cursor/skills/dukang-release/SKILL.md @@ -4,6 +4,7 @@ description: >- 杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。 Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产, or @杜康发布 / @dukang-release. +disable-model-invocation: true --- # 杜康发布 diff --git a/.cursor/skills/dukang-task-card/SKILL.md b/.cursor/skills/dukang-task-card/SKILL.md index 94320c2..1aecad3 100644 --- a/.cursor/skills/dukang-task-card/SKILL.md +++ b/.cursor/skills/dukang-task-card/SKILL.md @@ -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 From 608e3e5ec5101307b43bd376774a60d8eef1ddf2 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 23 Aug 2026 14:37:36 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=95=B4=E7=90=86=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/_diag_wx_key.mjs | 77 ---------- deploy/enable-api-lingshi-ssl.sh | 65 -------- deploy/enable-runxian-apex-ssl.sh | 59 -------- deploy/enable-runxian-dukang-ssl.sh | 57 ------- deploy/nginx-ai-mirror-http.conf | 33 ---- deploy/nginx-ai-mirror.conf | 54 ------- deploy/nginx-lingshivip-http.conf | 105 ------------- deploy/nginx-lingshivip.conf | 135 ----------------- deploy/nginx-runxian-apex.conf | 53 ------- deploy/nginx-runxian-dukang-ssl.conf | 218 --------------------------- deploy/nginx-runxian-dukang.conf | 154 ------------------- deploy/remote-continue-m-domain.sh | 39 ----- deploy/remote-continue.sh | 57 ------- deploy/remote-setup.sh | 79 ---------- deploy/server.env.machine | 4 - deploy/setup-webhook.sh | 30 +--- 16 files changed, 8 insertions(+), 1211 deletions(-) delete mode 100644 deploy/_diag_wx_key.mjs delete mode 100644 deploy/enable-api-lingshi-ssl.sh delete mode 100644 deploy/enable-runxian-apex-ssl.sh delete mode 100644 deploy/enable-runxian-dukang-ssl.sh delete mode 100644 deploy/nginx-ai-mirror-http.conf delete mode 100644 deploy/nginx-ai-mirror.conf delete mode 100644 deploy/nginx-lingshivip-http.conf delete mode 100644 deploy/nginx-lingshivip.conf delete mode 100644 deploy/nginx-runxian-apex.conf delete mode 100644 deploy/nginx-runxian-dukang-ssl.conf delete mode 100644 deploy/nginx-runxian-dukang.conf delete mode 100644 deploy/remote-continue-m-domain.sh delete mode 100644 deploy/remote-continue.sh delete mode 100644 deploy/remote-setup.sh delete mode 100644 deploy/server.env.machine diff --git a/deploy/_diag_wx_key.mjs b/deploy/_diag_wx_key.mjs deleted file mode 100644 index 9bccaa3..0000000 --- a/deploy/_diag_wx_key.mjs +++ /dev/null @@ -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); diff --git a/deploy/enable-api-lingshi-ssl.sh b/deploy/enable-api-lingshi-ssl.sh deleted file mode 100644 index e028a91..0000000 --- a/deploy/enable-api-lingshi-ssl.sh +++ /dev/null @@ -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 已启用" diff --git a/deploy/enable-runxian-apex-ssl.sh b/deploy/enable-runxian-apex-ssl.sh deleted file mode 100644 index 5284292..0000000 --- a/deploy/enable-runxian-apex-ssl.sh +++ /dev/null @@ -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 已启用" diff --git a/deploy/enable-runxian-dukang-ssl.sh b/deploy/enable-runxian-dukang-ssl.sh deleted file mode 100644 index 046495e..0000000 --- a/deploy/enable-runxian-dukang-ssl.sh +++ /dev/null @@ -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)" diff --git a/deploy/nginx-ai-mirror-http.conf b/deploy/nginx-ai-mirror-http.conf deleted file mode 100644 index 1a9b700..0000000 --- a/deploy/nginx-ai-mirror-http.conf +++ /dev/null @@ -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; - } -} diff --git a/deploy/nginx-ai-mirror.conf b/deploy/nginx-ai-mirror.conf deleted file mode 100644 index 7d39051..0000000 --- a/deploy/nginx-ai-mirror.conf +++ /dev/null @@ -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; - } -} diff --git a/deploy/nginx-lingshivip-http.conf b/deploy/nginx-lingshivip-http.conf deleted file mode 100644 index 4da8d6a..0000000 --- a/deploy/nginx-lingshivip-http.conf +++ /dev/null @@ -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; - } -} diff --git a/deploy/nginx-lingshivip.conf b/deploy/nginx-lingshivip.conf deleted file mode 100644 index 1efb64b..0000000 --- a/deploy/nginx-lingshivip.conf +++ /dev/null @@ -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; - } -} diff --git a/deploy/nginx-runxian-apex.conf b/deploy/nginx-runxian-apex.conf deleted file mode 100644 index 74fdd4d..0000000 --- a/deploy/nginx-runxian-apex.conf +++ /dev/null @@ -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; - } -} diff --git a/deploy/nginx-runxian-dukang-ssl.conf b/deploy/nginx-runxian-dukang-ssl.conf deleted file mode 100644 index 85f6e78..0000000 --- a/deploy/nginx-runxian-dukang-ssl.conf +++ /dev/null @@ -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; - } -} diff --git a/deploy/nginx-runxian-dukang.conf b/deploy/nginx-runxian-dukang.conf deleted file mode 100644 index 4acd06e..0000000 --- a/deploy/nginx-runxian-dukang.conf +++ /dev/null @@ -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; - } -} diff --git a/deploy/remote-continue-m-domain.sh b/deploy/remote-continue-m-domain.sh deleted file mode 100644 index 3ad2743..0000000 --- a/deploy/remote-continue-m-domain.sh +++ /dev/null @@ -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 统一部署完成" diff --git a/deploy/remote-continue.sh b/deploy/remote-continue.sh deleted file mode 100644 index c075873..0000000 --- a/deploy/remote-continue.sh +++ /dev/null @@ -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/ diff --git a/deploy/remote-setup.sh b/deploy/remote-setup.sh deleted file mode 100644 index 67d8c64..0000000 --- a/deploy/remote-setup.sh +++ /dev/null @@ -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 diff --git a/deploy/server.env.machine b/deploy/server.env.machine deleted file mode 100644 index c5acb1f..0000000 --- a/deploy/server.env.machine +++ /dev/null @@ -1,4 +0,0 @@ -# 服务器机器相关(可合并到 .env.production 的参考项) -PORT=8090 -DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke" -REDIS_URL="redis://localhost:6379" diff --git a/deploy/setup-webhook.sh b/deploy/setup-webhook.sh index efdf9a1..74b3610 100644 --- a/deploy/setup-webhook.sh +++ b/deploy/setup-webhook.sh @@ -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 From 7e12eb3ca68824cfd8146588023cefaf9171ae1d Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 23 Aug 2026 16:41:22 +0800 Subject: [PATCH 3/3] =?UTF-8?q?v3.5.6=E7=89=88=E6=9C=AC=E8=BF=AD=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + AGENTS.md | 176 ++---------- .../RedeemRecordDetailDescriptions.tsx | 14 + .../src/components/StorePackageAuditPanel.tsx | 8 +- apps/admin-web/src/layouts/AdminLayout.tsx | 4 +- apps/admin-web/src/pages/DevPlanTasksPage.tsx | 105 ++++++- .../src/pages/LogisticsBillsPage.tsx | 64 ++++- apps/admin-web/src/pages/PartnerBillsPage.tsx | 120 +++++++- apps/admin-web/src/pages/StoreBillsPage.tsx | 80 +++++- .../src/pages/StorePackageAuditsPage.tsx | 70 +++-- .../src/pages/SupportTicketsPage.tsx | 13 +- apps/admin-web/src/pages/WineryBillsPage.tsx | 51 +++- docs/杜康好客-v3-PRD.md | 11 +- docs/杜康好客-v3-现状对照.md | 2 + docs/杜康好客-v3.5.6-开发文档.md | 129 +++++++++ packages/shared-types/src/dev-plan.ts | 24 +- packages/shared-types/src/hq-permissions.ts | 17 +- packages/shared-types/src/support-ticket.ts | 1 + pnpm-lock.yaml | 155 +++++++--- server/dukang-api/package.json | 1 + .../migrate-dev-plan-attachments-v356.sql | 2 + .../migrate-finance-payment-ref-v356.sql | 5 + server/dukang-api/prisma/schema.prisma | 5 + .../src/common/store/store-bank.util.ts | 86 ++++++ .../dev-plan/admin-dev-plan-export.util.ts | 270 ++++++++++++++++++ .../dev-plan/admin-dev-plan.controller.ts | 6 + .../src/modules/dev-plan/dev-plan.service.ts | 148 ++++++++++ .../src/modules/dev-plan/dto/dev-plan.dto.ts | 41 +++ .../ops/admin-redeem-debug.controller.ts | 7 +- .../src/modules/ops/admin-redeem.service.ts | 6 + .../ops/admin-xiaofeixia.controller.ts | 7 +- .../settlement/settlement.controller.ts | 12 +- .../modules/settlement/settlement.service.ts | 174 +++++++++-- 33 files changed, 1537 insertions(+), 278 deletions(-) create mode 100644 docs/杜康好客-v3.5.6-开发文档.md create mode 100644 server/dukang-api/prisma/migrate-dev-plan-attachments-v356.sql create mode 100644 server/dukang-api/prisma/migrate-finance-payment-ref-v356.sql create mode 100644 server/dukang-api/src/common/store/store-bank.util.ts create mode 100644 server/dukang-api/src/modules/dev-plan/admin-dev-plan-export.util.ts diff --git a/.gitignore b/.gitignore index 6103248..4a392d1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 317b84c..be10f31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/单测过 diff --git a/apps/admin-web/src/components/RedeemRecordDetailDescriptions.tsx b/apps/admin-web/src/components/RedeemRecordDetailDescriptions.tsx index 3742597..39f7b42 100644 --- a/apps/admin-web/src/components/RedeemRecordDetailDescriptions.tsx +++ b/apps/admin-web/src/components/RedeemRecordDetailDescriptions.tsx @@ -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 = {store?.address ?? '—'} {store?.partnerAccount?.companyName ?? '—'} + + {store?.primaryAccount?.bankAccountName || '—'} + + + {store?.primaryAccount?.bankAccountNo || '—'} + + + {store?.primaryAccount?.bankBranch || '—'} + {coupon?.couponNo ?? '—'} {coupon?.order?.orderNo ?? '—'} {Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? ( diff --git a/apps/admin-web/src/components/StorePackageAuditPanel.tsx b/apps/admin-web/src/components/StorePackageAuditPanel.tsx index adc0356..a449275 100644 --- a/apps/admin-web/src/components/StorePackageAuditPanel.tsx +++ b/apps/admin-web/src/components/StorePackageAuditPanel.tsx @@ -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({ {images.map((url) => ( - + ))} @@ -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} /> )) ) : ( diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 17942e8..af6f784 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -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', diff --git a/apps/admin-web/src/pages/DevPlanTasksPage.tsx b/apps/admin-web/src/pages/DevPlanTasksPage.tsx index b203c5d..2d1d76c 100644 --- a/apps/admin-web/src/pages/DevPlanTasksPage.tsx +++ b/apps/admin-web/src/pages/DevPlanTasksPage.tsx @@ -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(null); const [saving, setSaving] = useState(false); const [dispatching, setDispatching] = useState(false); + const [exportFormat, setExportFormat] = useState('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 = [ { 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 ? ( + + + {row.attachmentUrls.slice(0, 3).map((url) => ( + + ))} + + + ) : ( + '—' + ), + }, { 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() { 开发计划 · 任务列表 - + + { + paymentRef = e.target.value; + }} + /> + + ), 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() { {detail.paidAt ? fmtTime(detail.paidAt) : '—'} + + {detail.paymentRef ? String(detail.paymentRef) : '—'} + + {providerBank(detail) ? ( + <> + + 收款账户 + + + + {providerBank(detail)?.bankAccountName || '—'} + + + {providerBank(detail)?.bankName || '—'} + + + {providerBank(detail)?.bankBranch || '—'} + + + {providerBank(detail)?.bankAccountNo || '—'} + + + + ) : null} ([]); + const [detail, setDetail] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); useEffect(() => { void request>(`/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: ( +
+
+ 将标记 {ids.length} 笔账单为已打款 + {amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。 +
+ { + paymentRef = e.target.value; + }} + /> +
+ ), 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(`/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) => ( + {(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && ( + + setDrawerOpen(false)} + width={560} + > + {detail && ( + <> + + {detail.billNo} + {partnerName(detail)} + + {String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)} + + + ¥{Number(detail.orderCommission).toFixed(2)} + + + ¥{Number(detail.redeemCommission).toFixed(2)} + + + ¥{Number(detail.totalAmount).toFixed(2)} + + + + {STATUS_LABELS[detail.status] || detail.status} + + + + {detail.paidAt ? fmtTime(detail.paidAt) : '—'} + + + {detail.paymentRef ? String(detail.paymentRef) : '—'} + + + + 收款账户 + + + + {partnerBank(detail)?.bankAccountName || '—'} + + + {partnerBank(detail)?.bankAccountNo || '—'} + + + {partnerBank(detail)?.bankBranch || '—'} + + + + )} + ); } diff --git a/apps/admin-web/src/pages/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx index cc78dd8..5ae17a0 100644 --- a/apps/admin-web/src/pages/StoreBillsPage.tsx +++ b/apps/admin-web/src/pages/StoreBillsPage.tsx @@ -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 = { 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 = { 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: ( +
+
+ 将确认 {ids.length} 笔 T+1 门店对账单 + {amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。 +
+ { + paymentRef = e.target.value; + }} + /> +
+ ), 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) => ( - {STORE_SETTLEMENT_KIND_LABELS[k] ?? k} + + {STORE_SETTLEMENT_KIND_LABELS[k] ?? k} + ), }, { @@ -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) => ( - {storeSettlementStatusLabel(row.kind, s)} + + {storeSettlementStatusLabel(row.kind, s)} + ), }, { @@ -339,7 +383,8 @@ export default function StoreBillsPage() { 门店账单 - 含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口 + 含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。 + 「已打款」= T+1 出账终态,「已结算」= 手动提现终态。 {overdueSummary && overdueSummary.overdueCount > 0 ? ( @@ -503,7 +548,32 @@ export default function StoreBillsPage() { {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} + + {detail.paymentRef ? String(detail.paymentRef) : '—'} + + {(detail.storeAccount as { + bankAccountName?: string; + bankAccountNo?: string; + bankBranch?: string; + } | null) ? ( + <> + + 收款账户 + + + + {(detail.storeAccount as { bankAccountName?: string }).bankAccountName || '—'} + + + {(detail.storeAccount as { bankAccountNo?: string }).bankAccountNo || '—'} + + + {(detail.storeAccount as { bankBranch?: string }).bankBranch || '—'} + + + + ) : null} 核销明细 diff --git a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx index 84c696d..b1273fb 100644 --- a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx +++ b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx @@ -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({ {liveUrls.map((url) => ( - + ))} @@ -99,7 +101,7 @@ function InfoChangeImageDiff({ {proposedUrls.map((url) => ( - + ))} @@ -128,6 +130,7 @@ function InfoChangeAuditPanel({ const [rejectOpen, setRejectOpen] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [activeId, setActiveId] = useState(null); + const [infoFullscreen, setInfoFullscreen] = useState(false); async function reload(nextPage = page, nextStatus = status) { setLoading(true); @@ -286,25 +289,33 @@ function InfoChangeAuditPanel({ setDetailOpen(false)} + onClose={() => { + setDetailOpen(false); + setInfoFullscreen(false); + }} extra={ - detail?.status === 'PENDING' ? ( - - - - - ) : null + + + {detail?.status === 'PENDING' ? ( + <> + + + + ) : null} + } > {detailLoading ? ( @@ -328,7 +339,12 @@ function InfoChangeAuditPanel({ label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field} > {d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? ( - + ) : ( @@ -388,6 +404,7 @@ export default function StorePackageAuditsPage() { const [detailOpen, setDetailOpen] = useState(false); const [activeRequestId, setActiveRequestId] = useState(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() { setDetailOpen(false)} + onClose={() => { + setDetailOpen(false); + setPackageFullscreen(false); + }} destroyOnClose + extra={ + + } > {activeRequestId ? ( { setDetailOpen(false); void reload(page, status); diff --git a/apps/admin-web/src/pages/SupportTicketsPage.tsx b/apps/admin-web/src/pages/SupportTicketsPage.tsx index eea6009..2b25a62 100644 --- a/apps/admin-web/src/pages/SupportTicketsPage.tsx +++ b/apps/admin-web/src/pages/SupportTicketsPage.tsx @@ -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, }), diff --git a/apps/admin-web/src/pages/WineryBillsPage.tsx b/apps/admin-web/src/pages/WineryBillsPage.tsx index f67f054..22c1d20 100644 --- a/apps/admin-web/src/pages/WineryBillsPage.tsx +++ b/apps/admin-web/src/pages/WineryBillsPage.tsx @@ -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: ( +
+
+ 将确认 {ids.length} 笔酒厂对账单 + {amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。 +
+ { + paymentRef = e.target.value; + }} + /> +
+ ), 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} {detail.paidAt ? fmtTime(detail.paidAt) : '—'} + + {detail.paymentRef ? String(detail.paymentRef) : '—'} + + {detail.wineryBank ? ( + <> + + 收款账户(酒厂) + + + + {detail.wineryBank.bankAccountName || '—'} + + + {detail.wineryBank.bankName || '—'} + + + {detail.wineryBank.bankBranch || '—'} + + + {detail.wineryBank.bankAccountNo || '—'} + + + + ) : null} 订单明细 diff --git a/docs/杜康好客-v3-PRD.md b/docs/杜康好客-v3-PRD.md index 9098bb1..657acea 100644 --- a/docs/杜康好客-v3-PRD.md +++ b/docs/杜康好客-v3-PRD.md @@ -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 | diff --git a/docs/杜康好客-v3-现状对照.md b/docs/杜康好客-v3-现状对照.md index df40f55..950852b 100644 --- a/docs/杜康好客-v3-现状对照.md +++ b/docs/杜康好客-v3-现状对照.md @@ -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 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 | diff --git a/docs/杜康好客-v3.5.6-开发文档.md b/docs/杜康好客-v3.5.6-开发文档.md new file mode 100644 index 0000000..7396ce7 --- /dev/null +++ b/docs/杜康好客-v3.5.6-开发文档.md @@ -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 +- [ ] 门店审核对比可全屏;图片放大可预览 diff --git a/packages/shared-types/src/dev-plan.ts b/packages/shared-types/src/dev-plan.ts index 104c5f7..ffef5a9 100644 --- a/packages/shared-types/src/dev-plan.ts +++ b/packages/shared-types/src/dev-plan.ts @@ -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( diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts index c3f6953..830b9e1 100644 --- a/packages/shared-types/src/hq-permissions.ts +++ b/packages/shared-types/src/hq-permissions.ts @@ -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 = { - SUPER_ADMIN: hqBasePermissionKeys(), + SUPER_ADMIN: [...hqBasePermissionKeys(), ...HQ_DEBUG_PERMISSION_KEYS], OPS: [ 'dashboard', 'users', diff --git a/packages/shared-types/src/support-ticket.ts b/packages/shared-types/src/support-ticket.ts index 99a1e0d..62e9254 100644 --- a/packages/shared-types/src/support-ticket.ts +++ b/packages/shared-types/src/support-ticket.ts @@ -96,6 +96,7 @@ export interface RejectSupportTicketRequest { export interface CreateDevPlanTaskFromTicketInput { content: string; type: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION'; + attachmentUrls?: string[]; } export interface ReviewSupportTicketRequest { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99fb1fd..7e43cad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index fd7674d..410afb8 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -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", diff --git a/server/dukang-api/prisma/migrate-dev-plan-attachments-v356.sql b/server/dukang-api/prisma/migrate-dev-plan-attachments-v356.sql new file mode 100644 index 0000000..15f3282 --- /dev/null +++ b/server/dukang-api/prisma/migrate-dev-plan-attachments-v356.sql @@ -0,0 +1,2 @@ +-- v3.5.6: 开发计划任务附件 +ALTER TABLE `dev_plan_task` ADD COLUMN `attachment_urls` JSON NULL AFTER `support_ticket_id`; diff --git a/server/dukang-api/prisma/migrate-finance-payment-ref-v356.sql b/server/dukang-api/prisma/migrate-finance-payment-ref-v356.sql new file mode 100644 index 0000000..381a34f --- /dev/null +++ b/server/dukang-api/prisma/migrate-finance-payment-ref-v356.sql @@ -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`; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 349f757..1bbee69 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -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) diff --git a/server/dukang-api/src/common/store/store-bank.util.ts b/server/dukang-api/src/common/store/store-bank.util.ts new file mode 100644 index 0000000..2e99329 --- /dev/null +++ b/server/dukang-api/src/common/store/store-bank.util.ts @@ -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 { + 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> { + const map = new Map(); + 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 { + 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, + }; +} diff --git a/server/dukang-api/src/modules/dev-plan/admin-dev-plan-export.util.ts b/server/dukang-api/src/modules/dev-plan/admin-dev-plan-export.util.ts new file mode 100644 index 0000000..609806b --- /dev/null +++ b/server/dukang-api/src/modules/dev-plan/admin-dev-plan-export.util.ts @@ -0,0 +1,270 @@ +import { + DEV_PLAN_TASK_STATUS_LABELS, + DEV_PLAN_TASK_TYPE_LABELS, + type DevPlanTaskStatusDto, + type DevPlanTaskTypeDto, +} from '@dukang/shared-types'; +import ExcelJS from 'exceljs'; +import * as fs from 'fs'; +import * as path from 'path'; +import PDFDocument from 'pdfkit'; +import { + Document, + HeadingLevel, + ImageRun, + Packer, + Paragraph, + Table, + TableCell, + TableRow, + TextRun, + WidthType, +} from 'docx'; + +const EXPORT_HEADERS = [ + '任务编号', + '类型', + '状态', + '内容', + '关联工单', + '创建人', + '创建时间', + '完成时间', + '附件', +] as const; + +export type DevPlanTaskExportRow = { + taskNo: string; + type: string; + status: string; + content: string; + supportTicketNo: string; + creatorName: string; + createdAt: string; + completedAt: string; + attachmentUrls: string[]; +}; + +export type DevPlanExportFormat = 'markdown' | 'docx' | 'xlsx' | 'pdf'; + +export function mapTaskToExportRow(task: { + taskNo: string; + type: DevPlanTaskTypeDto; + status: DevPlanTaskStatusDto; + content: string; + supportTicketNo?: string | null; + creatorName?: string | null; + createdAt: string; + completedAt?: string | null; + attachmentUrls?: string[] | null; +}): DevPlanTaskExportRow { + return { + taskNo: task.taskNo, + type: DEV_PLAN_TASK_TYPE_LABELS[task.type] ?? task.type, + status: DEV_PLAN_TASK_STATUS_LABELS[task.status] ?? task.status, + content: task.content, + supportTicketNo: task.supportTicketNo ?? '', + creatorName: task.creatorName ?? '', + createdAt: task.createdAt.slice(0, 19).replace('T', ' '), + completedAt: task.completedAt ? task.completedAt.slice(0, 19).replace('T', ' ') : '', + attachmentUrls: task.attachmentUrls ?? [], + }; +} + +function rowToCells(row: DevPlanTaskExportRow): string[] { + return [ + row.taskNo, + row.type, + row.status, + row.content, + row.supportTicketNo, + row.creatorName, + row.createdAt, + row.completedAt, + row.attachmentUrls.join(' '), + ]; +} + +function resolvePdfFontPath(): string { + const candidates = [ + process.env.EXPORT_PDF_FONT_PATH, + path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'), + path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'), + path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'), + path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'), + '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc', + 'C:\\Windows\\Fonts\\simhei.ttf', + 'C:\\Windows\\Fonts\\msyh.ttc', + ].filter(Boolean) as string[]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/'); +} + +export function buildDevPlanMarkdown(rows: DevPlanTaskExportRow[]): Buffer { + const lines: string[] = ['# 开发计划任务导出', '']; + rows.forEach((row, index) => { + lines.push(`## ${index + 1}. ${row.taskNo} [${row.type}] ${row.status}`); + lines.push(''); + lines.push(row.content); + lines.push(''); + if (row.supportTicketNo) lines.push(`- 关联工单:${row.supportTicketNo}`); + if (row.creatorName) lines.push(`- 创建人:${row.creatorName}`); + lines.push(`- 创建时间:${row.createdAt}`); + if (row.completedAt) lines.push(`- 完成时间:${row.completedAt}`); + if (row.attachmentUrls.length) { + lines.push('- 附件:'); + for (const url of row.attachmentUrls) { + lines.push(` ![附件](${url})`); + lines.push(` ${url}`); + } + } + lines.push(''); + }); + return Buffer.from(lines.join('\n'), 'utf8'); +} + +async function fetchImageBuffer(url: string): Promise { + 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 { + 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 { + 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 { + 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'; + } +} diff --git a/server/dukang-api/src/modules/dev-plan/admin-dev-plan.controller.ts b/server/dukang-api/src/modules/dev-plan/admin-dev-plan.controller.ts index 8682729..a692ef3 100644 --- a/server/dukang-api/src/modules/dev-plan/admin-dev-plan.controller.ts +++ b/server/dukang-api/src/modules/dev-plan/admin-dev-plan.controller.ts @@ -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, diff --git a/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts b/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts index 69102e5..e77cafe 100644 --- a/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts +++ b/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts @@ -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, + }; + } + } diff --git a/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts b/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts index 0096e6d..59dcab2 100644 --- a/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts +++ b/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts @@ -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 }) diff --git a/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts b/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts index 57712f3..0c16e04 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem-debug.controller.ts @@ -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) {} diff --git a/server/dukang-api/src/modules/ops/admin-redeem.service.ts b/server/dukang-api/src/modules/ops/admin-redeem.service.ts index 4187be2..a5d40cb 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem.service.ts @@ -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), diff --git a/server/dukang-api/src/modules/ops/admin-xiaofeixia.controller.ts b/server/dukang-api/src/modules/ops/admin-xiaofeixia.controller.ts index 6aa1e0a..f5bc32c 100644 --- a/server/dukang-api/src/modules/ops/admin-xiaofeixia.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-xiaofeixia.controller.ts @@ -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) {} diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 71acbd2..1f1c2af 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -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); } } diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 55cf34e..a898034 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -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(','), ); }