From 26334ed072828d8963d0e66f73292e5283f0db24 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Thu, 20 Aug 2026 20:09:05 +0800 Subject: [PATCH] =?UTF-8?q?v3.5.3=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B02?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/pages/InvoicesPage.tsx | 36 +- apps/admin-web/src/pages/OrdersPage.tsx | 22 +- .../admin-web/src/pages/RedeemRecordsPage.tsx | 25 +- apps/admin-web/src/pages/StoreBillsPage.tsx | 5 +- .../src/pages/StorePackageAuditsPage.tsx | 26 +- apps/admin-web/src/pages/StoresPage.tsx | 23 ++ .../src/pages/WecomMessagePushesPage.tsx | 222 +++++++++++- .../src/assets/icons/store-benefit.png | Bin 29106 -> 207 bytes docs/杜康好客-v3.5.3-开发文档.md | 51 ++- .../shared-types/src/store-info-change.ts | 28 ++ .../shared-types/src/wecom-message-push.ts | 137 ++++++- server/dukang-api/.env.example | 6 + server/dukang-api/.env.production.example | 5 + server/dukang-api/.env.staging.example | 5 + server/dukang-api/prisma/schema.prisma | 13 + .../wecom/wecom-message-push.service.ts | 333 ++++++++++++++++-- .../wecom/wecom-push-template.defaults.ts | 157 +++++++++ .../modules/ops/admin-invoices.controller.ts | 2 + .../admin-wecom-push-templates.controller.ts | 69 ++++ .../dukang-api/src/modules/ops/ops.module.ts | 2 + .../src/modules/redeem/redeem.service.ts | 18 + .../modules/settlement/settlement.service.ts | 29 +- .../store/store-info-change.service.ts | 19 + .../modules/store/store-package.service.ts | 32 +- .../src/modules/store/store.service.ts | 32 +- .../src/modules/trade/trade.service.ts | 58 ++- 26 files changed, 1238 insertions(+), 117 deletions(-) create mode 100644 server/dukang-api/src/integrations/wecom/wecom-push-template.defaults.ts create mode 100644 server/dukang-api/src/modules/ops/admin-wecom-push-templates.controller.ts 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 adfce201ade1235c8287491dde6efe9a7893d00c..d14eb8fabe6432a42eef0b4bcd10f33a1202aee7 100644 GIT binary patch literal 207 zcmeAS@N?(olHy`uVBq!ia0vp^fgsGm1|(PYdzk^LX`U{QAr-gY-Z;qFV8G*WQSZ^& zo3Vjkf(jd&zFtuIAooPhjw?5s8K@En8d~~t-%Gl?2)c>{A`_c;tT#?QQojDnLpNdB zJd;!=x3)iPInAik%opUXO@geCxeqeQg; literal 29106 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 **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, -- 2.53.0