feat(partner): let staff manage stores and fix env photo dupes
CI / verify (pull_request) Has been cancelled

Allow store:create/manage sub-accounts to edit, open/close, and re-upload media; dedupe ENV photos on write/read and replace via media API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 10:36:29 +08:00
parent 14b867a3a5
commit de396442a4
6 changed files with 357 additions and 40 deletions
@@ -75,6 +75,15 @@ export class PartnerStoreController {
) {
return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
}
@Put(':id/media')
updateMedia(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: Record<string, unknown>,
) {
return this.storeService.partnerUpdateStoreMedia(user.actorId, BigInt(id), body);
}
}
@Controller('partner/dashboard')
@@ -85,15 +85,7 @@ export class StoreService {
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
}
const media = await this.prisma.commonResource.findMany({
where: {
ownerType: 'STORE',
ownerId: storeId,
status: 'ACTIVE',
bizType: { in: ['ENV', 'CONTRACT'] },
},
orderBy: { sortOrder: 'asc' },
});
const media = await this.loadPartnerStoreMedia(storeId);
return serializeBigInt(mapStoreCompat({ ...store, media }));
}
@@ -169,9 +161,7 @@ export class StoreService {
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
: [];
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
if (!coverUrl) throw new BadRequestException('请上传门头照');
@@ -317,7 +307,7 @@ export class StoreService {
status: 'OPEN' | 'PAUSED' | 'CLOSED',
) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
await this.assertCanMutateStore(account, partnerAccountId, storeId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
});
@@ -365,7 +355,7 @@ export class StoreService {
body: Record<string, unknown>,
) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
await this.assertCanMutateStore(account, partnerAccountId, storeId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
});
@@ -429,6 +419,115 @@ export class StoreService {
return this.partnerGetStore(partnerAccountId, storeId);
}
/** 重新上传门头照 / 环境照:先软删旧 ENV,再写入去重后的新图(最多 3 张) */
async partnerUpdateStoreMedia(
partnerAccountId: bigint,
storeId: bigint,
body: Record<string, unknown>,
) {
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
await this.assertCanMutateStore(account, partnerAccountId, storeId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerAccountId: primaryId },
});
if (!store) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') {
throw new BadRequestException('门店已关闭,不可编辑');
}
if (store.auditStatus === 'PENDING') {
throw new BadRequestException('门店审核中,暂不可修改资料');
}
const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined;
const hasEnv = body.envPhotoUrls !== undefined;
const envPhotoUrls = hasEnv ? this.normalizeEnvPhotoUrls(body.envPhotoUrls) : undefined;
if (coverUrl !== undefined && !coverUrl) {
throw new BadRequestException('请上传门头照');
}
if (envPhotoUrls !== undefined && envPhotoUrls.length < 3) {
throw new BadRequestException('请上传至少 3 张环境照片');
}
if (coverUrl === undefined && envPhotoUrls === undefined) {
throw new BadRequestException('请至少更新门头照或环境照片');
}
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
if (coverUrl !== undefined) {
if (store.coverResourceId) {
await this.prisma.commonResource.update({
where: { id: store.coverResourceId },
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
});
} else {
const cover = await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket,
ossKey: coverUrl,
url: coverUrl,
},
});
await this.prisma.store.update({
where: { id: storeId },
data: { coverResourceId: cover.id },
});
}
}
if (envPhotoUrls !== undefined) {
await this.prisma.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: storeId, bizType: 'ENV', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
for (let i = 0; i < envPhotoUrls.length; i++) {
await this.prisma.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: storeId,
bizType: 'ENV',
mediaType: 'IMAGE',
ossBucket,
ossKey: envPhotoUrls[i],
url: envPhotoUrls[i],
sortOrder: i,
},
});
}
}
const resubmitAudit = store.auditStatus === 'REJECTED';
if (resubmitAudit) {
await this.prisma.store.update({
where: { id: storeId },
data: {
auditStatus: 'PENDING',
rejectReason: null,
auditedAt: null,
status: store.status === 'OPEN' ? 'PAUSED' : store.status,
},
});
await this.prisma.commonEvent.create({
data: {
eventType: 'STORE_AUDIT',
refType: 'STORE',
refId: storeId,
actorType: 'PARTNER',
actorId: partnerAccountId,
status: 'PENDING',
param1: 'RESUBMIT',
param1Desc: 'audit_type',
remark: '合伙人重新上传资料后重新提交审核',
},
});
}
return this.partnerGetStore(partnerAccountId, storeId);
}
async getShopStore(storeAccountId: bigint, storeId: bigint) {
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
@@ -889,6 +988,81 @@ export class StoreService {
return account.isPrimary !== 1;
}
private partnerPermissionList(account: { permissions?: unknown }): string[] {
return Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
}
/** 主账号,或具备 store:manage / store:create 的子账号可改门店 */
private async assertCanMutateStore(
account: { isPrimary: number; permissions?: unknown },
partnerAccountId: bigint,
storeId: bigint,
) {
if (!this.isSubAccount(account)) return;
const perms = this.partnerPermissionList(account);
const canManage = perms.includes('store:manage');
const canCreate = perms.includes('store:create');
if (!canManage && !canCreate) {
throw new ForbiddenException('子账号无门店管理权限');
}
// store:manage 可管团队门店;仅 store:create 只能改自己录入的店
if (canManage) return;
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
}
private normalizeEnvPhotoUrls(raw: unknown, max = 3): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const urls: string[] = [];
for (const item of raw) {
const url = String(item ?? '').trim();
if (!url || seen.has(url)) continue;
seen.add(url);
urls.push(url);
if (urls.length >= max) break;
}
return urls;
}
/** 读取门店媒体;对重复 ENV URL 软删并只返回一份,修复历史 3→6 脏数据 */
private async loadPartnerStoreMedia(storeId: bigint) {
const media = await this.prisma.commonResource.findMany({
where: {
ownerType: 'STORE',
ownerId: storeId,
status: 'ACTIVE',
bizType: { in: ['ENV', 'CONTRACT'] },
},
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
const seenEnv = new Set<string>();
const duplicateEnvIds: bigint[] = [];
const kept: typeof media = [];
for (const row of media) {
if (row.bizType !== 'ENV') {
kept.push(row);
continue;
}
const key = row.url.trim();
if (seenEnv.has(key)) {
duplicateEnvIds.push(row.id);
continue;
}
seenEnv.add(key);
kept.push(row);
}
if (duplicateEnvIds.length > 0) {
await this.prisma.commonResource.updateMany({
where: { id: { in: duplicateEnvIds } },
data: { status: 'DELETED' },
});
}
return kept;
}
private assertPrimaryAccount(account: { isPrimary: number }) {
if (this.isSubAccount(account)) {
throw new ForbiddenException('子账号无权执行此操作');