import { BadRequestException, Inject, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { Prisma, type PartnerAccount } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants'; import type { IOssProvider } from '../../integrations/oss/oss.interface'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; const PRIMARY_SCENE_PREFIX = 'pa_'; const SUB_SCENE_PREFIX = 'sa_'; function maskPhoneNumber(phone: string | null) { if (!phone || phone.length < 7) return phone; return `${phone.slice(0, 3)}****${phone.slice(-4)}`; } function dayBounds(now = new Date()) { const todayStart = new Date(now); todayStart.setHours(0, 0, 0, 0); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); return { todayStart, monthStart }; } export type AssocScene = { kind: 'primary' | 'sub'; id: string }; export function parseAssocScene(raw?: string | null): AssocScene | null { const s = String(raw ?? '').trim(); if (!s) return null; const prefixes: Array<[string, AssocScene['kind']]> = [ [PRIMARY_SCENE_PREFIX, 'primary'], [SUB_SCENE_PREFIX, 'sub'], ]; for (const [prefix, kind] of prefixes) { if (s.startsWith(prefix)) { const id = s.slice(prefix.length); return /^\d+$/.test(id) ? { kind, id } : null; } } return null; } @Injectable() export class PartnerAssocService { private readonly logger = new Logger(PartnerAssocService.name); constructor( private readonly prisma: PrismaService, private readonly partnerCityService: PartnerCityService, @Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider, @Inject(OSS_PROVIDER) private readonly oss: IOssProvider, ) {} /** 解析 scene/partnerId,返回主账号与(可选的)子账号;校验激活状态 */ private async resolveAssocTarget(input: { scene?: string; partnerId?: string }) { const parsed = parseAssocScene(input.scene); let accountId: bigint; let sub: PartnerAccount | null = null; if (parsed) { accountId = BigInt(parsed.id); if (parsed.kind === 'sub') { const subAccount = await this.prisma.partnerAccount.findUnique({ where: { id: accountId }, }); if (!subAccount || subAccount.isPrimary === 1 || !subAccount.parentAccountId) { throw new BadRequestException('子账号不存在或无效'); } if (subAccount.status !== 'ACTIVE') { throw new BadRequestException('子账号已停用'); } sub = subAccount; accountId = subAccount.parentAccountId; } } else { const raw = input.partnerId?.trim(); if (!raw || !/^\d+$/.test(raw)) throw new BadRequestException('关联码无效'); accountId = BigInt(raw); } const primary = await this.partnerCityService.resolvePrimaryAccount(accountId); if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') { throw new BadRequestException('合伙人不存在或已停用'); } return { primary, sub }; } async bindUser(userId: bigint, input: { scene?: string; partnerId?: string }) { const { primary, sub } = await this.resolveAssocTarget(input); const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundException('用户不存在'); if (user.assocPartnerAccountId) { if (user.assocPartnerAccountId === primary.id) { return { bound: true, alreadyBound: true, partnerId: primary.id.toString(), subAccountId: sub ? sub.id.toString() : null, partnerName: primary.companyName || primary.name, }; } throw new BadRequestException('您已关联其他合伙人,无法更换'); } await this.prisma.user.update({ where: { id: userId }, data: { assocPartnerAccountId: primary.id, assocSubAccountId: sub ? sub.id : null, assocBoundAt: new Date(), ...(user.sourceType === 'ORGANIC' ? { sourceType: 'PARTNER_ASSOC', sourceRefId: primary.id } : {}), }, }); return { bound: true, alreadyBound: false, partnerId: primary.id.toString(), subAccountId: sub ? sub.id.toString() : null, partnerName: primary.companyName || primary.name, }; } async touchScan(input: { scene?: string; partnerId?: string; countScan?: boolean }) { const { primary, sub } = await this.resolveAssocTarget(input); const shouldCountScan = input.countScan !== false; let scanCount = 0; if (shouldCountScan) { if (sub) { const updated = await this.prisma.partnerAccount.update({ where: { id: sub.id }, data: { assocScanCount: { increment: 1 } }, select: { assocScanCount: true }, }); scanCount = updated.assocScanCount; } else { const updated = await this.prisma.partnerAccount.update({ where: { id: primary.id }, data: { assocScanCount: { increment: 1 } }, select: { assocScanCount: true }, }); scanCount = updated.assocScanCount; } } else { scanCount = sub ? (sub.assocScanCount ?? 0) : (primary.assocScanCount ?? 0); } return { partnerId: primary.id.toString(), subAccountId: sub ? sub.id.toString() : null, scanCounted: shouldCountScan, scanCount, }; } async unbindUser(userId: bigint) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundException('用户不存在'); await this.prisma.user.update({ where: { id: userId }, data: { assocPartnerAccountId: null, assocBoundAt: null }, }); return { ok: true, partnerId: null, partnerName: null }; } /** HQ 改绑:可换绑或清空;不影响已支付订单快照 */ async setUserAssoc(userId: bigint, partnerAccountId: bigint | null) { if (!partnerAccountId) { return this.unbindUser(userId); } const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') { throw new BadRequestException('合伙人不存在或已停用'); } const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundException('用户不存在'); await this.prisma.user.update({ where: { id: userId }, data: { assocPartnerAccountId: primary.id, assocBoundAt: new Date(), ...(user.sourceType === 'ORGANIC' ? { sourceType: 'PARTNER_ASSOC', sourceRefId: primary.id } : {}), }, }); return { ok: true, partnerId: primary.id.toString(), partnerName: primary.companyName || primary.name, }; } async getSummary(partnerAccountId: bigint) { const { self, primary, subAccountId } = await this.scopeOf(partnerAccountId); if (subAccountId) { const ensured = await this.ensureSubQrcode(subAccountId); const userCount = await this.prisma.user.count({ where: { assocPartnerAccountId: primary.id, assocSubAccountId: subAccountId }, }); return { partnerId: subAccountId.toString(), primaryAccountId: primary.id.toString(), isSubAccount: true, qrcodeUrl: ensured.qrcodeUrl, userCount, scanCount: self.assocScanCount ?? 0, companyName: primary.companyName, name: self.name, activityPosterId: null, }; } const ensured = await this.ensureQrcode(primary.id); const userCount = await this.prisma.user.count({ where: { assocPartnerAccountId: primary.id }, }); const childrenAgg = await this.prisma.partnerAccount.aggregate({ where: { parentAccountId: primary.id }, _sum: { assocScanCount: true }, }); const scanCount = (primary.assocScanCount ?? 0) + Number(childrenAgg._sum.assocScanCount ?? 0); const selectedPoster = primary.activityPosterId ? await this.prisma.activityPoster.findUnique({ where: { id: primary.activityPosterId }, select: { id: true, status: true }, }) : null; const activityPosterId = selectedPoster?.status === 'ACTIVE' ? selectedPoster.id.toString() : null; return { partnerId: primary.id.toString(), qrcodeUrl: ensured.qrcodeUrl, userCount, scanCount, companyName: primary.companyName, name: primary.name, activityPosterId, }; } /** 返回调用者账号、主账号、以及子账号维度(主账号为 null) */ private async scopeOf(partnerAccountId: bigint) { const self = await this.prisma.partnerAccount.findUnique({ where: { id: partnerAccountId }, }); if (!self) throw new NotFoundException('合伙人账号不存在'); const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const subAccountId = self.isPrimary === 1 ? null : self.id; return { self, primary, subAccountId }; } async getSelectedActivityPosterId(partnerAccountId: bigint): Promise { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); if (!primary.activityPosterId) return null; const poster = await this.prisma.activityPoster.findUnique({ where: { id: primary.activityPosterId }, select: { id: true, status: true }, }); return poster?.status === 'ACTIVE' ? poster.id.toString() : null; } async setSelectedActivityPoster(partnerAccountId: bigint, posterId: bigint | null) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); if (posterId) { const poster = await this.prisma.activityPoster.findFirst({ where: { id: posterId, status: 'ACTIVE' }, select: { id: true }, }); if (!poster) throw new BadRequestException('活动图不存在或已下架'); } await this.prisma.partnerAccount.update({ where: { id: primary.id }, data: { activityPosterId: posterId }, }); return { posterId: posterId?.toString() ?? null }; } async getStats(partnerAccountId: bigint) { const { primary, subAccountId } = await this.scopeOf(partnerAccountId); const { todayStart, monthStart } = dayBounds(); const userWhere: Prisma.UserWhereInput = subAccountId ? { assocPartnerAccountId: primary.id, assocSubAccountId: subAccountId } : { assocPartnerAccountId: primary.id }; const orderWhere = { payStatus: 'PAID' as const, user: subAccountId ? { assocSubAccountId: subAccountId } : { assocPartnerAccountId: primary.id }, }; const [userTotal, userToday, userMonth, orderTotal, orderToday, orderMonth] = await Promise.all([ this.prisma.user.count({ where: userWhere }), this.prisma.user.count({ where: { ...userWhere, assocBoundAt: { gte: todayStart } } }), this.prisma.user.count({ where: { ...userWhere, assocBoundAt: { gte: monthStart } } }), this.prisma.order.count({ where: orderWhere }), this.prisma.order.count({ where: { ...orderWhere, paidAt: { gte: todayStart } } }), this.prisma.order.count({ where: { ...orderWhere, paidAt: { gte: monthStart } } }), ]); return { userTotal, userToday, userMonth, orderTotal, orderToday, orderMonth }; } async listUsers( partnerAccountId: bigint, page = 1, pageSize = 20, maskPhone = false, opts: { keyword?: string; sort?: 'createdAt' | 'boundAt' | 'orderCount' } = {}, ) { const { primary, subAccountId } = await this.scopeOf(partnerAccountId); const keyword = opts.keyword?.trim(); const where: Prisma.UserWhereInput = subAccountId ? { assocPartnerAccountId: primary.id, assocSubAccountId: subAccountId } : { assocPartnerAccountId: primary.id }; if (keyword) { where.OR = [ { userNo: { contains: keyword } }, { nickname: { contains: keyword } }, { phone: { contains: keyword } }, { partnerNotes: { some: { partnerAccountId: primary.id, remark: { contains: keyword } }, }, }, ]; } const sort = opts.sort ?? 'boundAt'; const orderBy: Prisma.UserOrderByWithRelationInput = sort === 'createdAt' ? { createdAt: 'desc' } : sort === 'orderCount' ? { orders: { _count: 'desc' } } : { assocBoundAt: 'desc' }; const [items, total] = await Promise.all([ this.prisma.user.findMany({ where, orderBy, skip: (page - 1) * pageSize, take: pageSize, select: { id: true, userNo: true, nickname: true, hqRemark: true, phone: true, createdAt: true, assocBoundAt: true, partnerNotes: { where: { partnerAccountId: primary.id }, select: { remark: true }, take: 1, }, _count: { select: { orders: { where: { payStatus: 'PAID' } } } }, }, }), this.prisma.user.count({ where }), ]); return serializeBigInt({ items: items.map((u) => ({ id: u.id.toString(), userNo: u.userNo, nickname: u.nickname, ...(maskPhone ? { partnerRemark: u.partnerNotes[0]?.remark ?? null } : { hqRemark: u.hqRemark }), phone: maskPhone ? maskPhoneNumber(u.phone) : u.phone, createdAt: u.createdAt.toISOString(), boundAt: u.assocBoundAt?.toISOString() ?? '', orderCount: u._count.orders, })), total, page, pageSize, }); } async listAssocOrders(partnerAccountId: bigint, page = 1, pageSize = 20, userId?: bigint) { const { primary, subAccountId } = await this.scopeOf(partnerAccountId); if (userId) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { assocPartnerAccountId: true }, }); if (!user || user.assocPartnerAccountId !== primary.id) { throw new NotFoundException('用户未关联本合伙人'); } } const where: Prisma.OrderWhereInput = { payStatus: 'PAID', user: subAccountId ? { assocSubAccountId: subAccountId } : { assocPartnerAccountId: primary.id }, ...(userId ? { userId } : {}), }; const [items, total] = await Promise.all([ this.prisma.order.findMany({ where, orderBy: { paidAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, select: { id: true, orderNo: true, productName: true, quantity: true, payAmount: true, paidAt: true, status: true, }, }), this.prisma.order.count({ where }), ]); return serializeBigInt({ items: items.map((o) => ({ id: o.id.toString(), orderNo: o.orderNo, productName: o.productName, quantity: o.quantity, payAmount: Number(o.payAmount), paidAt: o.paidAt?.toISOString() ?? null, status: o.status, })), total, page, pageSize, }); } async setUserRemark(partnerAccountId: bigint, userId: bigint, remark?: string | null) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { assocPartnerAccountId: true }, }); if (!user || user.assocPartnerAccountId !== primary.id) { throw new NotFoundException('用户未关联本合伙人'); } const text = remark?.trim() || ''; if (!text) { await this.prisma.partnerUserNote.deleteMany({ where: { partnerAccountId: primary.id, userId }, }); return { ok: true, remark: null }; } if (text.length > 128) { throw new BadRequestException('备注最多 128 字'); } const row = await this.prisma.partnerUserNote.upsert({ where: { partnerAccountId_userId: { partnerAccountId: primary.id, userId } }, create: { partnerAccountId: primary.id, userId, remark: text }, update: { remark: text }, }); return { ok: true, remark: row.remark }; } async listCommissionOrders(partnerAccountId: bigint, page = 1, pageSize = 20) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const where = { partnerAccountIdAtPay: primary.id, payStatus: 'PAID' as const }; const [items, total] = await Promise.all([ this.prisma.order.findMany({ where, orderBy: { paidAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { user: { select: { phone: true } } }, }), this.prisma.order.count({ where }), ]); return serializeBigInt({ items: items.map((o) => { const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0; return { id: o.id.toString(), orderNo: o.orderNo, productName: o.productName, quantity: o.quantity, payAmount: Number(o.payAmount), rate, commission: Math.round(Number(o.payAmount) * rate * 100) / 100, paidAt: o.paidAt?.toISOString() ?? null, userPhone: o.user.phone, }; }), total, page, pageSize, }); } async ensureQrcode(partnerAccountId: bigint, force = false) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); if (!force && primary.assocQrcodeResourceId) { const resource = await this.prisma.commonResource.findUnique({ where: { id: primary.assocQrcodeResourceId }, }); if (resource?.url) { return { qrcodeId: primary.assocQrcodeId, qrcodeUrl: resource.url }; } } const scene = `${PRIMARY_SCENE_PREFIX}${primary.id.toString()}`; return this.generateAccountQrcode(primary, scene); } /** 生成/复用子账号自己的二维码(scene = sa_{subId}),写入子账号行 */ async ensureSubQrcode(subAccountId: bigint, force = false) { const sub = await this.prisma.partnerAccount.findUnique({ where: { id: subAccountId } }); if (!sub || sub.isPrimary === 1 || !sub.parentAccountId) { throw new BadRequestException('子账号不存在或无效'); } if (!force && sub.assocQrcodeResourceId) { const resource = await this.prisma.commonResource.findUnique({ where: { id: sub.assocQrcodeResourceId }, }); if (resource?.url) { return { qrcodeId: sub.assocQrcodeId, qrcodeUrl: resource.url }; } } const scene = `${SUB_SCENE_PREFIX}${sub.id.toString()}`; return this.generateAccountQrcode(sub, scene); } private async generateAccountQrcode(account: PartnerAccount, scene: string) { if (scene.length > 32) { throw new BadRequestException('合伙人 ID 过长,无法写入小程序码'); } const page = (process.env.WX_MINI_PROMO_PAGE || 'pages/home/index').replace(/^\//, ''); let pngBuffer: Buffer; try { pngBuffer = await this.wechat.getWxaCodeUnlimited({ scene, page, width: 430, checkPath: false, }); } catch (err) { this.logger.warn(`assoc qrcode failed account=${account.id}: ${err instanceof Error ? err.message : err}`); throw new BadRequestException('生成关联码失败,请稍后重试'); } const fileName = `partner-assoc-${account.id}.png`; const uploaded = await this.oss.putObject({ bizType: 'QRCODE', mediaType: 'IMAGE', fileName, buffer: pngBuffer, mimeType: 'image/png', }); const resource = await this.prisma.commonResource.create({ data: { ownerType: 'PARTNER', ownerId: account.id, bizType: 'QRCODE', mediaType: 'IMAGE', ossBucket: uploaded.bucket, ossKey: uploaded.ossKey, url: uploaded.url, fileName, fileSize: BigInt(pngBuffer.length), mimeType: 'image/png', }, }); await this.prisma.partnerAccount.update({ where: { id: account.id }, data: { assocQrcodeId: scene, assocQrcodeResourceId: resource.id }, }); return { qrcodeId: scene, qrcodeUrl: resource.url }; } async getQrcodeBuffer(partnerAccountId: bigint): Promise<{ buffer: Buffer; fileName: string }> { const summary = await this.getSummary(partnerAccountId); if (!summary.qrcodeUrl) { throw new NotFoundException('关联码尚未生成'); } const res = await fetch(summary.qrcodeUrl); if (!res.ok) throw new BadRequestException('下载关联码失败'); 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 account = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); if (!account.assocQrcodeResourceId) return null; const resource = await this.prisma.commonResource.findUnique({ where: { id: account.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-${account.id.toString()}.png` }; } }