Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e711be6c6 | |||
| 8a196c377e | |||
| 418e13a5e3 | |||
| 7fa69c2521 | |||
| b0874f07f6 | |||
| c26ebdde1b | |||
| b60b16a84f | |||
| b74dfd391a | |||
| ec9b7efcd6 | |||
| d04ca615ff | |||
| ebb34ad112 | |||
| d5994af431 | |||
| c29eb2c383 | |||
| 29e95bd5a3 | |||
| e5d7e468cc | |||
| 62d75a6869 | |||
| 8d70f0da72 | |||
| 92dfbf5722 |
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Button, Image, Input, Modal, Space, Typography, Upload, message } from 'antd';
|
||||
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||
@@ -13,6 +13,7 @@ type OssUploadProps = {
|
||||
accept?: string;
|
||||
maxSizeMb?: number;
|
||||
placeholder?: string;
|
||||
uploadHint?: ReactNode;
|
||||
};
|
||||
|
||||
function isImageUrl(url: string) {
|
||||
@@ -23,6 +24,32 @@ function isPdfUrl(url: string) {
|
||||
return /\.pdf(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function formatUploadImageMessage(result: UploadFileResult): string {
|
||||
const img = result.image;
|
||||
if (!img) return '上传成功';
|
||||
const sizeLabel = `${img.width}×${img.height}`;
|
||||
if (img.compressed && img.originalWidth && img.originalHeight) {
|
||||
const original = `${img.originalWidth}×${img.originalHeight}`;
|
||||
if (original !== sizeLabel) {
|
||||
return `上传成功(${sizeLabel},已由 ${original} 自动压缩)`;
|
||||
}
|
||||
}
|
||||
return `上传成功(${sizeLabel})`;
|
||||
}
|
||||
|
||||
function formatUploadImageHint(result: UploadFileResult): string | null {
|
||||
const img = result.image;
|
||||
if (!img) return null;
|
||||
const sizeLabel = `${img.width}×${img.height}`;
|
||||
if (img.compressed && img.originalWidth && img.originalHeight) {
|
||||
const original = `${img.originalWidth}×${img.originalHeight}`;
|
||||
if (original !== sizeLabel) {
|
||||
return `${sizeLabel}(已由 ${original} 自动压缩)`;
|
||||
}
|
||||
}
|
||||
return sizeLabel;
|
||||
}
|
||||
|
||||
export default function OssUpload({
|
||||
value,
|
||||
onChange,
|
||||
@@ -31,9 +58,11 @@ export default function OssUpload({
|
||||
mediaType = 'IMAGE',
|
||||
accept,
|
||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||
uploadHint,
|
||||
}: OssUploadProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
||||
const [imageMetaHint, setImageMetaHint] = useState<string | null>(null);
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||
@@ -45,7 +74,9 @@ export default function OssUpload({
|
||||
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
||||
onChange?.(result.url);
|
||||
onUploaded?.(result);
|
||||
message.success('上传成功');
|
||||
const successMessage = formatUploadImageMessage(result);
|
||||
setImageMetaHint(formatUploadImageHint(result));
|
||||
message.success(successMessage);
|
||||
onSuccess?.(result);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error('上传失败');
|
||||
@@ -116,6 +147,16 @@ export default function OssUpload({
|
||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||
)}
|
||||
{filePreview}
|
||||
{uploadHint ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{uploadHint}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{imageMetaHint ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
当前底图:{imageMetaHint}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
<Space wrap>
|
||||
<Upload
|
||||
accept={resolvedAccept}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||
formatOssMaxSizeMb,
|
||||
} from '@dukang/shared-ui/compressImage';
|
||||
import type { OssUploadImageMeta } from '@dukang/shared-types';
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
@@ -12,6 +13,7 @@ export type UploadFileResult = {
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
image?: OssUploadImageMeta;
|
||||
};
|
||||
|
||||
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
ACTIVITY_POSTER_STATUS_LABELS,
|
||||
ACTIVITY_POSTER_UPLOAD_HINT,
|
||||
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
||||
type ActivityPosterItem,
|
||||
type ActivityPosterQrSlot,
|
||||
@@ -280,7 +281,7 @@ export default function ActivityPostersPage() {
|
||||
<Input maxLength={128} />
|
||||
</Form.Item>
|
||||
<Form.Item name="imageUrl" label="活动图" rules={[{ required: true, message: '请上传活动图' }]}>
|
||||
<OssUpload bizType="ACTIVITY_POSTER" />
|
||||
<OssUpload bizType="ACTIVITY_POSTER" uploadHint={ACTIVITY_POSTER_UPLOAD_HINT} />
|
||||
</Form.Item>
|
||||
<Form.Item name="qrXPct" hidden><InputNumber /></Form.Item>
|
||||
<Form.Item name="qrYPct" hidden><InputNumber /></Form.Item>
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function PromoCodesPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '扫码',
|
||||
title: <Tooltip title="按人次:每次扫码或带参进入记一次,未登录也计入;同一人多次进入记多次">扫码</Tooltip>,
|
||||
dataIndex: 'scanCount',
|
||||
width: 70,
|
||||
render: (v: number, row) => (
|
||||
@@ -110,7 +110,7 @@ export default function PromoCodesPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '转化率',
|
||||
title: <Tooltip title="已完成订单 ÷ 扫码人次">转化率</Tooltip>,
|
||||
width: 90,
|
||||
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
|
||||
WECOM_BOT_ROLE_LABELS,
|
||||
WECOM_BOT_ROLES,
|
||||
WECOM_PLUGIN_API_KEY_HEADER,
|
||||
resolveWecomPluginPublicUrl,
|
||||
type LlmApiConfigOptionDto,
|
||||
type KnowledgeBaseOptionDto,
|
||||
type WecomAibotRuntimeDto,
|
||||
@@ -403,6 +406,34 @@ export default function WecomBotsPage() {
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="企微 API 插件(与上方长连接机器人独立)"
|
||||
description={
|
||||
<div>
|
||||
<div>
|
||||
插件 URL:
|
||||
<Typography.Text copyable>
|
||||
{resolveWecomPluginPublicUrl(window.location.hostname)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
OpenAPI:
|
||||
<Typography.Text copyable>
|
||||
{`${resolveWecomPluginPublicUrl(window.location.hostname)}/openapi.json`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text type="secondary">
|
||||
授权方式 Service token / API key,Header 名 {WECOM_PLUGIN_API_KEY_HEADER}
|
||||
;密钥只在服务器 .env 的 WECOM_PLUGIN_API_KEY,本页不展示。须先开
|
||||
WECOM_PLUGIN_ENABLED=true。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
|
||||
@@ -148,10 +148,10 @@ function ReportKindPane({
|
||||
label="发送时刻"
|
||||
extra={
|
||||
kind === 'daily'
|
||||
? '按北京时间当天该时刻发送当日数据。'
|
||||
? '按北京时间该时刻发送;数据截止发送日 0 点(前一天 24 点),即完整昨日。'
|
||||
: kind === 'weekly'
|
||||
? '按北京时间该星期该时刻发送上一自然周。'
|
||||
: '按北京时间每月该日该时刻发送上一自然月。'
|
||||
? '按北京时间该星期该时刻发送上一自然周;不含发送当天。'
|
||||
: '按北京时间每月该日该时刻发送上一自然月;不含发送当天。'
|
||||
}
|
||||
rules={[{ required: true, message: '请选择时刻' }]}
|
||||
>
|
||||
|
||||
@@ -37,8 +37,33 @@ const descContentStyle: CSSProperties = {
|
||||
wordBreak: 'break-all',
|
||||
};
|
||||
|
||||
const SCAN_HINT =
|
||||
'按人次累计:每次扫码或带参进入记一次,未登录也计入;同一用户多次进入记多次。同一次打开内的重复上报、以及登录后补归因不重复计。不是按人去重。';
|
||||
|
||||
const ATTRIBUTION_HINT =
|
||||
'首次触达本推广码的用户。每人只归因一次、只归一个码:已登录用户扫码或带参进入时,若还没有归因记录,则记到本码;之后再扫其他码不会改。与「扫码注册」不同:注册只统计来源仍是自然量并被标记为本码的用户;已有其他来源(如分享)的用户仍可计入归因,但不计入扫码注册。';
|
||||
'首次触达本推广码的用户,按人计:每人只归因一次、只归一个码。已登录扫码时,若还没有归因记录则记到本码;未登录先扫码、登录后再补记。之后再扫其他码不会改。未登录连续扫多个码时,登录后归到最后一次缓存的码。与「扫码注册」不同:已有分享等其他来源的用户仍可计入归因,但不计入扫码注册。';
|
||||
|
||||
const REGISTER_HINT =
|
||||
'用户来源仍是自然量时,才标记为本推广码。已有分享等其他来源的用户不计入,因此通常小于或等于归因用户数。';
|
||||
|
||||
const ORDER_HINT = '下单时绑定当时的归因推广码,仅统计已完成订单。';
|
||||
|
||||
const CONVERSION_HINT =
|
||||
'已完成订单数 ÷ 扫码进入次数(人次)。同一人多次扫码会放大分母,未登录扫码也计入。';
|
||||
|
||||
function StatHint({ label, hint }: { label: string; hint: string }) {
|
||||
return (
|
||||
<span>
|
||||
{label}
|
||||
<Tooltip title={hint} overlayInnerStyle={{ maxWidth: 360 }}>
|
||||
<QuestionCircleOutlined
|
||||
style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function downloadQrcode(url: string, filename: string) {
|
||||
try {
|
||||
@@ -221,20 +246,16 @@ export default function PromoCodeDetailPage() {
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
|
||||
>
|
||||
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||
<Statistic
|
||||
title={<StatHint label="扫码进入次数" hint={SCAN_HINT} />}
|
||||
value={stats?.scanCount ?? detail.scanCount}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={
|
||||
<span>
|
||||
归因用户数
|
||||
<Tooltip title={ATTRIBUTION_HINT} overlayInnerStyle={{ maxWidth: 360 }}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
title={<StatHint label="归因用户数" hint={ATTRIBUTION_HINT} />}
|
||||
value={stats?.attributionCount ?? 0}
|
||||
/>
|
||||
</Card>
|
||||
@@ -242,7 +263,7 @@ export default function PromoCodeDetailPage() {
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="扫码注册用户数"
|
||||
title={<StatHint label="扫码注册用户数" hint={REGISTER_HINT} />}
|
||||
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
|
||||
/>
|
||||
</Card>
|
||||
@@ -255,14 +276,7 @@ export default function PromoCodeDetailPage() {
|
||||
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
|
||||
>
|
||||
<Statistic
|
||||
title={
|
||||
<span>
|
||||
订单数
|
||||
<Tooltip title="仅统计已完成订单" overlayInnerStyle={{ maxWidth: 280 }}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
title={<StatHint label="订单数" hint={ORDER_HINT} />}
|
||||
value={stats?.orderCount ?? detail.orderCount}
|
||||
/>
|
||||
</Card>
|
||||
@@ -270,7 +284,7 @@ export default function PromoCodeDetailPage() {
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="转化率"
|
||||
title={<StatHint label="转化率" hint={CONVERSION_HINT} />}
|
||||
value={promoConversion(stats?.scanCount ?? detail.scanCount, stats?.orderCount ?? detail.orderCount)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -234,7 +234,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
style={{ marginTop: 16 }}
|
||||
extra={
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
仅统计功能上线后的新事件
|
||||
仅统计功能上线后的新事件。扫码进入按人次(多次进入记多次);归因 / 注册按人(每人一次)。
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -61,7 +61,11 @@ export default function PromoCodeUsersPage() {
|
||||
render: (v) => (v === detail.id ? <Tag color="blue">本码</Tag> : v || '—'),
|
||||
},
|
||||
{
|
||||
title: '首次触达',
|
||||
title: (
|
||||
<Tooltip title="该用户首次归因到本码的时间。无值表示仅用户来源指向本码,归因记录在其他码。">
|
||||
首次触达
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'firstTouchAt',
|
||||
width: 160,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
@@ -87,7 +91,10 @@ export default function PromoCodeUsersPage() {
|
||||
return (
|
||||
<>
|
||||
{settingsModal}
|
||||
<AdminListHeader settings={settingsButton} />
|
||||
<AdminListHeader
|
||||
settings={settingsButton}
|
||||
description="含归因到本码、或用户来源标记为本码的用户。"
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
|
||||
@@ -128,8 +128,12 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
|
||||
<p className="label-md text-muted">累计拓店: {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
{entry.periodStores > 0 ? (
|
||||
<>
|
||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -117,8 +117,12 @@ export default function LeaderboardPage() {
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
{entry.periodStores > 0 ? (
|
||||
<>
|
||||
<p className="headline-md text-primary">{entry.periodStores} 间</p>
|
||||
<p className="label-md text-muted">{periodSubLabel(period)}</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import { View, Text, Swiper, SwiperItem } from '@tarojs/components';
|
||||
import { formatRedeemRelativeTime } from '../lib/datetime';
|
||||
|
||||
export type StoreRedeemMarqueeItem = {
|
||||
userLabel: string;
|
||||
amount: string;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
type StoreRedeemMarqueeProps = {
|
||||
items: StoreRedeemMarqueeItem[];
|
||||
};
|
||||
|
||||
const STAY_MS = 20000;
|
||||
const STAY_MS = 5000;
|
||||
|
||||
function MarqueeRow({ item }: { item: StoreRedeemMarqueeItem }) {
|
||||
const timeLabel = formatRedeemRelativeTime(item.createdAt);
|
||||
return (
|
||||
<View className="store-detail-marquee-inner">
|
||||
<View className="store-detail-marquee-dot" />
|
||||
<Text className="store-detail-marquee-text">{item.userLabel} 刚刚到店核销</Text>
|
||||
<Text className="store-detail-marquee-text">
|
||||
{item.userLabel} {timeLabel}到店核销
|
||||
</Text>
|
||||
<Text className="store-detail-marquee-amount">{item.amount}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 核销记录:单条静止;多条竖向循环,每条停留 20 秒 */
|
||||
/** 核销记录:单条静止;多条竖向循环,支持手动滑动,每条停留 5 秒 */
|
||||
export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
||||
const list = useMemo(
|
||||
() =>
|
||||
@@ -30,6 +35,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
||||
.map((row) => ({
|
||||
userLabel: String(row.userLabel || '用户***').trim() || '用户***',
|
||||
amount: String(row.amount || '').trim(),
|
||||
createdAt: row.createdAt ?? null,
|
||||
}))
|
||||
.filter((row) => row.amount),
|
||||
[items],
|
||||
@@ -57,7 +63,7 @@ export default function StoreRedeemMarquee({ items }: StoreRedeemMarqueeProps) {
|
||||
indicatorDots={false}
|
||||
>
|
||||
{list.map((row, index) => (
|
||||
<SwiperItem key={`${row.userLabel}|${row.amount}|${index}`}>
|
||||
<SwiperItem key={`${row.userLabel}|${row.amount}|${row.createdAt}|${index}`}>
|
||||
<MarqueeRow item={row} />
|
||||
</SwiperItem>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,40 @@
|
||||
const MS_MINUTE = 60_000;
|
||||
const MS_HOUR = 60 * MS_MINUTE;
|
||||
const MS_DAY = 24 * MS_HOUR;
|
||||
|
||||
function shanghaiParts(input: string | Date) {
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const sh = new Date(d.getTime() + 8 * 60 * 60 * 1000);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return {
|
||||
year: sh.getUTCFullYear(),
|
||||
month: p(sh.getUTCMonth() + 1),
|
||||
day: p(sh.getUTCDate()),
|
||||
hour: p(sh.getUTCHours()),
|
||||
minute: p(sh.getUTCMinutes()),
|
||||
};
|
||||
}
|
||||
|
||||
/** 核销走马灯时间:24 小时内相对时间,超过则显示具体时间 */
|
||||
export function formatRedeemRelativeTime(input?: string | Date | null): string {
|
||||
if (input == null || input === '') return '刚刚';
|
||||
const at = input instanceof Date ? input : new Date(input);
|
||||
if (Number.isNaN(at.getTime())) return '刚刚';
|
||||
const diffMs = Date.now() - at.getTime();
|
||||
if (diffMs < MS_MINUTE) return '刚刚';
|
||||
if (diffMs < MS_HOUR) return `${Math.floor(diffMs / MS_MINUTE)}分钟前`;
|
||||
if (diffMs < MS_DAY) return `${Math.floor(diffMs / MS_HOUR)}小时前`;
|
||||
const parts = shanghaiParts(at);
|
||||
if (!parts) return '刚刚';
|
||||
const nowParts = shanghaiParts(new Date());
|
||||
const datePrefix =
|
||||
nowParts && parts.year === nowParts.year
|
||||
? `${parts.month}-${parts.day}`
|
||||
: `${parts.year}-${parts.month}-${parts.day}`;
|
||||
return `${datePrefix} ${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
|
||||
/** 格式化为 Asia/Shanghai:2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */
|
||||
export function formatShanghaiDateTime(input?: string | Date | null): string {
|
||||
if (input == null || input === '') return '—';
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
isHomeCatalogBootstrapped,
|
||||
setHomeCatalogCache,
|
||||
} from '../../lib/home-catalog-session';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
|
||||
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
import {
|
||||
@@ -202,8 +202,6 @@ export default function HomePage() {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
@@ -213,8 +211,6 @@ export default function HomePage() {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function LoginPage() {
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
||||
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
|
||||
const [showSmsForm, setShowSmsForm] = useState(true);
|
||||
const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -393,7 +393,7 @@ export default function LoginPage() {
|
||||
? '绑定手机号'
|
||||
: completeMode === 'wechat'
|
||||
? '授权登录'
|
||||
: '手机号快捷登录';
|
||||
: '验证码登录';
|
||||
|
||||
return (
|
||||
<PageShell variant="plain" className="login-page">
|
||||
@@ -451,32 +451,9 @@ export default function LoginPage() {
|
||||
|
||||
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||||
|
||||
{showPhoneQuick ? (
|
||||
<PhoneQuickLoginButton
|
||||
loading={phoneQuickLoading}
|
||||
agreed={agreed}
|
||||
onRequireAgree={() => ensureAgreed()}
|
||||
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
|
||||
onFail={(message) => setMsg(message)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showPhoneQuick ? (
|
||||
<View className="login-divider" style={{ marginTop: 20 }}>
|
||||
<View className="login-divider-line" />
|
||||
<Text
|
||||
className="login-divider-text"
|
||||
onClick={() => setShowSmsForm((v) => !v)}
|
||||
>
|
||||
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
|
||||
</Text>
|
||||
<View className="login-divider-line" />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{(showSmsForm || !showPhoneQuick) && (
|
||||
<>
|
||||
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}>
|
||||
<View className="login-field">
|
||||
<Text className="login-field-prefix">+86</Text>
|
||||
<Input
|
||||
className="login-field-input"
|
||||
@@ -526,6 +503,30 @@ export default function LoginPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{showPhoneQuick ? (
|
||||
<>
|
||||
<View className="login-divider" style={{ marginTop: 20 }}>
|
||||
<View className="login-divider-line" />
|
||||
<Text className="login-divider-text">或者</Text>
|
||||
<View className="login-divider-line" />
|
||||
</View>
|
||||
<PhoneQuickLoginButton
|
||||
loading={phoneQuickLoading}
|
||||
agreed={agreed}
|
||||
onRequireAgree={() => ensureAgreed()}
|
||||
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
|
||||
onFail={(message) => setMsg(message)}
|
||||
/>
|
||||
<Text
|
||||
className="login-divider-text"
|
||||
style={{ display: 'block', textAlign: 'center', marginTop: 12 }}
|
||||
onClick={() => setShowSmsForm((v) => !v)}
|
||||
>
|
||||
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{displayMsg ? (
|
||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||
{displayMsg}
|
||||
|
||||
@@ -6,7 +6,6 @@ import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
@@ -150,9 +149,6 @@ export default function OrderConfirmPickupPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认提交订单',
|
||||
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { goLogin } from '../../lib/auth-nav';
|
||||
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||
import { tryGetClientGpsLocation } from '../../lib/client-location';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
||||
@@ -291,9 +290,6 @@ export default function OrderConfirmPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,6 @@ import ProductCarousel from '../../components/ProductCarousel';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
import {
|
||||
getProductCarouselImages,
|
||||
@@ -236,8 +235,6 @@ export default function ProductDetailPage() {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
@@ -253,8 +250,6 @@ export default function ProductDetailPage() {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,10 @@ function readCachedRecord(): RedeemRecord | null {
|
||||
export default function RedeemSuccessPage() {
|
||||
const router = useRouter();
|
||||
const [fromHistory, setFromHistory] = useState(false);
|
||||
const [fromStore, setFromStore] = useState(false);
|
||||
const [record, setRecord] = useState<RedeemRecord | null>(null);
|
||||
const [score, setScore] = useState(5);
|
||||
const [serviceScore, setServiceScore] = useState(5);
|
||||
const [envScore, setEnvScore] = useState(5);
|
||||
const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']);
|
||||
const [comment, setComment] = useState('');
|
||||
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||||
@@ -68,8 +70,12 @@ export default function RedeemSuccessPage() {
|
||||
const rated = Boolean(record?.rating);
|
||||
|
||||
const applyRating = useCallback((rating: StoreRatingDto) => {
|
||||
const nextScore = Number(rating.serviceScore || rating.envScore || 5);
|
||||
setScore(Number.isFinite(nextScore) && nextScore > 0 ? Math.min(5, Math.round(nextScore)) : 5);
|
||||
const nextService = Number(rating.serviceScore || 5);
|
||||
const nextEnv = Number(rating.envScore || 5);
|
||||
setServiceScore(
|
||||
Number.isFinite(nextService) && nextService > 0 ? Math.min(5, Math.round(nextService)) : 5,
|
||||
);
|
||||
setEnvScore(Number.isFinite(nextEnv) && nextEnv > 0 ? Math.min(5, Math.round(nextEnv)) : 5);
|
||||
setTags(Array.isArray(rating.tags) ? rating.tags : []);
|
||||
setComment(String(rating.comment || ''));
|
||||
setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []);
|
||||
@@ -105,6 +111,7 @@ export default function RedeemSuccessPage() {
|
||||
const id = String(options?.id || router.params.id || '').trim();
|
||||
const from = String(options?.from || router.params.from || '');
|
||||
setFromHistory(from === 'history');
|
||||
setFromStore(from === 'store');
|
||||
if (id) {
|
||||
void loadRecord(id);
|
||||
return;
|
||||
@@ -138,7 +145,7 @@ export default function RedeemSuccessPage() {
|
||||
function leave() {
|
||||
clearCache();
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (fromHistory && pages.length > 1) {
|
||||
if ((fromHistory || fromStore) && pages.length > 1) {
|
||||
Taro.navigateBack();
|
||||
return;
|
||||
}
|
||||
@@ -187,8 +194,8 @@ export default function RedeemSuccessPage() {
|
||||
method: 'POST',
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
serviceScore: score,
|
||||
envScore: score,
|
||||
serviceScore,
|
||||
envScore,
|
||||
comment: comment.trim(),
|
||||
tags,
|
||||
imageUrls,
|
||||
@@ -199,7 +206,7 @@ export default function RedeemSuccessPage() {
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls },
|
||||
rating: { serviceScore, envScore, comment, tags, imageUrls },
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
@@ -228,21 +235,39 @@ export default function RedeemSuccessPage() {
|
||||
</View>
|
||||
|
||||
<View className="review-card">
|
||||
<Text className="review-card-title">本次到店体验</Text>
|
||||
<Text className="review-card-title">服务评分</Text>
|
||||
<View className="review-star-row">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<Text
|
||||
key={value}
|
||||
className={`review-star${value <= score ? ' review-star--active' : ''}`}
|
||||
key={`service-${value}`}
|
||||
className={`review-star${value <= serviceScore ? ' review-star--active' : ''}`}
|
||||
onClick={() => {
|
||||
if (!rated) setScore(value);
|
||||
if (!rated) setServiceScore(value);
|
||||
}}
|
||||
>
|
||||
★
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text className="review-score-label">{SCORE_LABELS[score]}</Text>
|
||||
<Text className="review-score-label">{SCORE_LABELS[serviceScore]}</Text>
|
||||
</View>
|
||||
|
||||
<View className="review-card">
|
||||
<Text className="review-card-title">环境评分</Text>
|
||||
<View className="review-star-row">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<Text
|
||||
key={`env-${value}`}
|
||||
className={`review-star${value <= envScore ? ' review-star--active' : ''}`}
|
||||
onClick={() => {
|
||||
if (!rated) setEnvScore(value);
|
||||
}}
|
||||
>
|
||||
★
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text className="review-score-label">{SCORE_LABELS[envScore]}</Text>
|
||||
</View>
|
||||
|
||||
<View className="review-card">
|
||||
|
||||
@@ -16,7 +16,7 @@ import ProductCarousel from '../../components/ProductCarousel';
|
||||
import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee';
|
||||
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { request, toast, isLoggedIn } from '../../lib/api';
|
||||
import { toMoneyNumber } from '../../lib/money';
|
||||
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
||||
import { track } from '../../lib/analytics';
|
||||
@@ -158,7 +158,7 @@ function toMarqueeItem(row: RecentRedeem): StoreRedeemMarqueeItem | null {
|
||||
const userLabel = String(row.userLabel || '用户***').trim() || '用户***';
|
||||
const amount = formatRedeemAmountYuan(row.amount);
|
||||
if (!amount) return null;
|
||||
return { userLabel, amount };
|
||||
return { userLabel, amount, createdAt: row.createdAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -189,6 +189,7 @@ export default function StoreDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const [pendingRatingId, setPendingRatingId] = useState<string | null>(null);
|
||||
const storeRef = useRef<Store | null>(null);
|
||||
storeRef.current = store;
|
||||
|
||||
@@ -274,6 +275,13 @@ export default function StoreDetailPage() {
|
||||
if (!id) return;
|
||||
void loadStore(id);
|
||||
void loadRecentRedeems(id);
|
||||
if (isLoggedIn()) {
|
||||
void request<{ id?: string | null }>(`/redeem/records/pending-rating?storeId=${id}`)
|
||||
.then((data) => setPendingRatingId(data?.id ? String(data.id) : null))
|
||||
.catch(() => setPendingRatingId(null));
|
||||
} else {
|
||||
setPendingRatingId(null);
|
||||
}
|
||||
});
|
||||
|
||||
const sharePayload = useMemo(() => {
|
||||
@@ -530,8 +538,20 @@ export default function StoreDetailPage() {
|
||||
) : null}
|
||||
|
||||
<View className="store-detail-bar">
|
||||
{pendingRatingId ? (
|
||||
<View
|
||||
className="u-btn u-btn--block"
|
||||
className="store-detail-bar-btn store-detail-bar-btn--ghost"
|
||||
onClick={() =>
|
||||
Taro.navigateTo({
|
||||
url: `/pages/redeem-success/index?id=${pendingRatingId}&from=store`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text>评价门店</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View
|
||||
className={`store-detail-bar-btn store-detail-bar-btn--primary${pendingRatingId ? '' : ' store-detail-bar-btn--full'}`}
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
<Text>到店核销</Text>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '套餐详情',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/store-detail.css';
|
||||
import '../../styles/product-detail.css';
|
||||
@@ -7,7 +7,13 @@ import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import PageNavBar from '../../components/PageNavBar';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import {
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
|
||||
type StorePackage = {
|
||||
name: string;
|
||||
@@ -46,6 +52,7 @@ function formatPriceYuan(price: string | number) {
|
||||
export default function StorePackageDetailPage() {
|
||||
const router = useRouter();
|
||||
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId));
|
||||
const [packageIndex, setPackageIndex] = useState(() => parsePackageIndex(router.params.index));
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [pkg, setPkg] = useState<StorePackage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -89,10 +96,34 @@ export default function StorePackageDetailPage() {
|
||||
const sid = pickStoreId(options?.storeId || router.params.storeId);
|
||||
const index = parsePackageIndex(options?.index ?? router.params.index);
|
||||
setStoreId(sid);
|
||||
setPackageIndex(index);
|
||||
void loadPackage(sid, index);
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
const imageUrls = useMemo(
|
||||
() => (pkg ? normalizeStorePackageImageUrls(pkg) : []),
|
||||
[pkg],
|
||||
);
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() =>
|
||||
buildSceneSharePayload('storeDetail', {
|
||||
path: `/pages/store-package-detail/index?storeId=${storeId}&index=${packageIndex}`,
|
||||
dynamicTitle: pkg
|
||||
? storeName
|
||||
? `${pkg.name} · ${storeName}`
|
||||
: pkg.name
|
||||
: undefined,
|
||||
dynamicDesc: pkg?.dishes?.trim() || undefined,
|
||||
dynamicImageUrl: imageUrls[0] || undefined,
|
||||
}),
|
||||
[imageUrls, pkg, packageIndex, storeId, storeName],
|
||||
);
|
||||
const sharePayloadRef = useRef(sharePayload);
|
||||
sharePayloadRef.current = sharePayload;
|
||||
const shareQuery = storeId ? `storeId=${storeId}&index=${packageIndex}` : '';
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length > 1) Taro.navigateBack();
|
||||
else if (storeId) {
|
||||
@@ -100,30 +131,36 @@ export default function StorePackageDetailPage() {
|
||||
} else {
|
||||
Taro.switchTab({ url: '/pages/stores/index' });
|
||||
}
|
||||
}
|
||||
}, [storeId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-package-detail-page">
|
||||
<PageNavBar title="套餐详情" solid onBack={goBack} />
|
||||
<View className="page-with-nav-bar u-empty">加载中…</View>
|
||||
</PageShell>
|
||||
);
|
||||
// 不用 useShare* Hook:Taro 编译会把它们折进 return,触发 React #310
|
||||
useEffect(() => {
|
||||
const page = Taro.getCurrentInstance().page as
|
||||
| {
|
||||
onShareAppMessage?: () => ReturnType<typeof toWeappShareMessage>;
|
||||
onShareTimeline?: () => ReturnType<typeof toWeappShareTimeline>;
|
||||
}
|
||||
| undefined;
|
||||
if (!page) return;
|
||||
page.onShareAppMessage = () => toWeappShareMessage(sharePayloadRef.current);
|
||||
page.onShareTimeline = () => toWeappShareTimeline(sharePayloadRef.current, shareQuery);
|
||||
}, [shareQuery]);
|
||||
|
||||
if (!pkg) {
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-package-detail-page">
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<PageNavBar title="套餐详情" solid onBack={goBack} />
|
||||
<View className="page-with-nav-bar u-empty">{loadError || '套餐不存在'}</View>
|
||||
<View className="page-with-nav-bar u-empty">
|
||||
{loading ? '加载中…' : loadError || '套餐不存在'}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrls = normalizeStorePackageImageUrls(pkg);
|
||||
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-package-detail-page">
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<PageNavBar title={pkg.name} solid titleVisible onBack={goBack} />
|
||||
|
||||
<View className="store-package-detail-body">
|
||||
|
||||
@@ -492,7 +492,9 @@ export default function StoresPage() {
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text className="store-card-redeem">核销{s.redeemCount ?? 0}次</Text>
|
||||
{Number(s.redeemCount) > 0 ? (
|
||||
<Text className="store-card-redeem">核销{s.redeemCount}次</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className="store-card-row store-card-row--mid">
|
||||
<Text className="store-card-address" numberOfLines={2}>
|
||||
|
||||
@@ -68,9 +68,11 @@
|
||||
| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.16 | [`企微智能机器人 API 插件`](./杜康好客-v3.5.16-开发文档.md) | 🔶 开发完成 |
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-09-03 | v3.5.16:企微 API 插件只读数据面 `GET /api/v1/wecom/plugin/*`(X-Api-Key);与长连接 Bot 独立 |
|
||||
| 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls`) |
|
||||
| 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
|
||||
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
|
||||
|
||||
@@ -32,9 +32,11 @@
|
||||
| 订单金额 | 已付 `payAmount`(`paidAt`) | 区间内支付 |
|
||||
| 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 |
|
||||
|
||||
- **日报**:当天;默认 20:00 发送;新增文案「当日新增」。
|
||||
- **周报**:上一自然周(周一~周日);默认周一 09:00。
|
||||
- **月报**:上一自然月;默认每月 1 日 09:00。
|
||||
- **日报**:发送日前一自然日(截账至发送日 0 点 / 前一天 24 点);默认 20:00 发送;新增文案「当日新增」。
|
||||
- **周报**:上一自然周(周一~周日);默认周一 09:00;期末为该周日 24 点。
|
||||
- **月报**:上一自然月;默认每月 1 日 09:00;期末为该月最后一日 24 点。
|
||||
|
||||
三种报都不含发送当天发生额。
|
||||
|
||||
错过发送时刻会在之后补发一次(按 `last_sent_period` 去重)。
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# 杜康好客 · v3.5.16 开发文档
|
||||
|
||||
> **2026-09-03** · integrations/wecom · admin-web · domain · shared-types
|
||||
> **主题**:企微智能机器人 **API 插件**只读数据面
|
||||
|
||||
---
|
||||
|
||||
## 1. 版本目标
|
||||
|
||||
企微后台「添加 API 插件」对接本系统:企微托管大模型 HTTP 调公网接口拉数。与 HQ「企微机器人」长连接 Bot **独立**,不改指令/审批/短信验身。
|
||||
|
||||
**不做**:写操作、明文手机/地址、把 `/admin/*` JWT 接口暴露给企微。
|
||||
|
||||
---
|
||||
|
||||
## 2. 与长连接 Bot 的分工
|
||||
|
||||
| | 长连接智能机器人 | API 插件 |
|
||||
|--|------------------|----------|
|
||||
| 对话 | 我们收消息并回复 | 企微自带模型组织回复 |
|
||||
| 入口 | HQ 企微机器人 + `@wecom/aibot-node-sdk` | 企微「添加 API 插件」 |
|
||||
| 鉴权 | BotID / Secret | Header `X-Api-Key` |
|
||||
| 能力 | 指令 + 工单/审批等 | 第一期只读查询 |
|
||||
|
||||
建议:客服/技术支持继续走长连接;另建一只「运营查询」机器人只挂本插件。
|
||||
|
||||
---
|
||||
|
||||
## 3. 环境变量
|
||||
|
||||
```
|
||||
WECOM_PLUGIN_ENABLED=true
|
||||
WECOM_PLUGIN_API_KEY=<随机长密钥>
|
||||
```
|
||||
|
||||
只放 `.env` / `.env.staging` / `.env.production`,不进 HQ 系统设置、不进 Git。
|
||||
|
||||
---
|
||||
|
||||
## 4. 接口
|
||||
|
||||
前缀:`/api/v1/wecom/plugin`
|
||||
鉴权:Header `X-Api-Key`(未启用 / 无 Key / 错 Key 一律 401)
|
||||
响应:`{ code, message, data }`(`GET .../openapi.json` **除外**,原样 OpenAPI 3.0)
|
||||
列表 `pageSize` 默认 5、最大 10。手机号 `maskContactPhone`。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 插件说明 |
|
||||
| GET | `/openapi.json` | 供第 2 步导入工具 |
|
||||
| GET | `/orders?q=` | 订单号 |
|
||||
| GET | `/users?q=` | 用户号或 11 位手机 |
|
||||
| GET | `/stores?q=` | 门店名 |
|
||||
| GET | `/redeems?q=` | 核销单号或门店名 |
|
||||
| GET | `/promo-codes?q=` | 推广码 / 名称 |
|
||||
| GET | `/promo-codes/:code/stats` | 推广码统计 |
|
||||
| GET | `/metrics?kind=` | `today` \| `daily` \| `weekly` \| `monthly` |
|
||||
|
||||
经营指标口径与 v3.5.15 报告一致:用户=有效未合并;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。`today` 期末为当前时刻。
|
||||
|
||||
审计:`log_wecom_bot.botKey=plugin`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 企微表单(上线后)
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 插件 URL | 生产 `https://api.dukanghaoke.com/api/v1/wecom/plugin`;测试 `https://api-test.dukanghaoke.com/api/v1/wecom/plugin` |
|
||||
| 授权 | Service token / API key |
|
||||
| 位置 | Header |
|
||||
| Parameter name | `X-Api-Key` |
|
||||
| Service token | 与 `WECOM_PLUGIN_API_KEY` 相同 |
|
||||
| 第 2 步导入 | `…/wecom/plugin/openapi.json`(同一把 Key) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 联调清单(staging curl)
|
||||
|
||||
先在 `api-test` 的 `.env.staging` 打开开关并写入 Key,重启 `dukang-api`。
|
||||
|
||||
```bash
|
||||
BASE=https://api-test.dukanghaoke.com/api/v1/wecom/plugin
|
||||
KEY='<WECOM_PLUGIN_API_KEY>'
|
||||
|
||||
# 无 Key → 401
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' "$BASE/metrics"
|
||||
|
||||
# 错 Key → 401
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -H "X-Api-Key: wrong" "$BASE/metrics"
|
||||
|
||||
# 经营指标 / OpenAPI / 订单 / 门店 / 推广码
|
||||
curl -sS -H "X-Api-Key: $KEY" "$BASE/metrics?kind=today"
|
||||
curl -sS -H "X-Api-Key: $KEY" "$BASE/openapi.json" | head -c 200
|
||||
curl -sS -H "X-Api-Key: $KEY" "$BASE/orders?q=DK"
|
||||
curl -sS -H "X-Api-Key: $KEY" "$BASE/stores?q=店"
|
||||
curl -sS -H "X-Api-Key: $KEY" "$BASE/promo-codes?q=DK"
|
||||
```
|
||||
|
||||
企微:导入 OpenAPI → 白名单会话用自然语言问订单/门店 → HQ「企微机器人 → 日志」出现 `plugin`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 变更面
|
||||
|
||||
| 层 | 路径 |
|
||||
|----|------|
|
||||
| domain | `wecom-plugin.ts` |
|
||||
| shared-types | `wecom-plugin.ts` |
|
||||
| API | `integrations/wecom/wecom-plugin.*`;`WecomModule` 注册 Controller |
|
||||
| HQ | `WecomBotsPage.tsx` 插件 URL / Header 提示(不展示 Key) |
|
||||
| env | `.env*.example`:`WECOM_PLUGIN_ENABLED` / `WECOM_PLUGIN_API_KEY` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收
|
||||
|
||||
- [ ] 无 Key / 错 Key → 401
|
||||
- [ ] curl 带 Key 查订单 / 门店 / 推广码 / 今日指标,`{ code:0, data }` 且手机脱敏
|
||||
- [ ] `openapi.json` 为 OpenAPI 文档(无信封)
|
||||
- [ ] 企微第 2 步可导入工具;白名单会话能问到真实数据
|
||||
- [ ] HQ 企微机器人日志可见 `plugin`
|
||||
- [ ] 现有长连接 Bot 行为不变
|
||||
+3
-1
@@ -46,7 +46,9 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
||||
|
||||
**HQ 概览(v4.0.14 / v4.0.15)**:`GET /admin/dashboard/analytics` 支持日/周/月/季/年分桶;默认窗口为上一档起点~今天。全局筛城市+时间;日期快捷上周/上月/上季度;总量/增量单选分开展示。改粒度不改日期。查询右侧可下载当前折线图 PDF(不含 KPI/待办)。v4.0.15 起为多张全宽折线图:总量=桶末日存量,增量=桶内新增;用户线=推广码/关联合伙人/活动,门店线=关联合伙人,订单线=用户/关联合伙人/商品,核销线=门店/关联合伙人,合伙人单线。订单与核销同时出笔数和金额。关联合伙人=`assoc_partner_account_id`;活动=关联合伙人当前活动图。「查看」只带全局城市与日期。无权限模块后端不算不返回。时间按北京日历。
|
||||
|
||||
**HQ 企微报告(v3.5.15)**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。日报=当天存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。
|
||||
**HQ 企微报告(v3.5.15)**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。
|
||||
|
||||
**企微 API 插件(v3.5.16)**:`GET /api/v1/wecom/plugin/*`,Header `X-Api-Key`;只读订单/用户/门店/核销/推广码/经营指标。与长连接 Bot 独立。OpenAPI:`GET /api/v1/wecom/plugin/openapi.json`。
|
||||
|
||||
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| 版本线 | **v4.0.15** HQ 概览折线图(粒度分桶、总量/增量、维度线条) |
|
||||
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||
| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||
|
||||
## 1. 版本交付
|
||||
|
||||
@@ -36,4 +36,4 @@
|
||||
| 2026-09-02 | v4.0.9:子账号默认启用;关联码已扫码;零元账单不同步;银行账号自填;周账周一 08:00;预付款预估;子账号用户管理无活动图 |
|
||||
| 2026-09-02 | v4.0.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
|
||||
| 2026-09-02 | v4.0.14:HQ 概览日/周/月/季/年、环比;全局城市/时间 + 五板块筛;订单/核销笔数与金额 |
|
||||
| 2026-09-02 | v4.0.15:HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期 |
|
||||
| 2026-09-02 | v4.0.15:HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期;活动图上传超限自动压缩并提示尺寸 |
|
||||
|
||||
@@ -77,3 +77,38 @@
|
||||
- [ ] 客服无合伙人/门店/核销图
|
||||
- [ ] 待办卡与超管发版区不变
|
||||
- [ ] domain 折线单测通过
|
||||
|
||||
---
|
||||
|
||||
## 6. 活动图上传压缩(DPT-20260902-849)
|
||||
|
||||
### 6.1 目标
|
||||
|
||||
HQ 上传活动底图时,后端用 Sharp 自动等比缩放至 v4.0.7 底图限制(最长边 ≤ 2500px、宽×高 ≤ 400 万),并在响应与表单中提示尺寸。
|
||||
|
||||
### 6.2 规则
|
||||
|
||||
- 入口:`POST /common/resources/upload`,`bizType=ACTIVITY_POSTER`、`mediaType=IMAGE`
|
||||
- 尺寸合规:原样存 OSS,响应 `image.compressed=false`
|
||||
- 尺寸超限:Sharp 等比缩放后存 OSS,响应含 `width/height/originalWidth/originalHeight/compressed`
|
||||
- 有 alpha 通道保留 PNG;否则按原 mime 或 JPEG quality 85 输出
|
||||
- 压缩后仍超过 `OSS_MAX_UPLOAD_BYTES` → 4xx,文案含当前尺寸
|
||||
- **手动粘贴 URL** 仍不经上传压缩;合成时沿用 v4.0.7 校验
|
||||
|
||||
### 6.3 变更面
|
||||
|
||||
| 层 | 路径 |
|
||||
|----|------|
|
||||
| shared-types | `activity-poster.ts`:`activityPosterResizeTarget`、`OssUploadImageMeta`、`ACTIVITY_POSTER_UPLOAD_HINT` |
|
||||
| API | `common/image/activity-poster-upload.util.ts`、`resource.service.ts` |
|
||||
| HQ | `OssUpload.tsx`、`ActivityPostersPage.tsx`、`lib/upload.ts` |
|
||||
|
||||
无 Prisma 迁移。
|
||||
|
||||
### 6.4 验收
|
||||
|
||||
- [ ] 上传 1080×1920:成功,提示尺寸,未标记压缩
|
||||
- [ ] 上传 4000×3000:成功,提示压缩后尺寸与原尺寸
|
||||
- [ ] 保存后「为该合伙人下载」/ zip 导出不再报底图过大
|
||||
- [ ] 非图片 / 损坏文件:明确 4xx
|
||||
- [ ] AVATAR 等其他 bizType 行为不变
|
||||
|
||||
@@ -150,6 +150,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑
|
||||
| 能力 | 入口 |
|
||||
|------|------|
|
||||
| 智能机器人 | `/wecom/bots` 长连接指令 |
|
||||
| API 插件 | `/api/v1/wecom/plugin` Header `X-Api-Key` 只读查询(与长连接独立) |
|
||||
| 消息推送 | `/wecom/pushes` Webhook+eventKey |
|
||||
| 日志 | `/logs/wecom-bots` |
|
||||
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
|
||||
|
||||
@@ -413,4 +413,5 @@ export * from './shanghai-date';
|
||||
export * from './dashboard-period';
|
||||
export * from './dashboard-series';
|
||||
export * from './wecom-report';
|
||||
export * from './wecom-plugin';
|
||||
export * from './shipping-address';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { maskContactPhone } from './phone';
|
||||
import {
|
||||
clampWecomPluginPageSize,
|
||||
isWecomPluginEnabled,
|
||||
parseWecomPluginMetricsKind,
|
||||
toWecomPluginUserView,
|
||||
verifyWecomPluginApiKey,
|
||||
wecomPluginMetricsPeriod,
|
||||
} from './wecom-plugin';
|
||||
|
||||
describe('verifyWecomPluginApiKey', () => {
|
||||
it('accepts an exact match', () => {
|
||||
expect(verifyWecomPluginApiKey('secret-token', 'secret-token')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong, empty, or missing keys', () => {
|
||||
expect(verifyWecomPluginApiKey('secret-token', 'other-token')).toBe(false);
|
||||
expect(verifyWecomPluginApiKey('secret-token', 'secret-toke')).toBe(false);
|
||||
expect(verifyWecomPluginApiKey('', 'secret-token')).toBe(false);
|
||||
expect(verifyWecomPluginApiKey('secret-token', '')).toBe(false);
|
||||
expect(verifyWecomPluginApiKey('secret-token', null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWecomPluginEnabled', () => {
|
||||
it('requires both the switch and a non-empty key', () => {
|
||||
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: 'k' })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'true', WECOM_PLUGIN_API_KEY: '' })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isWecomPluginEnabled({ WECOM_PLUGIN_ENABLED: 'false', WECOM_PLUGIN_API_KEY: 'k' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampWecomPluginPageSize / parseWecomPluginMetricsKind', () => {
|
||||
it('defaults and caps pageSize at 10', () => {
|
||||
expect(clampWecomPluginPageSize(undefined)).toBe(5);
|
||||
expect(clampWecomPluginPageSize('3')).toBe(3);
|
||||
expect(clampWecomPluginPageSize(99)).toBe(10);
|
||||
expect(clampWecomPluginPageSize(0)).toBe(5);
|
||||
});
|
||||
|
||||
it('parses metrics kind, defaulting empty to today', () => {
|
||||
expect(parseWecomPluginMetricsKind(undefined)).toBe('today');
|
||||
expect(parseWecomPluginMetricsKind('weekly')).toBe('weekly');
|
||||
expect(parseWecomPluginMetricsKind('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toWecomPluginUserView', () => {
|
||||
it('masks phone and fills empty nickname', () => {
|
||||
expect(toWecomPluginUserView({ userNo: 'DK1', nickname: null, phone: '13800138000' })).toEqual({
|
||||
userNo: 'DK1',
|
||||
nickname: '—',
|
||||
phone: '138****8000',
|
||||
});
|
||||
expect(maskContactPhone('13800138000')).toBe('138****8000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wecomPluginMetricsPeriod', () => {
|
||||
it('today starts at Shanghai midnight and ends at now', () => {
|
||||
const now = new Date('2026-09-03T21:15:00+08:00');
|
||||
const p = wecomPluginMetricsPeriod('today', now);
|
||||
expect(p.kind).toBe('today');
|
||||
expect(p.periodKey).toBe('2026-09-03');
|
||||
expect(p.start.toISOString()).toBe(new Date('2026-09-03T00:00:00+08:00').toISOString());
|
||||
expect(p.endExclusive.getTime()).toBe(now.getTime());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { maskContactPhone } from './phone';
|
||||
import { shanghaiYmd, startOfShanghaiDay } from './shanghai-date';
|
||||
import { wecomReportPeriod, type WecomReportKind, type WecomReportPeriod } from './wecom-report';
|
||||
|
||||
export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5;
|
||||
export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10;
|
||||
|
||||
export const WECOM_PLUGIN_METRICS_KINDS = ['today', 'daily', 'weekly', 'monthly'] as const;
|
||||
export type WecomPluginMetricsKind = (typeof WECOM_PLUGIN_METRICS_KINDS)[number];
|
||||
|
||||
export type WecomPluginMetricsPeriod = Omit<WecomReportPeriod, 'kind'> & {
|
||||
kind: WecomPluginMetricsKind;
|
||||
};
|
||||
|
||||
export function isWecomPluginMetricsKind(v: string): v is WecomPluginMetricsKind {
|
||||
return (WECOM_PLUGIN_METRICS_KINDS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
export function parseWecomPluginMetricsKind(raw?: string | null): WecomPluginMetricsKind | null {
|
||||
const v = String(raw ?? '').trim().toLowerCase();
|
||||
if (!v) return 'today';
|
||||
return isWecomPluginMetricsKind(v) ? v : null;
|
||||
}
|
||||
|
||||
export function clampWecomPluginPageSize(raw?: string | number | null): number {
|
||||
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return WECOM_PLUGIN_PAGE_SIZE_DEFAULT;
|
||||
return Math.min(WECOM_PLUGIN_PAGE_SIZE_MAX, Math.max(1, Math.floor(n)));
|
||||
}
|
||||
|
||||
export function clampWecomPluginPage(raw?: string | number | null): number {
|
||||
const n = typeof raw === 'number' ? raw : Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return 1;
|
||||
return Math.min(100, Math.floor(n));
|
||||
}
|
||||
|
||||
/** 长度必须相同后再逐字符 XOR,避免短密码走快速失败路径时的明显差异(仍非密码学级) */
|
||||
export function verifyWecomPluginApiKey(provided: string, expected?: string | null): boolean {
|
||||
const exp = String(expected ?? '');
|
||||
const got = String(provided ?? '');
|
||||
if (!exp || !got || exp.length !== got.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < exp.length; i++) {
|
||||
diff |= exp.charCodeAt(i) ^ got.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export function isWecomPluginEnabled(env: {
|
||||
WECOM_PLUGIN_ENABLED?: string;
|
||||
WECOM_PLUGIN_API_KEY?: string;
|
||||
}): boolean {
|
||||
return env.WECOM_PLUGIN_ENABLED === 'true' && Boolean(String(env.WECOM_PLUGIN_API_KEY ?? '').trim());
|
||||
}
|
||||
|
||||
export function wecomPluginMetricsPeriod(
|
||||
kind: WecomPluginMetricsKind,
|
||||
now = new Date(),
|
||||
): WecomPluginMetricsPeriod {
|
||||
if (kind === 'today') {
|
||||
const start = startOfShanghaiDay(now);
|
||||
const ymd = shanghaiYmd(start);
|
||||
return {
|
||||
kind,
|
||||
start,
|
||||
endExclusive: now,
|
||||
periodKey: ymd,
|
||||
title: `今日(${ymd})`,
|
||||
rangeLabel: ymd,
|
||||
incrementLabel: '今日新增',
|
||||
};
|
||||
}
|
||||
const period = wecomReportPeriod(kind as WecomReportKind, now);
|
||||
return { ...period, kind };
|
||||
}
|
||||
|
||||
export function toWecomPluginUserView(user: {
|
||||
userNo: string;
|
||||
nickname?: string | null;
|
||||
phone?: string | null;
|
||||
}): { userNo: string; nickname: string; phone: string } {
|
||||
return {
|
||||
userNo: user.userNo,
|
||||
nickname: user.nickname?.trim() || '—',
|
||||
phone: maskContactPhone(user.phone),
|
||||
};
|
||||
}
|
||||
@@ -25,11 +25,12 @@ const emptyStats = {
|
||||
};
|
||||
|
||||
describe('wecomReportPeriod', () => {
|
||||
it('daily is the Shanghai calendar day', () => {
|
||||
it('daily is the previous Shanghai calendar day (closed at send-day 00:00)', () => {
|
||||
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
|
||||
expect(p.periodKey).toBe('2026-09-02');
|
||||
expect(p.periodKey).toBe('2026-09-01');
|
||||
expect(p.incrementLabel).toBe('当日新增');
|
||||
expect(p.start.toISOString()).toBe(new Date('2026-09-02T00:00:00+08:00').toISOString());
|
||||
expect(p.start.toISOString()).toBe(new Date('2026-09-01T00:00:00+08:00').toISOString());
|
||||
expect(p.endExclusive.toISOString()).toBe(new Date('2026-09-02T00:00:00+08:00').toISOString());
|
||||
});
|
||||
|
||||
it('weekly is the previous natural week', () => {
|
||||
@@ -37,26 +38,28 @@ describe('wecomReportPeriod', () => {
|
||||
expect(p.periodKey).toBe('2026-08-24');
|
||||
expect(p.rangeLabel).toBe('2026-08-24 ~ 2026-08-30');
|
||||
expect(p.incrementLabel).toBe('本期新增');
|
||||
expect(p.endExclusive.toISOString()).toBe(new Date('2026-08-31T00:00:00+08:00').toISOString());
|
||||
});
|
||||
|
||||
it('monthly is the previous natural month', () => {
|
||||
const p = wecomReportPeriod('monthly', new Date('2026-09-01T09:00:00+08:00'));
|
||||
expect(p.periodKey).toBe('2026-08');
|
||||
expect(p.title).toBe('月报(2026年8月)');
|
||||
expect(p.endExclusive.toISOString()).toBe(new Date('2026-09-01T00:00:00+08:00').toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('wecomReportCutoff', () => {
|
||||
it('caps an in-progress daily period at now', () => {
|
||||
it('daily closes at send-day midnight, not send clock time', () => {
|
||||
const now = new Date('2026-09-02T20:00:00+08:00');
|
||||
const p = wecomReportPeriod('daily', now);
|
||||
expect(wecomReportCutoff(p, now).getTime()).toBe(now.getTime());
|
||||
expect(wecomReportCutoff(p).getTime()).toBe(new Date('2026-09-02T00:00:00+08:00').getTime());
|
||||
});
|
||||
|
||||
it('uses period end for a completed week', () => {
|
||||
const now = new Date('2026-09-02T09:00:00+08:00');
|
||||
const p = wecomReportPeriod('weekly', now);
|
||||
expect(wecomReportCutoff(p, now).getTime()).toBe(p.endExclusive.getTime());
|
||||
expect(wecomReportCutoff(p).getTime()).toBe(p.endExclusive.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +89,7 @@ describe('wecomReportShouldFire', () => {
|
||||
expect(
|
||||
wecomReportShouldFire(
|
||||
'daily',
|
||||
{ ...base, lastSentPeriod: '2026-09-02' },
|
||||
{ ...base, lastSentPeriod: '2026-09-01' },
|
||||
new Date('2026-09-02T21:00:00+08:00'),
|
||||
),
|
||||
).toBe(false);
|
||||
@@ -125,7 +128,7 @@ describe('formatWecomReportMarkdown', () => {
|
||||
it('renders stock plus increment lines', () => {
|
||||
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
|
||||
const md = formatWecomReportMarkdown(p, emptyStats);
|
||||
expect(md).toContain('**杜康好客 · 日报(2026-09-02)**');
|
||||
expect(md).toContain('**杜康好客 · 日报(2026-09-01)**');
|
||||
expect(md).toContain('用户数量:10(当日新增 2)');
|
||||
expect(md).toContain('订单金额:1000.00(当日新增 80.50)');
|
||||
expect(md).toContain('核销单数量:8(当日新增 3)');
|
||||
|
||||
@@ -67,15 +67,21 @@ function lastDayOfShanghaiMonth(year: number, month: number): number {
|
||||
return Number(shanghaiYmd(new Date(shanghaiMonthRange(year, month).endExclusive.getTime() - 1)).slice(8, 10));
|
||||
}
|
||||
|
||||
/** 日报=当天;周报=上一自然周;月报=上一自然月(北京日历) */
|
||||
/** 发送日北京 0 点 = 前一天 24 点,账期不含发送当天 */
|
||||
export function wecomReportAsOf(now = new Date()): Date {
|
||||
return startOfShanghaiDay(now);
|
||||
}
|
||||
|
||||
/** 日报=昨日;周报=上一自然周;月报=上一自然月。期末均为某日 0 点。 */
|
||||
export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): WecomReportPeriod {
|
||||
const asOf = wecomReportAsOf(now);
|
||||
if (kind === 'daily') {
|
||||
const start = startOfShanghaiDay(now);
|
||||
const start = addShanghaiDays(asOf, -1);
|
||||
const ymd = shanghaiYmd(start);
|
||||
return {
|
||||
kind,
|
||||
start,
|
||||
endExclusive: addShanghaiDays(start, 1),
|
||||
endExclusive: asOf,
|
||||
periodKey: ymd,
|
||||
title: `日报(${ymd})`,
|
||||
rangeLabel: ymd,
|
||||
@@ -83,7 +89,7 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
|
||||
};
|
||||
}
|
||||
if (kind === 'weekly') {
|
||||
const { start, endExclusive } = previousShanghaiWeek(now);
|
||||
const { start, endExclusive } = previousShanghaiWeek(asOf);
|
||||
const from = shanghaiYmd(start);
|
||||
const to = shanghaiYmd(new Date(endExclusive.getTime() - 1));
|
||||
return {
|
||||
@@ -96,7 +102,7 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
|
||||
incrementLabel: '本期新增',
|
||||
};
|
||||
}
|
||||
const { year, month } = previousShanghaiMonth(now);
|
||||
const { year, month } = previousShanghaiMonth(asOf);
|
||||
const { start, endExclusive } = shanghaiMonthRange(year, month);
|
||||
return {
|
||||
kind,
|
||||
@@ -109,9 +115,9 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
|
||||
};
|
||||
}
|
||||
|
||||
/** 进行中的周期截到 now,已结束的周期用期末 */
|
||||
export function wecomReportCutoff(period: WecomReportPeriod, now = new Date()): Date {
|
||||
return now.getTime() < period.endExclusive.getTime() ? now : period.endExclusive;
|
||||
/** 账期期末:发送日 0 点或上一周/月结束 0 点,不含发送当天发生额 */
|
||||
export function wecomReportCutoff(period: WecomReportPeriod): Date {
|
||||
return period.endExclusive;
|
||||
}
|
||||
|
||||
export function wecomReportDueAt(kind: WecomReportKind, schedule: WecomReportSchedule, now = new Date()): Date {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
activityPosterPackFileName,
|
||||
activityPosterQrSlotPx,
|
||||
activityPosterResizeTarget,
|
||||
activityPosterTemplateTooLarge,
|
||||
} from './activity-poster';
|
||||
|
||||
@@ -37,6 +38,30 @@ describe('activityPosterTemplateTooLarge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('activityPosterResizeTarget', () => {
|
||||
it('returns null for 1080x1920', () => {
|
||||
expect(activityPosterResizeTarget(1080, 1920)).toBeNull();
|
||||
});
|
||||
|
||||
it('scales edge over 2500', () => {
|
||||
const target = activityPosterResizeTarget(4000, 3000);
|
||||
expect(target).not.toBeNull();
|
||||
expect(Math.max(target!.width, target!.height)).toBeLessThanOrEqual(2500);
|
||||
expect(activityPosterTemplateTooLarge(target!.width, target!.height)).toBe(false);
|
||||
});
|
||||
|
||||
it('scales pixel count over 4e6', () => {
|
||||
const target = activityPosterResizeTarget(2500, 2000);
|
||||
expect(target).not.toBeNull();
|
||||
expect(target!.width * target!.height).toBeLessThanOrEqual(4_000_000);
|
||||
expect(activityPosterTemplateTooLarge(target!.width, target!.height)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not upscale small images', () => {
|
||||
expect(activityPosterResizeTarget(100, 100)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('activityPosterPackFileName', () => {
|
||||
it('uses city, company, id', () => {
|
||||
expect(activityPosterPackFileName({
|
||||
|
||||
@@ -72,6 +72,39 @@ export function activityPosterTemplateTooLarge(width: number, height: number): b
|
||||
return w > ACTIVITY_POSTER_MAX_EDGE || h > ACTIVITY_POSTER_MAX_EDGE || w * h > ACTIVITY_POSTER_MAX_PIXELS;
|
||||
}
|
||||
|
||||
/** HQ 上传活动图时的尺寸说明(前后端共用文案) */
|
||||
export const ACTIVITY_POSTER_UPLOAD_HINT =
|
||||
'底图最长边不超过 2500px,总像素不超过 400 万;超出将自动压缩';
|
||||
|
||||
export type OssUploadImageMeta = {
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth?: number;
|
||||
originalHeight?: number;
|
||||
compressed: boolean;
|
||||
fileSize: number;
|
||||
};
|
||||
|
||||
/** 若底图超限,返回等比缩放后的目标宽高;否则 null(不放大) */
|
||||
export function activityPosterResizeTarget(
|
||||
width: number,
|
||||
height: number,
|
||||
): { width: number; height: number } | null {
|
||||
const w = Math.max(1, Math.round(width));
|
||||
const h = Math.max(1, Math.round(height));
|
||||
if (!activityPosterTemplateTooLarge(w, h)) return null;
|
||||
|
||||
const maxEdge = Math.max(w, h);
|
||||
const scaleEdge = maxEdge > ACTIVITY_POSTER_MAX_EDGE ? ACTIVITY_POSTER_MAX_EDGE / maxEdge : 1;
|
||||
const scalePixels = w * h > ACTIVITY_POSTER_MAX_PIXELS ? Math.sqrt(ACTIVITY_POSTER_MAX_PIXELS / (w * h)) : 1;
|
||||
const scale = Math.min(scaleEdge, scalePixels);
|
||||
|
||||
return {
|
||||
width: Math.max(1, Math.round(w * scale)),
|
||||
height: Math.max(1, Math.round(h * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
/** zip / 单张下载文件名:`{城市}_{公司或姓名}_{id}.png` */
|
||||
export function activityPosterPackFileName(input: {
|
||||
cityName?: string | null;
|
||||
|
||||
@@ -29,6 +29,7 @@ export * from './city-warehouse';
|
||||
export * from './fulfillment-provider';
|
||||
export * from './system-config';
|
||||
export * from './wecom-bot';
|
||||
export * from './wecom-plugin';
|
||||
export * from './wecom-message-push';
|
||||
export * from './wecom-report';
|
||||
export * from './llm-config';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
WECOM_PLUGIN_API_KEY_HEADER,
|
||||
WECOM_PLUGIN_BASE_PATH,
|
||||
resolveWecomPluginPublicUrl,
|
||||
} from './wecom-plugin';
|
||||
|
||||
describe('resolveWecomPluginPublicUrl', () => {
|
||||
it('maps HQ production host to api.dukanghaoke.com', () => {
|
||||
expect(resolveWecomPluginPublicUrl('admin.dukanghaoke.com')).toBe(
|
||||
`https://api.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps staging hosts to api-test', () => {
|
||||
expect(resolveWecomPluginPublicUrl('admin-test.dukanghaoke.com')).toBe(
|
||||
`https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`,
|
||||
);
|
||||
expect(resolveWecomPluginPublicUrl('api-test.dukanghaoke.com')).toBe(
|
||||
`https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to local API for unknown hosts', () => {
|
||||
expect(resolveWecomPluginPublicUrl('localhost')).toBe(
|
||||
`http://localhost:3010${WECOM_PLUGIN_BASE_PATH}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses X-Api-Key as the plugin auth header', () => {
|
||||
expect(WECOM_PLUGIN_API_KEY_HEADER).toBe('X-Api-Key');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/** 企微智能机器人 API 插件(只读数据面,与长连接 Bot 独立) */
|
||||
|
||||
export const WECOM_PLUGIN_API_KEY_HEADER = 'X-Api-Key';
|
||||
|
||||
export const WECOM_PLUGIN_BASE_PATH = '/api/v1/wecom/plugin';
|
||||
|
||||
export const WECOM_PLUGIN_PAGE_SIZE_DEFAULT = 5;
|
||||
|
||||
export const WECOM_PLUGIN_PAGE_SIZE_MAX = 10;
|
||||
|
||||
export const WECOM_PLUGIN_METRICS_KINDS = ['today', 'daily', 'weekly', 'monthly'] as const;
|
||||
|
||||
export type WecomPluginMetricsKind = (typeof WECOM_PLUGIN_METRICS_KINDS)[number];
|
||||
|
||||
/** 按 HQ 当前域名推断插件公网 Base URL(不含密钥) */
|
||||
export function resolveWecomPluginPublicUrl(hostname: string): string {
|
||||
const host = String(hostname || '').toLowerCase();
|
||||
if (host === 'admin.dukanghaoke.com' || host === 'api.dukanghaoke.com') {
|
||||
return `https://api.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`;
|
||||
}
|
||||
if (host.includes('dukanghaoke.com') && host.includes('test')) {
|
||||
return `https://api-test.dukanghaoke.com${WECOM_PLUGIN_BASE_PATH}`;
|
||||
}
|
||||
return `http://localhost:3010${WECOM_PLUGIN_BASE_PATH}`;
|
||||
}
|
||||
@@ -67,6 +67,10 @@ WX_MINI_MSG_AES_KEY=
|
||||
# 企业微信智能机器人总开关(Bot 实例在 HQ「企微机器人」模块创建)
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
|
||||
# 企微「API 插件」只读数据面(与长连接 Bot 独立;密钥勿提交)
|
||||
WECOM_PLUGIN_ENABLED=false
|
||||
# WECOM_PLUGIN_API_KEY=
|
||||
|
||||
# 运营告警 Webhook(已废弃运行时读取,仅 seed 一次性导入到 HQ「消息推送」)
|
||||
# 配置后执行 pnpm prisma:seed-wecom-push 或 API 启动时自动 ensureDefaults
|
||||
# WECOM_ALERT_ENABLED=false
|
||||
|
||||
@@ -54,6 +54,10 @@ WX_MINI_MSG_AES_KEY=
|
||||
# 企业微信机器人总开关(实例在 HQ 企微机器人模块维护)
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
|
||||
# 企微 API 插件只读数据面(与长连接 Bot 独立)
|
||||
WECOM_PLUGIN_ENABLED=false
|
||||
# WECOM_PLUGIN_API_KEY=
|
||||
|
||||
# 运营告警:企业微信群机器人 Webhook
|
||||
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
|
||||
# WECOM_ALERT_ENABLED=false
|
||||
|
||||
@@ -54,6 +54,10 @@ WX_MINI_MSG_AES_KEY=
|
||||
|
||||
WECOM_AIBOT_ENABLED=false
|
||||
|
||||
# 企微 API 插件(测试环境单独一把 Key)
|
||||
WECOM_PLUGIN_ENABLED=false
|
||||
# WECOM_PLUGIN_API_KEY=
|
||||
|
||||
# 运营告警:企业微信群机器人 Webhook
|
||||
# 运营告警 Webhook(已废弃运行时读取,仅 seed 导入 HQ「消息推送」)
|
||||
# WECOM_ALERT_ENABLED=false
|
||||
|
||||
@@ -34,12 +34,15 @@ async function main() {
|
||||
['monthly', '经营月报', 9],
|
||||
];
|
||||
for (const [kind, name, hour] of seeds) {
|
||||
await prisma.$executeRaw`
|
||||
INSERT IGNORE INTO wecom_report_push
|
||||
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day)
|
||||
VALUES
|
||||
(${kind}, ${name}, ${PLACEHOLDER}, 0, ${hour}, 0, 1, 1)
|
||||
`;
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT IGNORE INTO wecom_report_push
|
||||
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 0, ?, 0, 1, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))`,
|
||||
kind,
|
||||
name,
|
||||
PLACEHOLDER,
|
||||
hour,
|
||||
);
|
||||
}
|
||||
console.log('migrate-wecom-report done');
|
||||
}
|
||||
|
||||
@@ -561,7 +561,7 @@ model WecomReportPush {
|
||||
lastSentPeriod String? @map("last_sent_period") @db.VarChar(16)
|
||||
lastSentAt DateTime? @map("last_sent_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
@@map("wecom_report_push")
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const fixed = {
|
||||
WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay',
|
||||
OSS_UPLOAD_PREFIX: 'staging/uploads',
|
||||
WECOM_AIBOT_ENABLED: 'false',
|
||||
WECOM_PLUGIN_ENABLED: 'false',
|
||||
};
|
||||
|
||||
const preferFromProd = [
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
activityPosterResizeTarget,
|
||||
activityPosterTemplateTooLarge,
|
||||
} from '@dukang/shared-types';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const JPEG_QUALITY = 85;
|
||||
|
||||
export type ActivityPosterUploadPrepared = {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
compressed: boolean;
|
||||
};
|
||||
|
||||
function resolveOutputMime(hasAlpha: boolean, inputMime?: string): string {
|
||||
if (hasAlpha) return 'image/png';
|
||||
if (inputMime === 'image/webp') return 'image/jpeg';
|
||||
if (inputMime?.startsWith('image/')) return inputMime;
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
async function encodeImage(
|
||||
pipeline: sharp.Sharp,
|
||||
mimeType: string,
|
||||
): Promise<Buffer> {
|
||||
if (mimeType === 'image/png') {
|
||||
return pipeline.png().toBuffer();
|
||||
}
|
||||
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
|
||||
return pipeline.jpeg({ quality: JPEG_QUALITY }).toBuffer();
|
||||
}
|
||||
if (mimeType === 'image/webp') {
|
||||
return pipeline.webp({ quality: JPEG_QUALITY }).toBuffer();
|
||||
}
|
||||
return pipeline.jpeg({ quality: JPEG_QUALITY }).toBuffer();
|
||||
}
|
||||
|
||||
export async function prepareActivityPosterUpload(input: {
|
||||
buffer: Buffer;
|
||||
mimeType?: string;
|
||||
}): Promise<ActivityPosterUploadPrepared> {
|
||||
if (!input.buffer?.length) {
|
||||
throw new BadRequestException('活动图文件为空');
|
||||
}
|
||||
if (input.mimeType && !input.mimeType.startsWith('image/')) {
|
||||
throw new BadRequestException('活动图仅支持图片文件');
|
||||
}
|
||||
|
||||
const meta = await sharp(input.buffer, { failOn: 'none' }).metadata();
|
||||
if (!meta.width || !meta.height) {
|
||||
throw new BadRequestException('活动图无法读取尺寸,请换一张图片');
|
||||
}
|
||||
|
||||
const originalWidth = meta.width;
|
||||
const originalHeight = meta.height;
|
||||
const resizeTarget = activityPosterResizeTarget(originalWidth, originalHeight);
|
||||
|
||||
if (!resizeTarget) {
|
||||
return {
|
||||
buffer: input.buffer,
|
||||
mimeType: input.mimeType || 'image/jpeg',
|
||||
width: originalWidth,
|
||||
height: originalHeight,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
compressed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const hasAlpha = meta.hasAlpha === true;
|
||||
const outputMime = resolveOutputMime(hasAlpha, input.mimeType);
|
||||
const buffer = await encodeImage(
|
||||
sharp(input.buffer, { failOn: 'none' }).resize(resizeTarget.width, resizeTarget.height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
}),
|
||||
outputMime,
|
||||
);
|
||||
const metaOut = await sharp(buffer).metadata();
|
||||
const width = metaOut.width ?? resizeTarget.width;
|
||||
const height = metaOut.height ?? resizeTarget.height;
|
||||
|
||||
if (activityPosterTemplateTooLarge(width, height)) {
|
||||
throw new BadRequestException('活动图压缩后仍超出尺寸限制,请换一张较小的图片');
|
||||
}
|
||||
|
||||
return {
|
||||
buffer,
|
||||
mimeType: outputMime,
|
||||
width,
|
||||
height,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
compressed: true,
|
||||
};
|
||||
}
|
||||
@@ -6,10 +6,19 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
function skipResponseWrap(url?: string): boolean {
|
||||
const path = (url || '').split('?')[0];
|
||||
return path.endsWith('/wecom/plugin/openapi.json');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest<{ originalUrl?: string; url?: string }>();
|
||||
const res = context.switchToHttp().getResponse<{ headersSent?: boolean }>();
|
||||
if (skipResponseWrap(req.originalUrl || req.url)) {
|
||||
return next.handle();
|
||||
}
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
if (res.headersSent) return data;
|
||||
@@ -22,3 +31,4 @@ export class ResponseInterceptor implements NestInterceptor {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
clampWecomPluginPage,
|
||||
clampWecomPluginPageSize,
|
||||
maskContactPhone,
|
||||
MOBILE_PHONE_RE,
|
||||
parseWecomPluginMetricsKind,
|
||||
toWecomPluginUserView,
|
||||
wecomPluginMetricsPeriod,
|
||||
type WecomReportStats,
|
||||
} from '@dukang/domain';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { PromoCodeService } from '../../modules/promo/promo-code.service';
|
||||
import { WecomBotAuditService } from './wecom-bot-audit.service';
|
||||
import { WECOM_PLUGIN_AUDIT_BOT } from './wecom-plugin.constants';
|
||||
|
||||
function asNumber(v: Prisma.Decimal | number | null | undefined): number {
|
||||
if (v == null) return 0;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
|
||||
return Number(v);
|
||||
}
|
||||
|
||||
function requireQuery(q?: string): string {
|
||||
const v = String(q ?? '').trim();
|
||||
if (!v) throw new BadRequestException('请提供查询关键词 q');
|
||||
return v;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WecomPluginQueryService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly promo: PromoCodeService,
|
||||
private readonly audit: WecomBotAuditService,
|
||||
) {}
|
||||
|
||||
info() {
|
||||
return {
|
||||
name: '杜康好客运营查询',
|
||||
description: '企微智能机器人只读 API 插件。查询订单、用户、门店、核销、推广码与经营指标。',
|
||||
auth: { header: 'X-Api-Key' },
|
||||
tools: ['orders', 'users', 'stores', 'redeems', 'promo-codes', 'metrics'],
|
||||
};
|
||||
}
|
||||
|
||||
queryOrders(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
||||
const keyword = requireQuery(q);
|
||||
const take = clampWecomPluginPageSize(pageSize);
|
||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.order.read',
|
||||
permission: 'order.read',
|
||||
inputSummary: keyword,
|
||||
},
|
||||
async () => {
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where: { orderNo: { contains: keyword } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
user: { select: { userNo: true, nickname: true, phone: true } },
|
||||
delivery: { select: { trackingNo: true, provider: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where: { orderNo: { contains: keyword } } }),
|
||||
]);
|
||||
return {
|
||||
total,
|
||||
items: items.map((o) => ({
|
||||
orderNo: o.orderNo,
|
||||
status: o.status,
|
||||
payStatus: o.payStatus,
|
||||
deliveryType: o.deliveryType,
|
||||
productName: o.productName,
|
||||
quantity: o.quantity,
|
||||
payAmount: asNumber(o.payAmount),
|
||||
user: toWecomPluginUserView({
|
||||
userNo: o.user?.userNo || '—',
|
||||
nickname: o.user?.nickname,
|
||||
phone: o.user?.phone,
|
||||
}),
|
||||
receiverName: o.receiverName,
|
||||
receiverPhone: maskContactPhone(o.receiverPhone),
|
||||
receiverCity: o.receiverCity,
|
||||
trackingNo: o.delivery?.trackingNo || null,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryUsers(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
||||
const keyword = requireQuery(q);
|
||||
const take = clampWecomPluginPageSize(pageSize);
|
||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.user.read',
|
||||
permission: 'user.read',
|
||||
inputSummary: MOBILE_PHONE_RE.test(keyword) ? maskContactPhone(keyword) : keyword,
|
||||
},
|
||||
async () => {
|
||||
const where = MOBILE_PHONE_RE.test(keyword)
|
||||
? { phone: keyword, mergedIntoUserId: null }
|
||||
: { userNo: { contains: keyword }, mergedIntoUserId: null };
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
userNo: true,
|
||||
nickname: true,
|
||||
phone: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
_count: { select: { orders: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
const balances = await Promise.all(
|
||||
rows.map((u) =>
|
||||
this.prisma.benefitCoupon.aggregate({
|
||||
where: { userId: u.id, status: 'ACTIVE' },
|
||||
_sum: { balance: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return {
|
||||
total,
|
||||
items: rows.map((u, i) => ({
|
||||
...toWecomPluginUserView(u),
|
||||
status: u.status,
|
||||
orderCount: u._count.orders,
|
||||
benefitBalance: asNumber(balances[i]?._sum.balance),
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryStores(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
||||
const keyword = requireQuery(q);
|
||||
const take = clampWecomPluginPageSize(pageSize);
|
||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.store.read',
|
||||
permission: 'store.read',
|
||||
inputSummary: keyword,
|
||||
},
|
||||
async () => {
|
||||
const where = { name: { contains: keyword } };
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.store.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
name: true,
|
||||
status: true,
|
||||
cityName: true,
|
||||
district: true,
|
||||
address: true,
|
||||
contactPhone: true,
|
||||
phone: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.store.count({ where }),
|
||||
]);
|
||||
return {
|
||||
total,
|
||||
items: rows.map((s) => ({
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
cityName: s.cityName,
|
||||
district: s.district,
|
||||
address: s.address,
|
||||
contactPhone: maskContactPhone(s.contactPhone || s.phone),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryRedeems(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
||||
const keyword = requireQuery(q);
|
||||
const take = clampWecomPluginPageSize(pageSize);
|
||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.redeem.read',
|
||||
permission: 'redeem.read',
|
||||
inputSummary: keyword,
|
||||
},
|
||||
async () => {
|
||||
const byNo = await this.prisma.redeemRecord.findMany({
|
||||
where: { redeemNo: { contains: keyword } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
include: { store: { select: { name: true } } },
|
||||
});
|
||||
const rows =
|
||||
byNo.length > 0
|
||||
? byNo
|
||||
: await this.prisma.redeemRecord.findMany({
|
||||
where: { store: { name: { contains: keyword } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
include: { store: { select: { name: true } } },
|
||||
});
|
||||
const total =
|
||||
byNo.length > 0
|
||||
? await this.prisma.redeemRecord.count({ where: { redeemNo: { contains: keyword } } })
|
||||
: await this.prisma.redeemRecord.count({
|
||||
where: { store: { name: { contains: keyword } } },
|
||||
});
|
||||
return {
|
||||
total,
|
||||
items: rows.map((r) => ({
|
||||
redeemNo: r.redeemNo,
|
||||
amount: asNumber(r.amount),
|
||||
channel: r.channel,
|
||||
storeName: r.store?.name || '—',
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryPromoCodes(wecomUserId: string, q?: string, page?: string, pageSize?: string) {
|
||||
const keyword = requireQuery(q);
|
||||
const take = clampWecomPluginPageSize(pageSize);
|
||||
const skip = (clampWecomPluginPage(page) - 1) * take;
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.promo.read',
|
||||
inputSummary: keyword,
|
||||
},
|
||||
async () => {
|
||||
const where = {
|
||||
OR: [
|
||||
{ code: { contains: keyword.toUpperCase() } },
|
||||
{ name: { contains: keyword } },
|
||||
],
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.commonPromoCode.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
scene: true,
|
||||
status: true,
|
||||
scanCount: true,
|
||||
orderCount: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.commonPromoCode.count({ where }),
|
||||
]);
|
||||
return { total, items: rows };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryPromoCodeStats(wecomUserId: string, code?: string) {
|
||||
const keyword = requireQuery(code);
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.promo.stats',
|
||||
inputSummary: keyword,
|
||||
},
|
||||
async () => {
|
||||
const row =
|
||||
(await this.prisma.commonPromoCode.findUnique({
|
||||
where: { code: keyword.toUpperCase() },
|
||||
select: { id: true, code: true, name: true, status: true },
|
||||
})) ||
|
||||
(await this.prisma.commonPromoCode.findFirst({
|
||||
where: { code: { contains: keyword.toUpperCase() } },
|
||||
select: { id: true, code: true, name: true, status: true },
|
||||
}));
|
||||
if (!row) throw new NotFoundException(`未找到推广码:${keyword}`);
|
||||
const stats = await this.promo.stats(row.id);
|
||||
return { code: row.code, name: row.name, status: row.status, stats };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
queryMetrics(wecomUserId: string, kindRaw?: string) {
|
||||
const kind = parseWecomPluginMetricsKind(kindRaw);
|
||||
if (!kind) {
|
||||
throw new BadRequestException('kind 须为 today | daily | weekly | monthly');
|
||||
}
|
||||
return this.audit.run(
|
||||
{
|
||||
bot: WECOM_PLUGIN_AUDIT_BOT,
|
||||
wecomUserId,
|
||||
action: 'plugin.metrics.read',
|
||||
inputSummary: kind,
|
||||
},
|
||||
async () => {
|
||||
const period = wecomPluginMetricsPeriod(kind);
|
||||
const stats = await this.loadStats(period.start, period.endExclusive);
|
||||
return {
|
||||
kind: period.kind,
|
||||
title: period.title,
|
||||
rangeLabel: period.rangeLabel,
|
||||
incrementLabel: period.incrementLabel,
|
||||
periodKey: period.periodKey,
|
||||
stats,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 日报口径:用户=有效未合并;订单金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */
|
||||
private async loadStats(start: Date, cutoff: Date): Promise<WecomReportStats> {
|
||||
const userBase = { status: 1, mergedIntoUserId: null } as const;
|
||||
const partnerBase = { isPrimary: 1 } as const;
|
||||
const paid = { payStatus: 'PAID' as const };
|
||||
|
||||
const [
|
||||
usersTotal,
|
||||
usersIncrement,
|
||||
partnersTotal,
|
||||
partnersIncrement,
|
||||
storesTotal,
|
||||
storesIncrement,
|
||||
ordersTotal,
|
||||
ordersIncrement,
|
||||
orderAmountTotal,
|
||||
orderAmountIncrement,
|
||||
redeemsTotal,
|
||||
redeemsIncrement,
|
||||
redeemAmountTotal,
|
||||
redeemAmountIncrement,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count({ where: { ...userBase, createdAt: { lt: cutoff } } }),
|
||||
this.prisma.user.count({
|
||||
where: { ...userBase, createdAt: { gte: start, lt: cutoff } },
|
||||
}),
|
||||
this.prisma.partnerAccount.count({
|
||||
where: { ...partnerBase, createdAt: { lt: cutoff } },
|
||||
}),
|
||||
this.prisma.partnerAccount.count({
|
||||
where: { ...partnerBase, createdAt: { gte: start, lt: cutoff } },
|
||||
}),
|
||||
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
|
||||
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
||||
this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }),
|
||||
this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
||||
this.prisma.order.aggregate({
|
||||
_sum: { payAmount: true },
|
||||
where: { ...paid, paidAt: { lt: cutoff } },
|
||||
}),
|
||||
this.prisma.order.aggregate({
|
||||
_sum: { payAmount: true },
|
||||
where: { ...paid, paidAt: { gte: start, lt: cutoff } },
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
|
||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
||||
this.prisma.redeemRecord.aggregate({
|
||||
_sum: { amount: true },
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
}),
|
||||
this.prisma.redeemRecord.aggregate({
|
||||
_sum: { amount: true },
|
||||
where: { createdAt: { gte: start, lt: cutoff } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
usersTotal,
|
||||
usersIncrement,
|
||||
partnersTotal,
|
||||
partnersIncrement,
|
||||
storesTotal,
|
||||
storesIncrement,
|
||||
ordersTotal,
|
||||
ordersIncrement,
|
||||
orderAmountTotal: asNumber(orderAmountTotal._sum.payAmount),
|
||||
orderAmountIncrement: asNumber(orderAmountIncrement._sum.payAmount),
|
||||
redeemsTotal,
|
||||
redeemsIncrement,
|
||||
redeemAmountTotal: asNumber(redeemAmountTotal._sum.amount),
|
||||
redeemAmountIncrement: asNumber(redeemAmountIncrement._sum.amount),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
|
||||
/** 审计占位:插件无长连接 Bot 行,botKey 固定为 plugin */
|
||||
export const WECOM_PLUGIN_AUDIT_BOT: WecomBotRuntimeConfig = {
|
||||
id: '',
|
||||
key: 'plugin',
|
||||
role: 'OPERATIONS',
|
||||
name: '企微 API 插件',
|
||||
enabled: true,
|
||||
botId: '',
|
||||
secret: '',
|
||||
welcome: '',
|
||||
avatarUrl: null,
|
||||
permissions: [],
|
||||
reviewSuperAdminWecomUserIds: [],
|
||||
aiEnabled: false,
|
||||
llmConfigId: null,
|
||||
knowledgeBaseId: null,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { WecomPluginGuard } from './wecom-plugin.guard';
|
||||
import { WecomPluginQueryService } from './wecom-plugin-query.service';
|
||||
import { WECOM_PLUGIN_OPENAPI } from './wecom-plugin.openapi';
|
||||
|
||||
@Controller('wecom/plugin')
|
||||
@UseGuards(WecomPluginGuard)
|
||||
export class WecomPluginController {
|
||||
constructor(private readonly query: WecomPluginQueryService) {}
|
||||
|
||||
@Get()
|
||||
info() {
|
||||
return this.query.info();
|
||||
}
|
||||
|
||||
@Get('openapi.json')
|
||||
openapi() {
|
||||
return WECOM_PLUGIN_OPENAPI;
|
||||
}
|
||||
|
||||
@Get('orders')
|
||||
orders(
|
||||
@Req() req: Request,
|
||||
@Query('q') q?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.query.queryOrders(pluginCaller(req), q, page, pageSize);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
users(
|
||||
@Req() req: Request,
|
||||
@Query('q') q?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.query.queryUsers(pluginCaller(req), q, page, pageSize);
|
||||
}
|
||||
|
||||
@Get('stores')
|
||||
stores(
|
||||
@Req() req: Request,
|
||||
@Query('q') q?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.query.queryStores(pluginCaller(req), q, page, pageSize);
|
||||
}
|
||||
|
||||
@Get('redeems')
|
||||
redeems(
|
||||
@Req() req: Request,
|
||||
@Query('q') q?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.query.queryRedeems(pluginCaller(req), q, page, pageSize);
|
||||
}
|
||||
|
||||
@Get('promo-codes')
|
||||
promoCodes(
|
||||
@Req() req: Request,
|
||||
@Query('q') q?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.query.queryPromoCodes(pluginCaller(req), q, page, pageSize);
|
||||
}
|
||||
|
||||
@Get('promo-codes/:code/stats')
|
||||
promoStats(@Req() req: Request, @Param('code') code: string) {
|
||||
return this.query.queryPromoCodeStats(pluginCaller(req), code);
|
||||
}
|
||||
|
||||
@Get('metrics')
|
||||
metrics(@Req() req: Request, @Query('kind') kind?: string) {
|
||||
return this.query.queryMetrics(pluginCaller(req), kind);
|
||||
}
|
||||
}
|
||||
|
||||
function pluginCaller(req: Request): string {
|
||||
const raw = req.headers['x-wecom-userid'] ?? req.headers['userid'];
|
||||
const v = Array.isArray(raw) ? raw[0] : raw;
|
||||
return String(v || 'plugin').trim() || 'plugin';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { isWecomPluginEnabled, verifyWecomPluginApiKey } from '@dukang/domain';
|
||||
|
||||
@Injectable()
|
||||
export class WecomPluginGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (!isWecomPluginEnabled(process.env)) {
|
||||
throw new UnauthorizedException('Unauthorized');
|
||||
}
|
||||
const req = context.switchToHttp().getRequest<{ headers: Record<string, unknown> }>();
|
||||
const provided = headerValue(req.headers, 'x-api-key');
|
||||
if (!verifyWecomPluginApiKey(provided, process.env.WECOM_PLUGIN_API_KEY)) {
|
||||
throw new UnauthorizedException('Unauthorized');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function headerValue(headers: Record<string, unknown>, name: string): string {
|
||||
const raw = headers[name];
|
||||
if (Array.isArray(raw)) return String(raw[0] ?? '').trim();
|
||||
return String(raw ?? '').trim();
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
const envelope = (dataSchema: Record<string, unknown>) => ({
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: 'integer', example: 0 },
|
||||
message: { type: 'string', example: 'ok' },
|
||||
data: dataSchema,
|
||||
},
|
||||
required: ['code', 'message', 'data'],
|
||||
});
|
||||
|
||||
const qParam = {
|
||||
name: 'q',
|
||||
in: 'query',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
description: '查询关键词',
|
||||
};
|
||||
|
||||
const pageParams = [
|
||||
{
|
||||
name: 'page',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: { type: 'integer', default: 1, minimum: 1 },
|
||||
},
|
||||
{
|
||||
name: 'pageSize',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: { type: 'integer', default: 5, minimum: 1, maximum: 10 },
|
||||
description: '默认 5,最大 10',
|
||||
},
|
||||
];
|
||||
|
||||
const unauthorized = {
|
||||
description: '缺少或错误的 X-Api-Key,或插件未启用',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: 'integer' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function listPath(summary: string, description: string, qDescription: string) {
|
||||
return {
|
||||
get: {
|
||||
summary,
|
||||
description,
|
||||
operationId: summary,
|
||||
parameters: [{ ...qParam, description: qDescription }, ...pageParams],
|
||||
responses: {
|
||||
200: {
|
||||
description: '查询结果',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: envelope({
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: { type: 'integer' },
|
||||
items: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
401: unauthorized,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** OpenAPI 3.0:企微「添加插件工具」可导入。须原样返回,不要套 {code,message,data}。 */
|
||||
export const WECOM_PLUGIN_OPENAPI = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: '杜康好客运营查询',
|
||||
description:
|
||||
'企业内部只读查询。手机号已脱敏。鉴权:Header X-Api-Key。响应除本文件外均为 { code, message, data }。',
|
||||
version: '1.0.0',
|
||||
},
|
||||
servers: [
|
||||
{ url: 'https://api.dukanghaoke.com/api/v1/wecom/plugin', description: '生产' },
|
||||
{ url: 'https://api-test.dukanghaoke.com/api/v1/wecom/plugin', description: '测试' },
|
||||
],
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'X-Api-Key',
|
||||
},
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
'/orders': listPath('查询订单', '按订单号模糊查询', '订单号,如 DK20260903xxxx'),
|
||||
'/users': listPath('查询用户', '按用户号或 11 位手机号查询;手机号脱敏', '用户号或手机号'),
|
||||
'/stores': listPath('查询门店', '按门店名称模糊查询', '门店名称关键词'),
|
||||
'/redeems': listPath('查询核销', '按核销单号或门店名查询', '核销单号或门店名'),
|
||||
'/promo-codes': listPath('查询推广码', '按推广码 code 或名称查询', '推广码或名称'),
|
||||
'/promo-codes/{code}/stats': {
|
||||
get: {
|
||||
summary: '推广码统计',
|
||||
operationId: '查询推广码统计',
|
||||
parameters: [
|
||||
{
|
||||
name: 'code',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
description: '推广码 code',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: '扫码/成交统计',
|
||||
content: { 'application/json': { schema: envelope({ type: 'object' }) } },
|
||||
},
|
||||
401: unauthorized,
|
||||
},
|
||||
},
|
||||
},
|
||||
'/metrics': {
|
||||
get: {
|
||||
summary: '经营指标',
|
||||
description:
|
||||
'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径(用户有效未合并,订单金额=已付 payAmount)。',
|
||||
operationId: '查询经营指标',
|
||||
parameters: [
|
||||
{
|
||||
name: 'kind',
|
||||
in: 'query',
|
||||
required: false,
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['today', 'daily', 'weekly', 'monthly'],
|
||||
default: 'today',
|
||||
},
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: '存量与新增',
|
||||
content: { 'application/json': { schema: envelope({ type: 'object' }) } },
|
||||
},
|
||||
401: unauthorized,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { CommonModule } from '../../modules/common/common.module';
|
||||
import { DevPlanModule } from '../../modules/dev-plan/dev-plan.module';
|
||||
import { PromoModule } from '../../modules/promo/promo.module';
|
||||
import { SettlementModule } from '../../modules/settlement/settlement.module';
|
||||
import { IntegrationsModule } from '../integrations.module';
|
||||
import { LlmModule } from '../llm/llm.module';
|
||||
@@ -10,16 +11,21 @@ import { WecomBotAiService } from './wecom-bot-ai.service';
|
||||
import { WecomBotAuditService } from './wecom-bot-audit.service';
|
||||
import { WecomBotCapabilityService } from './wecom-bot-capability.service';
|
||||
import { WecomBotSessionService } from './wecom-bot-session.service';
|
||||
import { WecomPluginController } from './wecom-plugin.controller';
|
||||
import { WecomPluginGuard } from './wecom-plugin.guard';
|
||||
import { WecomPluginQueryService } from './wecom-plugin-query.service';
|
||||
|
||||
|
||||
/** 企微多机器人:依赖 Common(工单)+ Settlement/DevPlan + Promo + Integrations(短信)+ Llm */
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => CommonModule),
|
||||
forwardRef(() => IntegrationsModule),
|
||||
DevPlanModule,
|
||||
SettlementModule,
|
||||
PromoModule,
|
||||
LlmModule,
|
||||
],
|
||||
controllers: [WecomPluginController],
|
||||
providers: [
|
||||
WecomBotSessionService,
|
||||
WecomBotAuditService,
|
||||
@@ -27,6 +33,8 @@ import { WecomBotSessionService } from './wecom-bot-session.service';
|
||||
WecomBotActionsService,
|
||||
WecomBotAiService,
|
||||
WecomAibotService,
|
||||
WecomPluginGuard,
|
||||
WecomPluginQueryService,
|
||||
],
|
||||
exports: [WecomAibotService, WecomBotAuditService],
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@pri
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { prepareActivityPosterUpload } from '../../common/image/activity-poster-upload.util';
|
||||
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||
import { logOssUpload, type OssActorRef } from '../../integrations/oss/oss-log.util';
|
||||
@@ -105,19 +106,75 @@ export class ResourceService {
|
||||
if (dto.bizType === 'AVATAR' && !file.mimetype?.startsWith('image/')) {
|
||||
throw new BadRequestException('头像仅支持图片文件');
|
||||
}
|
||||
if (dto.bizType === 'ACTIVITY_POSTER' && dto.mediaType === 'IMAGE' && !file.mimetype?.startsWith('image/')) {
|
||||
throw new BadRequestException('活动图仅支持图片文件');
|
||||
}
|
||||
|
||||
let uploadBuffer = file.buffer;
|
||||
let uploadMimeType = file.mimetype;
|
||||
let imageMeta: {
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
compressed: boolean;
|
||||
fileSize: number;
|
||||
} | undefined;
|
||||
|
||||
if (dto.bizType === 'ACTIVITY_POSTER' && dto.mediaType === 'IMAGE') {
|
||||
const prepared = await prepareActivityPosterUpload({
|
||||
buffer: file.buffer,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
if (prepared.buffer.length > maxUploadBytes) {
|
||||
const message = `活动图压缩后仍超过 ${Math.floor(maxUploadBytes / 1024 / 1024)}MB(当前 ${prepared.width}×${prepared.height})`;
|
||||
await logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_PUT_OBJECT',
|
||||
actorRef: actor,
|
||||
requestBody: {
|
||||
...requestBody,
|
||||
imageWidth: prepared.width,
|
||||
imageHeight: prepared.height,
|
||||
compressed: prepared.compressed,
|
||||
},
|
||||
status: 'FAILED',
|
||||
errorMessage: message,
|
||||
});
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
uploadBuffer = prepared.buffer;
|
||||
uploadMimeType = prepared.mimeType;
|
||||
imageMeta = {
|
||||
width: prepared.width,
|
||||
height: prepared.height,
|
||||
originalWidth: prepared.originalWidth,
|
||||
originalHeight: prepared.originalHeight,
|
||||
compressed: prepared.compressed,
|
||||
fileSize: prepared.buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.oss.putObject({
|
||||
bizType: dto.bizType,
|
||||
mediaType: dto.mediaType,
|
||||
fileName: file.originalname || 'upload.bin',
|
||||
buffer: file.buffer,
|
||||
mimeType: file.mimetype,
|
||||
buffer: uploadBuffer,
|
||||
mimeType: uploadMimeType,
|
||||
});
|
||||
await logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_PUT_OBJECT',
|
||||
actorRef: actor,
|
||||
requestBody,
|
||||
requestBody: {
|
||||
...requestBody,
|
||||
...(imageMeta
|
||||
? {
|
||||
imageWidth: imageMeta.width,
|
||||
imageHeight: imageMeta.height,
|
||||
compressed: imageMeta.compressed,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
responseBody: {
|
||||
bucket: result.bucket,
|
||||
region: result.region,
|
||||
@@ -139,14 +196,14 @@ export class ResourceService {
|
||||
ossKey: result.ossKey,
|
||||
url: result.url,
|
||||
fileName: file.originalname || 'avatar',
|
||||
fileSize: BigInt(file.size),
|
||||
mimeType: file.mimetype,
|
||||
fileSize: BigInt(uploadBuffer.length),
|
||||
mimeType: uploadMimeType,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
return serializeBigInt({ ...result, resourceId: resource.id });
|
||||
return serializeBigInt({ ...result, resourceId: resource.id, ...(imageMeta ? { image: imageMeta } : {}) });
|
||||
}
|
||||
return result;
|
||||
return { ...result, ...(imageMeta ? { image: imageMeta } : {}) };
|
||||
} catch (err) {
|
||||
await logOssUpload(this.prisma, {
|
||||
scene: 'UPLOAD_PUT_OBJECT',
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma, type WecomReportPush } from '@prisma/client';
|
||||
import {
|
||||
formatWecomReportMarkdown,
|
||||
isWecomReportKind,
|
||||
@@ -56,30 +56,12 @@ function clampMinute(n: number | undefined, fallback: number): number {
|
||||
return Math.min(59, Math.max(0, Math.floor(n)));
|
||||
}
|
||||
|
||||
type ReportRow = {
|
||||
id: bigint;
|
||||
kind: string;
|
||||
name: string;
|
||||
webhookUrl: string;
|
||||
enabled: boolean | number;
|
||||
mentionWecomUserId: string | null;
|
||||
sendHour: number;
|
||||
sendMinute: number;
|
||||
sendWeekday: number;
|
||||
sendMonthDay: number;
|
||||
lastSentPeriod: string | null;
|
||||
lastSentAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function asBool(v: boolean | number): boolean {
|
||||
return v === true || v === 1;
|
||||
}
|
||||
type ReportRow = WecomReportPush;
|
||||
|
||||
@Injectable()
|
||||
export class AdminWecomReportsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AdminWecomReportsService.name);
|
||||
private defaultsReady = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -96,42 +78,48 @@ export class AdminWecomReportsService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 早期 INSERT IGNORE 未写 updated_at,Prisma db push 的列又无默认值,
|
||||
* MySQL 会落成 0000-00-00,后续 Prisma 读行即报 invalid datetime。
|
||||
*/
|
||||
private async repairZeroDatetimes(): Promise<void> {
|
||||
await this.prisma.$executeRawUnsafe(`
|
||||
UPDATE wecom_report_push
|
||||
SET created_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE CAST(created_at AS CHAR) REGEXP '^0000|-00-'
|
||||
`);
|
||||
await this.prisma.$executeRawUnsafe(`
|
||||
UPDATE wecom_report_push
|
||||
SET updated_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE CAST(updated_at AS CHAR) REGEXP '^0000|-00-'
|
||||
`);
|
||||
await this.prisma.$executeRawUnsafe(`
|
||||
UPDATE wecom_report_push
|
||||
SET last_sent_at = NULL
|
||||
WHERE last_sent_at IS NOT NULL AND CAST(last_sent_at AS CHAR) REGEXP '^0000|-00-'
|
||||
`);
|
||||
}
|
||||
|
||||
async ensureDefaults(): Promise<void> {
|
||||
if (this.defaultsReady) return;
|
||||
await this.repairZeroDatetimes();
|
||||
for (const seed of KIND_SEED) {
|
||||
await this.prisma.$executeRaw`
|
||||
INSERT IGNORE INTO wecom_report_push
|
||||
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day)
|
||||
VALUES
|
||||
(${seed.kind}, ${seed.name}, ${WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK}, 0, ${seed.sendHour}, ${seed.sendMinute}, 1, 1)
|
||||
`;
|
||||
const existing = await this.prisma.wecomReportPush.findUnique({ where: { kind: seed.kind } });
|
||||
if (existing) continue;
|
||||
await this.prisma.wecomReportPush.create({
|
||||
data: {
|
||||
kind: seed.kind,
|
||||
name: seed.name,
|
||||
webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
|
||||
enabled: false,
|
||||
sendHour: seed.sendHour,
|
||||
sendMinute: seed.sendMinute,
|
||||
sendWeekday: 1,
|
||||
sendMonthDay: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async findByKind(kind: string): Promise<ReportRow | null> {
|
||||
const rows = await this.prisma.$queryRaw<ReportRow[]>`
|
||||
SELECT id, kind, name,
|
||||
webhook_url AS webhookUrl, enabled,
|
||||
mention_wecom_user_id AS mentionWecomUserId,
|
||||
send_hour AS sendHour, send_minute AS sendMinute,
|
||||
send_weekday AS sendWeekday, send_month_day AS sendMonthDay,
|
||||
last_sent_period AS lastSentPeriod, last_sent_at AS lastSentAt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM wecom_report_push WHERE kind = ${kind} LIMIT 1
|
||||
`;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async findAll(): Promise<ReportRow[]> {
|
||||
return this.prisma.$queryRaw<ReportRow[]>`
|
||||
SELECT id, kind, name,
|
||||
webhook_url AS webhookUrl, enabled,
|
||||
mention_wecom_user_id AS mentionWecomUserId,
|
||||
send_hour AS sendHour, send_minute AS sendMinute,
|
||||
send_weekday AS sendWeekday, send_month_day AS sendMonthDay,
|
||||
last_sent_period AS lastSentPeriod, last_sent_at AS lastSentAt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM wecom_report_push
|
||||
`;
|
||||
this.defaultsReady = true;
|
||||
}
|
||||
|
||||
parseKind(raw: string): WecomReportKind {
|
||||
@@ -143,7 +131,7 @@ export class AdminWecomReportsService implements OnModuleInit {
|
||||
|
||||
async list(): Promise<WecomReportPushDto[]> {
|
||||
await this.ensureDefaults();
|
||||
const rows = await this.findAll();
|
||||
const rows = await this.prisma.wecomReportPush.findMany();
|
||||
const byKind = new Map(rows.map((r) => [r.kind, r]));
|
||||
return KIND_SEED.map((s) => {
|
||||
const row = byKind.get(s.kind);
|
||||
@@ -154,14 +142,14 @@ export class AdminWecomReportsService implements OnModuleInit {
|
||||
|
||||
async detail(kind: WecomReportKind): Promise<WecomReportPushDto> {
|
||||
await this.ensureDefaults();
|
||||
const row = await this.findByKind(kind);
|
||||
const row = await this.prisma.wecomReportPush.findUnique({ where: { kind } });
|
||||
if (!row) throw new NotFoundException('报告配置不存在');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(kind: WecomReportKind, dto: UpdateWecomReportPushRequest): Promise<WecomReportPushDto> {
|
||||
await this.ensureDefaults();
|
||||
const existing = await this.findByKind(kind);
|
||||
const existing = await this.prisma.wecomReportPush.findUnique({ where: { kind } });
|
||||
if (!existing) throw new NotFoundException('报告配置不存在');
|
||||
|
||||
const webhookUrl =
|
||||
@@ -169,8 +157,8 @@ export class AdminWecomReportsService implements OnModuleInit {
|
||||
if (!webhookUrl) throw new BadRequestException('请填写 Webhook URL');
|
||||
|
||||
const name = dto.name !== undefined ? dto.name.trim() || existing.name : existing.name;
|
||||
const enabled = dto.enabled !== undefined ? (dto.enabled ? 1 : 0) : asBool(existing.enabled) ? 1 : 0;
|
||||
const mention =
|
||||
const enabled = dto.enabled !== undefined ? dto.enabled : existing.enabled;
|
||||
const mentionWecomUserId =
|
||||
dto.mentionWecomUserId === undefined
|
||||
? existing.mentionWecomUserId
|
||||
: dto.mentionWecomUserId?.trim() || null;
|
||||
@@ -186,20 +174,19 @@ export class AdminWecomReportsService implements OnModuleInit {
|
||||
? Math.min(31, Math.max(1, Math.floor(dto.sendMonthDay) || 1))
|
||||
: existing.sendMonthDay;
|
||||
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE wecom_report_push SET
|
||||
name = ${name},
|
||||
webhook_url = ${webhookUrl},
|
||||
enabled = ${enabled},
|
||||
mention_wecom_user_id = ${mention},
|
||||
send_hour = ${sendHour},
|
||||
send_minute = ${sendMinute},
|
||||
send_weekday = ${sendWeekday},
|
||||
send_month_day = ${sendMonthDay}
|
||||
WHERE kind = ${kind}
|
||||
`;
|
||||
const row = await this.findByKind(kind);
|
||||
if (!row) throw new NotFoundException('报告配置不存在');
|
||||
const row = await this.prisma.wecomReportPush.update({
|
||||
where: { kind },
|
||||
data: {
|
||||
name,
|
||||
webhookUrl,
|
||||
enabled,
|
||||
mentionWecomUserId,
|
||||
sendHour,
|
||||
sendMinute,
|
||||
sendWeekday,
|
||||
sendMonthDay,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
@@ -218,7 +205,7 @@ export class AdminWecomReportsService implements OnModuleInit {
|
||||
|
||||
async send(kind: WecomReportKind, opts?: { markSent?: boolean }): Promise<WecomReportSendResultDto> {
|
||||
await this.ensureDefaults();
|
||||
const row = await this.findByKind(kind);
|
||||
const row = await this.prisma.wecomReportPush.findUnique({ where: { kind } });
|
||||
if (!row) throw new NotFoundException('报告配置不存在');
|
||||
const url = row.webhookUrl.trim();
|
||||
if (!url || url.includes('key=PENDING')) {
|
||||
@@ -236,12 +223,10 @@ export class AdminWecomReportsService implements OnModuleInit {
|
||||
throw new BadRequestException('Webhook 发送失败,请检查地址或群机器人是否可用');
|
||||
}
|
||||
if (opts?.markSent !== false) {
|
||||
const sentAt = new Date();
|
||||
await this.prisma.$executeRaw`
|
||||
UPDATE wecom_report_push
|
||||
SET last_sent_period = ${period.periodKey}, last_sent_at = ${sentAt}
|
||||
WHERE kind = ${kind}
|
||||
`;
|
||||
await this.prisma.wecomReportPush.update({
|
||||
where: { kind },
|
||||
data: { lastSentPeriod: period.periodKey, lastSentAt: new Date() },
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
@@ -258,14 +243,14 @@ export class AdminWecomReportsService implements OnModuleInit {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const rows = await this.findAll();
|
||||
const rows = await this.prisma.wecomReportPush.findMany();
|
||||
for (const row of rows) {
|
||||
if (!asBool(row.enabled)) continue;
|
||||
if (!row.enabled) continue;
|
||||
if (!isWecomReportKind(row.kind)) continue;
|
||||
const due = wecomReportShouldFire(
|
||||
row.kind,
|
||||
{
|
||||
enabled: asBool(row.enabled),
|
||||
enabled: row.enabled,
|
||||
sendHour: row.sendHour,
|
||||
sendMinute: row.sendMinute,
|
||||
sendWeekday: row.sendWeekday,
|
||||
@@ -366,7 +351,7 @@ export class AdminWecomReportsService implements OnModuleInit {
|
||||
name: row.name,
|
||||
webhookUrl: row.webhookUrl,
|
||||
webhookUrlMasked: maskWecomWebhookUrl(row.webhookUrl),
|
||||
enabled: asBool(row.enabled),
|
||||
enabled: row.enabled,
|
||||
mentionWecomUserId: row.mentionWecomUserId,
|
||||
sendHour: row.sendHour,
|
||||
sendMinute: row.sendMinute,
|
||||
|
||||
@@ -51,6 +51,11 @@ export class UserRedeemController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('records/pending-rating')
|
||||
pendingRating(@CurrentUser() user: AuthUser, @Query('storeId') storeId: string) {
|
||||
return this.redeemService.getPendingRatingRecord(user.actorId, storeId);
|
||||
}
|
||||
|
||||
@Get('records/:id')
|
||||
record(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.redeemService.getUserRecord(user.actorId, id);
|
||||
|
||||
@@ -3,11 +3,18 @@ import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { SettlementModule } from '../settlement/settlement.module';
|
||||
import { StoreModule } from '../store/store.module';
|
||||
import { RedeemService } from './redeem.service';
|
||||
import { ShopRedeemController, UserRedeemController } from './redeem.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, AnalyticsModule, BenefitModule, forwardRef(() => SettlementModule)],
|
||||
imports: [
|
||||
IamModule,
|
||||
AnalyticsModule,
|
||||
BenefitModule,
|
||||
forwardRef(() => SettlementModule),
|
||||
forwardRef(() => StoreModule),
|
||||
],
|
||||
controllers: [UserRedeemController, ShopRedeemController],
|
||||
providers: [RedeemService],
|
||||
exports: [RedeemService],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
@@ -33,6 +35,7 @@ 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';
|
||||
import { StoreService } from '../store/store.service';
|
||||
|
||||
type TokenPayload = {
|
||||
userId: string;
|
||||
@@ -123,6 +126,8 @@ export class RedeemService {
|
||||
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
private readonly systemConfig: SystemConfigService,
|
||||
@Inject(forwardRef(() => StoreService))
|
||||
private readonly storeService: StoreService,
|
||||
) {}
|
||||
|
||||
private maskPhoneForStore(phone: string) {
|
||||
@@ -1388,6 +1393,22 @@ export class RedeemService {
|
||||
});
|
||||
}
|
||||
|
||||
async getPendingRatingRecord(userId: bigint, storeId: string) {
|
||||
const sid = String(storeId || '').trim();
|
||||
if (!/^\d+$/.test(sid)) return null;
|
||||
const record = await this.prisma.redeemRecord.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
storeId: BigInt(sid),
|
||||
rating: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!record) return null;
|
||||
return serializeBigInt({ id: record.id });
|
||||
}
|
||||
|
||||
async submitRating(
|
||||
userId: bigint,
|
||||
body: {
|
||||
@@ -1421,6 +1442,7 @@ export class RedeemService {
|
||||
imageUrls,
|
||||
},
|
||||
});
|
||||
await this.storeService.refreshStoreRatingFromReviews(record.storeId);
|
||||
return serializeBigInt(mapStoreRating(rating));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1668,4 +1668,19 @@ export class StoreService {
|
||||
await this.partnerGetStore(partnerAccountId, storeId);
|
||||
return this.replaceStoreCategories(storeId, categoryIds, priorities);
|
||||
}
|
||||
|
||||
/** 评价提交后回写门店综合评分(服务+环境均值) */
|
||||
async refreshStoreRatingFromReviews(storeId: bigint) {
|
||||
const agg = await this.prisma.storeRating.aggregate({
|
||||
where: { storeId },
|
||||
_avg: { serviceScore: true, envScore: true },
|
||||
});
|
||||
const avgService = Number(agg._avg.serviceScore ?? 0);
|
||||
const avgEnv = Number(agg._avg.envScore ?? 0);
|
||||
const rating = Math.round(((avgService + avgEnv) / 2) * 100) / 100;
|
||||
await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
data: { rating },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user