Files
dukang/server/dukang-api/src/modules/store/partner-assoc.service.ts
T
jacy 4bdb09068c feat(store): v4.0.18 门店多收款账户与子账号继承二维码
C 端门店列表拼接省市区县地址;门店多银行账户与默认打款账户;子账号独立 sa_ 关联码及统计维度;同步 v4.0.18 开发文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 15:56:16 +08:00

606 lines
21 KiB
TypeScript

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<string | null> {
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` };
}
}