From cdc4b82931875a675ec76e4097d6142ba9639a1a Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 1 Sep 2026 15:12:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20v4.0.7=20HQ=20=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E5=9B=BE=E5=BF=AB=E9=93=BE=E4=B8=8E=E5=8B=BE=E9=80=89=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HQ 指定一张活动图为勾选主合伙人合成 PNG/zip;城市合伙人页增加快链与单下/导出。 Co-authored-by: Cursor --- .../ActivityPosterDownloadModal.tsx | 100 +++++++ .../src/components/PartnerAssocPanel.tsx | 22 +- apps/admin-web/src/lib/api.ts | 48 ++++ .../src/pages/ActivityPostersPage.tsx | 74 ++++- apps/admin-web/src/pages/CityPartnersPage.tsx | 69 ++++- deploy/nginx-dukang-staging.conf | 26 ++ deploy/nginx-dukanghaoke.conf | 26 ++ docs/杜康好客-v3编码手册.md | 2 +- docs/杜康好客-v4-PRD.md | 6 +- docs/杜康好客-v4-现状对照.md | 8 +- docs/杜康好客-v4.0.7-开发文档.md | 49 ++++ .../shared-types/src/activity-poster.test.ts | 48 +++- packages/shared-types/src/activity-poster.ts | 33 +++ pnpm-lock.yaml | 270 ++++++++++++++++++ server/dukang-api/package.json | 2 + .../scripts/bench-activity-poster-pack.ts | 64 +++++ .../hq-operation/hq-operation.constants.ts | 2 + .../modules/ops/activity-poster.service.ts | 158 +++++++++- .../ops/admin-activity-posters.controller.ts | 34 ++- .../modules/ops/dto/activity-poster.dto.ts | 13 +- .../modules/store/partner-assoc.service.ts | 15 + 21 files changed, 1041 insertions(+), 28 deletions(-) create mode 100644 apps/admin-web/src/components/ActivityPosterDownloadModal.tsx create mode 100644 docs/杜康好客-v4.0.7-开发文档.md create mode 100644 server/dukang-api/scripts/bench-activity-poster-pack.ts diff --git a/apps/admin-web/src/components/ActivityPosterDownloadModal.tsx b/apps/admin-web/src/components/ActivityPosterDownloadModal.tsx new file mode 100644 index 0000000..5aaaa2d --- /dev/null +++ b/apps/admin-web/src/components/ActivityPosterDownloadModal.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react'; +import { Form, Modal, Select, Typography, message } from 'antd'; +import type { ActivityPosterItem } from '@dukang/shared-types'; +import { request, requestDownload, type Paginated } from '../lib/api'; +import { ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants'; + +type Props = { + open: boolean; + partnerIds: string[]; + onClose: () => void; +}; + +export default function ActivityPosterDownloadModal({ open, partnerIds, onClose }: Props) { + const [posters, setPosters] = useState([]); + const [posterId, setPosterId] = useState(); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const multi = partnerIds.length > 1; + + useEffect(() => { + if (!open) return; + setPosterId(undefined); + setLoading(true); + void request>( + `/admin/activity-posters?status=ACTIVE&page=1&pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`, + ) + .then((res) => { + const items = res.items ?? []; + setPosters(items); + setPosterId(items[0]?.id); + }) + .catch((e) => message.error(e instanceof Error ? e.message : '加载活动图失败')) + .finally(() => setLoading(false)); + }, [open]); + + async function submit() { + if (!posterId) { + message.error('请选择活动图'); + return; + } + if (!partnerIds.length) { + message.error('请选择合伙人'); + return; + } + setSubmitting(true); + try { + if (partnerIds.length === 1) { + await requestDownload( + `/admin/activity-posters/${posterId}/image?partnerId=${encodeURIComponent(partnerIds[0])}`, + {}, + 'activity-poster.png', + ); + } else { + await requestDownload( + `/admin/activity-posters/${posterId}/partner-pack`, + { method: 'POST', body: JSON.stringify({ partnerIds }) }, + 'activity-poster-pack.zip', + ); + } + message.success('已开始下载'); + onClose(); + } catch (e) { + message.error(e instanceof Error ? e.message : '下载失败'); + } finally { + setSubmitting(false); + } + } + + return ( + void submit()} + confirmLoading={submitting} + okText="下载" + okButtonProps={{ disabled: loading || !posterId }} + destroyOnClose + > + + {multi + ? '将把所选主合伙人的关联码贴进同一张活动图,打包为 zip。无关联码或测试账号会跳过。' + : '将把该主合伙人的关联码贴进所选活动图后下载 PNG。'} + +
+ + (); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [posterDownloadIds, setPosterDownloadIds] = useState(null); + const [canDownloadPosters, setCanDownloadPosters] = useState(false); const loadCities = useCallback(async () => { const res = await request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); @@ -169,6 +173,12 @@ export default function CityPartnersPage() { void loadCities(); }, [loadCities]); + useEffect(() => { + void request('/admin/auth/me') + .then((p) => setCanDownloadPosters((p.permissionKeys ?? []).includes('activity_posters'))) + .catch(() => setCanDownloadPosters(false)); + }, []); + const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined; @@ -373,17 +383,34 @@ export default function CityPartnersPage() { ), }, + { + title: '活动图', + width: 70, + render: (_, row) => ( + + 查看 + + ), + }, { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, { title: '操作', - width: 140, + width: 220, fixed: 'right', render: (_, row) => ( + {canDownloadPosters ? ( + + ) : null} + 活动图 全部关联用户 + {canDownloadPosters ? ( + + ) : null} + ) : null} + + {detail.managedWarehouseName ?? ( @@ -812,6 +871,12 @@ export default function CityPartnersPage() {
+ + 0} + partnerIds={posterDownloadIds ?? []} + onClose={() => setPosterDownloadIds(null)} + /> ); } diff --git a/deploy/nginx-dukang-staging.conf b/deploy/nginx-dukang-staging.conf index f69078e..3f3c773 100644 --- a/deploy/nginx-dukang-staging.conf +++ b/deploy/nginx-dukang-staging.conf @@ -33,6 +33,19 @@ server { client_max_body_size 50m; + location ~ ^/api/v1/admin/activity-posters/[^/]+/partner-pack$ { + proxy_pass http://127.0.0.1:8190; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + location / { proxy_pass http://127.0.0.1:8190; proxy_http_version 1.1; @@ -183,6 +196,19 @@ server { include /opt/dukang-staging/deploy/nginx-no-crawler.conf; + location ~ ^/api/v1/admin/activity-posters/[^/]+/partner-pack$ { + proxy_pass http://127.0.0.1:8190; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + location /api/ { proxy_pass http://127.0.0.1:8190; proxy_http_version 1.1; diff --git a/deploy/nginx-dukanghaoke.conf b/deploy/nginx-dukanghaoke.conf index c6d519e..8bb6c6d 100644 --- a/deploy/nginx-dukanghaoke.conf +++ b/deploy/nginx-dukanghaoke.conf @@ -31,6 +31,19 @@ server { include /opt/dukang/deploy/nginx-deploy-webhook.conf; + location ~ ^/api/v1/admin/activity-posters/[^/]+/partner-pack$ { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + location / { proxy_pass http://127.0.0.1:8090; proxy_http_version 1.1; @@ -173,6 +186,19 @@ server { include /opt/dukang/deploy/nginx-no-crawler.conf; + location ~ ^/api/v1/admin/activity-posters/[^/]+/partner-pack$ { + proxy_pass http://127.0.0.1:8090; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + location /api/ { proxy_pass http://127.0.0.1:8090; proxy_http_version 1.1; diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index 9eaff45..0d73a3b 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -56,7 +56,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd **HQ 门店账单打款凭证**:确认打款 / 提现通过可填 `paymentRef`,并可上传照片(`paymentProofUrls`,OSS `PAYMENT_PROOF`,最多 9 张)。详情与 T+1 导出展示。 -**活动图(v4.0.2)**:规则见 v4-PRD §6。表 `activity_poster`;HQ `GET/POST/PUT/DELETE /admin/activity-posters`(权限 `activity_posters`);合伙人 `GET /partner/activity-posters` · `GET/PUT /partner/activity-posters/selection`(写入 `partner_account.activity_poster_id`)· `GET /partner/activity-posters/:id/image`(合成本人关联码)。`GET /partner/assoc` 返回 `activityPosterId`。码栏百分比相对图宽。子账号无入口。 +**活动图(v4.0.2 / v4.0.7)**:规则见 v4-PRD §6。表 `activity_poster`;HQ `GET/POST/PUT/DELETE /admin/activity-posters`(权限 `activity_posters`);合伙人 `GET /partner/activity-posters` · `GET/PUT /partner/activity-posters/selection`(写入 `partner_account.activity_poster_id`)· `GET /partner/activity-posters/:id/image`(合成本人关联码)。`GET /partner/assoc` 返回 `activityPosterId`。码栏百分比相对图宽。子账号无入口。v4.0.7:HQ `GET /admin/activity-posters/:id/image?partnerId=` 单张合成 PNG;`POST /admin/activity-posters/:id/partner-pack` `{ partnerIds }` 流式 zip(仅勾选,测试号/无码 skip)。城市合伙人快链 `/activity-posters?partnerId=`。不预生成缓存图、不批量调微信补码。 **合伙人关联与订单佣金(v4.0.1)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `GET /partner/assoc` · `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。 diff --git a/docs/杜康好客-v4-PRD.md b/docs/杜康好客-v4-PRD.md index aa20112..e72139a 100644 --- a/docs/杜康好客-v4-PRD.md +++ b/docs/杜康好客-v4-PRD.md @@ -1,8 +1,8 @@ # 杜康好客 · V4 PRD -> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账与订单完全比对 +> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出 > 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账)。 -> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md) +> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md) ## 0. 版本 @@ -11,6 +11,7 @@ | 4.0.1 | 08-29 / 08-30 | 合伙人关联码;订单佣金只认关联;账单酒单/核销分列;去掉区县酒单佣金;HQ 关联筛选与快链;合伙人 H5 用户管理与首页统计 | [`v4.0.1`](./杜康好客-v4.0.1-开发文档.md) | | 4.0.2 | 08-30 | HQ 活动图模板(底图 + 方形码栏 + 文案);合伙人下载合成关联码;所选图写入库并作为用户管理主图 | [`v4.0.2`](./杜康好客-v4.0.2-开发文档.md) | | 4.0.6 | 08-31 | 酒厂 T+3=每 3 天出一期;全部已完成已付订单(含现场提货);应付为 0 仍出账;核对补生成 | [`v4.0.6`](./杜康好客-v4.0.6-开发文档.md) | +| 4.0.7 | 09-01 | HQ 城市合伙人活动图快链;指定一张活动图为单个或勾选主合伙人合成下载(PNG / zip) | [`v4.0.7`](./杜康好客-v4.0.7-开发文档.md) | ## 1. 锚点(沿用 V3,佣金归属改写) @@ -75,6 +76,7 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照 - 微信内下载失败则预览 + 长按保存(与关联码下载一致)。 - 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。 - HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。 +- HQ 城市合伙人页提供活动图快链(列表列 / 详情 / 顶栏,进入 `/activity-posters?partnerId=`)。可指定一张已上架活动图:**单个下载**合成 PNG,或 **勾选主合伙人导出 zip**(仅已勾选,不做隐式全量)。合入码为已有 OSS 关联码;无码则单张报错、zip 记入跳过清单。不在本路径批量调微信补码,不预生成每人缓存图。 ## 7. 酒厂对账(v4.0.6) diff --git a/docs/杜康好客-v4-现状对照.md b/docs/杜康好客-v4-现状对照.md index 66cff2d..f84e898 100644 --- a/docs/杜康好客-v4-现状对照.md +++ b/docs/杜康好客-v4-现状对照.md @@ -3,14 +3,14 @@ > 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) > V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。 -## 0. 总览(2026-08-31) +## 0. 总览(2026-09-01) | 维度 | 结论 | |------|------| -| 版本线 | **v4.0.6** 酒厂对账与订单完全比对 | +| 版本线 | **v4.0.7** HQ 活动图快链与勾选导出 | | 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 | | 账单 | 酒订单 / 核销订单分列;酒厂含现场提货,零应付仍出账(无需打款) | -| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;用户管理主图跟选择走 | +| 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载 | ## 1. 版本交付 @@ -19,6 +19,7 @@ | 4.0.1 | [`关联码与分佣账单`](./杜康好客-v4.0.1-开发文档.md) | ✅ 已实现 | | 4.0.2 | [`活动图模板与关联码合成`](./杜康好客-v4.0.2-开发文档.md) | ✅ 已实现 | | 4.0.6 | [`酒厂对账核对`](./杜康好客-v4.0.6-开发文档.md) | ✅ 已实现 | +| 4.0.7 | [`活动图快链与勾选导出`](./杜康好客-v4.0.7-开发文档.md) | ✅ 已实现 | | 日期 | 说明 | |------|------| @@ -27,3 +28,4 @@ | 2026-08-30 | 合伙人 H5:首页关联用户/关联用户订单统计;用户管理 Tab(关联码+列表+备注);`partner_user_note` 与 HQ `hqRemark` 隔离 | | 2026-08-30 | v4.0.2:HQ 活动图(底图 + 拖拽码栏 + 文案);合伙人复制文案、下载合成关联码;选择写入库,用户管理主图下次登录仍显示 | | 2026-08-31 | v4.0.6:酒厂 T+3=每 3 天出一期(非每日);含现场提货;零应付仍出账;核对补生成 | +| 2026-09-01 | v4.0.7:HQ 城市合伙人活动图快链;单张/勾选导出合成图(PNG / zip) | diff --git a/docs/杜康好客-v4.0.7-开发文档.md b/docs/杜康好客-v4.0.7-开发文档.md new file mode 100644 index 0000000..f691b5b --- /dev/null +++ b/docs/杜康好客-v4.0.7-开发文档.md @@ -0,0 +1,49 @@ +# 杜康好客 · v4.0.7 开发文档 + +> **2026-09-01** · ops / store / admin-web +> **主题**:HQ 城市合伙人页活动图快链;指定一张活动图为单个或勾选的主合伙人合成关联码并下载(单张 PNG / zip) + +需求事实源:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) §6 + +--- + +## 1. 版本目标 + +| # | 任务 | 类型 | 交付 | +|---|------|------|------| +| 1 | 快链 | 需求 | 城市合伙人列表/详情/顶栏跳转活动图页(`?partnerId=`) | +| 2 | 单个下载 | 需求 | 行操作、详情抽屉、活动图页「为该合伙人下载」;弹窗选已上架活动图 → PNG | +| 3 | 勾选导出 | 需求 | 列表勾选主合伙人 → 选活动图 → zip(仅已勾选,非隐式全量) | + +**不做**:预生成每人缓存图;批量调微信补码;按每人当前所选活动图分别出图;BullMQ 异步包。 + +--- + +## 2. 规则 + +- 合入码 = 主合伙人已有 OSS 关联码。无码则单张报错、zip 写入 `_skipped.txt`。不在本路径调用 `getwxacodeunlimit`。 +- HQ 指定 **一张** 已上架活动图,贴进所有目标合伙人。 +- zip 仅含请求体 `partnerIds`;测试号(`isTest`)跳过。 +- 底图最长边 ≤ 2500 且宽×高 ≤ 4e6,超限报错。 +- Sharp 合成并发 1;底图只下载一次;zip 流式写出。 + +--- + +## 3. 变更面 + +- HQ:`GET /admin/activity-posters/:id/image?partnerId=` · `POST /admin/activity-posters/:id/partner-pack` `{ partnerIds }`;权限 `activity_posters` +- admin-web:城市合伙人快链 / 勾选导出 / 弹窗选图;活动图页 `?partnerId=` 上下文下载 +- nginx:partner-pack 路径 `proxy_buffering off` + `proxy_read_timeout 120s` +- `archiver` 依赖 + +无 Prisma 迁移。 + +--- + +## 4. 验收 + +- [ ] 城市合伙人「活动图」列、详情、顶栏快链进入活动图页;带 `partnerId` 时可「为该合伙人下载」 +- [ ] 行内/详情「下载活动图」弹窗选已上架图,得到该人合成 PNG;无关联码明确报错 +- [ ] 勾选 N 人导出 zip,文件名含城市/公司或姓名/id;缺码与测试号进 `_skipped.txt` +- [ ] 未勾选时导出按钮禁用;不做隐式全量 +- [ ] 底图过大被拒绝;打包期间不调用微信补码 diff --git a/packages/shared-types/src/activity-poster.test.ts b/packages/shared-types/src/activity-poster.test.ts index 3fb0e70..ea23593 100644 --- a/packages/shared-types/src/activity-poster.test.ts +++ b/packages/shared-types/src/activity-poster.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { activityPosterQrSlotPx } from './activity-poster'; +import { + activityPosterPackFileName, + activityPosterQrSlotPx, + activityPosterTemplateTooLarge, +} from './activity-poster'; describe('activityPosterQrSlotPx', () => { it('uses width for square size', () => { @@ -18,3 +22,45 @@ describe('activityPosterQrSlotPx', () => { }); }); }); + +describe('activityPosterTemplateTooLarge', () => { + it('allows 1080x1920', () => { + expect(activityPosterTemplateTooLarge(1080, 1920)).toBe(false); + }); + + it('rejects edge over 2500', () => { + expect(activityPosterTemplateTooLarge(2501, 1000)).toBe(true); + }); + + it('rejects pixel count over 4e6', () => { + expect(activityPosterTemplateTooLarge(2500, 2000)).toBe(true); + }); +}); + +describe('activityPosterPackFileName', () => { + it('uses city, company, id', () => { + expect(activityPosterPackFileName({ + cityName: '郑州', + companyName: '杜康合伙', + partnerName: '张三', + partnerId: '12', + })).toBe('郑州_杜康合伙_12.png'); + }); + + it('falls back to partner name when company empty', () => { + expect(activityPosterPackFileName({ + cityName: '洛阳', + companyName: ' ', + partnerName: '李四', + partnerId: '9', + })).toBe('洛阳_李四_9.png'); + }); + + it('strips path separators', () => { + expect(activityPosterPackFileName({ + cityName: 'a/b', + companyName: 'c:d', + partnerId: '1', + })).toBe('a-b_c-d_1.png'); + }); +}); diff --git a/packages/shared-types/src/activity-poster.ts b/packages/shared-types/src/activity-poster.ts index da3bddf..2d86e76 100644 --- a/packages/shared-types/src/activity-poster.ts +++ b/packages/shared-types/src/activity-poster.ts @@ -55,6 +55,39 @@ export type ActivityPosterSelectionRequest = { posterId?: string | null; }; +export type ActivityPosterPackRequest = { + partnerIds: string[]; +}; + +/** 底图最长边(px);超出则拒绝合成,避免单进程 Sharp 撑爆内存 */ +export const ACTIVITY_POSTER_MAX_EDGE = 2500; +/** 底图像素上限(宽×高) */ +export const ACTIVITY_POSTER_MAX_PIXELS = 4_000_000; +/** 单次 zip 最多合伙人数 */ +export const ACTIVITY_POSTER_PACK_MAX_PARTNERS = 200; + +export function activityPosterTemplateTooLarge(width: number, height: number): boolean { + const w = Math.max(0, Math.round(width)); + const h = Math.max(0, Math.round(height)); + return w > ACTIVITY_POSTER_MAX_EDGE || h > ACTIVITY_POSTER_MAX_EDGE || w * h > ACTIVITY_POSTER_MAX_PIXELS; +} + +/** zip / 单张下载文件名:`{城市}_{公司或姓名}_{id}.png` */ +export function activityPosterPackFileName(input: { + cityName?: string | null; + companyName?: string | null; + partnerName?: string | null; + partnerId: string; +}): string { + const label = (input.companyName?.trim() || input.partnerName?.trim() || '').trim(); + const raw = [input.cityName?.trim(), label, input.partnerId.trim()] + .filter(Boolean) + .join('_') + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/\s+/g, ''); + return `${raw || input.partnerId}.png`; +} + /** 码栏百分比 → 像素。size 相对图宽,保证正方形;落点夹在画布内。 */ export function activityPosterQrSlotPx( width: number, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 719a71f..a538c97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -352,6 +352,9 @@ importers: ali-oss: specifier: ^6.23.0 version: 6.23.0 + archiver: + specifier: ^7.0.1 + version: 7.0.1 bullmq: specifier: ^5.12.0 version: 5.79.2 @@ -404,6 +407,9 @@ importers: '@types/ali-oss': specifier: ^6.23.3 version: 6.23.3 + '@types/archiver': + specifier: ^6.0.4 + version: 6.0.4 '@types/express': specifier: ^4.17.21 version: 4.17.25 @@ -2708,6 +2714,9 @@ packages: '@types/ali-oss@6.23.3': resolution: {integrity: sha512-huSf6njkqhSeR37OMJTGMCyUg2d9Zp2AioefhYuUMeh0vNM+WybctrBor7bO/RcCbLCQI6sHn6NtPBIWALYVJg==} + '@types/archiver@6.0.4': + resolution: {integrity: sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==} + '@types/archy@0.0.31': resolution: {integrity: sha512-v+dxizsFVyXgD3EpFuqT9YjdEjbJmPxNf1QIX9ohZOhxh1ZF2yhqv3vYaeum9lg3VghhxS5S0a6yldN9J9lPEQ==} @@ -2828,6 +2837,9 @@ packages: '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + '@types/readdir-glob@1.1.5': + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} @@ -2991,6 +3003,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + abortcontroller-polyfill@1.7.8: resolution: {integrity: sha512-9f1iZ2uWh92VcrU9Y8x+LdM4DLj75VE0MJB8zuF1iUnroEptStw+DQ8EQPMUdfe5k+PkB1uUfDQfWbhstH8LrQ==} @@ -3115,10 +3131,18 @@ packages: resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} engines: {node: '>= 10'} + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + archiver@5.3.2: resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} engines: {node: '>= 10'} + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + archy@1.0.0: resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} @@ -3169,6 +3193,14 @@ packages: axios@1.18.1: resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-plugin-const-enum@1.2.0: resolution: {integrity: sha512-o1m/6iyyFnp9MRsK1dHF3bneqyf3AlM2q3A/YbgQr2pCat6B6XJVDv2TXqzfY2RYUi4mak6WAksSBPlyYGx9dg==} peerDependencies: @@ -3244,6 +3276,43 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.1: + resolution: {integrity: sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.4: + resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.5.2: + resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==} + base64-js@0.0.8: resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} engines: {node: '>= 0.4'} @@ -3317,6 +3386,10 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -3333,6 +3406,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buffers@0.1.1: resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} engines: {node: '>=0.2.0'} @@ -3552,6 +3628,10 @@ packages: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} @@ -3645,6 +3725,10 @@ packages: resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} engines: {node: '>= 10'} + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -4041,9 +4125,16 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -4086,6 +4177,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -4689,6 +4783,10 @@ packages: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5638,6 +5736,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + promise-polyfill@7.1.2: resolution: {integrity: sha512-FuEc12/eKqqoRYIGBrUptCBRhobL19PS2U31vMNTfyck1FxPyMfgsXyW4Mav85y/ZN1hop3hOwRlUDok23oYfQ==} @@ -5974,6 +6076,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} @@ -6352,6 +6458,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + strict-uri-encode@1.1.0: resolution: {integrity: sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==} engines: {node: '>=0.10.0'} @@ -6468,6 +6577,12 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + tar-stream@3.2.1: + resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + terser-webpack-plugin@5.6.1: resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} engines: {node: '>= 10.13.0'} @@ -6516,6 +6631,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -7082,6 +7200,10 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zrender@6.1.0: resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} @@ -9585,6 +9707,10 @@ snapshots: '@types/ali-oss@6.23.3': {} + '@types/archiver@6.0.4': + dependencies: + '@types/readdir-glob': 1.1.5 + '@types/archy@0.0.31': {} '@types/babel__core@7.20.5': @@ -9732,6 +9858,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 20.19.43 + '@types/responselike@1.0.3': dependencies: '@types/node': 20.19.43 @@ -9998,6 +10128,10 @@ snapshots: '@xtuc/long@4.2.2': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + abortcontroller-polyfill@1.7.8: {} accepts@1.3.8: @@ -10213,6 +10347,16 @@ snapshots: normalize-path: 3.0.0 readable-stream: 3.6.2 + archiver-utils@5.0.2: + dependencies: + glob: 10.4.5 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.18.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + archiver@5.3.2: dependencies: archiver-utils: 2.1.0 @@ -10223,6 +10367,20 @@ snapshots: tar-stream: 2.2.0 zip-stream: 4.1.1 + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.1 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + archy@1.0.0: {} arg@4.1.3: {} @@ -10268,6 +10426,8 @@ snapshots: - debug - supports-color + b4a@1.8.1: {} + babel-plugin-const-enum@1.2.0(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -10363,6 +10523,35 @@ snapshots: balanced-match@4.0.4: {} + bare-events@2.9.2: {} + + bare-fs@4.8.1: + dependencies: + bare-events: 2.9.2 + bare-path: 3.1.1 + bare-stream: 2.13.4(bare-events@2.9.2) + bare-url: 2.5.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.4(bare-events@2.9.2): + dependencies: + b4a: 1.8.1 + streamx: 2.28.1 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.5.2: + dependencies: + bare-path: 3.1.1 + base64-js@0.0.8: {} base64-js@1.5.1: {} @@ -10452,6 +10641,8 @@ snapshots: buffer-crc32@0.2.13: {} + buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} buffer-fill@1.0.0: {} @@ -10465,6 +10656,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffers@0.1.1: {} builtin-status-codes@3.0.0: {} @@ -10735,6 +10931,14 @@ snapshots: normalize-path: 3.0.0 readable-stream: 3.6.2 + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + compute-scroll-into-view@3.1.1: {} concat-map@0.0.1: {} @@ -10821,6 +11025,11 @@ snapshots: crc-32: 1.2.2 readable-stream: 3.6.2 + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + create-require@1.1.1: {} cron-parser@4.9.0: @@ -11274,8 +11483,16 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} exceljs@4.4.0: @@ -11366,6 +11583,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -12041,6 +12260,8 @@ snapshots: is-stream@1.1.0: {} + is-stream@2.0.1: {} + is-stream@3.0.0: {} is-type-of@1.4.0: @@ -12890,6 +13111,8 @@ snapshots: process-nextick-args@2.0.1: {} + process@0.11.10: {} + promise-polyfill@7.1.2: {} property-expr@2.0.6: {} @@ -13331,6 +13554,14 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdir-glob@1.1.3: dependencies: minimatch: 5.1.9 @@ -13773,6 +14004,15 @@ snapshots: streamsearch@1.1.0: {} + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-uri-encode@1.1.0: {} string-convert@0.2.1: {} @@ -13929,6 +14169,24 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + tar-stream@3.2.1: + dependencies: + b4a: 1.8.1 + bare-fs: 4.8.1 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + terser-webpack-plugin@5.6.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)(webpack@5.97.1(@swc/core@1.3.96(@swc/helpers@0.5.23))(postcss@8.5.15)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -13958,6 +14216,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + text-table@0.2.0: {} thenify-all@1.6.0: @@ -14569,6 +14833,12 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zrender@6.1.0: dependencies: tslib: 2.3.0 diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index 2d86d8b..562581f 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -42,6 +42,7 @@ "@sentry/node": "^10.69.0", "@wecom/aibot-node-sdk": "^1.0.7", "ali-oss": "^6.23.0", + "archiver": "^7.0.1", "bullmq": "^5.12.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", @@ -61,6 +62,7 @@ "@nestjs/cli": "^10.4.0", "@nestjs/schematics": "^10.1.0", "@types/ali-oss": "^6.23.3", + "@types/archiver": "^6.0.4", "@types/express": "^4.17.21", "@types/multer": "^2.1.0", "@types/node": "^20.14.0", diff --git a/server/dukang-api/scripts/bench-activity-poster-pack.ts b/server/dukang-api/scripts/bench-activity-poster-pack.ts new file mode 100644 index 0000000..297bef8 --- /dev/null +++ b/server/dukang-api/scripts/bench-activity-poster-pack.ts @@ -0,0 +1,64 @@ +/** + * Sequential Sharp compose bench (1 / 10 / 50) on a 1080×1920 template. + * Usage: node scripts/with-api-env.cjs npx ts-node --transpile-only scripts/bench-activity-poster-pack.ts + * or from dukang-api: npx ts-node --transpile-only scripts/bench-activity-poster-pack.ts + */ +import sharp from 'sharp'; +import { + DEFAULT_ACTIVITY_POSTER_QR_SLOT, + activityPosterQrSlotPx, + activityPosterTemplateTooLarge, +} from '@dukang/shared-types'; + +async function png(width: number, height: number, r: number, g: number, b: number) { + return sharp({ + create: { width, height, channels: 3, background: { r, g, b } }, + }) + .png() + .toBuffer(); +} + +async function compose(template: Buffer, qrPng: Buffer) { + const meta = await sharp(template).metadata(); + if (!meta.width || !meta.height) throw new Error('no size'); + if (activityPosterTemplateTooLarge(meta.width, meta.height)) throw new Error('too large'); + const { left, top, size } = activityPosterQrSlotPx( + meta.width, + meta.height, + DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrXPct, + DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrYPct, + DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrSizePct, + ); + const qr = await sharp(qrPng).resize(size, size, { fit: 'fill' }).png().toBuffer(); + return sharp(template).composite([{ input: qr, left, top }]).png().toBuffer(); +} + +function rssMb() { + return Math.round(process.memoryUsage().rss / 1024 / 1024); +} + +async function bench(n: number, template: Buffer, qr: Buffer) { + const t0 = Date.now(); + const rss0 = rssMb(); + let last = 0; + for (let i = 0; i < n; i += 1) { + const out = await compose(template, qr); + last = out.length; + } + const ms = Date.now() - t0; + return { n, ms, avgMs: Math.round(ms / n), rss0, rss1: rssMb(), lastKb: Math.round(last / 1024) }; +} + +async function main() { + const template = await png(1080, 1920, 160, 32, 32); + const qr = await png(430, 430, 255, 255, 255); + console.log(`template=${Math.round(template.length / 1024)}KB qr=${Math.round(qr.length / 1024)}KB rss=${rssMb()}MB`); + for (const n of [1, 10, 50]) { + const row = await bench(n, template, qr); + console.log( + `n=${row.n} total=${row.ms}ms avg=${row.avgMs}ms rss ${row.rss0}->${row.rss1}MB out≈${row.lastKb}KB`, + ); + } +} + +void main(); diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 01a912e..5785506 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -129,6 +129,7 @@ export const HqOperationAction = { ACTIVITY_POSTER_UPDATE: 'ACTIVITY_POSTER_UPDATE', ACTIVITY_POSTER_UPDATE_STATUS: 'ACTIVITY_POSTER_UPDATE_STATUS', ACTIVITY_POSTER_DELETE: 'ACTIVITY_POSTER_DELETE', + ACTIVITY_POSTER_PACK: 'ACTIVITY_POSTER_PACK', } as const; export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction]; @@ -263,6 +264,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.ACTIVITY_POSTER_UPDATE]: '编辑活动图', [HqOperationAction.ACTIVITY_POSTER_UPDATE_STATUS]: '活动图上下架', [HqOperationAction.ACTIVITY_POSTER_DELETE]: '删除活动图', + [HqOperationAction.ACTIVITY_POSTER_PACK]: '导出合伙人活动图', STORE_PAYOUT: '门店打款确认', }; diff --git a/server/dukang-api/src/modules/ops/activity-poster.service.ts b/server/dukang-api/src/modules/ops/activity-poster.service.ts index b1db723..ee51cfb 100644 --- a/server/dukang-api/src/modules/ops/activity-poster.service.ts +++ b/server/dukang-api/src/modules/ops/activity-poster.service.ts @@ -1,6 +1,12 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { activityPosterQrSlotPx } from '@dukang/shared-types'; +import { + activityPosterPackFileName, + activityPosterQrSlotPx, + activityPosterTemplateTooLarge, +} from '@dukang/shared-types'; +import archiver from 'archiver'; +import type { Response } from 'express'; import sharp from 'sharp'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; @@ -108,11 +114,7 @@ export class ActivityPosterService { } async composeForPartner(partnerAccountId: bigint, posterId: bigint) { - const poster = await this.require(posterId); - if (poster.status !== 'ACTIVE') { - throw new NotFoundException('活动图不存在或已下架'); - } - + const poster = await this.requireActive(posterId); let qr: { buffer: Buffer; fileName: string }; try { qr = await this.partnerAssoc.getQrcodeBuffer(partnerAccountId); @@ -123,17 +125,139 @@ export class ActivityPosterService { throw e; } const template = await this.fetchPngLike(poster.imageUrl, '活动图底图下载失败'); - const buffer = await this.compose(template, qr.buffer, poster); return { buffer, fileName: `activity-poster-${poster.id}.png` }; } - private async compose(template: Buffer, qrPng: Buffer, poster: PosterRow) { - const base = sharp(template); - const meta = await base.metadata(); + async composeForHqPartner(partnerAccountId: bigint, posterId: bigint) { + const poster = await this.requireActive(posterId); + const qr = await this.partnerAssoc.getExistingQrcodeBuffer(partnerAccountId); + if (!qr) { + throw new BadRequestException('关联码尚未生成,无法合成活动图'); + } + const template = await this.fetchPngLike(poster.imageUrl, '活动图底图下载失败'); + const buffer = await this.compose(template, qr.buffer, poster); + return { buffer, fileName: await this.fileNameForPartner(partnerAccountId) }; + } + + async packForPartners(posterId: bigint, partnerIds: string[], res: Response) { + const ids = uniqueNumericIds(partnerIds); + if (!ids.length) throw new BadRequestException('请选择有效的合伙人'); + + const poster = await this.requireActive(posterId); + const template = await this.fetchPngLike(poster.imageUrl, '活动图底图下载失败'); + await this.assertTemplateSize(template); + + const rows = await this.prisma.partnerAccount.findMany({ + where: { id: { in: ids.map((id) => BigInt(id)) }, isPrimary: 1 }, + select: { + id: true, + isTest: true, + companyName: true, + name: true, + assocQrcodeResource: { select: { url: true } }, + city: { select: { name: true } }, + }, + }); + const byId = new Map(rows.map((row) => [row.id.toString(), row])); + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="activity-poster-${poster.id.toString()}-pack.zip"`, + ); + res.setHeader('Cache-Control', 'no-store'); + + const archive = archiver('zip', { zlib: { level: 6 } }); + const done = new Promise((resolve, reject) => { + archive.on('end', () => resolve()); + archive.on('error', reject); + res.on('error', reject); + }); + archive.pipe(res); + + const skipped: string[] = []; + for (const id of ids) { + const row = byId.get(id); + if (!row) { + skipped.push(`${id}\t不是有效主合伙人`); + continue; + } + if (row.isTest) { + skipped.push(`${id}\t测试账号已跳过`); + continue; + } + const qrUrl = row.assocQrcodeResource?.url; + if (!qrUrl) { + skipped.push(`${id}\t关联码尚未生成`); + continue; + } + const qrRes = await fetch(qrUrl); + if (!qrRes.ok) { + skipped.push(`${id}\t关联码下载失败`); + continue; + } + const qrBuffer = Buffer.from(await qrRes.arrayBuffer()); + try { + const buffer = await this.compose(template, qrBuffer, poster); + archive.append(buffer, { + name: activityPosterPackFileName({ + cityName: row.city?.name, + companyName: row.companyName, + partnerName: row.name, + partnerId: id, + }), + }); + } catch { + skipped.push(`${id}\t合成失败`); + } + } + if (skipped.length) { + archive.append(`${skipped.join('\n')}\n`, { name: '_skipped.txt' }); + } + await archive.finalize(); + await done; + } + + private async fileNameForPartner(partnerAccountId: bigint) { + const row = await this.prisma.partnerAccount.findUnique({ + where: { id: partnerAccountId }, + select: { id: true, companyName: true, name: true, city: { select: { name: true } } }, + }); + return activityPosterPackFileName({ + cityName: row?.city?.name, + companyName: row?.companyName, + partnerName: row?.name, + partnerId: partnerAccountId.toString(), + }); + } + + private async requireActive(id: bigint) { + const poster = await this.require(id); + if (poster.status !== 'ACTIVE') { + throw new NotFoundException('活动图不存在或已下架'); + } + return poster; + } + + private async assertTemplateSize(template: Buffer) { + const meta = await sharp(template).metadata(); if (!meta.width || !meta.height) { throw new BadRequestException('活动图底图无法读取尺寸'); } + if (activityPosterTemplateTooLarge(meta.width, meta.height)) { + throw new BadRequestException('活动图底图过大,请压缩后再上传(最长边不超过 2500px)'); + } + } + + private async compose(template: Buffer, qrPng: Buffer, poster: PosterRow) { + const meta = await sharp(template).metadata(); + if (!meta.width || !meta.height) { + throw new BadRequestException('活动图底图无法读取尺寸'); + } + if (activityPosterTemplateTooLarge(meta.width, meta.height)) { + throw new BadRequestException('活动图底图过大,请压缩后再上传(最长边不超过 2500px)'); + } const { left, top, size } = activityPosterQrSlotPx( meta.width, meta.height, @@ -142,7 +266,7 @@ export class ActivityPosterService { Number(poster.qrSizePct), ); const qr = await sharp(qrPng).resize(size, size, { fit: 'fill' }).png().toBuffer(); - return base.composite([{ input: qr, left, top }]).png().toBuffer(); + return sharp(template).composite([{ input: qr, left, top }]).png().toBuffer(); } private async fetchPngLike(url: string, failMessage: string) { @@ -186,3 +310,15 @@ export class ActivityPosterService { }; } } + +function uniqueNumericIds(raw: string[]): string[] { + const seen = new Set(); + const ids: string[] = []; + for (const item of raw) { + const id = String(item ?? '').trim(); + if (!/^\d+$/.test(id) || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} diff --git a/server/dukang-api/src/modules/ops/admin-activity-posters.controller.ts b/server/dukang-api/src/modules/ops/admin-activity-posters.controller.ts index b2c3d29..9b8bf75 100644 --- a/server/dukang-api/src/modules/ops/admin-activity-posters.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-activity-posters.controller.ts @@ -1,10 +1,12 @@ -import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, UseGuards, BadRequestException } from '@nestjs/common'; +import type { Response } from 'express'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqPermissionGuard, RequireHqPermissions } from '../../common/guards/hq-permission.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { ActivityPosterService } from './activity-poster.service'; import { + ActivityPosterPackDto, ActivityPosterQueryDto, UpdateActivityPosterStatusDto, UpsertActivityPosterDto, @@ -21,6 +23,36 @@ export class AdminActivityPostersController { return this.service.adminList(query); } + @Get(':id/image') + async image( + @Param('id') id: string, + @Query('partnerId') partnerId: string, + @Res() res: Response, + ) { + if (!partnerId || !/^\d+$/.test(partnerId.trim())) { + throw new BadRequestException('请指定合伙人'); + } + const { buffer, fileName } = await this.service.composeForHqPartner(BigInt(partnerId.trim()), BigInt(id)); + res.setHeader('Content-Type', 'image/png'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="activity-poster.png"; filename*=UTF-8''${encodeURIComponent(fileName)}`, + ); + res.send(buffer); + } + + @Post(':id/partner-pack') + @HqOperation({ + action: HqOperationAction.ACTIVITY_POSTER_PACK, + refType: 'ACTIVITY_POSTER', + refIdParam: 'id', + includeBody: true, + includeResponse: false, + }) + async pack(@Param('id') id: string, @Body() dto: ActivityPosterPackDto, @Res() res: Response) { + await this.service.packForPartners(BigInt(id), dto.partnerIds, res); + } + @Get(':id') detail(@Param('id') id: string) { return this.service.adminDetail(BigInt(id)); diff --git a/server/dukang-api/src/modules/ops/dto/activity-poster.dto.ts b/server/dukang-api/src/modules/ops/dto/activity-poster.dto.ts index cfd2683..37a8552 100644 --- a/server/dukang-api/src/modules/ops/dto/activity-poster.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/activity-poster.dto.ts @@ -1,5 +1,8 @@ import { Type } from 'class-transformer'; import { + ArrayMaxSize, + ArrayMinSize, + IsArray, IsIn, IsInt, IsNumber, @@ -11,7 +14,7 @@ import { MinLength, ValidateIf, } from 'class-validator'; -import { ACTIVITY_POSTER_STATUSES } from '@dukang/shared-types'; +import { ACTIVITY_POSTER_PACK_MAX_PARTNERS, ACTIVITY_POSTER_STATUSES } from '@dukang/shared-types'; import { PaginationQueryDto } from './admin-query.dto'; export class ActivityPosterQueryDto extends PaginationQueryDto { @@ -76,3 +79,11 @@ export class ActivityPosterSelectionDto { @IsString() posterId?: string | null; } + +export class ActivityPosterPackDto { + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(ACTIVITY_POSTER_PACK_MAX_PARTNERS) + @IsString({ each: true }) + partnerIds!: string[]; +} diff --git a/server/dukang-api/src/modules/store/partner-assoc.service.ts b/server/dukang-api/src/modules/store/partner-assoc.service.ts index 4fc1f57..bab314d 100644 --- a/server/dukang-api/src/modules/store/partner-assoc.service.ts +++ b/server/dukang-api/src/modules/store/partner-assoc.service.ts @@ -452,4 +452,19 @@ export class PartnerAssocService { const buffer = Buffer.from(await res.arrayBuffer()); return { buffer, fileName: `partner-assoc-${summary.partnerId}.png` }; } + + /** 只读已有 OSS 关联码,不调微信补码。无码返回 null。 */ + async getExistingQrcodeBuffer(partnerAccountId: bigint): Promise<{ buffer: Buffer; fileName: string } | null> { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + if (!primary.assocQrcodeResourceId) return null; + const resource = await this.prisma.commonResource.findUnique({ + where: { id: primary.assocQrcodeResourceId }, + select: { url: true }, + }); + if (!resource?.url) return null; + const res = await fetch(resource.url); + if (!res.ok) return null; + const buffer = Buffer.from(await res.arrayBuffer()); + return { buffer, fileName: `partner-assoc-${primary.id.toString()}.png` }; + } }