From a9c466930d5e35d4c5eb0f01d7b76069e04ee37c Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 6 Sep 2026 14:18:08 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=92=8C=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/企微API插件-配置手册.md | 380 ++++++++++++++++++++++++++++++ docs/杜康好客-v3.5.17-开发文档.md | 2 + 2 files changed, 382 insertions(+) create mode 100644 docs/企微API插件-配置手册.md diff --git a/docs/企微API插件-配置手册.md b/docs/企微API插件-配置手册.md new file mode 100644 index 0000000..035cbbc --- /dev/null +++ b/docs/企微API插件-配置手册.md @@ -0,0 +1,380 @@ +# 企微 API 插件 · 配置手册 + +> 面向运营/技术:在企微后台「智能机器人 → 添加 API 插件」逐步填表。 +> 后端前缀:`/api/v1/wecom/plugin` · 鉴权 Header `X-Api-Key` · 与 HQ 长连接 Bot **独立**。 +> 密钥与权限在 HQ **企微机器人 → API 插件** 维护(v3.5.17+)。 + +--- + +## 1. 推荐流程(优先 OpenAPI 导入) + +**不要 17 个工具全手填。** 推荐: + +1. HQ → **企微机器人 → API 插件** → 新建实例,勾选工具权限,复制 **Key**(只显示一次)。 +2. 企微 **第 1 步**:填插件 URL + Header Key(见 §2)。 +3. 企微 **第 2 步**:**OpenAPI 导入** + - URL:`{Base URL}/openapi.json` + - **同样带 Header `X-Api-Key`**(与第 1 步相同 Key) + - 服务端按该 Key 的权限**只返回已授权路径**,与 HQ 勾选一致。 +4. 导入后逐条核对 §3「全局规则」;类型不对时按 §5 手工修正。 + +手填仅适用于:导入失败,或只加 2~3 个高频工具。 + +--- + +## 2. 第 1 步:添加 API 插件 + +| 企微表单字段 | 测试环境 | 生产环境 | +|-------------|---------|---------| +| **插件 URL** | `https://api-test.dukanghaoke.com/api/v1/wecom/plugin` | `https://api.dukanghaoke.com/api/v1/wecom/plugin` | +| **授权方式** | Service token / API key | 同左 | +| **位置** | Header | Header | +| **Parameter name** | `X-Api-Key` | `X-Api-Key` | +| **Service token** | HQ 该实例 Key | 同左 | + +**自检(应返回 `{ code, message, data }`,不是 401):** + +```bash +curl -H "X-Api-Key: <你的Key>" \ + "https://api-test.dukanghaoke.com/api/v1/wecom/plugin/metrics?kind=today" +``` + +HQ 页也会展示 Base URL 与 Header 名(不含密钥)。 + +--- + +## 3. 全局规则(调试不过先看这里) + +| 项 | 正确 | 错误 | +|----|------|------| +| HTTP 方法 | 全部 **GET** | POST、Body | +| 入参位置 | **Query** 或 **Path** | Body、Form | +| 响应顶层 | **`code`(Integer) + `message`(String) + `data`(Object)** | 只配 `data` 内字段 | +| 状态字段 | `status`、`payStatus`、`auditStatus` 等 → **String** | Integer | +| 模型可见 | 业务入参 `q`、`kind`、`from`… → **是** | 隐藏 `q` 模型不会传 | +| 分页 | `page` 默认 1;`pageSize` 默认 5,**最大 10** | 过大 pageSize | +| 鉴权 | 插件级 Header 已配 Key | Query 再传 apiKey | + +**统一响应信封(`openapi.json` 除外):** + +```json +{ + "code": 0, + "message": "ok", + "data": { } +} +``` + +企微「输出参数」:**先配顶层 `code` / `message` / `data`,再在 `data` 下配业务字段**。 + +--- + +## 4. HQ 权限与工具对照 + +只添加 HQ **已勾选**权限对应的工具;未授权返回 **403**。 + +| HQ 权限 | 路径 | 建议工具名 | +|---------|------|-----------| +| `metrics.read` | `/metrics` | 查询经营指标 | +| `order.read` | `/orders` | 查询订单 | +| `user.read` | `/users` | 查询用户 | +| `store.read` | `/stores` | 查询门店 | +| `redeem.read` | `/redeems` | 查询核销 | +| `promo.read` | `/promo-codes`、`/promo-codes/{code}/stats` | 查询推广码、推广码统计 | +| `store.audit.read` | `/store-audits`、`/store-info-audits`、`/store-info-audits/{id}`、`/store-package-audits`、`/store-package-audits/{id}` | 门店/信息/套餐审核 | +| `partner.read` | `/partners`、`/partners/{partnerId}/users|stores|orders` | 合伙人及关联数据 | + +**实例建议:** + +| 场景 | HQ 勾选 | +|------|---------| +| 日常运营 | metrics + order + user + store + promo + redeem | +| 审核值班 | 上表 + store.audit.read | +| 合伙人分析 | 上表 + partner.read | + +--- + +## 5. 第 2 步:工具参数明细 + +以下均为 **GET**。Base URL 与 §2 相同,路径为相对路径。 + +### 5.1 通用分页(有列表的工具) + +| 参数 | 位置 | 类型 | 必填 | 默认 | 模型可见 | 说明 | +|------|------|------|------|------|----------|------| +| page | Query | Integer | 否 | 1 | 否 | 页码 | +| pageSize | Query | Integer | 否 | 5 | 否 | 每页条数,最大 10 | + +**列表类 `data` 结构:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| total | Integer | 总条数 | +| items | Array | 结果列表 | + +--- + +### 5.2 查询经营指标 + +| 项 | 值 | +|----|-----| +| 权限 | `metrics.read` | +| 路径 | `/metrics` | + +**输入:** + +| 参数 | 位置 | 类型 | 必填 | 默认 | 模型可见 | 说明 | +|------|------|------|------|------|----------|------| +| kind | Query | String | 否 | today | **是** | `today` / `daily` / `weekly` / `monthly` | + +**输出 `data`:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| kind | String | 指标类型 | +| title | String | 标题 | +| rangeLabel | String | 统计区间 | +| incrementLabel | String | 如「今日新增」 | +| periodKey | String | 账期 key | +| stats | Object | 见下表 | + +**`stats` 字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| usersTotal / usersIncrement | Integer | 用户存量/增量 | +| partnersTotal / partnersIncrement | Integer | 合伙人 | +| storesTotal / storesIncrement | Integer | 门店存量/增量 | +| **newStores** | Integer | **新增门店数**(与 storesIncrement 相同) | +| ordersTotal / ordersIncrement | Integer | 订单笔数 | +| orderAmountTotal / orderAmountIncrement | Number | 订单金额(已付 payAmount) | +| redeemsTotal / redeemsIncrement | Integer | 核销笔数 | +| redeemAmountTotal / redeemAmountIncrement | Number | 核销金额 | + +口径与 HQ 企微经营报告一致;`today` 期末为当前时刻。 + +--- + +### 5.3 查询订单 + +| 权限 | `order.read` | +| 路径 | `/orders` | + +| 参数 | 位置 | 类型 | 必填 | 模型可见 | 说明 | +|------|------|------|------|----------|------| +| q | Query | String | **是** | **是** | 订单号,如 `DK20260903xxxx` | + +**`items[]`:** orderNo, status(String), payStatus(String), deliveryType, productName, quantity, payAmount(Number), user(Object), receiverName, receiverPhone, receiverCity, trackingNo, createdAt + +--- + +### 5.4 查询用户 + +| 权限 | `user.read` | +| 路径 | `/users` | + +| 参数 | 位置 | 类型 | 必填 | 模型可见 | 说明 | +|------|------|------|------|----------|------| +| q | Query | String | **是** | **是** | 用户号或 11 位手机号 | + +**`items[]`:** userNo, nickname, phone(脱敏), status, orderCount, benefitBalance, createdAt + +--- + +### 5.5 查询门店(含经营数据) + +| 权限 | `store.read` | +| 路径 | `/stores` | + +| 参数 | 位置 | 类型 | 必填 | 模型可见 | 说明 | +|------|------|------|------|----------|------| +| q | Query | String | **是** | **是** | 门店名称关键词 | + +**`items[]`:** id, name, status(String), auditStatus(String), cityName, district, address, contactPhone, rating(Number), redeemCount, totalRedeemedBenefitAmount, partnerName, partnerId, createdAt + +--- + +### 5.6 查询核销 + +| 权限 | `redeem.read` | +| 路径 | `/redeems` | + +| 参数 | q=核销单号或门店名(Query,必填,模型可见) + +**`items[]`:** redeemNo, amount, channel, storeName, createdAt + +--- + +### 5.7 查询推广码 + +| 权限 | `promo.read` | +| 路径 | `/promo-codes` | + +| 参数 | q=推广码 code 或名称(Query,必填,模型可见) + +**`items[]`:** code, name, scene, status(String), scanCount, orderCount + +--- + +### 5.8 推广码统计 + +| 权限 | `promo.read` | +| 路径 | `/promo-codes/{code}/stats` | + +| 参数 | 位置 | 类型 | 必填 | 模型可见 | +|------|------|------|------|----------| +| code | **Path** | String | 是 | **是** | + +**`data`:** code, name, status, stats(Object) + +--- + +### 5.9 门店入驻审核 + +| 权限 | `store.audit.read` | +| 路径 | `/store-audits` | + +| 参数 | 位置 | 类型 | 必填 | 默认 | 模型可见 | 说明 | +|------|------|------|------|------|----------|------| +| q | Query | String | 否 | — | 是 | 门店名称 | +| status | Query | String | 否 | PENDING | 是 | PENDING / APPROVED / REJECTED | + +**`items[]`:** id, name, status, auditStatus, rejectReason, cityName, address, contactPhone, partnerName, partnerId, createdAt + +--- + +### 5.10 门店信息变更审核列表 + +| 权限 | `store.audit.read` | +| 路径 | `/store-info-audits` | + +| 参数 | status(Query,默认 PENDING)+ 分页 + +**`items[]`:** id, storeId, storeName, status, changedFields, changedFieldLabels, submitterType, createdAt + +--- + +### 5.11 门店信息变更对比详情 + +| 权限 | `store.audit.read` | +| 路径 | `/store-info-audits/{id}` | + +| 参数 | id → **Path**,String,必填,模型可见(从列表取 id) + +**`data` 额外:** diffs[] → field, label, live, proposed + +--- + +### 5.12 门店套餐审核列表 + +| 权限 | `store.audit.read` | +| 路径 | `/store-package-audits` | + +| 参数 | status + 分页 + +**`items[]`:** id, storeId, storeName, status, packageCount, createdAt + +--- + +### 5.13 门店套餐对比详情 + +| 权限 | `store.audit.read` | +| 路径 | `/store-package-audits/{id}` | + +| 参数 | id → Path + +**`data`:** proposedPackages(Array), livePackages(Array) — 含 name, price, dishes, usableTime, otherNotes + +> 插件为**只读**;审核通过/驳回仍在 HQ 操作,不在插件内配置写接口。 + +--- + +### 5.14 查询合伙人 + +| 权限 | `partner.read` | +| 路径 | `/partners` | + +| 参数 | q=姓名/公司名/手机号/ID(Query,必填,模型可见) + +**`items[]`:** id, name, companyName, phone(脱敏), status, cityName, storeCount, userCount + +--- + +### 5.15 合伙人关联用户 / 门店 / 订单 + +先 **查询合伙人** 取得 `id`,再调下列接口。 + +| 工具名 | 路径 | +|--------|------| +| 合伙人关联用户 | `/partners/{partnerId}/users` | +| 合伙人名下门店 | `/partners/{partnerId}/stores` | +| 合伙人相关订单 | `/partners/{partnerId}/orders` | + +**Path:** + +| 参数 | 类型 | 必填 | 模型可见 | +|------|------|------|----------| +| partnerId | String | 是 | **是** | + +**Query(可选,建议模型可见):** + +| 参数 | 类型 | 说明 | +|------|------|------| +| from | String | 起始日期 `YYYY-MM-DD` 或 ISO | +| to | String | 结束日期(含当天) | +| page / pageSize | Integer | 分页 | + +**`data`:** partnerId, partnerName, total, items[] + +- **users**:关联用户(assocPartnerAccountId) +- **stores**:名下门店 + 经营数据 +- **orders**:佣金归属订单(partnerAccountIdAtPay) + +--- + +## 6. OpenAPI 导入 + +| 项 | 值 | +|----|-----| +| 导入 URL | `{Base URL}/openapi.json` | +| Header | `X-Api-Key: <同实例 Key>` | +| 格式 | OpenAPI 3.0(**无** `{code,message,data}` 信封,原样 JSON) | +| 过滤 | 仅含当前 Key 已授权 paths | + +示例: + +```bash +curl -H "X-Api-Key: " \ + "https://api-test.dukanghaoke.com/api/v1/wecom/plugin/openapi.json" +``` + +--- + +## 7. 调试检查清单 + +- [ ] 第 1 步 Key 与 HQ 实例一致,实例 **已启用** +- [ ] curl 带 Key 返回 `code: 0` +- [ ] 工具为 **GET**,入参在 **Query/Path**,非 Body +- [ ] 输出含 **code / message / data** +- [ ] status 等枚举字段类型为 **String** +- [ ] 未导入 HQ 未授权工具(否则 403) +- [ ] 企微白名单会话试问:「查一下今日经营指标」「订单 DK…」 +- [ ] HQ **日志 → 智能机器人日志** 出现 `botKey=plugin:{id}` + +--- + +## 8. 多实例与分工 + +| 企微插件 | HQ 实例 | 说明 | +|----------|---------|------| +| 运营查询 | 全权限 Key | metrics + 审核 + 合伙人等 | +| 客服只读 | 部分权限 Key | order + user + store | +| URL | **相同** Base URL | 仅 Key 不同 | + +--- + +## 9. 相关文档 + +- [杜康好客-v3.5.17-开发文档](./杜康好客-v3.5.17-开发文档.md) — 表结构、Admin API、权限目录 +- [杜康好客-v3.5.16-开发文档](./杜康好客-v3.5.16-开发文档.md) — 插件初版与 curl 示例 +- [杜康好客-v3编码手册](./杜康好客-v3编码手册.md) — 编码验收一句 diff --git a/docs/杜康好客-v3.5.17-开发文档.md b/docs/杜康好客-v3.5.17-开发文档.md index ddad2df..95917b5 100644 --- a/docs/杜康好客-v3.5.17-开发文档.md +++ b/docs/杜康好客-v3.5.17-开发文档.md @@ -75,6 +75,8 @@ ## 6. 企微侧怎么配 +详见 **[企微 API 插件 · 配置手册](./企微API插件-配置手册.md)**(第 1 步插件、第 2 步工具/OpenAPI 导入、全接口参数表)。 + 1. 插件 URL = HQ 页展示的 Base URL(全实例相同) 2. Header `X-Api-Key` = 该实例密钥 3. 第 2 步只添加 HQ 已勾选的工具(GET + Query);可用带 Key 的 `openapi.json` 对照路径 From 4bdb09068ce942d4709ce0366d2c8114ce801238 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 7 Sep 2026 15:56:16 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(store):=20v4.0.18=20=E9=97=A8=E5=BA=97?= =?UTF-8?q?=E5=A4=9A=E6=94=B6=E6=AC=BE=E8=B4=A6=E6=88=B7=E4=B8=8E=E5=AD=90?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=BB=A7=E6=89=BF=E4=BA=8C=E7=BB=B4=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C 端门店列表拼接省市区县地址;门店多银行账户与默认打款账户;子账号独立 sa_ 关联码及统计维度;同步 v4.0.18 开发文档。 Co-authored-by: Cursor --- .../src/components/StoreBankAccountsPanel.tsx | 178 +++++++++++++ apps/admin-web/src/pages/StoresPage.tsx | 9 + apps/h5-partner/src/pages/AssocQrcodePage.tsx | 5 +- apps/h5-partner/src/pages/UsersManagePage.tsx | 4 +- apps/h5-shop/src/App.tsx | 2 + apps/h5-shop/src/pages/BankAccountsPage.tsx | 252 ++++++++++++++++++ apps/h5-shop/src/pages/MinePage.tsx | 6 + apps/h5-shop/src/styles.css | 12 + apps/mini-user/src/lib/promo.ts | 2 +- apps/mini-user/src/lib/store-display.ts | 16 ++ apps/mini-user/src/pages/stores/index.tsx | 11 +- docs/杜康好客-v4-PRD.md | 17 +- docs/杜康好客-v4-现状对照.md | 6 +- docs/杜康好客-v4.0.18-开发文档.md | 106 ++++++++ packages/shared-types/src/partner-assoc.ts | 6 + packages/shared-types/src/settlement.ts | 19 ++ .../migrate-store-bank-account-v4018.sql | 43 +++ .../migrate-user-assoc-sub-account-v4018.sql | 5 + server/dukang-api/prisma/schema.prisma | 24 ++ .../src/common/store/store-bank.util.ts | 107 ++++++-- .../src/modules/ops/admin-redeem.service.ts | 4 +- .../modules/settlement/settlement.service.ts | 41 ++- .../modules/store/partner-assoc.service.ts | 198 ++++++++++---- .../modules/store/store-bank.controller.ts | 93 +++++++ .../src/modules/store/store-bank.service.ts | 121 +++++++++ .../src/modules/store/store.module.ts | 8 +- 26 files changed, 1193 insertions(+), 102 deletions(-) create mode 100644 apps/admin-web/src/components/StoreBankAccountsPanel.tsx create mode 100644 apps/h5-shop/src/pages/BankAccountsPage.tsx create mode 100644 docs/杜康好客-v4.0.18-开发文档.md create mode 100644 server/dukang-api/prisma/migrate-store-bank-account-v4018.sql create mode 100644 server/dukang-api/prisma/migrate-user-assoc-sub-account-v4018.sql create mode 100644 server/dukang-api/src/modules/store/store-bank.controller.ts create mode 100644 server/dukang-api/src/modules/store/store-bank.service.ts diff --git a/apps/admin-web/src/components/StoreBankAccountsPanel.tsx b/apps/admin-web/src/components/StoreBankAccountsPanel.tsx new file mode 100644 index 0000000..123b377 --- /dev/null +++ b/apps/admin-web/src/components/StoreBankAccountsPanel.tsx @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { StoreBankAccountDto } from '@dukang/shared-types'; +import { request } from '../lib/api'; + +type Props = { storeId: string }; + +type FormValues = { bankAccountName: string; bankAccountNo: string; bankBranch?: string }; + +export default function StoreBankAccountsPanel({ storeId }: Props) { + const [form] = Form.useForm(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [editingId, setEditingId] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await request(`/admin/stores/${storeId}/bank-accounts`); + setItems(res ?? []); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载失败'); + } finally { + setLoading(false); + } + }, [storeId]); + + useEffect(() => { + void load(); + }, [load]); + + function openCreate() { + setEditingId(null); + form.resetFields(); + setModalOpen(true); + } + + function openEdit(item: StoreBankAccountDto) { + setEditingId(item.id); + form.setFieldsValue({ + bankAccountName: item.bankAccountName, + bankAccountNo: item.bankAccountNo, + bankBranch: item.bankBranch ?? '', + }); + setModalOpen(true); + } + + async function submit() { + const v = await form.validateFields(); + setSubmitting(true); + try { + const body = JSON.stringify({ + bankAccountName: v.bankAccountName.trim(), + bankAccountNo: v.bankAccountNo.replace(/\s+/g, ''), + bankBranch: v.bankBranch?.trim() || undefined, + }); + if (editingId) { + await request(`/admin/stores/${storeId}/bank-accounts/${editingId}`, { method: 'PUT', body }); + } else { + await request(`/admin/stores/${storeId}/bank-accounts`, { method: 'POST', body }); + } + message.success('已保存'); + setModalOpen(false); + void load(); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSubmitting(false); + } + } + + async function setDefault(id: string) { + try { + await request(`/admin/stores/${storeId}/bank-accounts/${id}/default`, { method: 'POST' }); + message.success('已设为默认打款账户'); + void load(); + } catch (e) { + message.error(e instanceof Error ? e.message : '设置失败'); + } + } + + async function remove(id: string) { + try { + await request(`/admin/stores/${storeId}/bank-accounts/${id}`, { method: 'DELETE' }); + message.success('已删除'); + void load(); + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败'); + } + } + + const columns: ColumnsType = [ + { title: '收款人', dataIndex: 'bankAccountName', width: 140 }, + { title: '银行账号', dataIndex: 'bankAccountNo', width: 180 }, + { title: '开户行', dataIndex: 'bankBranch', render: (v) => v || '—' }, + { + title: '默认', + dataIndex: 'isDefault', + width: 80, + render: (v) => (v ? 默认 : null), + }, + { + title: '操作', + width: 220, + render: (_, row) => ( + + + {!row.isDefault ? ( + + ) : null} + {!row.isDefault ? ( + void remove(row.id)} + > + + + ) : null} + + ), + }, + ]; + + return ( +
+ + + 每门店可维护多个收款账户,标记「默认」的账户用于核销结算与提现打款;切换即时生效、无需重启服务。 + + + + + setModalOpen(false)} + onOk={() => void submit()} + okText="保存" + > +
+ + + + + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 14007b7..29242c4 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -51,6 +51,7 @@ import TencentLocPickerModal from '../components/TencentLocPickerModal'; import AdminStorePackagesSection, { type AdminStorePackagesHandle, } from '../components/AdminStorePackagesSection'; +import StoreBankAccountsPanel from '../components/StoreBankAccountsPanel'; import StorePackageAuditPanel, { auditStorePackageRequest, } from '../components/StorePackageAuditPanel'; @@ -1297,6 +1298,14 @@ export default function StoresPage() { ), }, + { + key: 'bank-accounts', + label: '收款账户', + forceRender: true, + children: ( + + ), + }, { key: 'media', label: '审核材料', diff --git a/apps/h5-partner/src/pages/AssocQrcodePage.tsx b/apps/h5-partner/src/pages/AssocQrcodePage.tsx index 9983b99..9b29a9d 100644 --- a/apps/h5-partner/src/pages/AssocQrcodePage.tsx +++ b/apps/h5-partner/src/pages/AssocQrcodePage.tsx @@ -61,7 +61,9 @@ export default function AssocQrcodePage() {

- 用户扫码后首次锁定本合伙人,后续酒单按订单佣金结算 + {summary?.isSubAccount + ? '子账号专属二维码:用户扫码后锁定主账号(佣金归主账号),额外计入本子账号统计' + : '用户扫码后首次锁定本合伙人,后续酒单按订单佣金结算'}

{summary?.qrcodeUrl ? ( 已关联 {summary?.userCount ?? 0} 人 + {typeof summary?.scanCount === 'number' ? ` · 已扫码 ${summary.scanCount} 次` : ''}

+

收款账户

+ + +
+

+ 用于总部打款,可维护多个账户;标记「默认」的账户用于提现打款,切换即时生效、无需重启。 +

+ + {loading ? ( +

加载中…

+ ) : items.length === 0 ? ( +

暂无收款账户{isPrimary ? ',请新增' : ''}

+ ) : ( +
+ {items.map((it) => ( +
+
+
+
+ 收款人 + {it.bankAccountName} + {it.isDefault ? ( + 默认 + ) : null} +
+

账号: {it.bankAccountNo}

+ {it.bankBranch ?

开户行: {it.bankBranch}

: null} +
+
+ {isPrimary ? ( +
+ + {!it.isDefault ? ( + + ) : null} + {!it.isDefault ? ( + + ) : null} +
+ ) : null} +
+ ))} +
+ )} + + {isPrimary ? ( +
+

+ {editing ? '编辑账户' : '新增账户'} +

+
+ + setForm({ ...form, bankAccountName: e.target.value })} + /> + {fieldErrors.bankAccountName ? ( +

{fieldErrors.bankAccountName}

+ ) : null} + + + setForm({ ...form, bankAccountNo: e.target.value.replace(/[^\d]/g, '') })} + /> + {fieldErrors.bankAccountNo ? ( +

{fieldErrors.bankAccountNo}

+ ) : null} + + + setForm({ ...form, bankBranch: e.target.value })} + /> + +
+ + {editing ? ( + + ) : null} +
+
+
+ ) : ( +

仅主账号可维护收款账户

+ )} + + {msg ?

{msg}

: null} +
+
+ ); +} diff --git a/apps/h5-shop/src/pages/MinePage.tsx b/apps/h5-shop/src/pages/MinePage.tsx index 471cf12..304c8a9 100644 --- a/apps/h5-shop/src/pages/MinePage.tsx +++ b/apps/h5-shop/src/pages/MinePage.tsx @@ -153,6 +153,12 @@ export default function MinePage() { account_balance_wallet 结算提现 + {profile?.isPrimary ? ( + + ) : null} {profile?.isPrimary ? ( + {row.editable ? ( + <> + + void removeOther(row)} + > + + + + ) : null} + + ), + }, + ]; + + const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-bank-accounts', baseColumns, { + page, + pageSize, + }); + + return ( +
+ {settingsModal} + + 新增账户 + + } + /> + +
{ + setFilters({ + type: v.type || '', + cityId: v.cityId || '', + keyword: v.keyword?.trim() || '', + }); + setPage(1); + }} + > + + ({ value: c.id, label: c.name }))} + /> + + + + + + + + + + + + + + + + + + +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + + setOtherOpen(false)} + onOk={() => void saveOther()} + confirmLoading={saving} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + +
+ + setRemarkTarget(null)} + onOk={() => void saveRemark()} + confirmLoading={saving} + destroyOnClose + > +
+ + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/HqPermissionsPage.tsx b/apps/admin-web/src/pages/HqPermissionsPage.tsx index 4b03d93..b4d087e 100644 --- a/apps/admin-web/src/pages/HqPermissionsPage.tsx +++ b/apps/admin-web/src/pages/HqPermissionsPage.tsx @@ -246,7 +246,7 @@ export default function HqPermissionsPage() { 按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。 超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市、修改用户关联合伙人需在「按用户分配」或「按角色分配」中单独勾选(默认均无)。 运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。 - 「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。 + 「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单、物流对账与银行账户。 ; + staff?: AdminStoreStaffItem[]; }; type StoreOption = { id: string; name: string }; +const STAFF_ROLE_OPTIONS = Object.entries(STORE_STAFF_ROLE_LABELS).map(([value, label]) => ({ value, label })); +const STAFF_PERMISSION_OPTIONS = STORE_STAFF_DEFAULT_PERMISSIONS.map((value) => ({ + value, + label: STORE_STAFF_PERMISSION_LABELS[value], +})); + export default function StoreAccountsPage() { const [form] = Form.useForm(); const [createForm] = Form.useForm(); + const [staffForm] = Form.useForm(); + const [staffEditForm] = Form.useForm(); const [filters, setFilters] = useState>({}); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/store-accounts', @@ -50,9 +64,14 @@ export default function StoreAccountsPage() { const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const [staffOpen, setStaffOpen] = useState(false); + const [staffEditOpen, setStaffEditOpen] = useState(false); + const [editingStaffId, setEditingStaffId] = useState(null); const [stores, setStores] = useState([]); const [deletingStaffId, setDeletingStaffId] = useState(null); + const parentStoreOptions = (detail?.stores ?? []).map((s) => ({ value: s.id, label: s.name })); + async function loadStores() { const res = await request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setStores(res.items); @@ -64,6 +83,34 @@ export default function StoreAccountsPage() { void reload(); } + function openAddStaff() { + if (!detail) return; + if (!detail.stores?.length) { + message.warning('请先为该主账号绑定门店'); + return; + } + staffForm.resetFields(); + staffForm.setFieldsValue({ + staffRole: StoreStaffRole.CASHIER, + permissions: [...STORE_STAFF_DEFAULT_PERMISSIONS], + storeIds: detail.stores.map((s) => s.id), + }); + setStaffOpen(true); + } + + function openEditStaff(staff: AdminStoreStaffItem) { + setEditingStaffId(staff.id); + staffEditForm.setFieldsValue({ + name: staff.name, + phone: staff.phone, + staffRole: staff.staffRole ?? StoreStaffRole.CASHIER, + status: staff.status, + permissions: staff.permissions?.length ? staff.permissions : [...STORE_STAFF_DEFAULT_PERMISSIONS], + storeIds: staff.storeIds?.length ? staff.storeIds : (staff.stores ?? []).map((s) => s.id), + }); + setStaffEditOpen(true); + } + async function deleteStaff(staffId: string) { if (!detail) return; setDeletingStaffId(staffId); @@ -151,13 +198,58 @@ export default function StoreAccountsPage() { const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize }); + const staffColumns: ColumnsType = [ + { title: '姓名', dataIndex: 'name', width: 90 }, + { title: '手机', dataIndex: 'phone', width: 120 }, + { + title: '角色', + dataIndex: 'staffRole', + width: 80, + render: (role) => STORE_STAFF_ROLE_LABELS[role as StoreStaffRole] || role || '—', + }, + { + title: '门店', + render: (_, staff) => + staff.stores?.length ? staff.stores.map((s) => s.name).join('、') : '—', + }, + { + title: '状态', + dataIndex: 'status', + width: 70, + render: (s) => {ACCOUNT_STATUS_LABELS[s] || s}, + }, + { + title: '操作', + width: 120, + render: (_, staff) => ( + + + void deleteStaff(staff.id)} + > + + + + ), + }, + ]; + return (
{settingsModal} setDrawerOpen(false)} extra={ detail && (
{ACCOUNT_STATUS_LABELS[s] || s}, - }, - { - title: '操作', - width: 80, - render: (_, staff) => ( - void deleteStaff(staff.id)} - > - - - ), - }, - ]} - /> - - ) : ( - - 暂无子账号 - - )} +
+ + 子账号({detail.staff?.length ?? 0})· 仅主账号可添加,不可多级 + + +
+
)} @@ -321,6 +387,87 @@ export default function StoreAccountsPage() { + setStaffOpen(false)} + onOk={async () => { + if (!detail) return; + const v = await staffForm.validateFields(); + await request(`/admin/store-accounts/${detail.id}/staff`, { + method: 'POST', + body: JSON.stringify(v), + }); + message.success('子账号已创建'); + setStaffOpen(false); + await refreshDetail(detail.id); + }} + > +
+ + + + + + + + + + + +
+ setStaffEditOpen(false)} + onOk={async () => { + if (!detail || !editingStaffId) return; + const v = await staffEditForm.validateFields(); + await request(`/admin/store-accounts/${detail.id}/staff/${editingStaffId}`, { + method: 'PUT', + body: JSON.stringify(v), + }); + message.success('子账号已更新'); + setStaffEditOpen(false); + await refreshDetail(detail.id); + }} + > +
+ + + + + + ({ value, label }))} /> + + +
0; +} diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index ba3f393..9a8f1f2 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -17,11 +17,13 @@ import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../component import BenefitIntroCard from '../../components/BenefitIntroCard'; import WechatShareReady from '../../components/WechatShareReady'; import { request, toast, isLoggedIn } from '../../lib/api'; +import { fetchClientConfig } from '../../lib/pay-wechat'; import { toMoneyNumber } from '../../lib/money'; import { maskPhone, toDialablePhone } from '../../lib/phone'; import { track } from '../../lib/analytics'; import { fullStoreAddress, + shouldShowStoreRedeemCount, storeCategoryTags, storeStarCount, type StoreCategoryTreeNode, @@ -190,6 +192,7 @@ export default function StoreDetailPage() { const [loadError, setLoadError] = useState(''); const [headerSolid, setHeaderSolid] = useState(false); const [pendingRatingId, setPendingRatingId] = useState(null); + const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true); const storeRef = useRef(null); storeRef.current = store; @@ -197,6 +200,12 @@ export default function StoreDetailPage() { setHeaderSolid(scrollTop > 100); }); + useEffect(() => { + void fetchClientConfig() + .then((cfg) => setShowStoreRedeemCount(cfg.showStoreRedeemCount !== false)) + .catch(() => undefined); + }, []); + const loadStore = useCallback(async (id: string) => { if (!id) { setLoading(false); @@ -440,7 +449,7 @@ export default function StoreDetailPage() { ))} - {Number(store.redeemCount) > 0 ? ( + {shouldShowStoreRedeemCount(showStoreRedeemCount, store.redeemCount) ? ( 核销{store.redeemCount}次 ) : null} diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx index 2a5b162..73ec286 100644 --- a/apps/mini-user/src/pages/stores/index.tsx +++ b/apps/mini-user/src/pages/stores/index.tsx @@ -28,6 +28,7 @@ import { import { FALLBACK_CITY_CODE } from '../../lib/product-images'; import { formatDistanceMeters } from '../../lib/geo'; import { getToken, request, toast } from '../../lib/api'; +import { fetchClientConfig } from '../../lib/pay-wechat'; import { getStoresListCache, isStoresSessionBootstrapped, @@ -42,7 +43,7 @@ import { toWeappShareTimeline, } from '../../lib/wechat-share'; import BenefitSloganBar from '../../components/BenefitSloganBar'; -import { fullStoreAddress, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display'; +import { fullStoreAddress, shouldShowStoreRedeemCount, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display'; import openBadgeImg from '../../assets/icons/store-open-badge.png'; type Store = { @@ -123,13 +124,21 @@ export default function StoresPage() { const [categoryTree, setCategoryTree] = useState([]); const [sort, setSort] = useState(() => cached?.sort ?? 'nearby'); const [sortOpen, setSortOpen] = useState(false); + const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true); const fetchCityKeyRef = useRef(cached?.cityKey ?? null); const fetchSeqRef = useRef(0); const regionRef = useRef(region); regionRef.current = region; const regionLabel = formatRegionLabel(region); const categoryLabel = formatCategoryLabel(category); - const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先'; + const sortOptions = useMemo( + () => + showStoreRedeemCount + ? STORE_SORT_OPTIONS + : STORE_SORT_OPTIONS.filter((o) => o.key !== 'redeem'), + [showStoreRedeemCount], + ); + const sortLabel = sortOptions.find((o) => o.key === sort)?.label ?? '附近优先'; const showBootLoading = loading && stores.length === 0; const childIdsByParent = useMemo(() => { @@ -143,6 +152,22 @@ export default function StoresPage() { return map; }, [categoryTree]); + useEffect(() => { + void fetchClientConfig() + .then((cfg) => { + const enabled = cfg.showStoreRedeemCount !== false; + setShowStoreRedeemCount(enabled); + if (!enabled) { + setSort((prev) => { + if (prev !== 'redeem') return prev; + patchStoresFilterCache({ sort: 'nearby' }); + return 'nearby'; + }); + } + }) + .catch(() => undefined); + }, []); + async function fetchStores( nextCode: string, coords: UserCoords | null, @@ -329,7 +354,7 @@ export default function StoresPage() { if (sort === 'rating') { const diff = storeStarCount(b.rating) - storeStarCount(a.rating); if (diff !== 0) return diff; - } else if (sort === 'redeem') { + } else if (sort === 'redeem' && showStoreRedeemCount) { const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0); if (diff !== 0) return diff; } @@ -338,7 +363,7 @@ export default function StoresPage() { return da - db; }); return next; - }, [stores, region, category, keyword, sort, childIdsByParent]); + }, [stores, region, category, keyword, sort, childIdsByParent, showStoreRedeemCount]); function applySearch() { const next = keywordInput.trim(); @@ -497,7 +522,7 @@ export default function StoresPage() { ))} - {Number(s.redeemCount) > 0 ? ( + {shouldShowStoreRedeemCount(showStoreRedeemCount, s.redeemCount) ? ( 核销{s.redeemCount}次 ) : null} @@ -551,7 +576,7 @@ export default function StoresPage() { - {STORE_SORT_OPTIONS.map((opt) => ( + {sortOptions.map((opt) => ( return code || MOCK_SMS_FIXED_CODE; } +/** C 端门店是否展示核销次数(系统设置 SHOW_STORE_REDEEM_COUNT;未配置默认开) */ +export function isShowStoreRedeemCountEnabled(env?: Record): boolean { + const raw = (readEnv(env).SHOW_STORE_REDEEM_COUNT ?? '').trim().toLowerCase(); + if (!raw) return true; + return raw === 'true' || raw === '1'; +} + /** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */ export const DEFAULT_SHARE_TITLE = '你吃饭,我买单'; export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用'; diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts index 7f8796e..6f5827d 100644 --- a/packages/shared-types/src/wechat.ts +++ b/packages/shared-types/src/wechat.ts @@ -74,6 +74,8 @@ export type ClientRuntimeConfig = { partnerOnboardCsHint?: string | null; /** 小程序各场景分享文案/图 */ share?: MiniShareRuntime; + /** C 端门店列表/详情是否展示核销次数;未下发时按开启处理 */ + showStoreRedeemCount?: boolean; }; /** 是否展示微信授权入口 */ diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index 900b9fb..5a18e59 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -83,6 +83,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ requiresRestart: false, description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)', }, + { + key: 'SHOW_STORE_REDEEM_COUNT', + label: 'C 端展示门店核销次数', + group: G.feature, + type: 'boolean', + requiresRestart: false, + description: '关闭后小程序门店列表/详情不再展示「核销N次」,接口也不再返回核销次数', + }, { key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false }, { key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false }, @@ -612,6 +620,7 @@ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.k /** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */ export const SYSTEM_CONFIG_DEFAULTS: Record = { + SHOW_STORE_REDEEM_COUNT: 'true', USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''), SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''), BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE, diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts index 8dbe02d..2dd51f1 100644 --- a/server/dukang-api/src/modules/common/client-config.controller.ts +++ b/server/dukang-api/src/modules/common/client-config.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get } from '@nestjs/common'; import { + isShowStoreRedeemCountEnabled, parseMiniHomeBanners, resolveClientBrandRuntime, resolveMiniShareRuntime, @@ -44,6 +45,7 @@ export class ClientConfigController { (env.PARTNER_ONBOARD_CS_HINT ?? '').trim() || '使用问题、提现问题等随时可联系【杜康好客】客服', share, + showStoreRedeemCount: isShowStoreRedeemCountEnabled(env), }; } } diff --git a/server/dukang-api/src/modules/ops/admin-partners.controller.ts b/server/dukang-api/src/modules/ops/admin-partners.controller.ts index a49798b..ace49de 100644 --- a/server/dukang-api/src/modules/ops/admin-partners.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-partners.controller.ts @@ -1,4 +1,5 @@ -import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; +import type { Response } from 'express'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqPermissionGuard, @@ -80,6 +81,17 @@ export class AdminPartnersController { return this.assoc.listUsers(BigInt(id), Number(page) || 1, Number(pageSize) || 20); } + @Get(':id/assoc/qrcode') + async assocQrcode(@Param('id') id: string, @Res() res: Response) { + const { buffer, fileName } = await this.assoc.getQrcodeBuffer(BigInt(id)); + res.setHeader('Content-Type', 'image/png'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`, + ); + res.send(buffer); + } + @Post(':id/assoc/qrcode') regenQrcode(@Param('id') id: string) { return this.assoc.ensureQrcode(BigInt(id), true); diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index aad112b..114a7cd 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -5,7 +5,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types'; +import { loadAppConfig, ClientApp, SmsScene, isShowStoreRedeemCountEnabled } from '@dukang/shared-types'; import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types'; import { normalizeStorePackageImageUrls } from '@dukang/shared-types'; import { @@ -36,6 +36,7 @@ import { } from '../../common/test-whitelist/test-whitelist.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import { formatPartnerWecomLabel } from './wecom-submitter-label'; +import { SystemConfigService } from '../../common/system-config/system-config.service'; function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number { const toRad = (d: number) => (d * Math.PI) / 180; @@ -86,6 +87,7 @@ export class StoreService { private readonly tencentLbs: TencentLbsProvider, private readonly testWhitelist: TestWhitelistService, private readonly wecomPush: WecomMessagePushService, + private readonly systemConfig: SystemConfigService, ) {} /** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */ @@ -218,11 +220,12 @@ export class StoreService { latitude?: unknown; longitude?: unknown; sortOrder?: number; - redeemCount: number; + redeemCount?: number; }; + const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv()); const redeemGroups = - visible.length === 0 + !showRedeemCount || visible.length === 0 ? [] : await this.prisma.redeemRecord.groupBy({ by: ['storeId'], @@ -252,7 +255,9 @@ export class StoreService { items.push({ ...mapped, distanceMeters, - redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0, + ...(showRedeemCount + ? { redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0 } + : {}), }); } @@ -286,6 +291,7 @@ export class StoreService { throw new NotFoundException('门店不存在'); } const coords = await this.ensureStoreCoordinates(store); + const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv()); const [media, packageRows, redeemCount] = await Promise.all([ this.prisma.commonResource.findMany({ where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' }, @@ -295,7 +301,7 @@ export class StoreService { where: { storeId: id }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }), - this.prisma.redeemRecord.count({ where: { storeId: id } }), + showRedeemCount ? this.prisma.redeemRecord.count({ where: { storeId: id } }) : Promise.resolve(null), ]); const { visibilityWhitelistEnabled: _wl, ...rest } = store; return serializeBigInt( @@ -304,7 +310,7 @@ export class StoreService { ...rest, latitude: coords?.latitude ?? store.latitude, longitude: coords?.longitude ?? store.longitude, - redeemCount, + ...(showRedeemCount && redeemCount != null ? { redeemCount } : {}), media, packages: packageRows.map((p) => { const imageUrls = normalizeStorePackageImageUrls({