fix(partner): grant store staff open/close and media permissions by default
CI / verify (pull_request) Has been cancelled

Default new sub-accounts to store:create+store:manage, backfill empty permissions on /partner/me, and treat legacy store staff as allowed to mutate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 10:43:15 +08:00
parent de396442a4
commit c2914c37e5
9 changed files with 302 additions and 258 deletions
@@ -292,7 +292,7 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
subs={detail.children ?? []} subs={detail.children ?? []}
onAdd={() => { onAdd={() => {
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
}} }}
onEdit={(row) => { onEdit={(row) => {
@@ -224,7 +224,7 @@ export default function CityPartnersPage() {
async function openAddSubAccount(parentId: string) { async function openAddSubAccount(parentId: string) {
await openPartner(parentId); await openPartner(parentId);
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
} }
@@ -482,7 +482,7 @@ export default function CityPartnersPage() {
subs={detail.children ?? []} subs={detail.children ?? []}
onAdd={() => { onAdd={() => {
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
}} }}
onEdit={(sub) => openSubEdit(sub, detail.id)} onEdit={(sub) => openSubEdit(sub, detail.id)}
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
function openAddSub(parent: AccountTreeRow) { function openAddSub(parent: AccountTreeRow) {
setSubParent(parent); setSubParent(parent);
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
} }
+12 -3
View File
@@ -41,14 +41,23 @@ export function canAccessPartnerStores(account: PartnerMe | null | undefined): b
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']); return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
} }
/** 编辑资料 / 开闭店 / 重新上传:主账号或 store:manage / store:create */ /** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean { export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['store:manage', 'store:create']); if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasAnyPartnerPermission(account, ['store:manage', 'store:create'])) return true;
// 合伙人端早期创建的子账号可能 permissions 为空,按门店员工放开
return !Array.isArray(account.permissions) || account.permissions.length === 0;
} }
/** 录入新店 */ /** 录入新店 */
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean { export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
return hasPartnerPermission(account, 'store:create'); if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasPartnerPermission(account, 'store:create')) return true;
return !Array.isArray(account.permissions) || account.permissions.length === 0;
} }
/** 与后端 GET /partner/orders 权限点一致 */ /** 与后端 GET /partner/orders 权限点一致 */
+6 -7
View File
@@ -1,9 +1,5 @@
import { import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
PartnerStaffRole, import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
type CreatePartnerStaffRequest,
type PartnerStaffItem,
type UpdatePartnerStaffRequest,
} from '@dukang/shared-types';
import { request } from './api'; import { request } from './api';
export function listPartnerStaff() { export function listPartnerStaff() {
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
name: body.name, name: body.name,
phone: body.phone, phone: body.phone,
smsCode: body.smsCode, smsCode: body.smsCode,
staffRole: PartnerStaffRole.INTERNAL, staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: body.permissions?.length
? body.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
}), }),
}); });
} }
@@ -89,3 +89,9 @@ export const PARTNER_PERMISSION_LABELS: Record<PartnerPermissionKey, string> = {
'store:create': '开店管理', 'store:create': '开店管理',
'order:view': '订单查看', 'order:view': '订单查看',
}; };
/** 门店类子账号默认权限:录入、开闭店、维护资料 */
export const DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS: PartnerPermissionKey[] = [
'store:create',
'store:manage',
];
@@ -1,240 +1,245 @@
import { import {
BadRequestException,
BadRequestException, Injectable,
NotFoundException,
Injectable, } from '@nestjs/common';
import { ClientApp, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
NotFoundException, import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
} from '@nestjs/common'; import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AnalyticsService } from '../analytics/analytics.service';
import { ClientApp, PartnerStaffRole, SmsScene } from '@dukang/shared-types'; import { AuthService } from './auth.service';
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
import { PrismaService } from '../../common/prisma/prisma.module';
@Injectable()
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; export class PartnerStaffService {
constructor(
import type { AuthUser } from '../../common/guards/jwt-auth.guard'; private readonly prisma: PrismaService,
private readonly analytics: AnalyticsService,
import { AnalyticsService } from '../analytics/analytics.service'; private readonly authService: AuthService,
) {}
import { AuthService } from './auth.service';
async listStaff(parentAccountId: bigint) {
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto'; const rows = await this.prisma.partnerAccount.findMany({
where: { parentAccountId },
orderBy: { createdAt: 'desc' },
});
@Injectable() return rows.map((row) => this.toStaffItem(row));
}
export class PartnerStaffService {
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
constructor( const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
private readonly prisma: PrismaService, where: { id: parentAccountId },
});
private readonly analytics: AnalyticsService, if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
private readonly authService: AuthService, }
) {} const normalized = phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalized)) {
throw new BadRequestException('请输入正确的手机号码');
}
async listStaff(parentAccountId: bigint) {
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
const rows = await this.prisma.partnerAccount.findMany({ if (existing) throw new BadRequestException('该手机号已被使用');
where: { parentAccountId }, const masked = this.maskPhone(normalized);
try {
orderBy: { createdAt: 'desc' }, await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
clientApp: ClientApp.PARTNER_H5,
}); });
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
return rows.map((row) => this.toStaffItem(row)); phone: masked,
scene: SmsScene.PARTNER_STAFF_ADD,
} status: 'success',
});
} catch (err) {
if (err instanceof BadRequestException) {
async sendStaffPhoneSms(actor: AuthUser, phone: string) { this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
phone: masked,
const parentAccountId = actor.actorId; scene: SmsScene.PARTNER_STAFF_ADD,
status: 'failed',
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({ reason: err.message,
});
where: { id: parentAccountId }, }
throw err;
}); }
if (parent.isPrimary !== 1) { return { ok: true, maskedPhone: masked };
}
throw new BadRequestException('仅主账号可添加子账号');
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
} const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
const normalized = phone.trim(); if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
if (!/^1[3-9]\d{9}$/.test(normalized)) { }
throw new BadRequestException('请输入正确的手机号码'); const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
} throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } }); if (existing) throw new BadRequestException('该手机号已被使用');
if (existing) throw new BadRequestException('该手机号已被使用'); const smsCode = dto.smsCode.trim();
if (!smsCode) throw new BadRequestException('请输入手机号验证码');
try {
await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD);
const masked = this.maskPhone(normalized); } catch (err) {
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
try { phone: this.maskPhone(phone),
reason: err instanceof BadRequestException ? err.message : '验证码错误',
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, { });
throw err;
clientApp: ClientApp.PARTNER_H5, }
}); const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
phone: masked, const permissions =
dto.permissions && dto.permissions.length > 0
scene: SmsScene.PARTNER_STAFF_ADD, ? dto.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS];
status: 'success',
const account = await this.prisma.partnerAccount.create({
}); data: {
phone,
} catch (err) { name,
staffRole,
if (err instanceof BadRequestException) { permissions,
isPrimary: 0,
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, { parentAccountId: parent.id,
status: 'DISABLED',
phone: masked, },
});
scene: SmsScene.PARTNER_STAFF_ADD,
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
status: 'failed', name,
phone: this.maskPhone(phone),
reason: err.message, staffRole,
permissions,
}); status: account.status,
phoneVerified: true,
} });
throw err; return this.toStaffItem(account);
}
}
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
const parentAccountId = actor.actorId;
const staff = await this.assertStaffOwned(parentAccountId, staffId);
return { ok: true, maskedPhone: masked }; const before = {
name: staff.name,
} staffRole: staff.staffRole,
status: staff.status,
};
const data: Record<string, unknown> = {};
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) { if (dto.name !== undefined) {
const name = dto.name.trim();
const parentAccountId = actor.actorId; if (!name) throw new BadRequestException('请填写真实姓名');
data.name = name;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({ }
if (dto.staffRole !== undefined) {
where: { id: parentAccountId }, data.staffRole = dto.staffRole as PartnerStaffRole;
}
}); if (dto.permissions !== undefined) {
data.permissions = dto.permissions;
if (parent.isPrimary !== 1) { }
if (dto.status !== undefined) {
throw new BadRequestException('仅主账号可添加子账号'); data.status = dto.status;
}
} const updated = await this.prisma.partnerAccount.update({
where: { id: staff.id },
data,
});
const phone = dto.phone.trim();
const onlyRoleChange =
if (!/^1[3-9]\d{9}$/.test(phone)) { (dto.staffRole !== undefined || dto.permissions !== undefined) &&
dto.name === undefined &&
throw new BadRequestException('请输入正确的手机号码'); dto.status === undefined;
const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update';
}
const primaryId = parentAccountId;
this.trackStaffEvent(actor, primaryId, eventName, staff.id, {
before,
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } }); after: {
name: updated.name,
if (existing) throw new BadRequestException('该手机号已被使用'); staffRole: updated.staffRole,
status: updated.status,
},
});
const smsCode = dto.smsCode.trim();
return this.toStaffItem(updated);
if (!smsCode) throw new BadRequestException('请输入手机号验证码'); }
try { async deleteStaff(actor: AuthUser, staffId: bigint) {
const parentAccountId = actor.actorId;
await this.authService.verifySmsCode(phone, smsCode, SmsScene.PARTNER_STAFF_ADD); const staff = await this.assertStaffOwned(parentAccountId, staffId);
} catch (err) { this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, {
name: staff.name,
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, { phone: this.maskPhone(staff.phone),
staffRole: staff.staffRole,
phone: this.maskPhone(phone), status: staff.status,
});
reason: err instanceof BadRequestException ? err.message : '验证码错误',
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
}); return { ok: true };
}
throw err;
private trackStaffEvent(
} actor: AuthUser,
primaryAccountId: bigint,
eventName: string,
refId: bigint,
const name = dto.name.trim(); extraJson?: Record<string, unknown>,
) {
if (!name) throw new BadRequestException('请填写真实姓名'); this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, {
partnerAccountId: primaryAccountId,
eventName,
refType: 'PARTNER_ACCOUNT',
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL; refId,
extraJson,
});
}
const account = await this.prisma.partnerAccount.create({
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
data: { const staff = await this.prisma.partnerAccount.findFirst({
where: { id: staffId, parentAccountId },
phone, });
if (!staff) throw new NotFoundException('子账号不存在');
name, return staff;
}
staffRole,
private toStaffItem(row: {
permissions: dto.permissions ?? undefined, id: bigint;
name: string;
isPrimary: 0, phone: string;
staffRole: string | null;
parentAccountId: parent.id, permissions?: unknown;
status: string;
status: 'DISABLED', lastLoginAt: Date | null;
}) {
}, return serializeBigInt({
id: row.id.toString(),
}); name: row.name,
phone: this.maskPhone(row.phone),
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, { status: row.status,
lastLoginAt: row.lastLoginAt?.toISOString(),
name, });
}
phone: this.maskPhone(phone),
private maskPhone(phone: string): string {
if (phone.length !== 11) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
}
}
@@ -1,5 +1,6 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
import { SettlementService } from './settlement.service'; import { SettlementService } from './settlement.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard'; import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
@@ -269,7 +270,7 @@ export class PartnerMeController {
} }
private async buildPartnerMe(actorId: bigint) { private async buildPartnerMe(actorId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({ let account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: actorId }, where: { id: actorId },
}); });
let primary = account; let primary = account;
@@ -278,6 +279,23 @@ export class PartnerMeController {
where: { id: account.parentAccountId }, where: { id: account.parentAccountId },
}); });
} }
// 门店类子账号若未配置权限,补齐开店/门店管理,便于开闭店与重传资料
if (account.isPrimary !== 1) {
const perms = Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
const hasStorePerm = perms.includes('store:create') || perms.includes('store:manage');
const warehouseOnly =
!hasStorePerm &&
perms.length > 0 &&
(perms.includes('warehouse:manage') || perms.includes('order:view'));
if (!hasStorePerm && !warehouseOnly) {
account = await this.prisma.partnerAccount.update({
where: { id: account.id },
data: { permissions: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS] },
});
}
}
const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id); const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id);
return { return {
id: account.id.toString(), id: account.id.toString(),
@@ -992,7 +992,7 @@ export class StoreService {
return Array.isArray(account.permissions) ? (account.permissions as string[]) : []; return Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
} }
/** 主账号,或具备 store:manage / store:create 的子账号可改门店 */ /** 主账号,或门店类子账号(含历史空权限)可改门店 */
private async assertCanMutateStore( private async assertCanMutateStore(
account: { isPrimary: number; permissions?: unknown }, account: { isPrimary: number; permissions?: unknown },
partnerAccountId: bigint, partnerAccountId: bigint,
@@ -1002,10 +1002,17 @@ export class StoreService {
const perms = this.partnerPermissionList(account); const perms = this.partnerPermissionList(account);
const canManage = perms.includes('store:manage'); const canManage = perms.includes('store:manage');
const canCreate = perms.includes('store:create'); const canCreate = perms.includes('store:create');
if (!canManage && !canCreate) { const legacyStoreStaff = perms.length === 0;
const warehouseOnly =
!canManage &&
!canCreate &&
!legacyStoreStaff &&
(perms.includes('warehouse:manage') ||
(perms.includes('order:view') && !perms.includes('store:create') && !perms.includes('store:manage')));
if (warehouseOnly || (!canManage && !canCreate && !legacyStoreStaff)) {
throw new ForbiddenException('子账号无门店管理权限'); throw new ForbiddenException('子账号无门店管理权限');
} }
// store:manage 可管团队门店;仅 store:create 只能改自己录入的店 // store:manage 可管团队门店;仅 store:create / 历史空权限只能改自己录入的店
if (canManage) return; if (canManage) return;
await this.assertStoreOwnedByAccount(partnerAccountId, storeId); await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
} }