5 Commits

Author SHA1 Message Date
jacy 81a6e3674b Merge pull request 'Dev' (#9) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/9
2026-07-17 10:50:17 +08:00
jacy 76270b20bf Merge branches 'dev_jacy' and 'dev' of git.yqidian.com:jacy/dukang into dev
CI / verify (pull_request) Has been cancelled
2026-07-17 10:43:19 +08:00
jacy c2914c37e5 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>
2026-07-17 10:43:15 +08:00
jacy 9f9b7cb2bd merge(dev_jacy): partner store manage and env photo dedupe 2026-07-17 10:36:31 +08:00
jacy de396442a4 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>
2026-07-17 10:36:29 +08:00
13 changed files with 653 additions and 292 deletions
@@ -292,7 +292,7 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(row) => {
@@ -224,7 +224,7 @@ export default function CityPartnersPage() {
async function openAddSubAccount(parentId: string) {
await openPartner(parentId);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
@@ -482,7 +482,7 @@ export default function CityPartnersPage() {
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(sub) => openSubEdit(sub, detail.id)}
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
function openAddSub(parent: AccountTreeRow) {
setSubParent(parent);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
+19
View File
@@ -41,6 +41,25 @@ export function canAccessPartnerStores(account: PartnerMe | null | undefined): b
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
}
/** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
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 {
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 权限点一致 */
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
+6 -7
View File
@@ -1,9 +1,5 @@
import {
PartnerStaffRole,
type CreatePartnerStaffRequest,
type PartnerStaffItem,
type UpdatePartnerStaffRequest,
} from '@dukang/shared-types';
import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
import { request } from './api';
export function listPartnerStaff() {
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
name: body.name,
phone: body.phone,
smsCode: body.smsCode,
staffRole: PartnerStaffRole.INTERNAL,
staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: body.permissions?.length
? body.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
}),
});
}
@@ -523,7 +523,9 @@ export default function StoreCreatePage() {
try {
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
const envPhotoUrls = Array.from(
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
).slice(0, 3);
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
+139 -20
View File
@@ -5,7 +5,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import OssUploadField from '../components/OssUploadField';
import {
canPartnerOpenStore,
storeAuditLabel,
@@ -16,19 +18,36 @@ import {
} from '../lib/storeStatus';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
const ENV_SLOT_COUNT = 3;
function uniqueEnvUrls(urls: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of urls) {
const url = raw.trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
}
return out;
}
export default function StoreDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { account } = usePartnerSession();
const subReadonly = isSubAccount(account);
const canMutate = canManagePartnerStore(account);
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
const [coverUrl, setCoverUrl] = useState('');
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [actionError, setActionError] = useState('');
const [wechatReady, setWechatReady] = useState(false);
function applyStore(data: Record<string, unknown>) {
setStore(data);
@@ -39,6 +58,15 @@ export default function StoreDetailPage() {
intro: String(data.intro || ''),
});
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
setCoverUrl(String(data.coverUrl || ''));
const envFromMedia = Array.isArray(data.media)
? uniqueEnvUrls(
(data.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || '')),
)
: [];
setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
}
useEffect(() => {
@@ -113,6 +141,42 @@ export default function StoreDetailPage() {
}
}
async function saveMedia() {
if (!id || mediaSaving || status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (auditStatus === 'PENDING') {
setActionError('门店审核中,暂不可修改资料');
return;
}
const nextCover = coverUrl.trim();
const nextEnv = uniqueEnvUrls(envPhotoUrls);
if (!nextCover) {
setActionError('请上传门头照');
return;
}
if (nextEnv.length < ENV_SLOT_COUNT) {
setActionError(`请上传至少 ${ENV_SLOT_COUNT} 张环境照片`);
return;
}
setMediaSaving(true);
setActionError('');
try {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
method: 'PUT',
body: JSON.stringify({
coverUrl: nextCover,
envPhotoUrls: nextEnv,
}),
});
applyStore(data);
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
} catch (e) {
setActionError(e instanceof Error ? e.message : '照片更新失败');
} finally {
setMediaSaving(false);
}
}
if (loadError) {
return (
<div className="partner-detail-page">
@@ -124,13 +188,17 @@ export default function StoreDetailPage() {
if (!store) return <div className="empty">...</div>;
const envPhotos = Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
: [];
const envPhotos = uniqueEnvUrls(
Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || ''))
: [],
);
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
const auditPending = auditStatus === 'PENDING';
const auditRejected = auditStatus === 'REJECTED';
const readOnly = subReadonly || status === 'CLOSED' || auditPending;
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
const canOpen = canPartnerOpenStore(auditStatus);
return (
@@ -170,7 +238,7 @@ export default function StoreDetailPage() {
)}
</section>
{!subReadonly && (
{canMutate && (
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
@@ -200,13 +268,29 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}></h3>
<div className="partner-cover">
<AppImage
src={store.coverUrl ? String(store.coverUrl) : null}
alt={form.name}
wrapperClassName="app-image--fill"
/>
</div>
{!canMutate || readOnly ? (
<div className="partner-cover">
<AppImage
src={coverUrl || null}
alt={form.name}
wrapperClassName="app-image--fill"
/>
</div>
) : (
<div style={{ marginBottom: 16 }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<OssUploadField
wide
bizType="STORE_TITLE"
mediaType="IMAGE"
value={coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={setCoverUrl}
label="点击更换门头照"
/>
</div>
)}
<div className="partner-field">
<label></label>
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
@@ -234,13 +318,48 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}></h3>
<span className="label-md text-muted">{envPhotos.length ? `已上传 ${envPhotos.length}` : '暂无照片'}</span>
<span className="label-md text-muted">
{canMutate && !readOnly
? `${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length}`
: envPhotos.length
? `已上传 ${envPhotos.length}`
: '暂无照片'}
</span>
</div>
{envPhotos.length > 0 ? (
{canMutate && !readOnly ? (
<>
<div className="partner-upload-grid">
{envPhotoUrls.map((url, index) => (
<OssUploadField
key={index}
compact
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
/>
))}
</div>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 16 }}
disabled={mediaSaving}
onClick={() => void saveMedia()}
>
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
upload
</span>
{mediaSaving ? '上传中…' : '重新上传照片'}
</button>
</>
) : envPhotos.length > 0 ? (
<div className="partner-photo-grid">
{envPhotos.map((photo, index) => (
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}>
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
{envPhotos.map((url, index) => (
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
<AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
</div>
))}
</div>
@@ -264,7 +383,7 @@ export default function StoreDetailPage() {
<footer className="partner-save-footer">
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
{!subReadonly && (
{canMutate && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
+8 -5
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
import {
canPartnerOpenStore,
storeAuditLabel,
@@ -27,7 +27,8 @@ export default function StoreListPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { account } = usePartnerSession();
const readonly = isSubAccount(account);
const canMutate = canManagePartnerStore(account);
const canCreate = canCreatePartnerStore(account);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [q, setQ] = useState('');
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
@@ -45,8 +46,8 @@ export default function StoreListPage() {
}, [navigate, loadStores]);
useEffect(() => {
document.title = readonly ? '我的门店' : '门店管理';
}, [readonly]);
document.title = canMutate ? '门店管理' : '我的门店';
}, [canMutate]);
const filtered = useMemo(() => stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
@@ -101,12 +102,14 @@ export default function StoreListPage() {
</div>
</div>
{canCreate && (
<Link to="/stores/new" className="partner-fab-link">
<button type="button" className="partner-btn-primary">
<span className="material-symbols-outlined">add_business</span>
</button>
</Link>
)}
{filtered.length === 0 && <div className="empty"></div>}
@@ -146,7 +149,7 @@ export default function StoreListPage() {
</div>
</div>
</Link>
{!readonly && (
{canMutate && (
<div className="partner-store-card-actions">
<button
type="button"
@@ -89,3 +89,9 @@ export const PARTNER_PERMISSION_LABELS: Record<PartnerPermissionKey, string> = {
'store:create': '开店管理',
'order:view': '订单查看',
};
/** 门店类子账号默认权限:录入、开闭店、维护资料 */
export const DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS: PartnerPermissionKey[] = [
'store:create',
'store:manage',
];
@@ -1,240 +1,245 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ClientApp, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AnalyticsService } from '../analytics/analytics.service';
import { AuthService } from './auth.service';
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
@Injectable()
export class PartnerStaffService {
constructor(
private readonly prisma: PrismaService,
private readonly analytics: AnalyticsService,
private readonly authService: AuthService,
) {}
async listStaff(parentAccountId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({
where: { parentAccountId },
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toStaffItem(row));
}
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
}
const normalized = phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalized)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
if (existing) throw new BadRequestException('该手机号已被使用');
const masked = this.maskPhone(normalized);
try {
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
clientApp: ClientApp.PARTNER_H5,
});
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
phone: masked,
scene: SmsScene.PARTNER_STAFF_ADD,
status: 'success',
});
} catch (err) {
if (err instanceof BadRequestException) {
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
phone: masked,
scene: SmsScene.PARTNER_STAFF_ADD,
status: 'failed',
reason: err.message,
});
}
throw err;
}
return { ok: true, maskedPhone: masked };
}
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
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 } });
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);
} catch (err) {
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
phone: this.maskPhone(phone),
reason: err instanceof BadRequestException ? err.message : '验证码错误',
});
throw err;
}
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
const account = await this.prisma.partnerAccount.create({
data: {
phone,
name,
staffRole,
permissions: dto.permissions ?? undefined,
isPrimary: 0,
parentAccountId: parent.id,
status: 'DISABLED',
},
});
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
name,
phone: this.maskPhone(phone),
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ClientApp, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS, PartnerStaffRole, SmsScene } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AnalyticsService } from '../analytics/analytics.service';
import { AuthService } from './auth.service';
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
@Injectable()
export class PartnerStaffService {
constructor(
private readonly prisma: PrismaService,
private readonly analytics: AnalyticsService,
private readonly authService: AuthService,
) {}
async listStaff(parentAccountId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({
where: { parentAccountId },
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toStaffItem(row));
}
async sendStaffPhoneSms(actor: AuthUser, phone: string) {
const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
}
const normalized = phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalized)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone: normalized } });
if (existing) throw new BadRequestException('该手机号已被使用');
const masked = this.maskPhone(normalized);
try {
await this.authService.sendSms(normalized, SmsScene.PARTNER_STAFF_ADD, {
clientApp: ClientApp.PARTNER_H5,
});
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
phone: masked,
scene: SmsScene.PARTNER_STAFF_ADD,
status: 'success',
});
} catch (err) {
if (err instanceof BadRequestException) {
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_send', parent.id, {
phone: masked,
scene: SmsScene.PARTNER_STAFF_ADD,
status: 'failed',
reason: err.message,
});
}
throw err;
}
return { ok: true, maskedPhone: masked };
}
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
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 } });
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);
} catch (err) {
this.trackStaffEvent(actor, parent.id, 'partner_staff_sms_verify_fail', parentAccountId, {
phone: this.maskPhone(phone),
reason: err instanceof BadRequestException ? err.message : '验证码错误',
});
throw err;
}
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
const permissions =
dto.permissions && dto.permissions.length > 0
? dto.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS];
const account = await this.prisma.partnerAccount.create({
data: {
phone,
name,
staffRole,
permissions,
isPrimary: 0,
parentAccountId: parent.id,
status: 'DISABLED',
},
});
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
name,
phone: this.maskPhone(phone),
staffRole,
permissions,
status: account.status,
phoneVerified: true,
});
return this.toStaffItem(account);
}
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
const parentAccountId = actor.actorId;
const staff = await this.assertStaffOwned(parentAccountId, staffId);
const before = {
name: staff.name,
staffRole: staff.staffRole,
status: staff.status,
};
const data: Record<string, unknown> = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
data.name = name;
}
if (dto.staffRole !== undefined) {
data.staffRole = dto.staffRole as PartnerStaffRole;
}
if (dto.permissions !== undefined) {
data.permissions = dto.permissions;
}
if (dto.status !== undefined) {
data.status = dto.status;
}
const updated = await this.prisma.partnerAccount.update({
where: { id: staff.id },
data,
});
const onlyRoleChange =
(dto.staffRole !== undefined || dto.permissions !== undefined) &&
dto.name === undefined &&
dto.status === undefined;
const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update';
const primaryId = parentAccountId;
this.trackStaffEvent(actor, primaryId, eventName, staff.id, {
before,
after: {
name: updated.name,
staffRole: updated.staffRole,
status: updated.status,
},
});
return this.toStaffItem(updated);
}
async deleteStaff(actor: AuthUser, staffId: bigint) {
const parentAccountId = actor.actorId;
const staff = await this.assertStaffOwned(parentAccountId, staffId);
this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, {
name: staff.name,
phone: this.maskPhone(staff.phone),
staffRole: staff.staffRole,
status: staff.status,
});
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
return { ok: true };
}
private trackStaffEvent(
actor: AuthUser,
primaryAccountId: bigint,
eventName: string,
refId: bigint,
extraJson?: Record<string, unknown>,
) {
this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, {
partnerAccountId: primaryAccountId,
eventName,
refType: 'PARTNER_ACCOUNT',
refId,
extraJson,
});
}
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
const staff = await this.prisma.partnerAccount.findFirst({
where: { id: staffId, parentAccountId },
});
if (!staff) throw new NotFoundException('子账号不存在');
return staff;
}
private toStaffItem(row: {
id: bigint;
name: string;
phone: string;
staffRole: string | null;
permissions?: unknown;
status: string;
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,
status: row.status,
lastLoginAt: row.lastLoginAt?.toISOString(),
});
}
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 { IsOptional, IsString } from 'class-validator';
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
import { SettlementService } from './settlement.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
@@ -269,7 +270,7 @@ export class PartnerMeController {
}
private async buildPartnerMe(actorId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
let account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: actorId },
});
let primary = account;
@@ -278,6 +279,23 @@ export class PartnerMeController {
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);
return {
id: account.id.toString(),
@@ -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,88 @@ export class StoreService {
return account.isPrimary !== 1;
}
private partnerPermissionList(account: { permissions?: unknown }): string[] {
return Array.isArray(account.permissions) ? (account.permissions as string[]) : [];
}
/** 主账号,或门店类子账号(含历史空权限)可改门店 */
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');
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('子账号无门店管理权限');
}
// 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('子账号无权执行此操作');