feat(partner): let staff manage stores and fix env photo dupes
CI / verify (pull_request) Has been cancelled
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:
@@ -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('子账号无权执行此操作');
|
||||
|
||||
Reference in New Issue
Block a user