Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fa69c2521 | |||
| b0874f07f6 | |||
| c26ebdde1b | |||
| b60b16a84f | |||
| b74dfd391a | |||
| ec9b7efcd6 | |||
| d04ca615ff | |||
| ebb34ad112 | |||
| d5994af431 | |||
| c29eb2c383 | |||
| 29e95bd5a3 | |||
| e5d7e468cc | |||
| 62d75a6869 | |||
| 8d70f0da72 | |||
| 92dfbf5722 |
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
|
import { Button, Image, Input, Modal, Space, Typography, Upload, message } from 'antd';
|
||||||
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadProps } from 'antd';
|
import type { UploadProps } from 'antd';
|
||||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||||
@@ -13,6 +13,7 @@ type OssUploadProps = {
|
|||||||
accept?: string;
|
accept?: string;
|
||||||
maxSizeMb?: number;
|
maxSizeMb?: number;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
uploadHint?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isImageUrl(url: string) {
|
function isImageUrl(url: string) {
|
||||||
@@ -23,6 +24,32 @@ function isPdfUrl(url: string) {
|
|||||||
return /\.pdf(\?|#|$)/i.test(url);
|
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({
|
export default function OssUpload({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -31,9 +58,11 @@ export default function OssUpload({
|
|||||||
mediaType = 'IMAGE',
|
mediaType = 'IMAGE',
|
||||||
accept,
|
accept,
|
||||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||||
|
uploadHint,
|
||||||
}: OssUploadProps) {
|
}: OssUploadProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
||||||
|
const [imageMetaHint, setImageMetaHint] = useState<string | null>(null);
|
||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||||
@@ -45,7 +74,9 @@ export default function OssUpload({
|
|||||||
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
||||||
onChange?.(result.url);
|
onChange?.(result.url);
|
||||||
onUploaded?.(result);
|
onUploaded?.(result);
|
||||||
message.success('上传成功');
|
const successMessage = formatUploadImageMessage(result);
|
||||||
|
setImageMetaHint(formatUploadImageHint(result));
|
||||||
|
message.success(successMessage);
|
||||||
onSuccess?.(result);
|
onSuccess?.(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error('上传失败');
|
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 }} />
|
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||||
)}
|
)}
|
||||||
{filePreview}
|
{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>
|
<Space wrap>
|
||||||
<Upload
|
<Upload
|
||||||
accept={resolvedAccept}
|
accept={resolvedAccept}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||||
formatOssMaxSizeMb,
|
formatOssMaxSizeMb,
|
||||||
} from '@dukang/shared-ui/compressImage';
|
} from '@dukang/shared-ui/compressImage';
|
||||||
|
import type { OssUploadImageMeta } from '@dukang/shared-types';
|
||||||
|
|
||||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export type UploadFileResult = {
|
|||||||
ossKey: string;
|
ossKey: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
mock: boolean;
|
mock: boolean;
|
||||||
|
image?: OssUploadImageMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
ACTIVITY_POSTER_STATUS_LABELS,
|
ACTIVITY_POSTER_STATUS_LABELS,
|
||||||
|
ACTIVITY_POSTER_UPLOAD_HINT,
|
||||||
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
||||||
type ActivityPosterItem,
|
type ActivityPosterItem,
|
||||||
type ActivityPosterQrSlot,
|
type ActivityPosterQrSlot,
|
||||||
@@ -280,7 +281,7 @@ export default function ActivityPostersPage() {
|
|||||||
<Input maxLength={128} />
|
<Input maxLength={128} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="imageUrl" label="活动图" rules={[{ required: true, message: '请上传活动图' }]}>
|
<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>
|
||||||
<Form.Item name="qrXPct" hidden><InputNumber /></Form.Item>
|
<Form.Item name="qrXPct" hidden><InputNumber /></Form.Item>
|
||||||
<Form.Item name="qrYPct" 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',
|
dataIndex: 'scanCount',
|
||||||
width: 70,
|
width: 70,
|
||||||
render: (v: number, row) => (
|
render: (v: number, row) => (
|
||||||
@@ -110,7 +110,7 @@ export default function PromoCodesPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '转化率',
|
title: <Tooltip title="已完成订单 ÷ 扫码人次">转化率</Tooltip>,
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -148,10 +148,10 @@ function ReportKindPane({
|
|||||||
label="发送时刻"
|
label="发送时刻"
|
||||||
extra={
|
extra={
|
||||||
kind === 'daily'
|
kind === 'daily'
|
||||||
? '按北京时间当天该时刻发送当日数据。'
|
? '按北京时间该时刻发送;数据截止发送日 0 点(前一天 24 点),即完整昨日。'
|
||||||
: kind === 'weekly'
|
: kind === 'weekly'
|
||||||
? '按北京时间该星期该时刻发送上一自然周。'
|
? '按北京时间该星期该时刻发送上一自然周;不含发送当天。'
|
||||||
: '按北京时间每月该日该时刻发送上一自然月。'
|
: '按北京时间每月该日该时刻发送上一自然月;不含发送当天。'
|
||||||
}
|
}
|
||||||
rules={[{ required: true, message: '请选择时刻' }]}
|
rules={[{ required: true, message: '请选择时刻' }]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -37,8 +37,33 @@ const descContentStyle: CSSProperties = {
|
|||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SCAN_HINT =
|
||||||
|
'按人次累计:每次扫码或带参进入记一次,未登录也计入;同一用户多次进入记多次。同一次打开内的重复上报、以及登录后补归因不重复计。不是按人去重。';
|
||||||
|
|
||||||
const ATTRIBUTION_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) {
|
async function downloadQrcode(url: string, filename: string) {
|
||||||
try {
|
try {
|
||||||
@@ -221,20 +246,16 @@ export default function PromoCodeDetailPage() {
|
|||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
|
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>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={<StatHint label="归因用户数" hint={ATTRIBUTION_HINT} />}
|
||||||
<span>
|
|
||||||
归因用户数
|
|
||||||
<Tooltip title={ATTRIBUTION_HINT} overlayInnerStyle={{ maxWidth: 360 }}>
|
|
||||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
value={stats?.attributionCount ?? 0}
|
value={stats?.attributionCount ?? 0}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -242,7 +263,7 @@ export default function PromoCodeDetailPage() {
|
|||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="扫码注册用户数"
|
title={<StatHint label="扫码注册用户数" hint={REGISTER_HINT} />}
|
||||||
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
|
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -255,14 +276,7 @@ export default function PromoCodeDetailPage() {
|
|||||||
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
|
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
|
||||||
>
|
>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={
|
title={<StatHint label="订单数" hint={ORDER_HINT} />}
|
||||||
<span>
|
|
||||||
订单数
|
|
||||||
<Tooltip title="仅统计已完成订单" overlayInnerStyle={{ maxWidth: 280 }}>
|
|
||||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
value={stats?.orderCount ?? detail.orderCount}
|
value={stats?.orderCount ?? detail.orderCount}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -270,7 +284,7 @@ export default function PromoCodeDetailPage() {
|
|||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
title="转化率"
|
title={<StatHint label="转化率" hint={CONVERSION_HINT} />}
|
||||||
value={promoConversion(stats?.scanCount ?? detail.scanCount, stats?.orderCount ?? detail.orderCount)}
|
value={promoConversion(stats?.scanCount ?? detail.scanCount, stats?.orderCount ?? detail.orderCount)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
|||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
extra={
|
extra={
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
仅统计功能上线后的新事件
|
仅统计功能上线后的新事件。扫码进入按人次(多次进入记多次);归因 / 注册按人(每人一次)。
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export default function PromoCodeUsersPage() {
|
|||||||
render: (v) => (v === detail.id ? <Tag color="blue">本码</Tag> : v || '—'),
|
render: (v) => (v === detail.id ? <Tag color="blue">本码</Tag> : v || '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '首次触达',
|
title: (
|
||||||
|
<Tooltip title="该用户首次归因到本码的时间。无值表示仅用户来源指向本码,归因记录在其他码。">
|
||||||
|
首次触达
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
dataIndex: 'firstTouchAt',
|
dataIndex: 'firstTouchAt',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (v) => (v ? fmtTime(v) : '—'),
|
render: (v) => (v ? fmtTime(v) : '—'),
|
||||||
@@ -87,7 +91,10 @@ export default function PromoCodeUsersPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader settings={settingsButton} />
|
<AdminListHeader
|
||||||
|
settings={settingsButton}
|
||||||
|
description="含归因到本码、或用户来源标记为本码的用户。"
|
||||||
|
/>
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
className="admin-table-nowrap"
|
className="admin-table-nowrap"
|
||||||
|
|||||||
@@ -32,9 +32,11 @@
|
|||||||
| 订单金额 | 已付 `payAmount`(`paidAt`) | 区间内支付 |
|
| 订单金额 | 已付 `payAmount`(`paidAt`) | 区间内支付 |
|
||||||
| 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 |
|
| 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 |
|
||||||
|
|
||||||
- **日报**:当天;默认 20:00 发送;新增文案「当日新增」。
|
- **日报**:发送日前一自然日(截账至发送日 0 点 / 前一天 24 点);默认 20:00 发送;新增文案「当日新增」。
|
||||||
- **周报**:上一自然周(周一~周日);默认周一 09:00。
|
- **周报**:上一自然周(周一~周日);默认周一 09:00;期末为该周日 24 点。
|
||||||
- **月报**:上一自然月;默认每月 1 日 09:00。
|
- **月报**:上一自然月;默认每月 1 日 09:00;期末为该月最后一日 24 点。
|
||||||
|
|
||||||
|
三种报都不含发送当天发生额。
|
||||||
|
|
||||||
错过发送时刻会在之后补发一次(按 `last_sent_period` 去重)。
|
错过发送时刻会在之后补发一次(按 `last_sent_period` 去重)。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**HQ 概览(v4.0.14 / v4.0.15)**:`GET /admin/dashboard/analytics` 支持日/周/月/季/年分桶;默认窗口为上一档起点~今天。全局筛城市+时间;日期快捷上周/上月/上季度;总量/增量单选分开展示。改粒度不改日期。查询右侧可下载当前折线图 PDF(不含 KPI/待办)。v4.0.15 起为多张全宽折线图:总量=桶末日存量,增量=桶内新增;用户线=推广码/关联合伙人/活动,门店线=关联合伙人,订单线=用户/关联合伙人/商品,核销线=门店/关联合伙人,合伙人单线。订单与核销同时出笔数和金额。关联合伙人=`assoc_partner_account_id`;活动=关联合伙人当前活动图。「查看」只带全局城市与日期。无权限模块后端不算不返回。时间按北京日历。
|
**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`。
|
||||||
|
|
||||||
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
| 版本线 | **v4.0.15** HQ 概览折线图(粒度分桶、总量/增量、维度线条) |
|
| 版本线 | **v4.0.15** HQ 概览折线图(粒度分桶、总量/增量、维度线条) |
|
||||||
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||||
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||||
| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||||
|
|
||||||
## 1. 版本交付
|
## 1. 版本交付
|
||||||
|
|
||||||
@@ -36,4 +36,4 @@
|
|||||||
| 2026-09-02 | v4.0.9:子账号默认启用;关联码已扫码;零元账单不同步;银行账号自填;周账周一 08:00;预付款预估;子账号用户管理无活动图 |
|
| 2026-09-02 | v4.0.9:子账号默认启用;关联码已扫码;零元账单不同步;银行账号自填;周账周一 08:00;预付款预估;子账号用户管理无活动图 |
|
||||||
| 2026-09-02 | v4.0.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
|
| 2026-09-02 | v4.0.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
|
||||||
| 2026-09-02 | v4.0.14:HQ 概览日/周/月/季/年、环比;全局城市/时间 + 五板块筛;订单/核销笔数与金额 |
|
| 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 折线单测通过
|
- [ ] 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 行为不变
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ const emptyStats = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('wecomReportPeriod', () => {
|
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'));
|
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.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', () => {
|
it('weekly is the previous natural week', () => {
|
||||||
@@ -37,26 +38,28 @@ describe('wecomReportPeriod', () => {
|
|||||||
expect(p.periodKey).toBe('2026-08-24');
|
expect(p.periodKey).toBe('2026-08-24');
|
||||||
expect(p.rangeLabel).toBe('2026-08-24 ~ 2026-08-30');
|
expect(p.rangeLabel).toBe('2026-08-24 ~ 2026-08-30');
|
||||||
expect(p.incrementLabel).toBe('本期新增');
|
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', () => {
|
it('monthly is the previous natural month', () => {
|
||||||
const p = wecomReportPeriod('monthly', new Date('2026-09-01T09:00:00+08:00'));
|
const p = wecomReportPeriod('monthly', new Date('2026-09-01T09:00:00+08:00'));
|
||||||
expect(p.periodKey).toBe('2026-08');
|
expect(p.periodKey).toBe('2026-08');
|
||||||
expect(p.title).toBe('月报(2026年8月)');
|
expect(p.title).toBe('月报(2026年8月)');
|
||||||
|
expect(p.endExclusive.toISOString()).toBe(new Date('2026-09-01T00:00:00+08:00').toISOString());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('wecomReportCutoff', () => {
|
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 now = new Date('2026-09-02T20:00:00+08:00');
|
||||||
const p = wecomReportPeriod('daily', now);
|
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', () => {
|
it('uses period end for a completed week', () => {
|
||||||
const now = new Date('2026-09-02T09:00:00+08:00');
|
const now = new Date('2026-09-02T09:00:00+08:00');
|
||||||
const p = wecomReportPeriod('weekly', now);
|
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(
|
expect(
|
||||||
wecomReportShouldFire(
|
wecomReportShouldFire(
|
||||||
'daily',
|
'daily',
|
||||||
{ ...base, lastSentPeriod: '2026-09-02' },
|
{ ...base, lastSentPeriod: '2026-09-01' },
|
||||||
new Date('2026-09-02T21:00:00+08:00'),
|
new Date('2026-09-02T21:00:00+08:00'),
|
||||||
),
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@@ -125,7 +128,7 @@ describe('formatWecomReportMarkdown', () => {
|
|||||||
it('renders stock plus increment lines', () => {
|
it('renders stock plus increment lines', () => {
|
||||||
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
|
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
|
||||||
const md = formatWecomReportMarkdown(p, emptyStats);
|
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('用户数量:10(当日新增 2)');
|
||||||
expect(md).toContain('订单金额:1000.00(当日新增 80.50)');
|
expect(md).toContain('订单金额:1000.00(当日新增 80.50)');
|
||||||
expect(md).toContain('核销单数量:8(当日新增 3)');
|
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));
|
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 {
|
export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): WecomReportPeriod {
|
||||||
|
const asOf = wecomReportAsOf(now);
|
||||||
if (kind === 'daily') {
|
if (kind === 'daily') {
|
||||||
const start = startOfShanghaiDay(now);
|
const start = addShanghaiDays(asOf, -1);
|
||||||
const ymd = shanghaiYmd(start);
|
const ymd = shanghaiYmd(start);
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
start,
|
start,
|
||||||
endExclusive: addShanghaiDays(start, 1),
|
endExclusive: asOf,
|
||||||
periodKey: ymd,
|
periodKey: ymd,
|
||||||
title: `日报(${ymd})`,
|
title: `日报(${ymd})`,
|
||||||
rangeLabel: ymd,
|
rangeLabel: ymd,
|
||||||
@@ -83,7 +89,7 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (kind === 'weekly') {
|
if (kind === 'weekly') {
|
||||||
const { start, endExclusive } = previousShanghaiWeek(now);
|
const { start, endExclusive } = previousShanghaiWeek(asOf);
|
||||||
const from = shanghaiYmd(start);
|
const from = shanghaiYmd(start);
|
||||||
const to = shanghaiYmd(new Date(endExclusive.getTime() - 1));
|
const to = shanghaiYmd(new Date(endExclusive.getTime() - 1));
|
||||||
return {
|
return {
|
||||||
@@ -96,7 +102,7 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
|
|||||||
incrementLabel: '本期新增',
|
incrementLabel: '本期新增',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { year, month } = previousShanghaiMonth(now);
|
const { year, month } = previousShanghaiMonth(asOf);
|
||||||
const { start, endExclusive } = shanghaiMonthRange(year, month);
|
const { start, endExclusive } = shanghaiMonthRange(year, month);
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
@@ -109,9 +115,9 @@ export function wecomReportPeriod(kind: WecomReportKind, now = new Date()): Weco
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 进行中的周期截到 now,已结束的周期用期末 */
|
/** 账期期末:发送日 0 点或上一周/月结束 0 点,不含发送当天发生额 */
|
||||||
export function wecomReportCutoff(period: WecomReportPeriod, now = new Date()): Date {
|
export function wecomReportCutoff(period: WecomReportPeriod): Date {
|
||||||
return now.getTime() < period.endExclusive.getTime() ? now : period.endExclusive;
|
return period.endExclusive;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wecomReportDueAt(kind: WecomReportKind, schedule: WecomReportSchedule, now = new Date()): Date {
|
export function wecomReportDueAt(kind: WecomReportKind, schedule: WecomReportSchedule, now = new Date()): Date {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
activityPosterPackFileName,
|
activityPosterPackFileName,
|
||||||
activityPosterQrSlotPx,
|
activityPosterQrSlotPx,
|
||||||
|
activityPosterResizeTarget,
|
||||||
activityPosterTemplateTooLarge,
|
activityPosterTemplateTooLarge,
|
||||||
} from './activity-poster';
|
} 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', () => {
|
describe('activityPosterPackFileName', () => {
|
||||||
it('uses city, company, id', () => {
|
it('uses city, company, id', () => {
|
||||||
expect(activityPosterPackFileName({
|
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;
|
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` */
|
/** zip / 单张下载文件名:`{城市}_{公司或姓名}_{id}.png` */
|
||||||
export function activityPosterPackFileName(input: {
|
export function activityPosterPackFileName(input: {
|
||||||
cityName?: string | null;
|
cityName?: string | null;
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ async function main() {
|
|||||||
['monthly', '经营月报', 9],
|
['monthly', '经营月报', 9],
|
||||||
];
|
];
|
||||||
for (const [kind, name, hour] of seeds) {
|
for (const [kind, name, hour] of seeds) {
|
||||||
await prisma.$executeRaw`
|
await prisma.$executeRawUnsafe(
|
||||||
INSERT IGNORE INTO wecom_report_push
|
`INSERT IGNORE INTO wecom_report_push
|
||||||
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day)
|
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day, created_at, updated_at)
|
||||||
VALUES
|
VALUES (?, ?, ?, 0, ?, 0, 1, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))`,
|
||||||
(${kind}, ${name}, ${PLACEHOLDER}, 0, ${hour}, 0, 1, 1)
|
kind,
|
||||||
`;
|
name,
|
||||||
|
PLACEHOLDER,
|
||||||
|
hour,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log('migrate-wecom-report done');
|
console.log('migrate-wecom-report done');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -561,7 +561,7 @@ model WecomReportPush {
|
|||||||
lastSentPeriod String? @map("last_sent_period") @db.VarChar(16)
|
lastSentPeriod String? @map("last_sent_period") @db.VarChar(16)
|
||||||
lastSentAt DateTime? @map("last_sent_at") @db.DateTime(3)
|
lastSentAt DateTime? @map("last_sent_at") @db.DateTime(3)
|
||||||
createdAt DateTime @default(now()) @map("created_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")
|
@@map("wecom_report_push")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@pri
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
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 { OSS_PROVIDER } from '../../integrations/integrations.constants';
|
||||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||||
import { logOssUpload, type OssActorRef } from '../../integrations/oss/oss-log.util';
|
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/')) {
|
if (dto.bizType === 'AVATAR' && !file.mimetype?.startsWith('image/')) {
|
||||||
throw new BadRequestException('头像仅支持图片文件');
|
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 {
|
try {
|
||||||
const result = await this.oss.putObject({
|
const result = await this.oss.putObject({
|
||||||
bizType: dto.bizType,
|
bizType: dto.bizType,
|
||||||
mediaType: dto.mediaType,
|
mediaType: dto.mediaType,
|
||||||
fileName: file.originalname || 'upload.bin',
|
fileName: file.originalname || 'upload.bin',
|
||||||
buffer: file.buffer,
|
buffer: uploadBuffer,
|
||||||
mimeType: file.mimetype,
|
mimeType: uploadMimeType,
|
||||||
});
|
});
|
||||||
await logOssUpload(this.prisma, {
|
await logOssUpload(this.prisma, {
|
||||||
scene: 'UPLOAD_PUT_OBJECT',
|
scene: 'UPLOAD_PUT_OBJECT',
|
||||||
actorRef: actor,
|
actorRef: actor,
|
||||||
requestBody,
|
requestBody: {
|
||||||
|
...requestBody,
|
||||||
|
...(imageMeta
|
||||||
|
? {
|
||||||
|
imageWidth: imageMeta.width,
|
||||||
|
imageHeight: imageMeta.height,
|
||||||
|
compressed: imageMeta.compressed,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
responseBody: {
|
responseBody: {
|
||||||
bucket: result.bucket,
|
bucket: result.bucket,
|
||||||
region: result.region,
|
region: result.region,
|
||||||
@@ -139,14 +196,14 @@ export class ResourceService {
|
|||||||
ossKey: result.ossKey,
|
ossKey: result.ossKey,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
fileName: file.originalname || 'avatar',
|
fileName: file.originalname || 'avatar',
|
||||||
fileSize: BigInt(file.size),
|
fileSize: BigInt(uploadBuffer.length),
|
||||||
mimeType: file.mimetype,
|
mimeType: uploadMimeType,
|
||||||
status: 'ACTIVE',
|
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) {
|
} catch (err) {
|
||||||
await logOssUpload(this.prisma, {
|
await logOssUpload(this.prisma, {
|
||||||
scene: 'UPLOAD_PUT_OBJECT',
|
scene: 'UPLOAD_PUT_OBJECT',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma, type WecomReportPush } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
formatWecomReportMarkdown,
|
formatWecomReportMarkdown,
|
||||||
isWecomReportKind,
|
isWecomReportKind,
|
||||||
@@ -56,30 +56,12 @@ function clampMinute(n: number | undefined, fallback: number): number {
|
|||||||
return Math.min(59, Math.max(0, Math.floor(n)));
|
return Math.min(59, Math.max(0, Math.floor(n)));
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportRow = {
|
type ReportRow = WecomReportPush;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminWecomReportsService implements OnModuleInit {
|
export class AdminWecomReportsService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(AdminWecomReportsService.name);
|
private readonly logger = new Logger(AdminWecomReportsService.name);
|
||||||
|
private defaultsReady = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
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> {
|
async ensureDefaults(): Promise<void> {
|
||||||
|
if (this.defaultsReady) return;
|
||||||
|
await this.repairZeroDatetimes();
|
||||||
for (const seed of KIND_SEED) {
|
for (const seed of KIND_SEED) {
|
||||||
await this.prisma.$executeRaw`
|
const existing = await this.prisma.wecomReportPush.findUnique({ where: { kind: seed.kind } });
|
||||||
INSERT IGNORE INTO wecom_report_push
|
if (existing) continue;
|
||||||
(kind, name, webhook_url, enabled, send_hour, send_minute, send_weekday, send_month_day)
|
await this.prisma.wecomReportPush.create({
|
||||||
VALUES
|
data: {
|
||||||
(${seed.kind}, ${seed.name}, ${WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK}, 0, ${seed.sendHour}, ${seed.sendMinute}, 1, 1)
|
kind: seed.kind,
|
||||||
`;
|
name: seed.name,
|
||||||
|
webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
|
||||||
|
enabled: false,
|
||||||
|
sendHour: seed.sendHour,
|
||||||
|
sendMinute: seed.sendMinute,
|
||||||
|
sendWeekday: 1,
|
||||||
|
sendMonthDay: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
this.defaultsReady = true;
|
||||||
|
|
||||||
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
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parseKind(raw: string): WecomReportKind {
|
parseKind(raw: string): WecomReportKind {
|
||||||
@@ -143,7 +131,7 @@ export class AdminWecomReportsService implements OnModuleInit {
|
|||||||
|
|
||||||
async list(): Promise<WecomReportPushDto[]> {
|
async list(): Promise<WecomReportPushDto[]> {
|
||||||
await this.ensureDefaults();
|
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]));
|
const byKind = new Map(rows.map((r) => [r.kind, r]));
|
||||||
return KIND_SEED.map((s) => {
|
return KIND_SEED.map((s) => {
|
||||||
const row = byKind.get(s.kind);
|
const row = byKind.get(s.kind);
|
||||||
@@ -154,14 +142,14 @@ export class AdminWecomReportsService implements OnModuleInit {
|
|||||||
|
|
||||||
async detail(kind: WecomReportKind): Promise<WecomReportPushDto> {
|
async detail(kind: WecomReportKind): Promise<WecomReportPushDto> {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
const row = await this.findByKind(kind);
|
const row = await this.prisma.wecomReportPush.findUnique({ where: { kind } });
|
||||||
if (!row) throw new NotFoundException('报告配置不存在');
|
if (!row) throw new NotFoundException('报告配置不存在');
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(kind: WecomReportKind, dto: UpdateWecomReportPushRequest): Promise<WecomReportPushDto> {
|
async update(kind: WecomReportKind, dto: UpdateWecomReportPushRequest): Promise<WecomReportPushDto> {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
const existing = await this.findByKind(kind);
|
const existing = await this.prisma.wecomReportPush.findUnique({ where: { kind } });
|
||||||
if (!existing) throw new NotFoundException('报告配置不存在');
|
if (!existing) throw new NotFoundException('报告配置不存在');
|
||||||
|
|
||||||
const webhookUrl =
|
const webhookUrl =
|
||||||
@@ -169,8 +157,8 @@ export class AdminWecomReportsService implements OnModuleInit {
|
|||||||
if (!webhookUrl) throw new BadRequestException('请填写 Webhook URL');
|
if (!webhookUrl) throw new BadRequestException('请填写 Webhook URL');
|
||||||
|
|
||||||
const name = dto.name !== undefined ? dto.name.trim() || existing.name : existing.name;
|
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 enabled = dto.enabled !== undefined ? dto.enabled : existing.enabled;
|
||||||
const mention =
|
const mentionWecomUserId =
|
||||||
dto.mentionWecomUserId === undefined
|
dto.mentionWecomUserId === undefined
|
||||||
? existing.mentionWecomUserId
|
? existing.mentionWecomUserId
|
||||||
: dto.mentionWecomUserId?.trim() || null;
|
: dto.mentionWecomUserId?.trim() || null;
|
||||||
@@ -186,20 +174,19 @@ export class AdminWecomReportsService implements OnModuleInit {
|
|||||||
? Math.min(31, Math.max(1, Math.floor(dto.sendMonthDay) || 1))
|
? Math.min(31, Math.max(1, Math.floor(dto.sendMonthDay) || 1))
|
||||||
: existing.sendMonthDay;
|
: existing.sendMonthDay;
|
||||||
|
|
||||||
await this.prisma.$executeRaw`
|
const row = await this.prisma.wecomReportPush.update({
|
||||||
UPDATE wecom_report_push SET
|
where: { kind },
|
||||||
name = ${name},
|
data: {
|
||||||
webhook_url = ${webhookUrl},
|
name,
|
||||||
enabled = ${enabled},
|
webhookUrl,
|
||||||
mention_wecom_user_id = ${mention},
|
enabled,
|
||||||
send_hour = ${sendHour},
|
mentionWecomUserId,
|
||||||
send_minute = ${sendMinute},
|
sendHour,
|
||||||
send_weekday = ${sendWeekday},
|
sendMinute,
|
||||||
send_month_day = ${sendMonthDay}
|
sendWeekday,
|
||||||
WHERE kind = ${kind}
|
sendMonthDay,
|
||||||
`;
|
},
|
||||||
const row = await this.findByKind(kind);
|
});
|
||||||
if (!row) throw new NotFoundException('报告配置不存在');
|
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +205,7 @@ export class AdminWecomReportsService implements OnModuleInit {
|
|||||||
|
|
||||||
async send(kind: WecomReportKind, opts?: { markSent?: boolean }): Promise<WecomReportSendResultDto> {
|
async send(kind: WecomReportKind, opts?: { markSent?: boolean }): Promise<WecomReportSendResultDto> {
|
||||||
await this.ensureDefaults();
|
await this.ensureDefaults();
|
||||||
const row = await this.findByKind(kind);
|
const row = await this.prisma.wecomReportPush.findUnique({ where: { kind } });
|
||||||
if (!row) throw new NotFoundException('报告配置不存在');
|
if (!row) throw new NotFoundException('报告配置不存在');
|
||||||
const url = row.webhookUrl.trim();
|
const url = row.webhookUrl.trim();
|
||||||
if (!url || url.includes('key=PENDING')) {
|
if (!url || url.includes('key=PENDING')) {
|
||||||
@@ -236,12 +223,10 @@ export class AdminWecomReportsService implements OnModuleInit {
|
|||||||
throw new BadRequestException('Webhook 发送失败,请检查地址或群机器人是否可用');
|
throw new BadRequestException('Webhook 发送失败,请检查地址或群机器人是否可用');
|
||||||
}
|
}
|
||||||
if (opts?.markSent !== false) {
|
if (opts?.markSent !== false) {
|
||||||
const sentAt = new Date();
|
await this.prisma.wecomReportPush.update({
|
||||||
await this.prisma.$executeRaw`
|
where: { kind },
|
||||||
UPDATE wecom_report_push
|
data: { lastSentPeriod: period.periodKey, lastSentAt: new Date() },
|
||||||
SET last_sent_period = ${period.periodKey}, last_sent_at = ${sentAt}
|
});
|
||||||
WHERE kind = ${kind}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -258,14 +243,14 @@ export class AdminWecomReportsService implements OnModuleInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const rows = await this.findAll();
|
const rows = await this.prisma.wecomReportPush.findMany();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!asBool(row.enabled)) continue;
|
if (!row.enabled) continue;
|
||||||
if (!isWecomReportKind(row.kind)) continue;
|
if (!isWecomReportKind(row.kind)) continue;
|
||||||
const due = wecomReportShouldFire(
|
const due = wecomReportShouldFire(
|
||||||
row.kind,
|
row.kind,
|
||||||
{
|
{
|
||||||
enabled: asBool(row.enabled),
|
enabled: row.enabled,
|
||||||
sendHour: row.sendHour,
|
sendHour: row.sendHour,
|
||||||
sendMinute: row.sendMinute,
|
sendMinute: row.sendMinute,
|
||||||
sendWeekday: row.sendWeekday,
|
sendWeekday: row.sendWeekday,
|
||||||
@@ -366,7 +351,7 @@ export class AdminWecomReportsService implements OnModuleInit {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
webhookUrl: row.webhookUrl,
|
webhookUrl: row.webhookUrl,
|
||||||
webhookUrlMasked: maskWecomWebhookUrl(row.webhookUrl),
|
webhookUrlMasked: maskWecomWebhookUrl(row.webhookUrl),
|
||||||
enabled: asBool(row.enabled),
|
enabled: row.enabled,
|
||||||
mentionWecomUserId: row.mentionWecomUserId,
|
mentionWecomUserId: row.mentionWecomUserId,
|
||||||
sendHour: row.sendHour,
|
sendHour: row.sendHour,
|
||||||
sendMinute: row.sendMinute,
|
sendMinute: row.sendMinute,
|
||||||
|
|||||||
Reference in New Issue
Block a user