feat(store): v4.0.18 门店多收款账户与子账号继承二维码
C 端门店列表拼接省市区县地址;门店多银行账户与默认打款账户;子账号独立 sa_ 关联码及统计维度;同步 v4.0.18 开发文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
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';
|
||||
@@ -13,7 +13,8 @@ import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.c
|
||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||
|
||||
const ASSOC_SCENE_PREFIX = 'pa_';
|
||||
const PRIMARY_SCENE_PREFIX = 'pa_';
|
||||
const SUB_SCENE_PREFIX = 'sa_';
|
||||
|
||||
function maskPhoneNumber(phone: string | null) {
|
||||
if (!phone || phone.length < 7) return phone;
|
||||
@@ -27,12 +28,20 @@ function dayBounds(now = new Date()) {
|
||||
return { todayStart, monthStart };
|
||||
}
|
||||
|
||||
export function parseAssocScene(raw?: string | null): string | null {
|
||||
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;
|
||||
if (s.startsWith(ASSOC_SCENE_PREFIX)) {
|
||||
const id = s.slice(ASSOC_SCENE_PREFIX.length);
|
||||
return /^\d+$/.test(id) ? id : 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;
|
||||
}
|
||||
@@ -48,15 +57,42 @@ export class PartnerAssocService {
|
||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||
) {}
|
||||
|
||||
async bindUser(userId: bigint, input: { scene?: string; partnerId?: string }) {
|
||||
const partnerIdRaw = parseAssocScene(input.scene) || input.partnerId?.trim();
|
||||
if (!partnerIdRaw || !/^\d+$/.test(partnerIdRaw)) {
|
||||
throw new BadRequestException('关联码无效');
|
||||
/** 解析 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(BigInt(partnerIdRaw));
|
||||
|
||||
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('用户不存在');
|
||||
@@ -67,6 +103,7 @@ export class PartnerAssocService {
|
||||
bound: true,
|
||||
alreadyBound: true,
|
||||
partnerId: primary.id.toString(),
|
||||
subAccountId: sub ? sub.id.toString() : null,
|
||||
partnerName: primary.companyName || primary.name,
|
||||
};
|
||||
}
|
||||
@@ -77,6 +114,7 @@ export class PartnerAssocService {
|
||||
where: { id: userId },
|
||||
data: {
|
||||
assocPartnerAccountId: primary.id,
|
||||
assocSubAccountId: sub ? sub.id : null,
|
||||
assocBoundAt: new Date(),
|
||||
...(user.sourceType === 'ORGANIC'
|
||||
? { sourceType: 'PARTNER_ASSOC', sourceRefId: primary.id }
|
||||
@@ -88,31 +126,37 @@ export class PartnerAssocService {
|
||||
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 partnerIdRaw = parseAssocScene(input.scene) || input.partnerId?.trim();
|
||||
if (!partnerIdRaw || !/^\d+$/.test(partnerIdRaw)) {
|
||||
throw new BadRequestException('关联码无效');
|
||||
}
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(BigInt(partnerIdRaw));
|
||||
if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('合伙人不存在或已停用');
|
||||
}
|
||||
const { primary, sub } = await this.resolveAssocTarget(input);
|
||||
const shouldCountScan = input.countScan !== false;
|
||||
let scanCount = primary.assocScanCount ?? 0;
|
||||
let scanCount = 0;
|
||||
if (shouldCountScan) {
|
||||
const updated = await this.prisma.partnerAccount.update({
|
||||
where: { id: primary.id },
|
||||
data: { assocScanCount: { increment: 1 } },
|
||||
select: { assocScanCount: true },
|
||||
});
|
||||
scanCount = updated.assocScanCount;
|
||||
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,
|
||||
};
|
||||
@@ -157,11 +201,35 @@ export class PartnerAssocService {
|
||||
}
|
||||
|
||||
async getSummary(partnerAccountId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
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 },
|
||||
@@ -175,13 +243,24 @@ export class PartnerAssocService {
|
||||
partnerId: primary.id.toString(),
|
||||
qrcodeUrl: ensured.qrcodeUrl,
|
||||
userCount,
|
||||
scanCount: primary.assocScanCount ?? 0,
|
||||
scanCount,
|
||||
companyName: primary.companyName,
|
||||
name: primary.name,
|
||||
activityPosterId: partnerAccountId === primary.id ? activityPosterId : null,
|
||||
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;
|
||||
@@ -209,12 +288,16 @@ export class PartnerAssocService {
|
||||
}
|
||||
|
||||
async getStats(partnerAccountId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const { primary, subAccountId } = await this.scopeOf(partnerAccountId);
|
||||
const { todayStart, monthStart } = dayBounds();
|
||||
const userWhere = { assocPartnerAccountId: primary.id };
|
||||
const userWhere: Prisma.UserWhereInput = subAccountId
|
||||
? { assocPartnerAccountId: primary.id, assocSubAccountId: subAccountId }
|
||||
: { assocPartnerAccountId: primary.id };
|
||||
const orderWhere = {
|
||||
payStatus: 'PAID' as const,
|
||||
user: { assocPartnerAccountId: primary.id },
|
||||
user: subAccountId
|
||||
? { assocSubAccountId: subAccountId }
|
||||
: { assocPartnerAccountId: primary.id },
|
||||
};
|
||||
const [userTotal, userToday, userMonth, orderTotal, orderToday, orderMonth] = await Promise.all([
|
||||
this.prisma.user.count({ where: userWhere }),
|
||||
@@ -234,9 +317,11 @@ export class PartnerAssocService {
|
||||
maskPhone = false,
|
||||
opts: { keyword?: string; sort?: 'createdAt' | 'boundAt' | 'orderCount' } = {},
|
||||
) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const { primary, subAccountId } = await this.scopeOf(partnerAccountId);
|
||||
const keyword = opts.keyword?.trim();
|
||||
const where: Prisma.UserWhereInput = { assocPartnerAccountId: primary.id };
|
||||
const where: Prisma.UserWhereInput = subAccountId
|
||||
? { assocPartnerAccountId: primary.id, assocSubAccountId: subAccountId }
|
||||
: { assocPartnerAccountId: primary.id };
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{ userNo: { contains: keyword } },
|
||||
@@ -301,7 +386,7 @@ export class PartnerAssocService {
|
||||
}
|
||||
|
||||
async listAssocOrders(partnerAccountId: bigint, page = 1, pageSize = 20, userId?: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const { primary, subAccountId } = await this.scopeOf(partnerAccountId);
|
||||
if (userId) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -313,7 +398,9 @@ export class PartnerAssocService {
|
||||
}
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
payStatus: 'PAID',
|
||||
user: { assocPartnerAccountId: primary.id },
|
||||
user: subAccountId
|
||||
? { assocSubAccountId: subAccountId }
|
||||
: { assocPartnerAccountId: primary.id },
|
||||
...(userId ? { userId } : {}),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -421,8 +508,29 @@ export class PartnerAssocService {
|
||||
return { qrcodeId: primary.assocQrcodeId, qrcodeUrl: resource.url };
|
||||
}
|
||||
}
|
||||
const scene = `${PRIMARY_SCENE_PREFIX}${primary.id.toString()}`;
|
||||
return this.generateAccountQrcode(primary, scene);
|
||||
}
|
||||
|
||||
const scene = `${ASSOC_SCENE_PREFIX}${primary.id.toString()}`;
|
||||
/** 生成/复用子账号自己的二维码(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 过长,无法写入小程序码');
|
||||
}
|
||||
@@ -436,11 +544,11 @@ export class PartnerAssocService {
|
||||
checkPath: false,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`assoc qrcode failed partner=${primary.id}: ${err instanceof Error ? err.message : err}`);
|
||||
this.logger.warn(`assoc qrcode failed account=${account.id}: ${err instanceof Error ? err.message : err}`);
|
||||
throw new BadRequestException('生成关联码失败,请稍后重试');
|
||||
}
|
||||
|
||||
const fileName = `partner-assoc-${primary.id}.png`;
|
||||
const fileName = `partner-assoc-${account.id}.png`;
|
||||
const uploaded = await this.oss.putObject({
|
||||
bizType: 'QRCODE',
|
||||
mediaType: 'IMAGE',
|
||||
@@ -451,7 +559,7 @@ export class PartnerAssocService {
|
||||
const resource = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'PARTNER',
|
||||
ownerId: primary.id,
|
||||
ownerId: account.id,
|
||||
bizType: 'QRCODE',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: uploaded.bucket,
|
||||
@@ -463,7 +571,7 @@ export class PartnerAssocService {
|
||||
},
|
||||
});
|
||||
await this.prisma.partnerAccount.update({
|
||||
where: { id: primary.id },
|
||||
where: { id: account.id },
|
||||
data: { assocQrcodeId: scene, assocQrcodeResourceId: resource.id },
|
||||
});
|
||||
return { qrcodeId: scene, qrcodeUrl: resource.url };
|
||||
@@ -482,16 +590,16 @@ export class PartnerAssocService {
|
||||
|
||||
/** 只读已有 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 account = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!account.assocQrcodeResourceId) return null;
|
||||
const resource = await this.prisma.commonResource.findUnique({
|
||||
where: { id: primary.assocQrcodeResourceId },
|
||||
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-${primary.id.toString()}.png` };
|
||||
return { buffer, fileName: `partner-assoc-${account.id.toString()}.png` };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user