v3.5.3版本更新2 #30
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '核销号',
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,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(),
|
||||
|
||||
@@ -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<Set<string>> {
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user