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:
@@ -41,6 +41,16 @@ 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 {
|
||||||
|
return hasAnyPartnerPermission(account, ['store:manage', 'store:create']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 录入新店 */
|
||||||
|
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
|
||||||
|
return hasPartnerPermission(account, 'store:create');
|
||||||
|
}
|
||||||
|
|
||||||
/** 与后端 GET /partner/orders 权限点一致 */
|
/** 与后端 GET /partner/orders 权限点一致 */
|
||||||
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
|
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
|
||||||
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
|
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
|
||||||
|
|||||||
@@ -523,7 +523,9 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
try {
|
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', {
|
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { toastSuccess } from '../lib/toast';
|
import { toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
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 {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
storeAuditLabel,
|
storeAuditLabel,
|
||||||
@@ -16,19 +18,36 @@ import {
|
|||||||
} from '../lib/storeStatus';
|
} from '../lib/storeStatus';
|
||||||
|
|
||||||
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
|
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() {
|
export default function StoreDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { account } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
const subReadonly = isSubAccount(account);
|
const canMutate = canManagePartnerStore(account);
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
|
||||||
|
const [coverUrl, setCoverUrl] = useState('');
|
||||||
|
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
|
||||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [mediaSaving, setMediaSaving] = useState(false);
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
|
const [wechatReady, setWechatReady] = useState(false);
|
||||||
|
|
||||||
function applyStore(data: Record<string, unknown>) {
|
function applyStore(data: Record<string, unknown>) {
|
||||||
setStore(data);
|
setStore(data);
|
||||||
@@ -39,6 +58,15 @@ export default function StoreDetailPage() {
|
|||||||
intro: String(data.intro || ''),
|
intro: String(data.intro || ''),
|
||||||
});
|
});
|
||||||
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
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(() => {
|
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) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="partner-detail-page">
|
<div className="partner-detail-page">
|
||||||
@@ -124,13 +188,17 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
if (!store) return <div className="empty">加载中...</div>;
|
if (!store) return <div className="empty">加载中...</div>;
|
||||||
|
|
||||||
const envPhotos = Array.isArray(store.media)
|
const envPhotos = uniqueEnvUrls(
|
||||||
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
|
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 auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
|
||||||
const auditPending = auditStatus === 'PENDING';
|
const auditPending = auditStatus === 'PENDING';
|
||||||
const auditRejected = auditStatus === 'REJECTED';
|
const auditRejected = auditStatus === 'REJECTED';
|
||||||
const readOnly = subReadonly || status === 'CLOSED' || auditPending;
|
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
|
||||||
const canOpen = canPartnerOpenStore(auditStatus);
|
const canOpen = canPartnerOpenStore(auditStatus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -170,7 +238,7 @@ export default function StoreDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!subReadonly && (
|
{canMutate && (
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>运营状态</h3>
|
<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' }}>
|
<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>
|
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}>基本信息</h3>
|
||||||
|
{!canMutate || readOnly ? (
|
||||||
<div className="partner-cover">
|
<div className="partner-cover">
|
||||||
<AppImage
|
<AppImage
|
||||||
src={store.coverUrl ? String(store.coverUrl) : null}
|
src={coverUrl || null}
|
||||||
alt={form.name}
|
alt={form.name}
|
||||||
wrapperClassName="app-image--fill"
|
wrapperClassName="app-image--fill"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="partner-field">
|
||||||
<label>门店名称</label>
|
<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 }} />
|
<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' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
|
<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>
|
<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>
|
</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">
|
<div className="partner-photo-grid">
|
||||||
{envPhotos.map((photo, index) => (
|
{envPhotos.map((url, index) => (
|
||||||
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}>
|
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
|
||||||
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
<AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,7 +383,7 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
<footer className="partner-save-footer">
|
<footer className="partner-save-footer">
|
||||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
<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}>
|
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||||
<span className="material-symbols-outlined">save</span>
|
<span className="material-symbols-outlined">save</span>
|
||||||
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { isLoggedIn, request } from '../lib/api';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { isSubAccount } from '../lib/partnerAccess';
|
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
storeAuditLabel,
|
storeAuditLabel,
|
||||||
@@ -27,7 +27,8 @@ export default function StoreListPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { account } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
const readonly = isSubAccount(account);
|
const canMutate = canManagePartnerStore(account);
|
||||||
|
const canCreate = canCreatePartnerStore(account);
|
||||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
|
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
|
||||||
@@ -45,8 +46,8 @@ export default function StoreListPage() {
|
|||||||
}, [navigate, loadStores]);
|
}, [navigate, loadStores]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = readonly ? '我的门店' : '门店管理';
|
document.title = canMutate ? '门店管理' : '我的门店';
|
||||||
}, [readonly]);
|
}, [canMutate]);
|
||||||
|
|
||||||
const filtered = useMemo(() => stores.filter((s) => {
|
const filtered = useMemo(() => stores.filter((s) => {
|
||||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||||
@@ -101,12 +102,14 @@ export default function StoreListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canCreate && (
|
||||||
<Link to="/stores/new" className="partner-fab-link">
|
<Link to="/stores/new" className="partner-fab-link">
|
||||||
<button type="button" className="partner-btn-primary">
|
<button type="button" className="partner-btn-primary">
|
||||||
<span className="material-symbols-outlined">add_business</span>
|
<span className="material-symbols-outlined">add_business</span>
|
||||||
录入新门店
|
录入新门店
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
{filtered.length === 0 && <div className="empty">暂无门店</div>}
|
||||||
|
|
||||||
@@ -146,7 +149,7 @@ export default function StoreListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{!readonly && (
|
{canMutate && (
|
||||||
<div className="partner-store-card-actions">
|
<div className="partner-store-card-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ export class PartnerStoreController {
|
|||||||
) {
|
) {
|
||||||
return this.storeService.partnerUpdateStoreBasic(user.actorId, BigInt(id), body);
|
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')
|
@Controller('partner/dashboard')
|
||||||
|
|||||||
@@ -85,15 +85,7 @@ export class StoreService {
|
|||||||
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
await this.assertStoreOwnedByAccount(partnerAccountId, storeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = await this.prisma.commonResource.findMany({
|
const media = await this.loadPartnerStoreMedia(storeId);
|
||||||
where: {
|
|
||||||
ownerType: 'STORE',
|
|
||||||
ownerId: storeId,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
bizType: { in: ['ENV', 'CONTRACT'] },
|
|
||||||
},
|
|
||||||
orderBy: { sortOrder: 'asc' },
|
|
||||||
});
|
|
||||||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +161,7 @@ export class StoreService {
|
|||||||
|
|
||||||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||||
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
|
const envPhotoUrls = this.normalizeEnvPhotoUrls(body.envPhotoUrls);
|
||||||
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
const contractUrl = body.contractUrl ? String(body.contractUrl).trim() : '';
|
||||||
|
|
||||||
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
if (!coverUrl) throw new BadRequestException('请上传门头照');
|
||||||
@@ -317,7 +307,7 @@ export class StoreService {
|
|||||||
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
||||||
) {
|
) {
|
||||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||||
this.assertPrimaryAccount(account);
|
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||||||
const store = await this.prisma.store.findFirst({
|
const store = await this.prisma.store.findFirst({
|
||||||
where: { id: storeId, partnerAccountId: primaryId },
|
where: { id: storeId, partnerAccountId: primaryId },
|
||||||
});
|
});
|
||||||
@@ -365,7 +355,7 @@ export class StoreService {
|
|||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||||
this.assertPrimaryAccount(account);
|
await this.assertCanMutateStore(account, partnerAccountId, storeId);
|
||||||
const store = await this.prisma.store.findFirst({
|
const store = await this.prisma.store.findFirst({
|
||||||
where: { id: storeId, partnerAccountId: primaryId },
|
where: { id: storeId, partnerAccountId: primaryId },
|
||||||
});
|
});
|
||||||
@@ -429,6 +419,115 @@ export class StoreService {
|
|||||||
return this.partnerGetStore(partnerAccountId, storeId);
|
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) {
|
async getShopStore(storeAccountId: bigint, storeId: bigint) {
|
||||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
@@ -889,6 +988,81 @@ export class StoreService {
|
|||||||
return account.isPrimary !== 1;
|
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 }) {
|
private assertPrimaryAccount(account: { isPrimary: number }) {
|
||||||
if (this.isSubAccount(account)) {
|
if (this.isSubAccount(account)) {
|
||||||
throw new ForbiddenException('子账号无权执行此操作');
|
throw new ForbiddenException('子账号无权执行此操作');
|
||||||
|
|||||||
Reference in New Issue
Block a user