Merge pull request 'Dev' (#31) from dev into main
CI / verify (push) Waiting to run

Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/31
This commit was merged in pull request #31.
This commit is contained in:
2026-08-20 20:23:20 +08:00
26 changed files with 1238 additions and 117 deletions
+34 -2
View File
@@ -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<Record<string, string>>({});
const [searchParams] = useSearchParams();
const initialStatus = searchParams.get('status')?.trim() || '';
const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || '';
const [filterForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
if (initialStatus) init.status = initialStatus;
if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/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<CreateFormValues>();
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() {
</Button>
</div>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
@@ -231,6 +260,9 @@ export default function InvoicesPage() {
]}
/>
</Form.Item>
<Form.Item name="invoiceNo" label="申请单号">
<Input allowClear placeholder="发票申请单号" />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
+20 -2
View File
@@ -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<OrderDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const deepLinkOpenedRef = useRef(false);
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
@@ -201,6 +204,21 @@ export default function OrdersPage() {
request<HqProfile>('/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);
+23 -2
View File
@@ -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<Record<string, string | boolean>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
if (initialRedeemNo) init.redeemNo = initialRedeemNo;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records',
() => {
@@ -43,6 +50,11 @@ export default function RedeemRecordsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | 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<Row> = [
{
title: '核销号',
+4 -1
View File
@@ -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<Record<string, string>>({
kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
status: initialStatus,
storeId: initialStoreId,
});
const [stores, setStores] = useState<StoreOption[]>([]);
@@ -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<StorePackageChangeStatus, string> = {
REJECTED: '已驳回',
};
const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
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) => (
<Tag key={f}>{INFO_CHANGE_FIELD_LABELS[f] ?? f}</Tag>
<Tag key={f}>{STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? f}</Tag>
))
: '—',
},
@@ -339,7 +325,7 @@ function InfoChangeAuditPanel({
{detail.diffs.map((d) => (
<Descriptions.Item
key={d.field}
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
>
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
+23
View File
@@ -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<StoreCreateForm>();
@@ -253,6 +255,7 @@ export default function StoresPage() {
const init: Record<string, string | boolean> = {};
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<StoreRow>(
@@ -294,6 +297,7 @@ export default function StoresPage() {
const [optionsLoading, setOptionsLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
const deepLinkStoreOpenedRef = useRef(false);
useEffect(() => {
void request<Paginated<CityOption>>(`/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);
@@ -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<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
@@ -257,11 +263,9 @@ export default function WecomMessagePushesPage() {
];
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<>
<Typography.Paragraph type="secondary">
Webhook
.env Webhook URL
Webhook / .env Webhook URL
</Typography.Paragraph>
<Form
@@ -332,10 +336,10 @@ export default function WecomMessagePushesPage() {
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:运营告警" />
<Input placeholder="如:业务待办通知群" />
</Form.Item>
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
<OssUpload />
<OssUpload bizType="WECOM_BOT_AVATAR" />
</Form.Item>
<Form.Item
name="webhookUrl"
@@ -400,6 +404,208 @@ export default function WecomMessagePushesPage() {
</Descriptions>
) : null}
</Modal>
</>
);
}
function TemplatesTab() {
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
const [loading, setLoading] = useState(false);
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('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<WecomPushTemplateDto[]>('/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<WecomPushTemplateDto>(
`/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<WecomPushTemplateDto>(
`/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 (
<>
<Typography.Paragraph type="secondary">
使 {'{{orderNo}}'} {'{{handleUrl}}'}
Webhook
</Typography.Paragraph>
<Space align="start" style={{ width: '100%' }} size={24} wrap>
<div style={{ minWidth: 200 }}>
<Typography.Text strong></Typography.Text>
<div style={{ marginTop: 8 }}>
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
<div key={k} style={{ marginBottom: 4 }}>
<Button
type={k === eventKey ? 'primary' : 'text'}
size="small"
onClick={() => selectEvent(k)}
block
style={{ textAlign: 'left' }}
>
{WECOM_TEMPLATE_EVENT_LABELS[k]}
</Button>
</div>
))}
</div>
</div>
<div style={{ flex: 1, minWidth: 360 }}>
<Form form={form} layout="vertical" disabled={loading}>
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="body"
label="正文(企微 markdown"
rules={[{ required: true }]}
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
>
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
<Input placeholder="去处理" />
</Form.Item>
<Space wrap>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
<Button loading={saving}></Button>
</Popconfirm>
<Button loading={testing} onClick={() => void testSend()}>
</Button>
</Space>
</Form>
{preview ? (
<div style={{ marginTop: 16 }}>
<Typography.Text strong></Typography.Text>
<pre
style={{
marginTop: 8,
padding: 12,
background: '#f5f5f5',
whiteSpace: 'pre-wrap',
borderRadius: 8,
}}
>
{preview}
</pre>
</div>
) : null}
</div>
</Space>
</>
);
}
export default function WecomMessagePushesPage() {
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<Tabs
items={[
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
]}
/>
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 207 B

+49 -2
View File
@@ -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`
@@ -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<StoreInfoChangeableField, string> = {
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;
+130 -7
View File
@@ -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<WecomPushCondition, string> = {
'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<WecomTemplateEventKey, string> = {
'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<WecomTemplateEventKey, string[]> = {
'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[] {
+6
View File
@@ -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)
@@ -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=
+5
View File
@@ -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=
+13
View File
@@ -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
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<PushRow[]> {
@@ -154,6 +213,58 @@ export class WecomMessagePushService implements OnModuleInit {
return pushes.length > 0;
}
/**
* 读 HQ 模板 → 插值 → 补快链 → 按条件路由推送。
* 失败只打日志,不抛给业务。
*/
async dispatchEvent(
eventKey: WecomTemplateEventKey,
vars: Record<string, string | number | null | undefined>,
options?: { applyMention?: boolean; handlePath?: string },
): Promise<number> {
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<string, string | number | null | undefined>,
handlePath?: string,
): Promise<string> {
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<string, string | number | null | undefined> = {
...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<WecomPushTemplateDto[]> {
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<WecomPushTemplateDto> {
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<WecomPushTemplateDto> {
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<WecomPushTemplateDto> {
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<string, string>;
handlePath: string;
} {
const samples: Record<WecomTemplateEventKey, { vars: Record<string, string>; 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(),
@@ -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, string | number | null | undefined>,
): 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}`;
}
@@ -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),
});
@@ -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;
}
}
@@ -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,
@@ -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,
@@ -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);
}
@@ -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<string, unknown>));
}
@@ -191,27 +191,17 @@ 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(
void this.wecomPush.dispatchEvent(
'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)}`,
),
{
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({
@@ -78,25 +78,24 @@ 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()}`,
},
);
}
@@ -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),
@@ -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,