Files
dukang/server/dukang-api/src/modules/ops/admin-stores.service.ts
T
jacy b626db5d84 feat(ops): add global test whitelist and exclude test accounts from settlement
Unify product/store visibility on HQ whitelist, mark isTest snapshots, and fix SUPER_ADMIN access for the new module.
2026-08-07 15:46:23 +08:00

825 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
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 type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { StoreCategoryService } from '../store/store-category.service';
import { AnalyticsService } from '../analytics/analytics.service';
import type {
CreateStoreAccountDto,
CreateStoreDto,
CreateStoreMediaDto,
UpdateStoreAccountDto,
UpdateStoreDto,
UpdateStoreMediaDto,
UpdateStoreStatusDto,
} from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
function normalizeStoreOptionalText(value: unknown): string | null {
if (value == null) return null;
const s = String(value).trim();
if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null;
return s;
}
function normalizeVisibilityPhones(phones?: string[]): string[] {
if (!phones?.length) return [];
const out: string[] = [];
const seen = new Set<string>();
for (const raw of phones) {
const phone = String(raw || '')
.replace(/\D/g, '')
.trim();
if (!phone || seen.has(phone)) continue;
if (!/^1\d{10}$/.test(phone)) {
throw new BadRequestException(`手机号格式无效:${raw}`);
}
seen.add(phone);
out.push(phone);
}
return out;
}
@Injectable()
export class AdminStoresService {
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
private readonly storeCategoryService: StoreCategoryService,
private readonly analyticsService: AnalyticsService,
private readonly testWhitelist: TestWhitelistService,
) {}
async listStores(query: AdminStoresQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
if (query.auditStatus) {
where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['equals'];
}
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.phone) where.phone = { contains: query.phone };
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.store.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
cityRef: { select: { id: true, name: true, code: true } },
partnerAccount: { select: { id: true, companyName: true, name: true, phone: true } },
category: { select: { id: true, name: true, parentId: true } },
bindings: {
where: { storeAccount: { isPrimary: 1 } },
take: 1,
include: {
storeAccount: { select: { id: true, phone: true, name: true, status: true } },
},
},
coverResource: { select: { id: true, url: true } },
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
},
}),
this.prisma.store.count({ where }),
]);
return serializeBigInt({
items: items.map((s) => {
const { visibilityPhones, ...rest } = s;
return mapStoreCompat({
...rest,
visibilityWhitelistEnabled: s.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((p) => p.phone),
partner: s.partnerAccount,
account: s.bindings[0]?.storeAccount ?? null,
bindings: undefined,
});
}),
total,
page,
pageSize,
});
}
async detailStore(id: bigint) {
const store = await this.prisma.store.findUnique({
where: { id },
include: {
cityRef: true,
partnerAccount: true,
category: true,
bindings: {
where: { storeAccount: { isPrimary: 1 } },
take: 1,
include: { storeAccount: true },
},
coverResource: true,
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
_count: { select: { redeemRecords: true, ratings: true } },
},
});
if (!store) throw new NotFoundException('门店不存在');
const [media, audits] = await Promise.all([
this.prisma.commonResource.findMany({
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE' },
orderBy: { sortOrder: 'asc' },
}),
this.prisma.commonEvent.findMany({
where: { eventType: 'STORE_AUDIT', refType: 'STORE', refId: id },
orderBy: { createdAt: 'desc' },
take: 5,
}),
]);
const { visibilityPhones, ...rest } = store;
return serializeBigInt(mapStoreCompat({
...rest,
visibilityWhitelistEnabled: store.visibilityWhitelistEnabled,
visibilityPhones: visibilityPhones.map((p) => p.phone),
partner: store.partnerAccount,
account: store.bindings[0]?.storeAccount ?? null,
/** 门店端登录手机号(store_account.phone),与 store.phone 应对齐 */
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
bindings: undefined,
media,
audits,
redeemCount: store._count.redeemRecords,
ratingCount: store._count.ratings,
_count: undefined,
}));
}
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) {
const store = await this.prisma.store.update({
where: { id },
data: { status: dto.status as 'OPEN' | 'PAUSED' | 'CLOSED' },
});
return serializeBigInt(store);
}
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
const store = await this.prisma.store.findUnique({ where: { id } });
if (!store) throw new NotFoundException('门店不存在');
if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') {
// 允许对已通过门店再次驳回/通过(总部纠错);PENDING/REJECTED/APPROVED 均可审核
}
if (!dto.approved) {
const reason = dto.remark?.trim();
if (!reason) throw new BadRequestException('驳回时必须填写原因');
}
const now = new Date();
const updated = await this.prisma.store.update({
where: { id },
data: dto.approved
? {
// 审核通过后保持闭店,由合伙人自行开门
status: store.status === 'CLOSED' ? 'CLOSED' : 'PAUSED',
auditStatus: 'APPROVED',
rejectReason: null,
auditedAt: now,
}
: {
status: 'PAUSED',
auditStatus: 'REJECTED',
rejectReason: dto.remark!.trim(),
auditedAt: now,
},
});
await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: id,
actorType: 'HQ',
status: dto.approved ? 'APPROVED' : 'REJECTED',
remark: dto.approved
? (dto.remark?.trim() || '审核通过,可开门营业')
: dto.remark!.trim(),
param1: dto.approved ? 'APPROVE' : 'REJECT',
param1Desc: 'audit_action',
},
});
if (updated.partnerAccountId) {
this.analyticsService.trackPartnerOneSafe(undefined, 'HQ_WEB', {
partnerAccountId: updated.partnerAccountId,
eventName: dto.approved ? 'partner_store_audit_approved' : 'partner_store_audit_rejected',
refType: 'STORE',
refId: id,
extraJson: {
storeId: id.toString(),
remark: dto.remark?.trim() || null,
},
});
}
return serializeBigInt({
...updated,
notifyHint: dto.approved
? '已通过审核,合伙人可在端内开门营业'
: '已驳回,驳回原因已同步至合伙人端',
});
}
async updateStore(id: bigint, dto: UpdateStoreDto) {
const current = await this.prisma.store.findUnique({ where: { id } });
if (!current) throw new NotFoundException('门店不存在');
const openTime = dto.openTime !== undefined ? dto.openTime?.trim() || '' : current.openTime || '';
const closeTime = dto.closeTime !== undefined ? dto.closeTime?.trim() || '' : current.closeTime || '';
const openTime2 =
dto.openTime2 !== undefined ? dto.openTime2?.trim() || '' : current.openTime2 || '';
const closeTime2 =
dto.closeTime2 !== undefined ? dto.closeTime2?.trim() || '' : current.closeTime2 || '';
if (dto.openTime !== undefined || dto.closeTime !== undefined || dto.openTime2 !== undefined || dto.closeTime2 !== undefined) {
const hoursCheck = validateBusinessHours([
{ open: openTime || '10:00', close: closeTime || '22:00' },
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
]);
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
}
const latitude = dto.latitude !== undefined ? (dto.latitude == null ? null : Number(dto.latitude)) : undefined;
const longitude = dto.longitude !== undefined ? (dto.longitude == null ? null : Number(dto.longitude)) : undefined;
if (latitude !== undefined || longitude !== undefined) {
if (latitude == null || longitude == null) {
throw new BadRequestException('经纬度须同时提供');
}
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
throw new BadRequestException('纬度无效');
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
throw new BadRequestException('经度无效');
}
}
let categoryId: bigint | undefined;
if (dto.categoryId !== undefined) {
if (!dto.categoryId?.trim()) {
throw new BadRequestException('请选择门店分类');
}
categoryId = BigInt(dto.categoryId);
await this.storeCategoryService.assertLeafCategoryId(categoryId);
}
if (dto.phone !== undefined) {
const normalizedPhone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
throw new BadRequestException('请输入正确的手机号码');
}
}
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) {
const nextEnabled =
dto.visibilityWhitelistEnabled !== undefined
? !!dto.visibilityWhitelistEnabled
: current.visibilityWhitelistEnabled;
if (nextEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
}
const bankTouched =
dto.bankAccountName !== undefined ||
dto.bankAccountNo !== undefined ||
dto.bankBranch !== undefined;
const needAccountSync =
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
// 登录凭证在 store_account.phone;必须与门店展示手机号同步
let primaryBinding: {
storeAccount: { id: bigint; phone: string; name: string } | null;
} | null = null;
if (needAccountSync) {
primaryBinding = await this.prisma.storeAccountStore.findFirst({
where: { storeId: id, storeAccount: { isPrimary: 1 } },
include: {
storeAccount: { select: { id: true, phone: true, name: true } },
},
});
if (!primaryBinding) {
primaryBinding = await this.prisma.storeAccountStore.findFirst({
where: { storeId: id },
include: {
storeAccount: { select: { id: true, phone: true, name: true } },
},
orderBy: { storeAccountId: 'asc' },
});
}
if (normalizedPhone !== undefined && !primaryBinding?.storeAccount) {
throw new BadRequestException('门店未绑定登录账号,无法修改手机号');
}
const primaryAccount = primaryBinding?.storeAccount;
if (
normalizedPhone !== undefined &&
primaryAccount &&
normalizedPhone !== primaryAccount.phone
) {
const occupied = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
});
if (occupied && occupied.id !== primaryAccount.id) {
throw new BadRequestException('该手机号已被其他门店账号使用');
}
}
if (dto.bankAccountNo !== undefined) {
const no = dto.bankAccountNo?.trim() || null;
if (no && !/^\d{16,19}$/.test(no)) {
throw new BadRequestException('银行卡号须为 1619 位数字');
}
}
}
await this.prisma.$transaction(async (tx) => {
await tx.store.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.benefitUsageRule !== undefined
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
: {}),
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
...(dto.province !== undefined ? { province: dto.province.trim() } : {}),
...(dto.city !== undefined ? { cityName: dto.city.trim() } : {}),
...(dto.district !== undefined ? { district: dto.district.trim() } : {}),
...(categoryId !== undefined ? { categoryId } : {}),
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
...(dto.openTime !== undefined ? { openTime: dto.openTime } : {}),
...(dto.closeTime !== undefined ? { closeTime: dto.closeTime } : {}),
...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}),
...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}),
...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}),
...(dto.visibilityWhitelistEnabled !== undefined
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}),
...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}),
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
},
});
if (dto.visibilityPhones !== undefined) {
// 分实体手机号已废弃,忽略写入
}
if (dto.coverUrl) {
if (current.coverResourceId) {
await tx.commonResource.update({
where: { id: current.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
});
} else {
const cover = await tx.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
}
}
if (primaryBinding?.storeAccount) {
const account = primaryBinding.storeAccount;
const accountData: {
name?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
} = {};
if (dto.name !== undefined) accountData.name = dto.name.trim();
if (normalizedPhone !== undefined && normalizedPhone !== account.phone) {
accountData.phone = normalizedPhone;
}
if (dto.bankAccountName !== undefined) {
accountData.bankAccountName = dto.bankAccountName?.trim() || null;
}
if (dto.bankAccountNo !== undefined) {
accountData.bankAccountNo = dto.bankAccountNo?.trim() || null;
}
if (dto.bankBranch !== undefined) {
accountData.bankBranch = dto.bankBranch?.trim() || null;
}
if (Object.keys(accountData).length) {
await tx.storeAccount.update({
where: { id: account.id },
data: accountData,
});
}
}
});
return this.detailStore(id);
}
async createStore(dto: CreateStoreDto) {
const normalizedPhone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existingAccount = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
});
if (existingAccount && existingAccount.isPrimary !== 1) {
throw new BadRequestException('该手机号已是门店子账号');
}
if (existingAccount && existingAccount.status !== 'ACTIVE') {
throw new BadRequestException('该手机号对应门店账号已停用');
}
const partnerAccountId = BigInt(dto.partnerAccountId);
const partnerAccount = await this.prisma.partnerAccount.findUnique({
where: { id: partnerAccountId },
});
if (!partnerAccount || partnerAccount.isPrimary !== 1) {
throw new BadRequestException('开城合伙人不存在');
}
const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
if (!city) throw new BadRequestException('开城城市不存在');
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
if (!dto.categoryId?.trim()) {
throw new BadRequestException('请选择门店分类');
}
const categoryId = BigInt(dto.categoryId);
await this.storeCategoryService.assertLeafCategoryId(categoryId);
const latitude = dto.latitude != null ? Number(dto.latitude) : null;
const longitude = dto.longitude != null ? Number(dto.longitude) : null;
if ((latitude == null) !== (longitude == null)) {
throw new BadRequestException('经纬度须同时提供');
}
if (
latitude != null &&
(!Number.isFinite(latitude) || !Number.isFinite(longitude!) || latitude < -90 || latitude > 90)
) {
throw new BadRequestException('经纬度无效');
}
const openTime = dto.openTime?.trim() || '10:00';
const closeTime = dto.closeTime?.trim() || '22:00';
const openTime2 = dto.openTime2?.trim() || '';
const closeTime2 = dto.closeTime2?.trim() || '';
const hoursCheck = validateBusinessHours([
{ open: openTime, close: closeTime },
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
]);
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
const intro = dto.intro?.trim() || null;
if (intro && (intro.length < 2 || intro.length > 500)) {
throw new BadRequestException('门店简介须为 2~500 字');
}
const benefitUsageRule = normalizeStoreOptionalText(dto.benefitUsageRule);
if (benefitUsageRule && benefitUsageRule.length > 1000) {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
if (whitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
const isTest =
dto.isTest !== undefined
? !!dto.isTest
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
const store = await this.prisma.store.create({
data: {
cityId: city.id,
partnerAccountId,
settlementRate: dto.settlementRate ?? 0.6,
categoryId,
name: dto.name,
phone: normalizedPhone,
province: dto.province ?? city.province,
cityName: dto.city ?? city.name,
district: dto.district ?? '',
address: dto.address,
intro,
benefitUsageRule,
avgPrice: dto.avgPrice ?? null,
openTime,
closeTime,
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
visibilityWhitelistEnabled: whitelistEnabled,
isTest,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN',
auditStatus: 'APPROVED',
auditedAt: new Date(),
rejectReason: null,
},
});
if (dto.coverUrl) {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: store.id,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
},
});
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
}
const envUrls = (dto.envPhotoUrls ?? []).filter(Boolean);
for (let i = 0; i < envUrls.length; i++) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: store.id,
bizType: 'ENV',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: envUrls[i],
url: envUrls[i],
sortOrder: i,
},
});
}
if (dto.contractUrl) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: store.id,
bizType: 'CONTRACT',
mediaType: 'FILE',
ossBucket: 'legacy',
ossKey: dto.contractUrl,
url: dto.contractUrl,
},
});
}
await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: store.id,
actorType: 'HQ',
status: 'APPROVED',
param1: 'NEW',
param1Desc: 'audit_type',
remark: 'HQ 后台新建',
},
});
const bankAccountName = dto.bankAccountName ?? null;
const bankAccountNo = dto.bankAccountNo ?? null;
const bankBranch = dto.bankBranch ?? null;
if (existingAccount) {
await this.prisma.storeAccountStore.create({
data: { storeAccountId: existingAccount.id, storeId: store.id },
});
if (bankAccountName || bankAccountNo || bankBranch) {
await this.prisma.storeAccount.update({
where: { id: existingAccount.id },
data: {
...(bankAccountName != null ? { bankAccountName } : {}),
...(bankAccountNo != null ? { bankAccountNo } : {}),
...(bankBranch != null ? { bankBranch } : {}),
},
});
}
} else {
await this.prisma.storeAccount.create({
data: {
phone: normalizedPhone,
name: dto.accountName ?? dto.name,
isPrimary: 1,
bankAccountName,
bankAccountNo,
bankBranch,
bindings: { create: [{ storeId: store.id }] },
},
});
}
return this.detailStore(store.id);
}
async createStoreAccount(dto: CreateStoreAccountDto) {
const store = await this.prisma.store.findUnique({
where: { id: BigInt(dto.storeId) },
include: { bindings: true },
});
if (!store) throw new BadRequestException('门店不存在');
const primaryBound = store.bindings.length > 0
? await this.prisma.storeAccount.findFirst({
where: {
isPrimary: 1,
bindings: { some: { storeId: store.id } },
},
})
: null;
if (primaryBound) throw new BadRequestException('门店已有主账号绑定');
const existing = await this.prisma.storeAccount.findUnique({ where: { phone: dto.phone } });
if (existing) {
if (existing.isPrimary !== 1) {
throw new BadRequestException('该手机号已是门店子账号');
}
await this.prisma.storeAccountStore.create({
data: { storeAccountId: existing.id, storeId: store.id },
});
return serializeBigInt(existing);
}
const account = await this.prisma.storeAccount.create({
data: {
phone: dto.phone,
name: dto.name,
isPrimary: 1,
bindings: { create: [{ storeId: store.id }] },
},
});
return serializeBigInt(account);
}
async listStoreMedia(query: AdminStoreMediaQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonResourceWhereInput = {
ownerType: 'STORE',
status: 'ACTIVE',
};
if (query.storeId) where.ownerId = BigInt(query.storeId);
if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.commonResource.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonResource.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async createStoreMedia(dto: CreateStoreMediaDto) {
const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } });
if (!store) throw new BadRequestException('门店不存在');
const media = await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: store.id,
bizType: 'ENV',
mediaType: dto.mediaType as 'IMAGE' | 'VIDEO',
ossBucket: 'legacy',
ossKey: dto.url,
url: dto.url,
sortOrder: dto.sortOrder ?? 0,
},
});
return serializeBigInt(media);
}
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) {
const media = await this.prisma.commonResource.update({
where: { id },
data: {
...(dto.url !== undefined ? { url: dto.url, ossKey: dto.url } : {}),
...(dto.mediaType !== undefined ? { mediaType: dto.mediaType as 'IMAGE' | 'VIDEO' } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
},
});
return serializeBigInt(media);
}
async deleteStoreMedia(id: bigint) {
await this.prisma.commonResource.update({
where: { id },
data: { status: 'DELETED' },
});
return { ok: true };
}
async listStoreAccounts(query: AdminStoreAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 };
if (query.phone) where.phone = { contains: query.phone };
if (query.storeId) {
where.bindings = { some: { storeId: BigInt(query.storeId) } };
}
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.storeAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
bindings: {
include: {
store: { select: { id: true, name: true, status: true, cityName: true } },
},
},
_count: { select: { childAccounts: true, bindings: true } },
},
}),
this.prisma.storeAccount.count({ where }),
]);
const mapped = items.map((row) => ({
...row,
storeCount: row._count.bindings,
staffCount: row._count.childAccounts,
stores: row.bindings.map((b) => b.store),
store: row.bindings[0]?.store ?? null,
}));
return serializeBigInt({ items: mapped, total, page, pageSize });
}
async detailStoreAccount(id: bigint) {
const account = await this.prisma.storeAccount.findUnique({
where: { id },
include: {
bindings: {
include: {
store: { include: { cityRef: true, partnerAccount: true } },
},
},
childAccounts: {
include: {
bindings: {
include: {
store: { select: { id: true, name: true, status: true } },
},
},
},
},
},
});
if (!account) throw new NotFoundException('门店账号不存在');
return serializeBigInt({
...account,
stores: account.bindings.map((b) => b.store),
store: account.bindings[0]?.store ?? null,
staff: account.childAccounts,
});
}
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) {
const account = await this.prisma.storeAccount.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
},
});
return serializeBigInt(account);
}
/** HQ 删除门店子账号(非主账号) */
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint) {
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
if (!parent || parent.isPrimary !== 1) {
throw new BadRequestException('主账号不存在');
}
const staff = await this.prisma.storeAccount.findFirst({
where: { id: staffId, parentAccountId, isPrimary: 0 },
});
if (!staff) throw new NotFoundException('子账号不存在');
const pending = await this.prisma.redeemPendingRecord.count({
where: { storeAccountId: staffId },
});
if (pending > 0) {
throw new BadRequestException('该子账号仍有待处理核销单,无法删除');
}
await this.prisma.storeAccount.delete({ where: { id: staffId } });
return { ok: true };
}
}