diff --git a/apps/admin-web/src/pages/InvoicesPage.tsx b/apps/admin-web/src/pages/InvoicesPage.tsx index 5fd284d..0f6e018 100644 --- a/apps/admin-web/src/pages/InvoicesPage.tsx +++ b/apps/admin-web/src/pages/InvoicesPage.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Button, Descriptions, @@ -63,12 +64,22 @@ type CreateFormValues = { }; export default function InvoicesPage() { - const [filters, setFilters] = useState>({}); + const [searchParams] = useSearchParams(); + const initialStatus = searchParams.get('status')?.trim() || ''; + const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || ''; + const [filterForm] = Form.useForm(); + const [filters, setFilters] = useState>(() => { + const init: Record = {}; + if (initialStatus) init.status = initialStatus; + if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo; + return init; + }); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/invoices', () => { const qs = new URLSearchParams(); if (filters.status) qs.set('status', filters.status); + if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo); return qs; }, [filters], @@ -81,12 +92,29 @@ export default function InvoicesPage() { const [createForm] = Form.useForm(); const invoiceKind = Form.useWatch('invoiceKind', createForm); const titleType = Form.useWatch('titleType', createForm); + const deepLinkOpenedRef = useRef(false); + + useEffect(() => { + filterForm.setFieldsValue({ + status: filters.status || undefined, + invoiceNo: filters.invoiceNo || undefined, + }); + }, [filterForm, filters.invoiceNo, filters.status]); async function openDetail(id: string) { setDetail(await request(`/admin/invoices/${id}`)); setDrawerOpen(true); } + useEffect(() => { + if (!initialInvoiceNo || deepLinkOpenedRef.current || loading) return; + const first = data?.items?.[0]; + if (first && String(first.invoiceNo) === initialInvoiceNo) { + deepLinkOpenedRef.current = true; + void openDetail(first.id); + } + }, [data, initialInvoiceNo, loading]); + async function issueWithFile(file: File) { if (!detail) return false; setUploading(true); @@ -213,6 +241,7 @@ export default function InvoicesPage() {
{ @@ -231,6 +260,9 @@ export default function InvoicesPage() { ]} /> + + + diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 3282636..e098b06 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; import { Alert, Button, @@ -162,6 +162,8 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S } export default function OrdersPage() { + const [searchParams] = useSearchParams(); + const initialOrderNo = searchParams.get('orderNo')?.trim() || ''; const [form] = Form.useForm(); const [shipForm] = Form.useForm(); const [logisticsForm] = Form.useForm(); @@ -172,6 +174,7 @@ export default function OrdersPage() { const [pageSize, setPageSize] = useState(20); const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const deepLinkOpenedRef = useRef(false); const [redeemDetail, setRedeemDetail] = useState | null>(null); const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false); const [redeemDetailLoading, setRedeemDetailLoading] = useState(false); @@ -201,6 +204,21 @@ export default function OrdersPage() { request('/admin/auth/me').then(setProfile).catch(() => {}); }, []); + useEffect(() => { + if (initialOrderNo) { + form.setFieldsValue({ orderNo: initialOrderNo }); + } + }, [form, initialOrderNo]); + + useEffect(() => { + if (!initialOrderNo || deepLinkOpenedRef.current || loading) return; + const first = data?.items?.[0]; + if (first && String(first.orderNo) === initialOrderNo) { + deepLinkOpenedRef.current = true; + void openDetail(first.id); + } + }, [data, initialOrderNo, loading]); + async function openRedeemDetail(redeemId: string) { setRedeemDetailLoading(true); setRedeemDrawerOpen(true); diff --git a/apps/admin-web/src/pages/RedeemRecordsPage.tsx b/apps/admin-web/src/pages/RedeemRecordsPage.tsx index 4522dca..54b69e4 100644 --- a/apps/admin-web/src/pages/RedeemRecordsPage.tsx +++ b/apps/admin-web/src/pages/RedeemRecordsPage.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types'; @@ -26,8 +27,14 @@ function maskPhone(phone: string | null | undefined) { } export default function RedeemRecordsPage() { + const [searchParams] = useSearchParams(); + const initialRedeemNo = searchParams.get('redeemNo')?.trim() || ''; const [form] = Form.useForm(); - const [filters, setFilters] = useState>({}); + const [filters, setFilters] = useState>(() => { + const init: Record = {}; + if (initialRedeemNo) init.redeemNo = initialRedeemNo; + return init; + }); const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList( '/admin/redeem-records', () => { @@ -43,6 +50,11 @@ export default function RedeemRecordsPage() { const [detail, setDetail] = useState | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); const [detailLoading, setDetailLoading] = useState(false); + const deepLinkOpenedRef = useRef(false); + + useEffect(() => { + if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo }); + }, [form, initialRedeemNo]); async function openDetail(id: string) { setDetailLoading(true); @@ -54,6 +66,15 @@ export default function RedeemRecordsPage() { } } + useEffect(() => { + if (!initialRedeemNo || deepLinkOpenedRef.current || loading) return; + const first = data?.items?.[0]; + if (first && String(first.redeemNo) === initialRedeemNo) { + deepLinkOpenedRef.current = true; + void openDetail(first.id); + } + }, [data, initialRedeemNo, loading]); + const columns: ColumnsType = [ { title: '核销号', diff --git a/apps/admin-web/src/pages/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx index 60577a8..cc78dd8 100644 --- a/apps/admin-web/src/pages/StoreBillsPage.tsx +++ b/apps/admin-web/src/pages/StoreBillsPage.tsx @@ -64,10 +64,13 @@ export default function StoreBillsPage() { const [searchParams] = useSearchParams(); const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : ''; const initialStoreId = searchParams.get('storeId') || ''; + const initialStatus = + searchParams.get('status')?.trim() || + (initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : ''); const [form] = Form.useForm(); const [filters, setFilters] = useState>({ kind: initialKind, - status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '', + status: initialStatus, storeId: initialStoreId, }); const [stores, setStores] = useState([]); diff --git a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx index 3c16615..84c696d 100644 --- a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx +++ b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx @@ -24,7 +24,10 @@ import type { StorePackageChangeRequestDto, StorePackageChangeStatus, } from '@dukang/shared-types'; -import { STORE_INFO_CHANGE_STATUS_LABELS } from '@dukang/shared-types'; +import { + STORE_INFO_CHANGE_STATUS_LABELS, + STORE_INFO_CHANGEABLE_FIELD_LABELS, +} from '@dukang/shared-types'; import { request, type Paginated } from '../lib/api'; import { notifyPackageAuditChanged } from '../lib/admin-events'; import { fmtTime } from '../lib/constants'; @@ -36,23 +39,6 @@ const HQ_PACKAGE_STATUS_LABELS: Record = { REJECTED: '已驳回', }; -const INFO_CHANGE_FIELD_LABELS: Record = { - name: '门店名称', - contactPhone: '联系电话', - address: '详细地址', - intro: '门店简介', - benefitUsageRule: '权益券使用规则', - latitude: '纬度', - longitude: '经度', - openTime: '营业开始', - closeTime: '营业结束', - openTime2: '第二段开始', - closeTime2: '第二段结束', - avgPrice: '人均费用', - coverUrl: '门头照', - envPhotoUrls: '环境照片', -}; - function fmtFieldValue(field: string, v: unknown): string { if (v == null || String(v).trim() === '') return '(空)'; if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') { @@ -222,7 +208,7 @@ function InfoChangeAuditPanel({ render: (_, row) => row.changedFields?.length ? row.changedFields.map((f) => ( - {INFO_CHANGE_FIELD_LABELS[f] ?? f} + {STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? f} )) : '—', }, @@ -339,7 +325,7 @@ function InfoChangeAuditPanel({ {detail.diffs.map((d) => ( {d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? ( diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 47a44ed..5c7243c 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -246,6 +246,8 @@ export default function StoresPage() { const [searchParams] = useSearchParams(); const initialCityId = searchParams.get('cityId') ?? ''; const initialPartnerId = searchParams.get('partnerId') ?? ''; + const initialAuditStatus = searchParams.get('auditStatus') ?? ''; + const initialStoreId = searchParams.get('storeId') ?? ''; const [form] = Form.useForm(); const [editForm] = Form.useForm(); const [createForm] = Form.useForm(); @@ -253,6 +255,7 @@ export default function StoresPage() { const init: Record = {}; if (initialCityId) init.cityId = initialCityId; if (initialPartnerId) init.partnerId = initialPartnerId; + if (initialAuditStatus) init.auditStatus = initialAuditStatus; return init; }); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( @@ -294,6 +297,7 @@ export default function StoresPage() { const [optionsLoading, setOptionsLoading] = useState(false); const [saving, setSaving] = useState(false); const [phoneMismatch, setPhoneMismatch] = useState(null); + const deepLinkStoreOpenedRef = useRef(false); useEffect(() => { void request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) @@ -304,6 +308,12 @@ export default function StoresPage() { .catch(() => {}); }, []); + useEffect(() => { + if (initialAuditStatus) { + form.setFieldsValue({ auditStatus: initialAuditStatus }); + } + }, [form, initialAuditStatus]); + useEffect(() => { const cityId = searchParams.get('cityId') ?? ''; const partnerId = searchParams.get('partnerId') ?? ''; @@ -454,6 +464,19 @@ export default function StoresPage() { setDrawerOpen(true); } + useEffect(() => { + if (!initialStoreId || deepLinkStoreOpenedRef.current || loading) return; + const row = data?.items?.find((s) => String(s.id) === initialStoreId); + if (row) { + deepLinkStoreOpenedRef.current = true; + void openStoreDetail(row); + } else if (data && (data.items?.length ?? 0) >= 0) { + // 列表无该店时仍尝试直拉详情 + deepLinkStoreOpenedRef.current = true; + void openStoreDetail({ id: initialStoreId } as StoreRow); + } + }, [data, initialStoreId, loading]); + async function saveStoreDetail() { if (!detail) return; setSaving(true); diff --git a/apps/admin-web/src/pages/WecomMessagePushesPage.tsx b/apps/admin-web/src/pages/WecomMessagePushesPage.tsx index 5e72086..649bbdd 100644 --- a/apps/admin-web/src/pages/WecomMessagePushesPage.tsx +++ b/apps/admin-web/src/pages/WecomMessagePushesPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Avatar, Button, @@ -13,6 +13,7 @@ import { Space, Switch, Table, + Tabs, Tag, Typography, message, @@ -21,8 +22,13 @@ import type { ColumnsType } from 'antd/es/table'; import { WECOM_PUSH_CONDITION_GROUPS, WECOM_PUSH_CONDITION_LABELS, + WECOM_TEMPLATE_EVENT_KEYS, + WECOM_TEMPLATE_EVENT_LABELS, + WECOM_TEMPLATE_PLACEHOLDERS, type WecomMessagePushDto, type WecomPushCondition, + type WecomPushTemplateDto, + type WecomTemplateEventKey, } from '@dukang/shared-types'; import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; @@ -78,7 +84,7 @@ function WecomPushConditionPicker({ ); } -export default function WecomMessagePushesPage() { +function PushRoutesTab() { const [filterForm] = Form.useForm(); const [form] = Form.useForm(); const [filters, setFilters] = useState>({}); @@ -257,11 +263,9 @@ export default function WecomMessagePushesPage() { ]; return ( -
- 企微机器人 · 消息推送 + <> - 配置群机器人 Webhook 多实例,按推送条件分发运营告警、技术支持工单、开发任务派发等消息。运行时不再读取 - .env 中的 Webhook URL。 + 配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。 - + - + ) : null} + + ); +} + +function TemplatesTab() { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(false); + const [eventKey, setEventKey] = useState('order.paid'); + const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>(); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [preview, setPreview] = useState(''); + + const load = useCallback(async () => { + setLoading(true); + try { + const list = await request('/admin/wecom-push-templates'); + setTemplates(list); + setEventKey((prev) => { + const current = list.find((t) => t.eventKey === prev) ?? list[0]; + if (current) { + form.setFieldsValue({ + title: current.title, + body: current.body, + handleLabel: current.handleLabel, + }); + return current.eventKey; + } + return prev; + }); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载模板失败'); + } finally { + setLoading(false); + } + }, [form]); + + useEffect(() => { + void load(); + }, [load]); + + function selectEvent(key: WecomTemplateEventKey) { + setEventKey(key); + const row = templates.find((t) => t.eventKey === key); + if (row) { + form.setFieldsValue({ + title: row.title, + body: row.body, + handleLabel: row.handleLabel, + }); + setPreview(''); + } + } + + async function save() { + const values = await form.validateFields(); + setSaving(true); + try { + const updated = await request( + `/admin/wecom-push-templates/${eventKey}`, + { + method: 'PUT', + body: JSON.stringify(values), + }, + ); + message.success('模板已保存'); + setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t))); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + async function reset() { + setSaving(true); + try { + const updated = await request( + `/admin/wecom-push-templates/${eventKey}/reset`, + { method: 'POST', body: '{}' }, + ); + message.success('已恢复默认文案'); + form.setFieldsValue({ + title: updated.title, + body: updated.body, + handleLabel: updated.handleLabel, + }); + setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t))); + setPreview(''); + } catch (e) { + message.error(e instanceof Error ? e.message : '恢复失败'); + } finally { + setSaving(false); + } + } + + async function testSend() { + setTesting(true); + try { + const res = await request<{ ok: boolean; message: string; preview: string }>( + `/admin/wecom-push-templates/${eventKey}/test`, + { method: 'POST', body: '{}' }, + ); + setPreview(res.preview || ''); + message.success(res.message || '已发送'); + } catch (e) { + message.error(e instanceof Error ? e.message : '测试失败'); + } finally { + setTesting(false); + } + } + + const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? []; + + return ( + <> + + 每种业务事件全站一份文案,使用 {'{{orderNo}}'} 等形式占位。快链由系统注入 {'{{handleUrl}}'} + ;须在「推送路由」中勾选对应条件并配置 Webhook 才会发出。 + + + +
+ 事件 +
+ {WECOM_TEMPLATE_EVENT_KEYS.map((k) => ( +
+ +
+ ))} +
+
+ +
+ + + + + `{{${p}}}`).join(' ')}`} + > + + + + + + + + void reset()}> + + + + + + + {preview ? ( +
+ 预览 +
+                {preview}
+              
+
+ ) : null} +
+
+ + ); +} + +export default function WecomMessagePushesPage() { + return ( +
+ 企微机器人 · 消息推送 + }, + { key: 'templates', label: '通知模板', children: }, + ]} + />
); } diff --git a/apps/mini-user/src/assets/icons/store-benefit.png b/apps/mini-user/src/assets/icons/store-benefit.png index adfce20..d14eb8f 100644 Binary files a/apps/mini-user/src/assets/icons/store-benefit.png and b/apps/mini-user/src/assets/icons/store-benefit.png differ diff --git a/docs/杜康好客-v3.5.3-开发文档.md b/docs/杜康好客-v3.5.3-开发文档.md index 636f794..809519d 100644 --- a/docs/杜康好客-v3.5.3-开发文档.md +++ b/docs/杜康好客-v3.5.3-开发文档.md @@ -1,7 +1,7 @@ # 杜康好客 · v3.5.3 版本更新 > **2026-08-20** · admin-web / mini-user / h5-partner / h5-shop / API -> 目标:门店封面/环境图体验、企微门店审核通知、总部抽屉内审套餐、小程序分享用业务标题主图、系统设置分享配置按场景折叠;**补修核销金额为 0 / 无法提现、测试流水计结算、核销浮动提示、好客权益金额图标**。 +> 目标:门店封面/环境图体验、企微门店审核通知、总部抽屉内审套餐、小程序分享用业务标题主图、系统设置分享配置按场景折叠;**补修核销金额为 0 / 无法提现、测试流水计结算、核销浮动提示、好客权益金额图标**;**企微业务通知可编辑模板 + 处理快链**。 ## 范围 @@ -19,8 +19,9 @@ | 8 | 测试流水计结算 | **取消**「测试流水不计结算」;核销一律建 payout;账单/佣金不再因 `isTest` 跳过 | | 9 | 核销校验浮动提示 | 门店 H5 / 小程序:超可用余额等提示改为页面中上部浮动气泡 | | 10 | 好客权益金额图标 | 小程序权益金额前去掉 ¥,改为门店核销语义图标(商品售价/实付仍用 ¥) | +| 11 | 企微业务通知扩展 | 订单支付/核销成功/信息变更/提现/发票;`wecom_push_template` 可编辑;处理快链 | -**配置进库**:`wecom_message_push` upsert「门店审核通知群」(无 webhook 时占位 URL + `enabled=false`)。无新 Prisma 表。 +**配置进库**:`wecom_message_push` upsert「门店审核通知群 / 业务待办通知群 / 成交播报群」(无 webhook 时占位 URL + `enabled=false`)。**新表** `wecom_push_template`(发版不可 skip-db)。 --- @@ -138,6 +139,48 @@ --- +## 11. 企微业务通知 + 可编辑模板 + 处理快链 + +### 事件条件 key + +| key | 触发 | 测试单 | +|-----|------|--------| +| `order.paid` | `afterOrderPaid` | 不推 | +| `redeem.success` | `executeRedeem` 成功后 | 不推 | +| `store.audit_pending` | 入驻/重提 PENDING | 推 | +| `store.package_audit_pending` | 套餐变更 PENDING | 推 | +| `store.info_change_pending` | 信息变更 PENDING | 推 | +| `store.withdraw_pending` | 手动提现 PENDING_REVIEW | 推 | +| `invoice.pending` | 发票申请 PENDING | 不推 | + +默认推送行:`业务待办通知群`、`成交播报群`(占位 webhook + 禁用,直至 HQ 配置)。 + +### 模板表 `wecom_push_template` + +- 按 `eventKey` 全站一份;启动 `ensureTemplates` **仅插入缺行**,不覆盖 HQ 已改 +- HQ「企微机器人 → 消息推送 → 通知模板」:编辑 / 恢复默认 / 示例测试推送 +- API:`GET/PUT /admin/wecom-push-templates/:eventKey`,`POST .../reset`,`POST .../test` +- 渲染:`WecomMessagePushService.dispatchEvent` → `{{var}}` 插值 → 条件路由 + +### 处理快链 + +环境变量 `HQ_ADMIN_PUBLIC_URL`(staging `https://admin-test.dukanghaoke.com`,生产 `https://admin.dukanghaoke.com`)。 +未配置时按 `WECOM_ALERT_ENV_LABEL` 回退(prod→生产域名,staging→测试域名,local→`http://localhost:5175`),**禁止**只发相对路径(企微会解析成 `http://orders/...`)。 + +| 事件 | 路径 | +|------|------| +| 订单 | `/orders?orderNo=` | +| 核销 | `/redeem-records?redeemNo=` | +| 入驻 | `/stores?auditStatus=PENDING&storeId=` | +| 套餐 | `/store-package-audits?requestId=` | +| 信息变更 | `/store-package-audits?tab=info&infoRequestId=` | +| 提现 | `/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=` | +| 发票 | `/invoices?status=PENDING&invoiceNo=` | + +提现待审改走 `store.withdraw_pending`,不再误用 `AlertService` `category: finance` → `alert.system`。 + +--- + ## 验收 - [ ] 合伙人可改门头/环境图(PENDING 除外) @@ -151,3 +194,7 @@ - [ ] 历史无 payout 的核销:打开「结算提现」后可用余额出现;测试核销同样计结算 - [ ] 门店 H5 / 小程序超额核销提示为中上部浮动气泡 - [ ] 小程序好客权益金额前为店铺图标,商品价仍为 ¥ +- [ ] HQ 可编辑企微通知模板;勾选条件 + 配置 webhook 后订单/核销/提现/发票/审核有推送 +- [ ] 企微消息「去处理」可打开 HQ 对应列表并尽量定位到该单(需登录) +- [ ] 测试订单/核销不推成交播报;发票申请测试单不推 +- [ ] 发版含 `wecom_push_template` 表与 `HQ_ADMIN_PUBLIC_URL` diff --git a/packages/shared-types/src/store-info-change.ts b/packages/shared-types/src/store-info-change.ts index 18f4798..9083dbd 100644 --- a/packages/shared-types/src/store-info-change.ts +++ b/packages/shared-types/src/store-info-change.ts @@ -32,6 +32,34 @@ export const STORE_INFO_CHANGEABLE_FIELDS = [ export type StoreInfoChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number]; +/** 可变字段中文名(企微推送 / HQ 审核列表共用) */ +export const STORE_INFO_CHANGEABLE_FIELD_LABELS: Record = { + name: '门店名称', + contactPhone: '联系电话', + address: '详细地址', + intro: '门店简介', + benefitUsageRule: '权益券使用规则', + latitude: '纬度', + longitude: '经度', + openTime: '营业开始', + closeTime: '营业结束', + openTime2: '第二段开始', + closeTime2: '第二段结束', + avgPrice: '人均费用', + coverUrl: '门头照', + envPhotoUrls: '环境照片', +}; + +/** 将字段 key 列表格式化为中文,如「门店名称、详细地址」 */ +export function formatStoreInfoChangeFieldLabels( + fields: readonly string[], + separator = '、', +): string { + return fields + .map((f) => STORE_INFO_CHANGEABLE_FIELD_LABELS[f as StoreInfoChangeableField] ?? f) + .join(separator); +} + /** 单条记录的字段详情(用于审核页 diff 对比) */ export interface StoreInfoChangeFieldDiff { field: StoreInfoChangeableField; diff --git a/packages/shared-types/src/wecom-message-push.ts b/packages/shared-types/src/wecom-message-push.ts index fbea046..76f36c0 100644 --- a/packages/shared-types/src/wecom-message-push.ts +++ b/packages/shared-types/src/wecom-message-push.ts @@ -1,4 +1,4 @@ -/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 门店审核) */ +/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知) */ export const WECOM_PUSH_CONDITIONS = [ 'alert.ops', 'support_ticket.created', @@ -9,6 +9,11 @@ export const WECOM_PUSH_CONDITIONS = [ 'dev_plan.task_dispatch', 'store.audit_pending', 'store.package_audit_pending', + 'store.info_change_pending', + 'store.withdraw_pending', + 'order.paid', + 'redeem.success', + 'invoice.pending', ] as const; export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number]; @@ -23,6 +28,11 @@ export const WECOM_PUSH_CONDITION_LABELS: Record = { 'dev_plan.task_dispatch': '开发任务评审派发', 'store.audit_pending': '门店提交/重提待审', 'store.package_audit_pending': '套餐变更待审', + 'store.info_change_pending': '门店信息变更待审', + 'store.withdraw_pending': '门店手动提现待审', + 'order.paid': '订单支付成功', + 'redeem.success': '门店核销成功', + 'invoice.pending': '发票申请待开票', }; export const WECOM_PUSH_CONDITION_GROUPS: Array<{ @@ -37,9 +47,25 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{ }, { key: 'pay_redeem', - label: '支付与核销', + label: '支付与核销异常', conditions: ['alert.pay', 'alert.redeem'], }, + { + key: 'deal_broadcast', + label: '成交播报', + conditions: ['order.paid', 'redeem.success'], + }, + { + key: 'biz_todo', + label: '业务待办', + conditions: [ + 'store.audit_pending', + 'store.package_audit_pending', + 'store.info_change_pending', + 'store.withdraw_pending', + 'invoice.pending', + ], + }, { key: 'system', label: '系统与结算', @@ -50,11 +76,6 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{ label: '开发计划', conditions: ['dev_plan.task_dispatch'], }, - { - key: 'store_audit', - label: '门店审核', - conditions: ['store.audit_pending', 'store.package_audit_pending'], - }, ]; /** 默认「运营告警」推送条件 */ @@ -79,12 +100,114 @@ export const WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS: WecomPushCondition[] = [ 'store.package_audit_pending', ]; +/** 默认「业务待办通知群」 */ +export const WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS: WecomPushCondition[] = [ + 'store.audit_pending', + 'store.package_audit_pending', + 'store.info_change_pending', + 'store.withdraw_pending', + 'invoice.pending', +]; + +/** 默认「成交播报群」 */ +export const WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS: WecomPushCondition[] = [ + 'order.paid', + 'redeem.success', +]; + export const WECOM_STORE_AUDIT_PUSH_NAME = '门店审核通知群'; +export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群'; +export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群'; /** 占位 webhook(未配置真实 key 时禁用,避免误推) */ export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=PENDING'; +/** 有 HQ 可编辑模板的业务事件(含入驻/套餐,不含 alert.*) */ +export const WECOM_TEMPLATE_EVENT_KEYS = [ + 'order.paid', + 'redeem.success', + 'store.audit_pending', + 'store.package_audit_pending', + 'store.info_change_pending', + 'store.withdraw_pending', + 'invoice.pending', +] as const; + +export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number]; + +export const WECOM_TEMPLATE_EVENT_LABELS: Record = { + 'order.paid': '订单支付成功', + 'redeem.success': '门店核销成功', + 'store.audit_pending': '门店提交/重提待审', + 'store.package_audit_pending': '套餐变更待审', + 'store.info_change_pending': '门店信息变更待审', + 'store.withdraw_pending': '门店手动提现待审', + 'invoice.pending': '发票申请待开票', +}; + +/** 各事件可用占位符说明(HQ 模板编辑) */ +export const WECOM_TEMPLATE_PLACEHOLDERS: Record = { + 'order.paid': ['orderNo', 'payAmount', 'cityName', 'skuSummary', 'phoneMasked', 'time', 'handleUrl', 'handleLabel'], + 'redeem.success': ['redeemNo', 'amount', 'storeName', 'channel', 'time', 'handleUrl', 'handleLabel'], + 'store.audit_pending': ['storeName', 'cityName', 'partnerLabel', 'action', 'time', 'handleUrl', 'handleLabel', 'storeId'], + 'store.package_audit_pending': [ + 'storeName', + 'cityName', + 'submitter', + 'packageCount', + 'time', + 'handleUrl', + 'handleLabel', + 'requestId', + ], + 'store.info_change_pending': [ + 'storeName', + 'cityName', + 'submitter', + 'changedFields', + 'time', + 'handleUrl', + 'handleLabel', + 'requestId', + ], + 'store.withdraw_pending': [ + 'storeName', + 'withdrawNo', + 'amount', + 'payoutCount', + 'time', + 'handleUrl', + 'handleLabel', + 'storeId', + ], + 'invoice.pending': [ + 'invoiceNo', + 'orderNo', + 'payAmount', + 'titleName', + 'phoneMasked', + 'time', + 'handleUrl', + 'handleLabel', + ], +}; + +export type WecomPushTemplateDto = { + id: string; + eventKey: WecomTemplateEventKey; + title: string; + body: string; + handleLabel: string; + updatedAt: string; +}; + +export type UpdateWecomPushTemplateRequest = { + title?: string; + body?: string; + handleLabel?: string; +}; + export function parseWecomPushConditions( raw?: string | string[] | null, ): WecomPushCondition[] { diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index eddca5c..7274204 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -69,6 +69,12 @@ WECOM_AIBOT_ENABLED=false # WECOM_ALERT_ENABLED=false # WECOM_ALERT_WEBHOOK_URL= WECOM_ALERT_ENV_LABEL=local +# HQ 企微业务通知「去处理」快链根地址(无尾斜杠;未配时按 WECOM_ALERT_ENV_LABEL 回退生产/测试/本机) +# HQ_ADMIN_PUBLIC_URL=http://localhost:5175 +# 生产请设:https://admin.dukanghaoke.com ;测试:https://admin-test.dukanghaoke.com +# WECOM_STORE_AUDIT_WEBHOOK_URL= +# WECOM_BIZ_TODO_WEBHOOK_URL= +# WECOM_DEAL_BROADCAST_WEBHOOK_URL= # 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用) # 控制台须开启 WebServiceAPI;推荐开启「签名校验」并配置下方 SK(服务端自动附 sig) diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example index 710eaba..e3b0746 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -57,6 +57,11 @@ WECOM_AIBOT_ENABLED=false # WECOM_ALERT_ENABLED=false # WECOM_ALERT_WEBHOOK_URL= WECOM_ALERT_ENV_LABEL=production +# HQ 企微业务通知「去处理」快链根地址 +HQ_ADMIN_PUBLIC_URL=https://admin.dukanghaoke.com +# WECOM_STORE_AUDIT_WEBHOOK_URL= +# WECOM_BIZ_TODO_WEBHOOK_URL= +# WECOM_DEAL_BROADCAST_WEBHOOK_URL= OSS_ACCESS_KEY_ID= OSS_ACCESS_KEY_SECRET= diff --git a/server/dukang-api/.env.staging.example b/server/dukang-api/.env.staging.example index 3c30438..3585f61 100644 --- a/server/dukang-api/.env.staging.example +++ b/server/dukang-api/.env.staging.example @@ -57,6 +57,11 @@ WECOM_AIBOT_ENABLED=false # WECOM_ALERT_ENABLED=false # WECOM_ALERT_WEBHOOK_URL= WECOM_ALERT_ENV_LABEL=staging +# HQ 企微业务通知「去处理」快链根地址 +HQ_ADMIN_PUBLIC_URL=https://admin-test.dukanghaoke.com +# WECOM_STORE_AUDIT_WEBHOOK_URL= +# WECOM_BIZ_TODO_WEBHOOK_URL= +# WECOM_DEAL_BROADCAST_WEBHOOK_URL= OSS_ACCESS_KEY_ID= OSS_ACCESS_KEY_SECRET= diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index cf1e858..6ab8e09 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -504,6 +504,19 @@ model WecomMessagePush { @@map("wecom_message_push") } +/// 企微业务通知文案模板(v3.5.3 · 按事件全站一份,HQ 可编辑) +model WecomPushTemplate { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + eventKey String @unique @map("event_key") @db.VarChar(64) + title String @db.VarChar(128) + body String @db.Text + handleLabel String @default("去处理") @map("handle_label") @db.VarChar(32) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + + @@map("wecom_push_template") +} + /// HQ 语言模型 API 配置(非超管仅可见/可开关自己创建的) model LlmApiConfig { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt diff --git a/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts b/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts index 75a7797..9f58286 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-message-push.service.ts @@ -1,18 +1,31 @@ import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { + WECOM_BIZ_TODO_PUSH_NAME, + WECOM_DEAL_BROADCAST_PUSH_NAME, WECOM_PUSH_CONDITIONS, WECOM_PUSH_DEFAULT_ALERT_CONDITIONS, + WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS, + WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS, WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS, WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS, WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK, WECOM_STORE_AUDIT_PUSH_NAME, + WECOM_TEMPLATE_EVENT_KEYS, maskWecomWebhookUrl, parseWecomPushConditions, type WecomMessagePushDto, type WecomPushCondition, + type WecomPushTemplateDto, + type WecomTemplateEventKey, } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util'; +import { + WECOM_PUSH_TEMPLATE_DEFAULTS, + buildHqHandleUrl, + getDefaultTemplate, + renderWecomTemplate, +} from './wecom-push-template.defaults'; type PushRow = { id: bigint; @@ -27,6 +40,16 @@ type PushRow = { updatedAt: Date; }; +type TemplateRow = { + id: bigint; + eventKey: string; + title: string; + body: string; + handleLabel: string; + createdAt: Date; + updatedAt: Date; +}; + @Injectable() export class WecomMessagePushService implements OnModuleInit { private readonly logger = new Logger(WecomMessagePushService.name); @@ -43,7 +66,7 @@ export class WecomMessagePushService implements OnModuleInit { } } - /** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送;并按名称 upsert「门店审核通知群」(v3.5.3) */ + /** 表空时从 .env / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 模板缺行 */ async ensureDefaults(): Promise { const count = await this.prisma.wecomMessagePush.count(); if (count === 0) { @@ -100,45 +123,81 @@ export class WecomMessagePushService implements OnModuleInit { } } - await this.ensureStoreAuditPush(); + await this.ensureNamedPush( + WECOM_STORE_AUDIT_PUSH_NAME, + WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS, + 20, + process.env.WECOM_STORE_AUDIT_WEBHOOK_URL, + ); + await this.ensureNamedPush( + WECOM_BIZ_TODO_PUSH_NAME, + WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS, + 25, + process.env.WECOM_BIZ_TODO_WEBHOOK_URL, + ); + await this.ensureNamedPush( + WECOM_DEAL_BROADCAST_PUSH_NAME, + WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS, + 30, + process.env.WECOM_DEAL_BROADCAST_WEBHOOK_URL, + ); + await this.ensureTemplates(); } - /** 按名称 upsert「门店审核通知群」:已有行保留 webhook;无行则 env 或占位 URL */ - private async ensureStoreAuditPush(): Promise { - const existing = await this.prisma.wecomMessagePush.findFirst({ - where: { name: WECOM_STORE_AUDIT_PUSH_NAME }, - }); + private async ensureNamedPush( + name: string, + conditions: WecomPushCondition[], + sortOrder: number, + envUrl?: string, + ): Promise { + const existing = await this.prisma.wecomMessagePush.findFirst({ where: { name } }); if (existing) return; - const conditionsJson = JSON.stringify(WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS); - const envUrl = (process.env.WECOM_STORE_AUDIT_WEBHOOK_URL || '').trim(); - - if (envUrl) { + const conditionsJson = JSON.stringify(conditions); + const url = (envUrl || '').trim(); + if (url) { await this.prisma.wecomMessagePush.create({ data: { - name: WECOM_STORE_AUDIT_PUSH_NAME, - webhookUrl: envUrl, + name, + webhookUrl: url, enabled: true, pushConditions: conditionsJson, - sortOrder: 20, + sortOrder, }, }); - this.logger.log(`seeded wecom message push: ${WECOM_STORE_AUDIT_PUSH_NAME} (from env)`); + this.logger.log(`seeded wecom message push: ${name} (from env)`); return; } await this.prisma.wecomMessagePush.create({ data: { - name: WECOM_STORE_AUDIT_PUSH_NAME, + name, webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK, enabled: false, pushConditions: conditionsJson, - sortOrder: 20, + sortOrder, }, }); - this.logger.log( - `seeded wecom message push: ${WECOM_STORE_AUDIT_PUSH_NAME} (placeholder, disabled)`, - ); + this.logger.log(`seeded wecom message push: ${name} (placeholder, disabled)`); + } + + /** 仅插入缺失 eventKey,不覆盖已有文案 */ + async ensureTemplates(): Promise { + for (const def of WECOM_PUSH_TEMPLATE_DEFAULTS) { + const existing = await this.prisma.wecomPushTemplate.findUnique({ + where: { eventKey: def.eventKey }, + }); + if (existing) continue; + await this.prisma.wecomPushTemplate.create({ + data: { + eventKey: def.eventKey, + title: def.title, + body: def.body, + handleLabel: def.handleLabel, + }, + }); + this.logger.log(`seeded wecom push template: ${def.eventKey}`); + } } async listMatchingPushes(eventKey: WecomPushCondition): Promise { @@ -154,6 +213,58 @@ export class WecomMessagePushService implements OnModuleInit { return pushes.length > 0; } + /** + * 读 HQ 模板 → 插值 → 补快链 → 按条件路由推送。 + * 失败只打日志,不抛给业务。 + */ + async dispatchEvent( + eventKey: WecomTemplateEventKey, + vars: Record, + options?: { applyMention?: boolean; handlePath?: string }, + ): Promise { + try { + const content = await this.renderEventContent(eventKey, vars, options?.handlePath); + return await this.dispatchMarkdown(eventKey, content, { + applyMention: options?.applyMention ?? false, + }); + } catch (e) { + this.logger.warn( + `dispatchEvent(${eventKey}) failed: ${e instanceof Error ? e.message : String(e)}`, + ); + return 0; + } + } + + async renderEventContent( + eventKey: WecomTemplateEventKey, + vars: Record, + handlePath?: string, + ): Promise { + const row = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } }); + const def = getDefaultTemplate(eventKey); + const body = row?.body || def?.body || `**${eventKey}**`; + const handleLabel = row?.handleLabel || def?.handleLabel || '去处理'; + + const merged: Record = { + ...vars, + handleLabel: vars.handleLabel ?? handleLabel, + time: + vars.time ?? + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }), + }; + + if (handlePath && !merged.handleUrl) { + merged.handleUrl = buildHqHandleUrl(handlePath); + } + + let content = renderWecomTemplate(body, merged).trim(); + const url = String(merged.handleUrl || '').trim(); + if (url && !content.includes(url) && !/\{\{handleUrl\}\}/.test(body)) { + content = `${content}\n[${handleLabel}](${url})`; + } + return content; + } + /** 向所有匹配 eventKey 的启用推送发送 markdown;返回成功发送数 */ async dispatchMarkdown( eventKey: WecomPushCondition, @@ -239,6 +350,188 @@ export class WecomMessagePushService implements OnModuleInit { : { ok: false, message: 'Webhook 调用失败,请检查 URL 或 API 日志' }; } + // ── templates CRUD ── + + async listTemplates(): Promise { + await this.ensureTemplates(); + const rows = await this.prisma.wecomPushTemplate.findMany({ + orderBy: { eventKey: 'asc' }, + }); + return rows.map((r) => this.templateToDto(r)); + } + + async getTemplate(eventKey: string): Promise { + this.assertTemplateKey(eventKey); + await this.ensureTemplates(); + const row = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } }); + if (!row) throw new BadRequestException('模板不存在'); + return this.templateToDto(row); + } + + async updateTemplate( + eventKey: string, + data: { title?: string; body?: string; handleLabel?: string }, + ): Promise { + this.assertTemplateKey(eventKey); + await this.ensureTemplates(); + const existing = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } }); + if (!existing) throw new BadRequestException('模板不存在'); + + const title = data.title != null ? String(data.title).trim() : undefined; + const body = data.body != null ? String(data.body).trim() : undefined; + const handleLabel = + data.handleLabel != null ? String(data.handleLabel).trim() || '去处理' : undefined; + if (title !== undefined && !title) throw new BadRequestException('标题不能为空'); + if (body !== undefined && !body) throw new BadRequestException('正文不能为空'); + + const row = await this.prisma.wecomPushTemplate.update({ + where: { eventKey }, + data: { + ...(title !== undefined ? { title } : {}), + ...(body !== undefined ? { body } : {}), + ...(handleLabel !== undefined ? { handleLabel } : {}), + }, + }); + return this.templateToDto(row); + } + + async resetTemplate(eventKey: string): Promise { + this.assertTemplateKey(eventKey); + const def = getDefaultTemplate(eventKey); + if (!def) throw new BadRequestException('无默认模板'); + await this.ensureTemplates(); + const row = await this.prisma.wecomPushTemplate.upsert({ + where: { eventKey }, + create: { + eventKey: def.eventKey, + title: def.title, + body: def.body, + handleLabel: def.handleLabel, + }, + update: { + title: def.title, + body: def.body, + handleLabel: def.handleLabel, + }, + }); + return this.templateToDto(row); + } + + /** 用示例变量渲染并推到勾选了该事件的启用群 */ + async testTemplate(eventKey: string): Promise<{ ok: boolean; message: string; preview: string }> { + this.assertTemplateKey(eventKey); + const sample = this.sampleVars(eventKey); + const preview = await this.renderEventContent( + eventKey, + sample.vars, + sample.handlePath, + ); + const sent = await this.dispatchMarkdown(eventKey, preview, { applyMention: false }); + if (sent === 0) { + return { + ok: false, + message: '没有已启用且勾选该事件的消息推送,请先配置 webhook', + preview, + }; + } + return { ok: true, message: `已发送至 ${sent} 个推送`, preview }; + } + + private sampleVars(eventKey: WecomTemplateEventKey): { + vars: Record; + handlePath: string; + } { + const samples: Record; handlePath: string }> = { + 'order.paid': { + vars: { + orderNo: 'DK202608200001', + payAmount: '199.00', + cityName: '郑州', + skuSummary: '杜康原浆 ×2', + phoneMasked: '138****8000', + }, + handlePath: '/orders?orderNo=DK202608200001', + }, + 'redeem.success': { + vars: { + redeemNo: 'RD202608200001', + amount: '88.00', + storeName: '示例门店', + channel: '扫码', + }, + handlePath: '/redeem-records?redeemNo=RD202608200001', + }, + 'store.audit_pending': { + vars: { + storeName: '示例门店', + cityName: '郑州', + partnerLabel: '示例合伙人', + action: '新建', + storeId: '1', + }, + handlePath: '/stores?auditStatus=PENDING&storeId=1', + }, + 'store.package_audit_pending': { + vars: { + storeName: '示例门店', + cityName: '郑州', + submitter: '合伙人', + packageCount: '3', + requestId: '1', + }, + handlePath: '/store-package-audits?requestId=1', + }, + 'store.info_change_pending': { + vars: { + storeName: '示例门店', + cityName: '郑州', + submitter: '门店', + changedFields: '门店名称、详细地址', + requestId: '1', + }, + handlePath: '/store-package-audits?tab=info&infoRequestId=1', + }, + 'store.withdraw_pending': { + vars: { + storeName: '示例门店', + withdrawNo: 'SW202608200001', + amount: '500.00', + payoutCount: '5', + storeId: '1', + }, + handlePath: '/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=1', + }, + 'invoice.pending': { + vars: { + invoiceNo: 'IV202608200001', + orderNo: 'DK202608200001', + payAmount: '199.00', + titleName: '示例公司', + phoneMasked: '138****8000', + }, + handlePath: '/invoices?status=PENDING&invoiceNo=IV202608200001', + }, + }; + return samples[eventKey]; + } + + private assertTemplateKey(eventKey: string): asserts eventKey is WecomTemplateEventKey { + if (!(WECOM_TEMPLATE_EVENT_KEYS as readonly string[]).includes(eventKey)) { + throw new BadRequestException(`无效模板事件:${eventKey}`); + } + } + + templateToDto(row: TemplateRow): WecomPushTemplateDto { + return { + id: row.id.toString(), + eventKey: row.eventKey as WecomTemplateEventKey, + title: row.title, + body: row.body, + handleLabel: row.handleLabel, + updatedAt: row.updatedAt.toISOString(), + }; + } + toDto(row: PushRow): WecomMessagePushDto { return { id: row.id.toString(), diff --git a/server/dukang-api/src/integrations/wecom/wecom-push-template.defaults.ts b/server/dukang-api/src/integrations/wecom/wecom-push-template.defaults.ts new file mode 100644 index 0000000..230fb88 --- /dev/null +++ b/server/dukang-api/src/integrations/wecom/wecom-push-template.defaults.ts @@ -0,0 +1,157 @@ +import type { WecomTemplateEventKey } from '@dukang/shared-types'; + +export type WecomTemplateDefault = { + eventKey: WecomTemplateEventKey; + title: string; + body: string; + handleLabel: string; +}; + +/** 代码内默认文案;ensureDefaults 仅在库中无行时写入,不覆盖 HQ 已改 */ +export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [ + { + eventKey: 'order.paid', + title: '订单支付成功', + body: [ + '**订单支付成功**', + '订单号:{{orderNo}}', + '实付:¥{{payAmount}}', + '城市:{{cityName}}', + '商品:{{skuSummary}}', + '用户:{{phoneMasked}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, + { + eventKey: 'redeem.success', + title: '门店核销成功', + body: [ + '**门店核销成功**', + '核销单号:{{redeemNo}}', + '金额:¥{{amount}}', + '门店:{{storeName}}', + '渠道:{{channel}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, + { + eventKey: 'store.audit_pending', + title: '门店审核待处理', + body: [ + '**门店审核待处理 · {{action}}**', + '门店:{{storeName}}', + '城市:{{cityName}}', + '合伙人:{{partnerLabel}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, + { + eventKey: 'store.package_audit_pending', + title: '套餐变更待审核', + body: [ + '**套餐变更待审核**', + '门店:{{storeName}}', + '城市:{{cityName}}', + '提交端:{{submitter}}', + '套餐条数:{{packageCount}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, + { + eventKey: 'store.info_change_pending', + title: '门店信息变更待审', + body: [ + '**门店信息变更待审**', + '门店:{{storeName}}', + '城市:{{cityName}}', + '提交端:{{submitter}}', + '变更字段:{{changedFields}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, + { + eventKey: 'store.withdraw_pending', + title: '门店提现待审', + body: [ + '**门店提现待审**', + '门店:{{storeName}}', + '提现单号:{{withdrawNo}}', + '金额:¥{{amount}}', + '明细笔数:{{payoutCount}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, + { + eventKey: 'invoice.pending', + title: '发票申请待开票', + body: [ + '**发票申请待开票**', + '申请单号:{{invoiceNo}}', + '订单号:{{orderNo}}', + '金额:¥{{payAmount}}', + '抬头:{{titleName}}', + '用户:{{phoneMasked}}', + '时间:{{time}}', + '[{{handleLabel}}]({{handleUrl}})', + ].join('\n'), + handleLabel: '去处理', + }, +]; + +export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined { + return WECOM_PUSH_TEMPLATE_DEFAULTS.find((t) => t.eventKey === eventKey); +} + +/** 将 body 中的 {{key}} 替换为 vars;缺失置空 */ +export function renderWecomTemplate( + body: string, + vars: Record, +): string { + return body.replace(/\{\{(\w+)\}\}/g, (_m, key: string) => { + const v = vars[key]; + if (v == null) return ''; + return String(v); + }); +} + +/** + * HQ 后台公网根地址(无尾斜杠)。 + * 优先 HQ_ADMIN_PUBLIC_URL;未配时按 WECOM_ALERT_ENV_LABEL / NODE_ENV 回退, + * 避免相对路径被企微解析成 http://orders/... 这类无效链接。 + */ +export function resolveHqAdminPublicBase(): string { + const configured = (process.env.HQ_ADMIN_PUBLIC_URL || '').trim().replace(/\/$/, ''); + if (configured) return configured; + + const label = (process.env.WECOM_ALERT_ENV_LABEL || process.env.NODE_ENV || '') + .trim() + .toLowerCase(); + if (label === 'production' || label === 'prod') { + return 'https://admin.dukanghaoke.com'; + } + if (label === 'staging' || label === 'test' || label === 'testing') { + return 'https://admin-test.dukanghaoke.com'; + } + // local / development / 未识别:本机 admin-web 默认端口 + return 'http://localhost:5175'; +} + +export function buildHqHandleUrl(pathWithQuery: string): string { + const base = resolveHqAdminPublicBase(); + let path = (pathWithQuery || '').trim(); + if (!path) return base; + if (!path.startsWith('/')) path = `/${path}`; + return `${base}${path}`; +} diff --git a/server/dukang-api/src/modules/ops/admin-invoices.controller.ts b/server/dukang-api/src/modules/ops/admin-invoices.controller.ts index 613d6ad..0678fbb 100644 --- a/server/dukang-api/src/modules/ops/admin-invoices.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-invoices.controller.ts @@ -19,11 +19,13 @@ export class AdminInvoicesController { @Get() list( @Query('status') status?: string, + @Query('invoiceNo') invoiceNo?: string, @Query('page') page = '1', @Query('pageSize') pageSize = '20', ) { return this.tradeService.adminListInvoices({ status, + invoiceNo, page: Number(page), pageSize: Number(pageSize), }); diff --git a/server/dukang-api/src/modules/ops/admin-wecom-push-templates.controller.ts b/server/dukang-api/src/modules/ops/admin-wecom-push-templates.controller.ts new file mode 100644 index 0000000..4f70233 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-wecom-push-templates.controller.ts @@ -0,0 +1,69 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Param, + Post, + Put, + UseGuards, +} from '@nestjs/common'; +import type { UpdateWecomPushTemplateRequest } from '@dukang/shared-types'; +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 { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; + +@Controller('admin/wecom-push-templates') +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('wecom_bots') +export class AdminWecomPushTemplatesController { + constructor(private readonly wecomPush: WecomMessagePushService) {} + + @Get() + list() { + return this.wecomPush.listTemplates(); + } + + @Get(':eventKey') + detail(@Param('eventKey') eventKey: string) { + return this.wecomPush.getTemplate(eventKey); + } + + @Put(':eventKey') + @HqOperation({ + action: HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE, + refType: 'WECOM_PUSH_TEMPLATE', + refIdField: 'eventKey', + includeBody: true, + }) + update(@Param('eventKey') eventKey: string, @Body() body: UpdateWecomPushTemplateRequest) { + return this.wecomPush.updateTemplate(eventKey, body); + } + + @Post(':eventKey/reset') + @HqOperation({ + action: HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE, + refType: 'WECOM_PUSH_TEMPLATE', + refIdField: 'eventKey', + }) + reset(@Param('eventKey') eventKey: string) { + return this.wecomPush.resetTemplate(eventKey); + } + + @Post(':eventKey/test') + @HqOperation({ + action: HqOperationAction.WECOM_MESSAGE_PUSH_TEST, + refType: 'WECOM_PUSH_TEMPLATE', + refIdField: 'eventKey', + }) + async test(@Param('eventKey') eventKey: string) { + const result = await this.wecomPush.testTemplate(eventKey); + if (!result.ok) throw new BadRequestException(result.message); + return result; + } +} diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index d6e50cd..8e312c4 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -69,6 +69,7 @@ import { AdminWecomBotsController } from './admin-wecom-bots.controller'; import { AdminWecomBotsService } from './admin-wecom-bots.service'; import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller'; import { AdminWecomMessagePushesService } from './admin-wecom-message-pushes.service'; +import { AdminWecomPushTemplatesController } from './admin-wecom-push-templates.controller'; import { AdminWecomBotLogsController } from './admin-wecom-bot-logs.controller'; import { AdminWecomBotLogsService } from './admin-wecom-bot-logs.service'; import { AdminLlmConfigsController } from './admin-llm-configs.controller'; @@ -124,6 +125,7 @@ import { AdminTestWhitelistController } from './admin-test-whitelist.controller' AdminSystemConfigController, AdminWecomBotsController, AdminWecomMessagePushesController, + AdminWecomPushTemplatesController, AdminWecomBotLogsController, AdminLlmConfigsController, AdminKnowledgeBasesController, diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index eba90a4..a290b2d 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -30,6 +30,7 @@ import { SettlementService } from '../settlement/settlement.service'; import { BenefitService } from '../benefit/benefit.service'; import { AuthService } from '../iam/auth.service'; import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service'; +import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; type TokenPayload = { userId: string; @@ -82,6 +83,7 @@ export class RedeemService { private readonly analyticsService: AnalyticsService, private readonly authService: AuthService, private readonly payRedeemAnomaly: PayRedeemAnomalyService, + private readonly wecomPush: WecomMessagePushService, ) {} private maskPhoneForStore(phone: string) { @@ -276,6 +278,22 @@ export class RedeemService { extraJson: redeemExtra, }); + if (!isTest) { + const channelLabel = redeemChannel === 'PHONE' ? '手机号' : '扫码'; + void this.wecomPush.dispatchEvent( + 'redeem.success', + { + redeemNo: record.redeemNo, + amount: amountNum.toFixed(2), + storeName: account.store.name || String(account.storeId), + channel: channelLabel, + }, + { + handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`, + }, + ); + } + return { ...record, amount: amountNum, diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 28f497b..55cf34e 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -18,6 +18,7 @@ import { import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AlertService } from '../../common/alert/alert.service'; +import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import { AnalyticsService } from '../analytics/analytics.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; @@ -86,6 +87,7 @@ export class SettlementService { private readonly partnerCityService: PartnerCityService, private readonly fulfillmentProviderService: FulfillmentProviderService, private readonly alert: AlertService, + private readonly wecomPush: WecomMessagePushService, ) {} // ─── Store payout (line) ───────────────────────────── @@ -386,20 +388,19 @@ export class SettlementService { }, }); - this.alert.notify({ - level: 'P1', - category: 'finance', - title: '门店提现待审', - detail: [ - `门店:${store.name}(${store.cityName || '-'} / ${store.phone || '-'})`, - `单号:${created.withdrawNo}`, - `金额:¥${Number(created.amount).toFixed(2)}`, - `明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`, - '请尽快在 HQ「财务 → 门店账单(手动提现)」处理。', - ].join('\n'), - dedupeKey: `store_withdraw_applied|${created.id.toString()}`, - dedupeTtlSec: 3600, - }); + void this.wecomPush.dispatchEvent( + 'store.withdraw_pending', + { + storeName: store.name, + withdrawNo: created.withdrawNo, + amount: Number(created.amount).toFixed(2), + payoutCount: String(created.payoutCount), + storeId: storeId.toString(), + }, + { + handlePath: `/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=${storeId.toString()}`, + }, + ); return serializeBigInt(created); } diff --git a/server/dukang-api/src/modules/store/store-info-change.service.ts b/server/dukang-api/src/modules/store/store-info-change.service.ts index 8b57a47..85242fa 100644 --- a/server/dukang-api/src/modules/store/store-info-change.service.ts +++ b/server/dukang-api/src/modules/store/store-info-change.service.ts @@ -10,6 +10,7 @@ import { } from '@dukang/domain'; import { STORE_INFO_CHANGEABLE_FIELDS, + formatStoreInfoChangeFieldLabels, type StoreInfoChangeFieldDiff, type StoreInfoChangeRequestDto, type StoreInfoChangeStatus, @@ -19,6 +20,7 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { StoreService } from './store.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; +import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number]; @@ -142,6 +144,7 @@ export class StoreInfoChangeService { private readonly prisma: PrismaService, private readonly storeService: StoreService, private readonly partnerCityService: PartnerCityService, + private readonly wecomPush: WecomMessagePushService, ) {} private async loadLiveMediaFields(storeId: bigint, coverResourceId: bigint | null) { @@ -329,6 +332,22 @@ export class StoreInfoChangeService { this.logger.log( `Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`, ); + + const submitterLabel = input.submitterType === 'PARTNER' ? '合伙人' : '门店'; + void this.wecomPush.dispatchEvent( + 'store.info_change_pending', + { + storeName: String(store.name ?? input.storeId), + cityName: String(store.cityName ?? '—'), + submitter: submitterLabel, + changedFields: formatStoreInfoChangeFieldLabels(changedFields), + requestId: created.id.toString(), + }, + { + handlePath: `/store-package-audits?tab=info&infoRequestId=${created.id.toString()}`, + }, + ); + return serializeBigInt(this.toDto(created as unknown as Record)); } diff --git a/server/dukang-api/src/modules/store/store-package.service.ts b/server/dukang-api/src/modules/store/store-package.service.ts index 14bd4c9..938bd93 100644 --- a/server/dukang-api/src/modules/store/store-package.service.ts +++ b/server/dukang-api/src/modules/store/store-package.service.ts @@ -191,28 +191,18 @@ export class StorePackageService { where: { id: storeId }, select: { name: true, cityName: true }, }); - const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); const submitterLabel = submitterType === 'PARTNER' ? '合伙人' : '门店'; - void this.wecomPush - .dispatchMarkdown( - 'store.package_audit_pending', - [ - '**套餐变更待审核**', - `门店:${store?.name ?? storeId}`, - store?.cityName ? `城市:${store.cityName}` : null, - `提交端:${submitterLabel}`, - `套餐条数:${packages.length}`, - `时间:${now}`, - ] - .filter(Boolean) - .join('\n'), - { applyMention: false }, - ) - .catch((e) => - this.logger.warn( - `store.package_audit_pending wecom push failed: ${e instanceof Error ? e.message : String(e)}`, - ), - ); + void this.wecomPush.dispatchEvent( + 'store.package_audit_pending', + { + storeName: store?.name ?? String(storeId), + cityName: store?.cityName || '—', + submitter: submitterLabel, + packageCount: String(packages.length), + requestId: req.id.toString(), + }, + { handlePath: `/store-package-audits?requestId=${req.id.toString()}` }, + ); return serializeBigInt({ id: req.id.toString(), diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index 9576ac0..f79d8a5 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -78,26 +78,25 @@ export class StoreService { /** 门店进入 PENDING 时通知企微(失败不挡业务) */ private notifyStoreAuditPending(opts: { + storeId: bigint; storeName: string; cityName?: string | null; partnerLabel?: string | null; submitType: '新建' | '重提'; }) { - const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); - const lines = [ - `**门店审核待处理 · ${opts.submitType}**`, - `门店:${opts.storeName}`, - opts.cityName ? `城市:${opts.cityName}` : null, - opts.partnerLabel ? `合伙人:${opts.partnerLabel}` : null, - `时间:${now}`, - ].filter(Boolean); - void this.wecomPush - .dispatchMarkdown('store.audit_pending', lines.join('\n'), { applyMention: false }) - .catch((e) => - this.logger.warn( - `store.audit_pending wecom push failed: ${e instanceof Error ? e.message : String(e)}`, - ), - ); + void this.wecomPush.dispatchEvent( + 'store.audit_pending', + { + storeName: opts.storeName, + cityName: opts.cityName || '—', + partnerLabel: opts.partnerLabel || '—', + action: opts.submitType, + storeId: opts.storeId.toString(), + }, + { + handlePath: `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`, + }, + ); } private async whitelistPhoneSet(): Promise> { @@ -593,6 +592,7 @@ export class StoreService { select: { name: true, phone: true }, }); this.notifyStoreAuditPending({ + storeId: store.id, storeName: store.name, cityName: store.cityName, partnerLabel: partner?.name || partner?.phone || String(primaryId), @@ -762,6 +762,7 @@ export class StoreService { select: { name: true, phone: true }, }); this.notifyStoreAuditPending({ + storeId: updated.id, storeName: updated.name, cityName: updated.cityName, partnerLabel: partner?.name || partner?.phone || String(primaryId), @@ -886,6 +887,7 @@ export class StoreService { select: { name: true, phone: true }, }); this.notifyStoreAuditPending({ + storeId: store.id, storeName: store.name, cityName: store.cityName, partnerLabel: partner?.name || partner?.phone || String(primaryId), diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 7a28c13..4d6832b 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -35,6 +35,7 @@ import { FulfillmentService } from '../fulfillment/fulfillment.service'; import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service'; import { AlertService } from '../../common/alert/alert.service'; import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service'; +import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import type { Request } from 'express'; @Injectable() @@ -56,6 +57,7 @@ export class TradeService { private readonly wechatOrderShipping: WechatOrderShippingService, private readonly payRedeemAnomaly: PayRedeemAnomalyService, private readonly alert: AlertService, + private readonly wecomPush: WecomMessagePushService, ) {} private readonly logger = new Logger(TradeService.name); @@ -429,9 +431,33 @@ export class TradeService { private async afterOrderPaid(orderId: bigint) { await this.benefitService.grantOnOrderPaid(orderId); - const order = await this.prisma.order.findUnique({ where: { id: orderId } }); + const order = await this.prisma.order.findUnique({ + where: { id: orderId }, + include: { + city: { select: { name: true } }, + user: { select: { phone: true } }, + }, + }); if (!order) return; + if (!order.isTest) { + const skuSummary = `${order.productName}×${order.quantity}`; + const phone = order.user?.phone || ''; + const phoneMasked = + phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—'; + void this.wecomPush.dispatchEvent( + 'order.paid', + { + orderNo: order.orderNo, + payAmount: Number(order.payAmount).toFixed(2), + cityName: order.city?.name || '—', + skuSummary, + phoneMasked, + }, + { handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` }, + ); + } + const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } }); if (!delivery) { await this.prisma.orderDelivery.create({ @@ -1090,6 +1116,26 @@ export class TradeService { remark: body.remark?.trim() || null, }, }); + + if (!order.isTest) { + const phone = resolved.phone.trim(); + const phoneMasked = + phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—'; + void this.wecomPush.dispatchEvent( + 'invoice.pending', + { + invoiceNo: invoice.invoiceNo, + orderNo: order.orderNo, + payAmount: Number(order.payAmount).toFixed(2), + titleName: invoice.titleName, + phoneMasked, + }, + { + handlePath: `/invoices?status=PENDING&invoiceNo=${encodeURIComponent(invoice.invoiceNo)}`, + }, + ); + } + return serializeBigInt({ ...invoice, orderNo: order.orderNo }); } @@ -1177,11 +1223,17 @@ export class TradeService { return this.createInvoice(order.userId, order.id, body); } - async adminListInvoices(query: { status?: string; page?: number; pageSize?: number }) { + async adminListInvoices(query: { + status?: string; + invoiceNo?: string; + page?: number; + pageSize?: number; + }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; - const where: { status?: never } = {}; + const where: { status?: never; invoiceNo?: { contains: string } } = {}; if (query.status) where.status = query.status as never; + if (query.invoiceNo?.trim()) where.invoiceNo = { contains: query.invoiceNo.trim() }; const [items, total] = await Promise.all([ this.prisma.userInvoice.findMany({ where,