From 88053353ab6dddee802097c07e3a31cf44dcf272 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 12 Aug 2026 22:48:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops,store,partner):=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=9D=83=E7=9B=8A=E5=88=97=20+=20=E9=97=A8=E5=BA=97=E5=90=88?= =?UTF-8?q?=E5=90=8C=E5=A4=9A=E5=9B=BE=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin 用户列表新增剩余/已用/累计好客权益金额三列(benefitCoupon groupBy 聚合,排除 VOID) - admin/partner 门店入驻合同支持多张照片与 PDF(CommonResource 多记录,复用 ENV 多图逻辑) - 新增 contract-urls.util 归一化工具,向后兼容旧 contractUrl 字段并标记 deprecated 需求1/2/3 --- .../src/components/MultiImageUpload.tsx | 47 +++++++++++++--- apps/admin-web/src/lib/api.ts | 6 +++ apps/admin-web/src/lib/storeCreate.ts | 3 +- apps/admin-web/src/pages/StoresPage.tsx | 44 +++++++++++---- apps/admin-web/src/pages/UsersPage.tsx | 54 ++++++++++++++++++- .../src/components/MultiOssUploadField.tsx | 54 +++++++++++++++---- apps/h5-partner/src/lib/storeDraft.ts | 29 ++++++++-- apps/h5-partner/src/pages/StoreCreatePage.tsx | 26 ++++++--- .../common/store-media/contract-urls.util.ts | 40 ++++++++++++++ .../src/modules/ops/admin-stores.service.ts | 26 ++++----- .../src/modules/ops/admin-users.service.ts | 53 +++++++++++++++++- .../src/modules/ops/dto/admin-mutate.dto.ts | 16 ++++++ .../src/modules/store/store.service.ts | 17 +++--- 13 files changed, 356 insertions(+), 59 deletions(-) create mode 100644 server/dukang-api/src/common/store-media/contract-urls.util.ts diff --git a/apps/admin-web/src/components/MultiImageUpload.tsx b/apps/admin-web/src/components/MultiImageUpload.tsx index f8d8fe8..7b358e6 100644 --- a/apps/admin-web/src/components/MultiImageUpload.tsx +++ b/apps/admin-web/src/components/MultiImageUpload.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Image, Space, Typography, Upload, message } from 'antd'; -import { UploadOutlined, DeleteOutlined } from '@ant-design/icons'; +import { UploadOutlined, DeleteOutlined, FilePdfOutlined } from '@ant-design/icons'; import type { UploadProps } from 'antd'; import { uploadFileToOss, type OssMediaType } from '../lib/upload'; @@ -13,12 +13,18 @@ type Props = { maxCount?: number; tip?: string; accept?: string; + /** 上传按钮文案,默认「批量上传图片」 */ + buttonText?: string; }; function normalizeUrls(value?: string[]) { return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean); } +function isPdf(url: string) { + return /\.pdf(\?|$)/i.test(url); +} + /** * 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。 * Form.Item 直接绑定 string[]。 @@ -31,6 +37,7 @@ export default function MultiImageUpload({ maxCount, tip, accept = 'image/*', + buttonText = '批量上传图片', }: Props) { const urls = normalizeUrls(value); const urlsRef = useRef(urls); @@ -143,12 +150,36 @@ export default function MultiImageUpload({ {urls.map((url, index) => (
- + {isPdf(url) ? ( + + + PDF + + ) : ( + + )} diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 24711e6..e44d304 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -168,6 +168,12 @@ export type AdminUserRow = { createdAt: string; orderCount: number; isTest?: boolean; + /** 好客权益·累计获得(含已使用,不含退款作废) */ + benefitTotalAmount?: number; + /** 好客权益·已使用(已核销) */ + benefitUsedAmount?: number; + /** 好客权益·剩余未使用 */ + benefitBalance?: number; }; export type AdminOrderItem = { diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index f595209..86f9453 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -22,7 +22,8 @@ export type StoreCreateForm = { avgPrice?: number | null; coverUrl?: string; envPhotoUrls?: string[]; - contractUrl?: string; + /** 签约合同,支持多张照片 / PDF */ + contractUrls?: string[]; bankAccountName: string; bankAccountNo: string; bankBranch: string; diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 9d7c08b..9928cf4 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -116,7 +116,7 @@ function StoreAuditMediaEditor() { type="info" showIcon style={{ marginBottom: 16 }} - message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。" + message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照、签约合同均最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。" /> @@ -133,8 +133,20 @@ function StoreAuditMediaEditor() { tip="环境照支持一次选择多张批量上传" /> - - + +
); @@ -393,9 +405,9 @@ export default function StoresPage() { const urls = envs.map((item) => item.url).filter(Boolean); return urls; })(), - contractUrl: (() => { + contractUrls: (() => { const { contracts } = collectMediaUrls(d); - return contracts[0]?.url || ''; + return contracts.map((item) => item.url).filter(Boolean); })(), province: d.province, city: d.cityName, @@ -442,7 +454,9 @@ export default function StoresPage() { envPhotoUrls: Array.isArray(v.envPhotoUrls) ? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean) : [], - contractUrl: v.contractUrl ?? '', + contractUrls: Array.isArray(v.contractUrls) + ? v.contractUrls.map((u: string) => String(u || '').trim()).filter(Boolean) + : [], intro: v.intro, benefitUsageRule: typeof v.benefitUsageRule === 'string' && @@ -605,6 +619,7 @@ export default function StoresPage() { } const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[]; + const contractUrls = (values.contractUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[]; await request('/admin/stores', { method: 'POST', body: JSON.stringify({ @@ -639,7 +654,7 @@ export default function StoresPage() { : {}), coverUrl: values.coverUrl?.trim() || undefined, envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, - contractUrl: values.contractUrl?.trim() || undefined, + contractUrls: contractUrls.length ? contractUrls : undefined, bankAccountName: values.bankAccountName.trim(), bankAccountNo: values.bankAccountNo.replace(/\s/g, ''), bankBranch: values.bankBranch.trim(), @@ -1340,8 +1355,19 @@ export default function StoresPage() { tip="环境照支持一次选择多张批量上传" /> - - + +
diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index 1995a73..c828398 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -30,6 +30,13 @@ type UserOrderRow = { createdAt: string; }; +/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */ +function fmtBenefit(v: number | null | undefined) { + const n = Number(v ?? 0); + if (!Number.isFinite(n) || n <= 0) return '—'; + return `¥${n.toFixed(2)}`; +} + type UserBehaviorLog = { id: string; eventName: string; @@ -315,6 +322,39 @@ export default function UsersPage() { render: (v) => (v ? 已合并 : '—'), }, { title: '订单数', dataIndex: 'orderCount', width: 80 }, + { + title: '剩余权益', + dataIndex: 'benefitBalance', + width: 110, + align: 'right', + sorter: (a, b) => Number(a.benefitBalance ?? 0) - Number(b.benefitBalance ?? 0), + render: (v: number | undefined) => + Number(v ?? 0) > 0 ? ( + + {fmtBenefit(v)} + + ) : ( + + ), + }, + { + title: '已用权益', + dataIndex: 'benefitUsedAmount', + width: 110, + align: 'right', + sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0), + render: (v: number | undefined) => ( + {fmtBenefit(v)} + ), + }, + { + title: '累计权益', + dataIndex: 'benefitTotalAmount', + width: 110, + align: 'right', + sorter: (a, b) => Number(a.benefitTotalAmount ?? 0) - Number(b.benefitTotalAmount ?? 0), + render: (v: number | undefined) => fmtBenefit(v), + }, { title: '注册时间', dataIndex: 'createdAt', @@ -389,7 +429,7 @@ export default function UsersPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1500 }} + scroll={{ x: 1830 }} rowSelection={canDeleteUsers ? { selectedRowKeys, preserveSelectedRowKeys: true, @@ -461,6 +501,18 @@ export default function UsersPage() { {detail.mergedFromCount ?? 0} {detail.orderCount} / {detail.addressCount} + + + + 剩余{' '} + + {fmtBenefit(detail.benefitBalance)} + + + 已用 {fmtBenefit(detail.benefitUsedAmount)} + 累计 {fmtBenefit(detail.benefitTotalAmount)} + + {new Date(detail.createdAt).toLocaleString('zh-CN')} diff --git a/apps/h5-partner/src/components/MultiOssUploadField.tsx b/apps/h5-partner/src/components/MultiOssUploadField.tsx index 3bdcab6..50067eb 100644 --- a/apps/h5-partner/src/components/MultiOssUploadField.tsx +++ b/apps/h5-partner/src/components/MultiOssUploadField.tsx @@ -13,12 +13,20 @@ type Props = { maxCount?: number; disabled?: boolean; label?: string; + /** 系统文件选择器的 accept,默认仅图片 */ + accept?: string; + /** 计量单位文案,如「张」「个」 */ + unit?: string; }; function isCancelError(msg: string): boolean { return /cancel|取消/i.test(msg); } +function isPdf(url: string): boolean { + return /\.pdf(\?|$)/i.test(url); +} + function normalizeUrls(value?: string[]) { return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean); } @@ -32,6 +40,8 @@ export default function MultiOssUploadField({ maxCount = 20, disabled, label, + accept = 'image/*', + unit = '张', }: Props) { const inputRef = useRef(null); const pickingRef = useRef(false); @@ -67,7 +77,7 @@ export default function MultiOssUploadField({ const room = Math.max(0, maxCount - current.length); const picked = files.slice(0, room); if (!picked.length) { - showUploadError(`最多 ${maxCount} 张`); + showUploadError(`最多 ${maxCount} ${unit}`); return; } setUploading(true); @@ -83,7 +93,7 @@ export default function MultiOssUploadField({ const next = [...urlsRef.current, ...appended]; urlsRef.current = next; onChangeRef.current?.(next); - toastSuccess(`已上传 ${appended.length} 张`); + toastSuccess(`已上传 ${appended.length} ${unit}`); } } catch (e) { showUploadError(e instanceof Error ? e.message : '上传失败'); @@ -126,7 +136,7 @@ export default function MultiOssUploadField({ {urls.map((url, index) => (
- + {isPdf(url) ? ( + + + description + + PDF + + ) : ( + + )} {!disabled ? ( diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts index c82c1b4..d52f08d 100644 --- a/apps/h5-partner/src/lib/storeDraft.ts +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -28,7 +28,8 @@ export type StoreDraftForm = { benefitUsageRule: string; coverUrl: string; envPhotoUrls: string[]; - contractUrl: string; + /** 签约合同,支持多张照片 / PDF */ + contractUrls: string[]; bankAccountName: string; bankAccountNo: string; bankBranch: string; @@ -70,7 +71,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({ benefitUsageRule: '', coverUrl: '', envPhotoUrls: ['', '', ''], - contractUrl: '', + contractUrls: [], bankAccountName: '', bankAccountNo: '', bankBranch: '', @@ -88,6 +89,9 @@ function timeToMinutes(hhmm: string): number { export const MIN_ENV_PHOTO_COUNT = 3; +/** 签约合同最多可上传的照片 / PDF 数量 */ +export const MAX_CONTRACT_COUNT = 20; + export function normalizeStringArray(urls: unknown, minLen: number): string[] { const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : []; while (arr.length < minLen) arr.push(''); @@ -118,6 +122,22 @@ export function normalizeStoreDraftForm(raw: Partial | null | un closeTime2: String(raw.closeTime2 ?? base.closeTime2), avgPrice: String(raw.avgPrice ?? base.avgPrice), envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT), + // 兼容旧草稿:单个 contractUrl 迁移为数组 + contractUrls: (() => { + const list = Array.isArray(raw.contractUrls) + ? raw.contractUrls + : [(raw as { contractUrl?: unknown }).contractUrl]; + const seen = new Set(); + const out: string[] = []; + for (const item of list) { + const url = String(item ?? '').trim(); + if (!url || seen.has(url)) continue; + seen.add(url); + out.push(url); + if (out.length >= MAX_CONTRACT_COUNT) break; + } + return out; + })(), packages: Array.isArray(raw.packages) ? raw.packages.map((p, i) => ({ name: String((p as { name?: string }).name ?? ''), @@ -224,12 +244,13 @@ export function validateStoreStep1( } export function validateStoreStep2( - form: Pick, + form: Pick, ): string | null { if (!form.coverUrl.trim()) return '请上传门头照'; const envCount = form.envPhotoUrls.filter((u) => u.trim()).length; if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`; - if (!form.contractUrl.trim()) return '请上传签约合同'; + const contractCount = (form.contractUrls ?? []).filter((u) => u.trim()).length; + if (contractCount < 1) return '请上传签约合同'; return null; } diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 313d975..1b00ab0 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -43,6 +43,8 @@ import { validateStoreStep3, MIN_ENV_PHOTO_COUNT, + + MAX_CONTRACT_COUNT, } from '../lib/storeDraft'; import StorePackagesForm from '../components/StorePackagesForm'; import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages'; @@ -476,7 +478,11 @@ export default function StoreCreatePage() { const envPhotoUrls = Array.from( new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)), - ).slice(0, 3); + ).slice(0, 20); + + const contractUrls = Array.from( + new Set((form.contractUrls ?? []).map((u) => u.trim()).filter(Boolean)), + ).slice(0, MAX_CONTRACT_COUNT); const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', { @@ -526,7 +532,7 @@ export default function StoreCreatePage() { envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, - contractUrl: form.contractUrl.trim() || undefined, + contractUrls: contractUrls.length ? contractUrls : undefined, bankAccountName: form.bankAccountName.trim(), @@ -1013,9 +1019,11 @@ export default function StoreCreatePage() {

签约合同 *

-

拍照上传签约协议首页与盖章页

+

+ 拍照上传签约协议首页与盖章页,支持多张,最多 {MAX_CONTRACT_COUNT} 个 +

- patchForm({ contractUrl })} + maxCount={MAX_CONTRACT_COUNT} - label="上传合同副本" + value={form.contractUrls ?? []} + + onChange={(urls) => patchForm({ contractUrls: urls })} + + label={`批量上传合同(${(form.contractUrls ?? []).length}/${MAX_CONTRACT_COUNT})`} /> diff --git a/server/dukang-api/src/common/store-media/contract-urls.util.ts b/server/dukang-api/src/common/store-media/contract-urls.util.ts new file mode 100644 index 0000000..7dac3fb --- /dev/null +++ b/server/dukang-api/src/common/store-media/contract-urls.util.ts @@ -0,0 +1,40 @@ +/** + * 门店签约合同(CommonResource bizType='CONTRACT')多附件工具。 + * + * 历史上合同只存单条记录(字段 contractUrl:string),现改为支持多张照片 / PDF。 + * 入参同时兼容新的 contractUrls:string[] 与旧的 contractUrl:string。 + */ + +export const MAX_CONTRACT_FILES = 20; + +/** + * 归一化合同附件地址:去空白、去重、限制数量。 + * @param urls 新字段 contractUrls + * @param legacy 旧字段 contractUrl(仅在 urls 未提供时生效) + */ +export function normalizeContractUrls( + urls?: unknown, + legacy?: string | null, +): string[] { + const raw: unknown[] = Array.isArray(urls) + ? urls + : legacy != null + ? [legacy] + : []; + + const seen = new Set(); + const out: string[] = []; + for (const item of raw) { + const url = String(item ?? '').trim(); + if (!url || seen.has(url)) continue; + seen.add(url); + out.push(url); + if (out.length >= MAX_CONTRACT_FILES) break; + } + return out; +} + +/** PDF 存 FILE,其余(合同照片)存 IMAGE,便于前端按图片预览 */ +export function contractMediaType(url: string): 'FILE' | 'IMAGE' { + return /\.pdf(\?|$)/i.test(url) ? 'FILE' : 'IMAGE'; +} diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index d3d066e..40f7ebb 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -4,6 +4,7 @@ import { validateBusinessHours } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { mapStoreCompat } from '../../common/compat/v31-compat'; +import { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util'; import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { StoreCategoryService } from '../store/store-category.service'; @@ -446,24 +447,23 @@ export class AdminStoresService { } } - if (dto.contractUrl !== undefined) { - const contractUrl = dto.contractUrl?.trim() || ''; + if (dto.contractUrls !== undefined || dto.contractUrl !== undefined) { + const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl); await tx.commonResource.updateMany({ where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' }, data: { status: 'DELETED' }, }); - if (contractUrl) { - const isPdf = /\.pdf(\?|$)/i.test(contractUrl); + for (let i = 0; i < contractUrls.length; i++) { await tx.commonResource.create({ data: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', - mediaType: isPdf ? 'FILE' : 'IMAGE', + mediaType: contractMediaType(contractUrls[i]), ossBucket: 'legacy', - ossKey: contractUrl, - url: contractUrl, - sortOrder: 0, + ossKey: contractUrls[i], + url: contractUrls[i], + sortOrder: i, }, }); } @@ -642,16 +642,18 @@ export class AdminStoresService { }); } - if (dto.contractUrl) { + const contractUrls = normalizeContractUrls(dto.contractUrls, dto.contractUrl); + for (let i = 0; i < contractUrls.length; i++) { await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: store.id, bizType: 'CONTRACT', - mediaType: 'FILE', + mediaType: contractMediaType(contractUrls[i]), ossBucket: 'legacy', - ossKey: dto.contractUrl, - url: dto.contractUrl, + ossKey: contractUrls[i], + url: contractUrls[i], + sortOrder: i, }, }); } diff --git a/server/dukang-api/src/modules/ops/admin-users.service.ts b/server/dukang-api/src/modules/ops/admin-users.service.ts index 5bad623..27008b7 100644 --- a/server/dukang-api/src/modules/ops/admin-users.service.ts +++ b/server/dukang-api/src/modules/ops/admin-users.service.ts @@ -6,6 +6,28 @@ import type { AdminUsersQueryDto } from './dto/admin-query.dto'; const FINISHED_ORDER_STATUSES = ['COMPLETED', 'CANCELLED', 'REFUNDED'] as const; +/** 权益统计口径:VOID(退款作废)不计入,与 C 端 listCoupons 一致 */ +const BENEFIT_STAT_STATUSES = ['ACTIVE', 'USED_UP'] as const; + +export type UserBenefitStat = { + /** 累计获得(含已使用) */ + benefitTotalAmount: number; + /** 已使用(已核销) */ + benefitUsedAmount: number; + /** 剩余未使用(可核销余额) */ + benefitBalance: number; +}; + +const EMPTY_BENEFIT_STAT: UserBenefitStat = { + benefitTotalAmount: 0, + benefitUsedAmount: 0, + benefitBalance: 0, +}; + +function toAmount(v: Prisma.Decimal | number | null | undefined) { + return v == null ? 0 : Number(v); +} + function mapAdminUserRow(u: { id: bigint; userNo: string; @@ -23,7 +45,7 @@ function mapAdminUserRow(u: { createdAt: Date; updatedAt: Date; _count: { orders: number }; -}) { +}, benefit: UserBenefitStat = EMPTY_BENEFIT_STAT) { return { id: u.id, userNo: u.userNo, @@ -42,6 +64,7 @@ function mapAdminUserRow(u: { createdAt: u.createdAt, updatedAt: u.updatedAt, orderCount: u._count.orders, + ...benefit, }; } @@ -90,14 +113,37 @@ export class AdminUsersService { this.prisma.user.count({ where }), ]); + const benefitMap = await this.loadBenefitStats(items.map((u) => u.id)); + return serializeBigInt({ - items: items.map((u) => mapAdminUserRow(u)), + items: items.map((u) => mapAdminUserRow(u, benefitMap.get(u.id.toString()))), total, page, pageSize, }); } + /** 批量聚合当前页用户的好客权益金额(累计获得 / 已使用 / 剩余) */ + private async loadBenefitStats(userIds: bigint[]): Promise> { + const map = new Map(); + if (!userIds.length) return map; + + const grouped = await this.prisma.benefitCoupon.groupBy({ + by: ['userId'], + where: { userId: { in: userIds }, status: { in: [...BENEFIT_STAT_STATUSES] } }, + _sum: { totalAmount: true, usedAmount: true, balance: true }, + }); + + for (const row of grouped) { + map.set(row.userId.toString(), { + benefitTotalAmount: toAmount(row._sum.totalAmount), + benefitUsedAmount: toAmount(row._sum.usedAmount), + benefitBalance: toAmount(row._sum.balance), + }); + } + return map; + } + async detail(id: bigint) { const user = await this.prisma.user.findUnique({ where: { id }, @@ -128,8 +174,11 @@ export class AdminUsersService { }); } + const benefitMap = await this.loadBenefitStats([user.id]); + return serializeBigInt({ ...user, + ...(benefitMap.get(user.id.toString()) ?? EMPTY_BENEFIT_STAT), wechatVerified: !!user.wxOpenId, mergedFromCount: user._count.mergedFrom, orderCount: user._count.orders, diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 2ab73f9..b93591f 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -105,10 +105,18 @@ export class CreateStoreDto { @ArrayMaxSize(20) envPhotoUrls?: string[]; + /** @deprecated 用 contractUrls;保留兼容旧客户端 */ @IsOptional() @IsString() contractUrl?: string; + /** 签约合同(支持多张照片 / PDF) */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(20) + contractUrls?: string[]; + @IsOptional() @IsNumber() @Min(0) @@ -190,10 +198,18 @@ export class UpdateStoreDto { @ArrayMaxSize(20) envPhotoUrls?: string[]; + /** @deprecated 用 contractUrls;保留兼容旧客户端 */ @IsOptional() @IsString() contractUrl?: string | null; + /** 签约合同(支持多张照片 / PDF),传空数组即清空 */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(20) + contractUrls?: string[]; + @IsOptional() @IsString() address?: string; diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index e6511b4..748712d 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -12,6 +12,7 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { mapStoreCompat } from '../../common/compat/v31-compat'; import { parseBigIntParam } from '../../common/parse-bigint'; +import { contractMediaType, normalizeContractUrls } from '../../common/store-media/contract-urls.util'; import { AnalyticsService } from '../analytics/analytics.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { AuthService } from '../iam/auth.service'; @@ -377,11 +378,14 @@ export class StoreService { const city = await this.resolvePartnerCity(partnerAccountId, body.cityId); const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : ''; const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls); - const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : ''; + const contractUrls = normalizeContractUrls( + body.contractUrls, + body.contractUrl != null ? String(body.contractUrl) : null, + ); if (!coverUrl) throw new BadRequestException('请上传门头照'); if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片'); - if (!contractUrl) throw new BadRequestException('请上传签约合同'); + if (!contractUrls.length) throw new BadRequestException('请上传签约合同'); const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null; const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null; @@ -486,16 +490,17 @@ export class StoreService { }); } - if (contractUrl) { + for (let i = 0; i < contractUrls.length; i++) { await this.prisma.commonResource.create({ data: { ownerType: 'STORE', ownerId: store.id, bizType: 'CONTRACT', - mediaType: 'FILE', + mediaType: contractMediaType(contractUrls[i]), ossBucket, - ossKey: contractUrl, - url: contractUrl, + ossKey: contractUrls[i], + url: contractUrls[i], + sortOrder: i, }, }); }