feat(supply-chain): add store account exports and permissions

This commit is contained in:
WuYongJie 2026-08-28 09:39:08 +08:00
parent bf85258d3c
commit 571b840911
15 changed files with 643 additions and 25 deletions

View File

@ -0,0 +1,62 @@
export {}
const mockHandleRequest = jest.fn()
const mockCreateWorkbook = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
}))
jest.mock('@/server/services/store-account-management-export.service', () => ({
storeAccountManagementExportService: {
createWorkbook: (...args: unknown[]) => mockCreateWorkbook(...args),
},
}))
const { GET } = require('./route')
describe('GET /api/supply-chain/store-account-management/export', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: (ctx: unknown) => unknown) => handler({
requestId: 'req-export',
user: { id: 'management-1', name: '经管人员', role: 'USER', businessRoles: ['MANAGEMENT'] },
}))
mockCreateWorkbook.mockResolvedValue({
content: Buffer.from('xlsx-content'),
fileName: '店铺账号管理-已选2条.xlsx',
count: 2,
})
})
it('passes selected identifiers to the export service and returns a private XLSX download', async () => {
const request = {
nextUrl: new URL('http://localhost/api/supply-chain/store-account-management/export?scope=selected&ids=account-1,account-2'),
}
const response = await GET(request)
expect(mockHandleRequest.mock.calls[0][2]).toEqual({ requireAuth: true })
expect(mockCreateWorkbook).toHaveBeenCalledWith({
ids: ['account-1', 'account-2'],
user: expect.objectContaining({ id: 'management-1' }),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('spreadsheetml.sheet')
expect(response.headers.get('Cache-Control')).toContain('no-store')
expect(response.headers.get('Content-Disposition')).toContain(encodeURIComponent('店铺账号管理-已选2条.xlsx'))
})
it('omits identifiers for a full export', async () => {
const request = {
nextUrl: new URL('http://localhost/api/supply-chain/store-account-management/export'),
}
await GET(request)
expect(mockCreateWorkbook).toHaveBeenCalledWith({
ids: undefined,
user: expect.objectContaining({ id: 'management-1' }),
})
})
})

View File

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server'
import { handleRequest } from '@/server/core/response'
import { storeAccountManagementExportService } from '@/server/services/store-account-management-export.service'
function parseSelectedIds(request: NextRequest) {
if (request.nextUrl.searchParams.get('scope') !== 'selected') return undefined
return (request.nextUrl.searchParams.get('ids') ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
}
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const payload = await storeAccountManagementExportService.createWorkbook({
ids: parseSelectedIds(request),
user: ctx.user,
})
return new NextResponse(new Uint8Array(payload.content), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(payload.fileName)}`,
'Cache-Control': 'private, no-store, max-age=0',
'X-Content-Type-Options': 'nosniff',
},
})
}, { requireAuth: true })
}

View File

@ -0,0 +1,108 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, Download, ListChecks } from 'lucide-react'
import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-management'
function getDownloadFileName(response: Response) {
const header = response.headers.get('Content-Disposition') ?? ''
const encoded = header.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
if (!encoded) return '店铺账号管理.xlsx'
try {
return decodeURIComponent(encoded)
} catch {
return '店铺账号管理.xlsx'
}
}
async function readError(response: Response) {
const payload = await response.json().catch(() => null)
return payload?.error?.message || '导出失败'
}
export function StoreAccountExportMenu({ selectedIds }: { selectedIds: string[] }) {
const [open, setOpen] = useState(false)
const [downloading, setDownloading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const closeOnOutside = (event: MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) setOpen(false)
}
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', closeOnOutside)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('mousedown', closeOnOutside)
document.removeEventListener('keydown', closeOnEscape)
}
}, [open])
const download = async (scope: 'selected' | 'all') => {
if (scope === 'selected' && selectedIds.length === 0) return
if (scope === 'selected' && selectedIds.length > STORE_ACCOUNT_EXPORT_SELECTION_LIMIT) {
window.alert(`单次最多导出${STORE_ACCOUNT_EXPORT_SELECTION_LIMIT}条已选账号,可分批导出或使用导出全部`)
return
}
setOpen(false)
setDownloading(true)
try {
const params = new URLSearchParams()
if (scope === 'selected') {
params.set('scope', 'selected')
params.set('ids', selectedIds.join(','))
}
const query = params.size > 0 ? `?${params}` : ''
const response = await fetch(`/api/supply-chain/store-account-management/export${query}`, {
cache: 'no-store',
})
if (!response.ok) throw new Error(await readError(response))
const url = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = url
link.download = getDownloadFileName(response)
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (error) {
window.alert(error instanceof Error ? error.message : '导出失败')
} finally {
setDownloading(false)
}
}
return (
<div ref={menuRef} className="store-account-export-menu">
<button
type="button"
disabled={downloading}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<Download size={15} /> {downloading ? '导出中' : '导出'} <ChevronDown size={13} />
</button>
{open && (
<div className="store-account-export-options" role="menu">
<button
type="button"
className="store-account-export-option"
disabled={selectedIds.length === 0}
onClick={() => void download('selected')}
>
<ListChecks size={15} />
<span><b>导出已选</b><small>{selectedIds.length > 0 ? `已选择 ${selectedIds.length} 条` : '请先勾选账号'}</small></span>
</button>
<button type="button" className="store-account-export-option" onClick={() => void download('all')}>
<Download size={15} />
<span><b>导出全部</b><small>不受当前筛选和分页影响</small></span>
</button>
</div>
)}
</div>
)
}

View File

@ -8,9 +8,10 @@ import { StoreAccountBatchUpdateDialog } from './StoreAccountBatchUpdateDialog'
import { StoreAccountImportProgress } from './StoreAccountImportProgress'
import { StoreAccountLogDialog } from './StoreAccountLogDialog'
import { StoreAccountPermissionDialog } from './StoreAccountPermissionDialog'
import { StoreAccountExportMenu } from './StoreAccountExportMenu'
import { useStoreAccountImport } from './useStoreAccountImport'
type Props = { initialData: StoreAccountPage; initialFilters: StoreAccountFilters; canEdit: boolean; canManagePermissions: boolean }
type Props = { initialData: StoreAccountPage; initialFilters: StoreAccountFilters; canEdit: boolean; canExport: boolean; canManagePermissions: boolean }
type Draft = Partial<Record<StoreAccountFieldKey, string>> & { id?: string }
async function readResponse(response: Response) {
@ -42,7 +43,7 @@ function rowToDraft(row: StoreAccountRow): Draft {
}
}
export function StoreAccountManagementView({ initialData, initialFilters, canEdit, canManagePermissions }: Props) {
export function StoreAccountManagementView({ initialData, initialFilters, canEdit, canExport, canManagePermissions }: Props) {
const [data, setData] = useState(initialData)
const [filters, setFilters] = useState(initialFilters)
const [draft, setDraft] = useState<Draft | null>(null)
@ -60,6 +61,7 @@ export function StoreAccountManagementView({ initialData, initialFilters, canEdi
const batchFields = useMemo(() => STORE_ACCOUNT_FIELDS
.filter((field) => !field.system && data.fieldAccess[field.key] === 'EDITABLE' && field.key !== 'password')
.map((field) => ({ key: field.key as StoreAccountBatchEditableFieldKey, label: field.label })), [data.fieldAccess])
const canSelectRows = canEdit || canExport
useEffect(() => {
if (!batchFields.some((field) => field.key === batchField) && batchFields[0]) setBatchField(batchFields[0].key)
@ -143,7 +145,7 @@ export function StoreAccountManagementView({ initialData, initialFilters, canEdi
return <div className="store-account-page">
<header className="store-account-toolbar">
<div><h1>店铺账号管理</h1><p>人工维护店铺账号、负责人和聚水潭服务信息</p></div>
<div className="store-account-actions"><span>共 {data.pageInfo.total} 条</span>{canManagePermissions && <button type="button" onClick={() => setPermissionsOpen(true)} title="字段权限"><Settings2 size={15} /> 字段权限</button>}{canEdit && <><button type="button" disabled={selectedIds.length === 0} onClick={() => { setBatchValue(''); setBatchOpen(true) }}><KeyRound size={15} /> 批量修改{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}</button><button type="button" onClick={() => void downloadTemplate()}><FileDown size={15} /> 下载模板</button><button type="button" disabled={storeAccountImport.isImporting} onClick={() => fileRef.current?.click()}><Upload size={15} /> {storeAccountImport.isImporting ? '导入中' : '导入'}</button><input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" hidden onChange={(e) => { const file = e.target.files?.[0]; if (file) void storeAccountImport.importFile(file); e.currentTarget.value = '' }} /><button type="button" className="primary" onClick={() => setDraft({ accountType: 'MAIN' })}><Plus size={15} /> 新增账号</button></>}</div>
<div className="store-account-actions"><span>共 {data.pageInfo.total} 条</span>{canExport && <StoreAccountExportMenu selectedIds={selectedIds} />}{canManagePermissions && <button type="button" onClick={() => setPermissionsOpen(true)} title="字段权限"><Settings2 size={15} /> 字段权限</button>}{canEdit && <><button type="button" disabled={selectedIds.length === 0} onClick={() => { setBatchValue(''); setBatchOpen(true) }}><KeyRound size={15} /> 批量修改{selectedIds.length > 0 ? `(${selectedIds.length})` : ''}</button><button type="button" onClick={() => void downloadTemplate()}><FileDown size={15} /> 下载模板</button><button type="button" disabled={storeAccountImport.isImporting} onClick={() => fileRef.current?.click()}><Upload size={15} /> {storeAccountImport.isImporting ? '导入中' : '导入'}</button><input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" hidden onChange={(e) => { const file = e.target.files?.[0]; if (file) void storeAccountImport.importFile(file); e.currentTarget.value = '' }} /><button type="button" className="primary" onClick={() => setDraft({ accountType: 'MAIN' })}><Plus size={15} /> 新增账号</button></>}</div>
</header>
<form className="store-account-filters" onSubmit={(e) => { e.preventDefault(); void load(filters, 1, data.pageInfo.pageSize) }}>
<input value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} placeholder="批量搜索,多个关键词用逗号分隔" />
@ -161,7 +163,7 @@ export function StoreAccountManagementView({ initialData, initialFilters, canEdi
<input value={filters.accountGroupName} onChange={(e) => setFilters({ ...filters, accountGroupName: e.target.value })} placeholder="账号分组" />
<button type="submit" className="primary">查询</button><button type="button" className="icon-button" title="重置筛选" aria-label="重置筛选" onClick={resetFilters}><RotateCcw size={15} /></button>
</form>
<div className="store-account-table-wrap"><table><thead><tr>{canEdit && <th className="select-column"><input type="checkbox" aria-label="全选当前页" checked={data.rows.length > 0 && data.rows.every((row) => selectedIds.includes(row.id))} onChange={(e) => setSelectedIds(e.target.checked ? Array.from(new Set([...selectedIds, ...data.rows.map((row) => row.id)])) : selectedIds.filter((id) => !data.rows.some((row) => row.id === id)))} /></th>}{visibleFields.map((field) => <th key={field.key}>{field.label}</th>)}<th>操作</th></tr></thead><tbody>{loading ? <tr><td colSpan={visibleFields.length + (canEdit ? 2 : 1)} className="empty">加载中...</td></tr> : data.rows.length === 0 ? <tr><td colSpan={visibleFields.length + (canEdit ? 2 : 1)} className="empty">暂无店铺账号</td></tr> : data.rows.map((row) => <tr key={row.id}>{canEdit && <td className="select-column"><input type="checkbox" aria-label={`选择${row.storeName}`} checked={selectedIds.includes(row.id)} onChange={(e) => setSelectedIds(e.target.checked ? [...selectedIds, row.id] : selectedIds.filter((id) => id !== row.id))} /></td>}{visibleFields.map((field) => <td key={field.key}>{field.key === 'accountType' ? formatStoreAccountType(row.accountType) : field.key === 'password' ? <span className="password-cell">{revealedPasswords[row.id] !== undefined ? revealedPasswords[row.id] || '未设置' : row.passwordConfigured ? '••••••' : '未设置'}{row.passwordConfigured && <button type="button" title={revealedPasswords[row.id] !== undefined ? '隐藏密码' : '查看密码'} onClick={() => void togglePassword(row)}>{revealedPasswords[row.id] !== undefined ? <EyeOff size={14} /> : <Eye size={14} />}</button>}</span> : field.key === 'createdAt' || field.key === 'updatedAt' ? formatTime(row[field.key]) : String(row[field.key as keyof StoreAccountRow] ?? '-')}</td>)}<td className="row-actions">{canEdit && <button type="button" onClick={() => setDraft(rowToDraft(row))}><KeyRound size={14} /> 编辑</button>}<button type="button" onClick={() => void openLogs(row)}><History size={14} /> 日志</button></td></tr>)}</tbody></table></div>
<div className="store-account-table-wrap"><table><thead><tr>{canSelectRows && <th className="select-column"><input type="checkbox" aria-label="全选当前页" checked={data.rows.length > 0 && data.rows.every((row) => selectedIds.includes(row.id))} onChange={(e) => setSelectedIds(e.target.checked ? Array.from(new Set([...selectedIds, ...data.rows.map((row) => row.id)])) : selectedIds.filter((id) => !data.rows.some((row) => row.id === id)))} /></th>}{visibleFields.map((field) => <th key={field.key}>{field.label}</th>)}<th>操作</th></tr></thead><tbody>{loading ? <tr><td colSpan={visibleFields.length + (canSelectRows ? 2 : 1)} className="empty">加载中...</td></tr> : data.rows.length === 0 ? <tr><td colSpan={visibleFields.length + (canSelectRows ? 2 : 1)} className="empty">暂无店铺账号</td></tr> : data.rows.map((row) => <tr key={row.id}>{canSelectRows && <td className="select-column"><input type="checkbox" aria-label={`选择${row.storeName}`} checked={selectedIds.includes(row.id)} onChange={(e) => setSelectedIds(e.target.checked ? [...selectedIds, row.id] : selectedIds.filter((id) => id !== row.id))} /></td>}{visibleFields.map((field) => <td key={field.key}>{field.key === 'accountType' ? formatStoreAccountType(row.accountType) : field.key === 'password' ? <span className="password-cell">{revealedPasswords[row.id] !== undefined ? revealedPasswords[row.id] || '未设置' : row.passwordConfigured ? '••••••' : '未设置'}{row.passwordConfigured && <button type="button" title={revealedPasswords[row.id] !== undefined ? '隐藏密码' : '查看密码'} onClick={() => void togglePassword(row)}>{revealedPasswords[row.id] !== undefined ? <EyeOff size={14} /> : <Eye size={14} />}</button>}</span> : field.key === 'createdAt' || field.key === 'updatedAt' ? formatTime(row[field.key]) : String(row[field.key as keyof StoreAccountRow] ?? '-')}</td>)}<td className="row-actions">{canEdit && <button type="button" onClick={() => setDraft(rowToDraft(row))}><KeyRound size={14} /> 编辑</button>}<button type="button" onClick={() => void openLogs(row)}><History size={14} /> 日志</button></td></tr>)}</tbody></table></div>
<footer className="store-account-pagination">
<span className="store-account-page-total">{loading ? '正在统计...' : `共 ${data.pageInfo.total} 条`}</span>
<button className="store-account-page-btn" type="button" disabled={data.pageInfo.page <= 1 || loading} onClick={() => void load(filters, data.pageInfo.page - 1)}><ArrowLeft size={12} strokeWidth={1.8} />上一页</button>
@ -310,4 +312,5 @@ const styles = `
.store-account-filters .store-name-clear{border:1px solid #d4d4d8;background:#fff;color:#52525b}
.store-account-filters .store-name-apply{border:1px solid #27272a;background:#27272a;color:#fff}
.store-account-actions button:disabled{opacity:.45;cursor:not-allowed}
.store-account-export-menu{position:relative}.store-account-export-options{position:absolute;top:calc(100% + 7px);right:0;z-index:90;width:230px;padding:6px;border:1px solid #d4d4d8;border-radius:6px;background:#fff;box-shadow:0 10px 24px rgba(0,0,0,.12)}.store-account-actions .store-account-export-option{display:flex;width:100%;height:auto;min-height:48px;justify-content:flex-start;gap:9px;padding:7px 9px;border:0;border-radius:4px;background:#fff;text-align:left}.store-account-actions .store-account-export-option:hover:not(:disabled){background:#f4f4f5}.store-account-actions .store-account-export-option>span{display:flex;min-width:0;flex-direction:column;gap:2px}.store-account-actions .store-account-export-option b{color:#27272a;font-size:12px;font-weight:600}.store-account-actions .store-account-export-option small{color:#71717a;font-size:10px;font-weight:400}.store-account-actions .store-account-export-option:disabled{opacity:.45}
`

View File

@ -25,6 +25,7 @@ import {
NO_STORE_ACCOUNT_PAGE_ACCESS,
resolveStoreAccountUserPageAccess,
} from './storeAccountPermissionScope'
import { saveStoreAccountFieldPermissionsAction } from './actions'
type PermissionSettings = {
permissions: StoreAccountFieldPermissionRecord[]
@ -173,11 +174,8 @@ export function StoreAccountPermissionDialog({
setSaving(true)
setMessage('')
try {
await readResponse(await fetch('/api/supply-chain/store-account-management/permissions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates }),
}))
const result = await saveStoreAccountFieldPermissionsAction({ updates })
if (!result.success) throw new Error(result.message)
await loadSettings()
await onSaved()
setMessage('权限已保存')

View File

@ -0,0 +1,34 @@
'use server'
import { z } from 'zod'
import { requireSession } from '@/lib/server-session'
import { isStoreAccountFieldKey } from '@/lib/store-account-management'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
const schema = z.object({
updates: z.array(z.object({
fieldKey: z.string(),
subjectKey: z.string().trim().min(1).max(120),
visibility: z.enum(['HIDDEN', 'READONLY', 'EDITABLE']).nullable(),
}).strict()).min(1).max(500),
}).strict()
export async function saveStoreAccountFieldPermissionsAction(input: unknown) {
const parsed = schema.safeParse(input)
if (!parsed.success || parsed.data.updates.some((item) => !isStoreAccountFieldKey(item.fieldKey))) {
return { success: false as const, message: '字段权限内容不正确' }
}
try {
const { authUser } = await requireSession('/login')
const data = await storeAccountManagementService.saveFieldPermissions({
updates: parsed.data.updates as never,
user: authUser,
})
return { success: true as const, data }
} catch (error) {
return {
success: false as const,
message: error instanceof Error ? error.message : '字段权限保存失败',
}
}
}

View File

@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
import { storeAccountManagementAccessService } from '@/server/services/store-account-management-access.service'
import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_FILTER_KEYS, type StoreAccountFilters } from '@/lib/store-account-management'
import { StoreAccountManagementView } from './StoreAccountManagementView'
@ -18,6 +19,9 @@ export default async function StoreAccountManagementPage({ searchParams }: { sea
const filters = Object.fromEntries(STORE_ACCOUNT_FILTER_KEYS.map((key) => [key, first(params[key]).slice(0, 120)])) as StoreAccountFilters
const page = Math.max(1, Number(first(params.page)) || 1)
const pageSize = [50, 100, 200].includes(Number(first(params.pageSize))) ? Number(first(params.pageSize)) : 50
const data = await storeAccountManagementService.listPage({ page, pageSize, filters: { ...EMPTY_STORE_ACCOUNT_FILTERS, ...filters }, user: authUser })
return <StoreAccountManagementView initialData={data} initialFilters={filters} canEdit={access.edit} canManagePermissions={access.edit && (authUser.role === 'ADMIN' || authUser.role === 'SUPER_ADMIN')} />
const [data, capabilities] = await Promise.all([
storeAccountManagementService.listPage({ page, pageSize, filters: { ...EMPTY_STORE_ACCOUNT_FILTERS, ...filters }, user: authUser }),
storeAccountManagementAccessService.getCapabilities(authUser, access),
])
return <StoreAccountManagementView initialData={data} initialFilters={filters} canEdit={access.edit} canExport={capabilities.canExport} canManagePermissions={capabilities.canManageFieldPermissions} />
}

View File

@ -21,6 +21,8 @@ export type StoreAccountFieldPermissionValue = StoreAccountFieldVisibility | nul
export type StoreAccountType = 'MAIN' | 'SUB' | '' | '/'
export type StoreAccountBatchEditableFieldKey = Exclude<StoreAccountFieldKey, 'password' | 'createdAt' | 'updatedAt'>
export const STORE_ACCOUNT_EXPORT_SELECTION_LIMIT = 200
export const STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES = [
{ key: 'design', label: '设计' },
{ key: 'planning', label: '企划' },

View File

@ -150,6 +150,14 @@ export class StoreAccountManagementRepository {
return prisma.storeAccount.findMany({ where: { id: { in: ids } }, select: ACCOUNT_SELECT })
}
async listForExport(ids?: string[]) {
return prisma.storeAccount.findMany({
where: ids ? { id: { in: ids } } : undefined,
select: ACCOUNT_SELECT,
orderBy: [{ storeName: 'asc' }, { account: 'asc' }],
})
}
async upsertByKey(data: {
storeName: string
accountType: string
@ -181,6 +189,17 @@ export class StoreAccountManagementRepository {
return prisma.storeAccountFieldPermission.findMany({ select: { fieldKey: true, subjectKey: true, visibility: true } })
}
async listPagePermissionOverrides() {
const rows = await prisma.supplyChainPermissionOverride.findMany({
where: { targetKey: 'storeAccountManagement' },
select: { subjectKey: true, action: true, enabled: true },
})
return Object.fromEntries(rows.map((row) => [
`storeAccountManagement:${row.subjectKey}:${row.action}`,
row.enabled,
]))
}
async saveFieldPermissions(params: { updates: StoreAccountFieldPermissionUpdate[]; updatedById: string }) {
return prisma.$transaction(params.updates.map((item) => item.visibility === null
? prisma.storeAccountFieldPermission.deleteMany({

View File

@ -0,0 +1,45 @@
import { StoreAccountManagementAccessService } from '../store-account-management-access.service'
const managementUser = {
id: 'management-1',
email: 'management@example.com',
name: '经管人员',
role: 'USER' as const,
status: 'ACTIVE' as const,
businessRoles: ['MANAGEMENT'],
}
describe('StoreAccountManagementAccessService', () => {
it('allows a management user with page view access to export and configure fields', async () => {
const getUserAccess = jest.fn().mockResolvedValue({ view: true, download: false, edit: false })
const service = new StoreAccountManagementAccessService({ getUserAccess })
await expect(service.getCapabilities(managementUser)).resolves.toEqual({
pageAccess: { view: true, download: false, edit: false },
canExport: true,
canManageFieldPermissions: true,
})
})
it('rejects management users without page view access', async () => {
const getUserAccess = jest.fn().mockResolvedValue({ view: false, download: false, edit: false })
const service = new StoreAccountManagementAccessService({ getUserAccess })
await expect(service.assertCanExport(managementUser)).rejects.toThrow('只有拥有页面权限的管理员或经管可以导出店铺账号')
await expect(service.assertCanManageFieldPermissions(managementUser)).rejects.toThrow('只有拥有页面权限的管理员或经管可以配置字段权限')
})
it('rejects ordinary users even when they can view the page', async () => {
const getUserAccess = jest.fn().mockResolvedValue({ view: true, download: true, edit: true })
const service = new StoreAccountManagementAccessService({ getUserAccess })
const ordinaryUser = {
id: 'user-1',
email: 'user@example.com',
name: '普通用户',
role: 'USER' as const,
status: 'ACTIVE' as const,
}
await expect(service.assertCanExport(ordinaryUser)).rejects.toThrow('只有拥有页面权限的管理员或经管可以导出店铺账号')
})
})

View File

@ -0,0 +1,92 @@
import ExcelJS from 'exceljs'
import { StoreAccountManagementExportService } from '../store-account-management-export.service'
import type { StoreAccountManagementRepository } from '@/server/repositories/store-account-management.repository'
import type { StoreAccountManagementAccessService } from '../store-account-management-access.service'
import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-management'
jest.mock('@/lib/store-account-crypto', () => ({
decryptStoreAccountPassword: (value: string) => value ? `明文:${value}` : '',
}))
const adminUser = {
id: 'admin-1',
email: 'admin@example.com',
name: '管理员',
role: 'ADMIN' as const,
status: 'ACTIVE' as const,
}
const record = {
id: 'account-1',
storeName: '测试店铺',
operationOwner: '负责人A',
operationDepartmentHead: '部门负责人A',
operationDirector: '总监A',
profitCenter: '利润中心A',
accountType: 'MAIN',
account: 'shop-account',
passwordCiphertext: 'ciphertext-1',
boundPhone: '13800000000',
verificationContact: '对接人A',
jstOrderService: 'JST-001',
accountGroupName: '账号组A',
createdAt: new Date('2026-08-01T00:00:00.000Z'),
updatedAt: new Date('2026-08-02T00:00:00.000Z'),
}
describe('StoreAccountManagementExportService', () => {
it('exports every column and writes the decrypted plaintext password', async () => {
const listForExport = jest.fn().mockResolvedValue([record])
const assertCanExport = jest.fn().mockResolvedValue(undefined)
const service = new StoreAccountManagementExportService(
{ listForExport } as unknown as StoreAccountManagementRepository,
{ assertCanExport } as unknown as StoreAccountManagementAccessService,
)
const payload = await service.createWorkbook({ user: adminUser })
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.load(payload.content)
const worksheet = workbook.getWorksheet('店铺账号')!
expect(assertCanExport).toHaveBeenCalledWith(adminUser)
expect(listForExport).toHaveBeenCalledWith(undefined)
expect(worksheet.getRow(1).values).toEqual([
undefined,
'店铺名称',
'运营负责人',
'运营部门负责人',
'运营总监',
'利润中心',
'账号类型',
'账号',
'密码',
'绑定手机号',
'手机号验证码对接人',
'聚水潭订单服务号',
'账号分组名称',
'创建时间',
'更新时间',
])
expect(worksheet.getCell('F2').value).toBe('主账号')
expect(worksheet.getCell('H2').value).toBe('明文:ciphertext-1')
expect(payload.count).toBe(1)
expect(payload.fileName).toContain('店铺账号管理-全部-')
})
it('exports only the selected identifiers and enforces the URL-safe selection limit', async () => {
const listForExport = jest.fn().mockResolvedValue([record])
const assertCanExport = jest.fn().mockResolvedValue(undefined)
const service = new StoreAccountManagementExportService(
{ listForExport } as unknown as StoreAccountManagementRepository,
{ assertCanExport } as unknown as StoreAccountManagementAccessService,
)
await service.createWorkbook({ ids: ['account-1', 'account-1'], user: adminUser })
expect(listForExport).toHaveBeenCalledWith(['account-1'])
await expect(service.createWorkbook({
ids: Array.from({ length: STORE_ACCOUNT_EXPORT_SELECTION_LIMIT + 1 }, (_, index) => `account-${index}`),
user: adminUser,
})).rejects.toThrow(`单次最多导出${STORE_ACCOUNT_EXPORT_SELECTION_LIMIT}条已选账号`)
})
})

View File

@ -70,14 +70,14 @@ describe('StoreAccountManagementService field permission settings', () => {
subjectKey: 'operation',
visibility: 'READONLY',
}])
jest.spyOn(supplyChainPermissionSettingsService, 'getSettings').mockResolvedValue({
overrides: {
'storeAccountManagement:operation:view': true,
'storeAccountManagement:user@user-1:edit': true,
'productArchive:operation:view': true,
},
const listPagePermissionOverrides = jest.fn().mockResolvedValue({
'storeAccountManagement:operation:view': true,
'storeAccountManagement:user@user-1:edit': true,
})
const service = new StoreAccountManagementService({ listFieldPermissions } as unknown as StoreAccountManagementRepository)
const service = new StoreAccountManagementService({
listFieldPermissions,
listPagePermissionOverrides,
} as unknown as StoreAccountManagementRepository)
const result = await service.getFieldPermissionSettings({
id: 'admin-1',
@ -94,7 +94,48 @@ describe('StoreAccountManagementService field permission settings', () => {
})
})
it('does not allow a non-admin to read or save field permission settings', async () => {
it('allows management users with page access to read and save field permissions', async () => {
jest.spyOn(supplyChainPermissionSettingsService, 'getUserAccess').mockResolvedValue({
view: true,
download: false,
edit: false,
})
const listFieldPermissions = jest.fn().mockResolvedValue([])
const listPagePermissionOverrides = jest.fn().mockResolvedValue({
'storeAccountManagement:operation:view': true,
})
const saveFieldPermissions = jest.fn().mockResolvedValue([])
const service = new StoreAccountManagementService({
listFieldPermissions,
listPagePermissionOverrides,
saveFieldPermissions,
} as unknown as StoreAccountManagementRepository)
const user = {
id: 'management-1',
email: 'management@example.com',
name: '经管人员',
role: 'USER' as const,
status: 'ACTIVE' as const,
businessRoles: ['MANAGEMENT'],
}
await expect(service.getFieldPermissionSettings(user)).resolves.toEqual({
fields: STORE_ACCOUNT_FIELDS,
permissions: [],
pagePermissionOverrides: { 'storeAccountManagement:operation:view': true },
})
await expect(service.saveFieldPermissions({
updates: [{ fieldKey: 'operationOwner', subjectKey: 'operation', visibility: 'READONLY' }],
user,
})).resolves.toEqual({ saved: 1, inherited: 0 })
})
it('does not allow an ordinary user to read or save field permission settings', async () => {
jest.spyOn(supplyChainPermissionSettingsService, 'getUserAccess').mockResolvedValue({
view: true,
download: true,
edit: true,
})
const listFieldPermissions = jest.fn()
const saveFieldPermissions = jest.fn()
const service = new StoreAccountManagementService({
@ -109,11 +150,11 @@ describe('StoreAccountManagementService field permission settings', () => {
status: 'ACTIVE' as const,
}
await expect(service.getFieldPermissionSettings(user)).rejects.toThrow('只有管理员可以查看字段权限配置')
await expect(service.getFieldPermissionSettings(user)).rejects.toThrow('只有拥有页面权限的管理员或经管可以配置字段权限')
await expect(service.saveFieldPermissions({
updates: [{ fieldKey: 'operationOwner', subjectKey: 'operation', visibility: 'READONLY' }],
user,
})).rejects.toThrow('只有管理员可以配置字段权限')
})).rejects.toThrow('只有拥有页面权限的管理员或经管可以配置字段权限')
expect(listFieldPermissions).not.toHaveBeenCalled()
expect(saveFieldPermissions).not.toHaveBeenCalled()
})

View File

@ -0,0 +1,53 @@
import type { AuthUser } from '@/server/core/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import {
canManageSupplyChainPermissions,
resolveSupplyChainPermissionSubjectKeys,
} from '@/lib/supply-chain-permission-settings'
import { supplyChainPermissionSettingsService } from './supply-chain-permission-settings.service'
type PageAccess = { view: boolean; download: boolean; edit: boolean }
type PermissionAccessReader = {
getUserAccess: (user: AuthUser, targetKey: 'storeAccountManagement') => Promise<PageAccess>
}
export function isStoreAccountSecurityManager(user: AuthUser) {
return canManageSupplyChainPermissions(user)
|| resolveSupplyChainPermissionSubjectKeys(user).includes('management')
}
export class StoreAccountManagementAccessService {
constructor(
private readonly permissionAccessReader: PermissionAccessReader = supplyChainPermissionSettingsService,
) {}
async getCapabilities(user: AuthUser, knownPageAccess?: PageAccess) {
const pageAccess = knownPageAccess
?? await this.permissionAccessReader.getUserAccess(user, 'storeAccountManagement')
const canManageSensitiveActions = pageAccess.view && isStoreAccountSecurityManager(user)
return {
pageAccess,
canExport: canManageSensitiveActions,
canManageFieldPermissions: canManageSensitiveActions,
}
}
async assertCanExport(user: AuthUser) {
const capabilities = await this.getCapabilities(user)
if (!capabilities.canExport) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有拥有页面权限的管理员或经管可以导出店铺账号')
}
return capabilities
}
async assertCanManageFieldPermissions(user: AuthUser) {
const capabilities = await this.getCapabilities(user)
if (!capabilities.canManageFieldPermissions) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有拥有页面权限的管理员或经管可以配置字段权限')
}
return capabilities
}
}
export const storeAccountManagementAccessService = new StoreAccountManagementAccessService()

View File

@ -0,0 +1,127 @@
import ExcelJS from 'exceljs'
import type { AuthUser } from '@/server/core/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import { decryptStoreAccountPassword } from '@/lib/store-account-crypto'
import { STORE_ACCOUNT_EXPORT_SELECTION_LIMIT } from '@/lib/store-account-management'
import {
storeAccountManagementRepository,
type StoreAccountManagementRepository,
} from '@/server/repositories/store-account-management.repository'
import {
storeAccountManagementAccessService,
type StoreAccountManagementAccessService,
} from './store-account-management-access.service'
const EXPORT_COLUMNS = [
{ key: 'storeName', header: '店铺名称', width: 24 },
{ key: 'operationOwner', header: '运营负责人', width: 16 },
{ key: 'operationDepartmentHead', header: '运营部门负责人', width: 18 },
{ key: 'operationDirector', header: '运营总监', width: 16 },
{ key: 'profitCenter', header: '利润中心', width: 16 },
{ key: 'accountType', header: '账号类型', width: 12 },
{ key: 'account', header: '账号', width: 24 },
{ key: 'password', header: '密码', width: 24 },
{ key: 'boundPhone', header: '绑定手机号', width: 18 },
{ key: 'verificationContact', header: '手机号验证码对接人', width: 22 },
{ key: 'jstOrderService', header: '聚水潭订单服务号', width: 22 },
{ key: 'accountGroupName', header: '账号分组名称', width: 18 },
{ key: 'createdAt', header: '创建时间', width: 20 },
{ key: 'updatedAt', header: '更新时间', width: 20 },
] as const
function formatAccountType(value: string) {
if (value === 'MAIN') return '主账号'
if (value === 'SUB') return '子账号'
return value
}
function fileStamp() {
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(new Date())
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ''
return `${value('year')}${value('month')}${value('day')}-${value('hour')}${value('minute')}${value('second')}`
}
export class StoreAccountManagementExportService {
constructor(
private readonly repository: StoreAccountManagementRepository = storeAccountManagementRepository,
private readonly accessService: StoreAccountManagementAccessService = storeAccountManagementAccessService,
) {}
async createWorkbook(input: { ids?: string[]; user: AuthUser }) {
await this.accessService.assertCanExport(input.user)
const selectedIds = input.ids
? [...new Set(input.ids.map((id) => id.trim()).filter(Boolean))]
: undefined
if (selectedIds && selectedIds.length === 0) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要导出的店铺账号')
}
if (selectedIds && selectedIds.length > STORE_ACCOUNT_EXPORT_SELECTION_LIMIT) {
throw new AppError(
ErrorCode.VALIDATION_INVALID_FORMAT,
`单次最多导出${STORE_ACCOUNT_EXPORT_SELECTION_LIMIT}条已选账号,可分批导出或使用导出全部`,
)
}
const rows = await this.repository.listForExport(selectedIds)
const workbook = new ExcelJS.Workbook()
workbook.creator = input.user.name
workbook.created = new Date()
const worksheet = workbook.addWorksheet('店铺账号')
worksheet.columns = EXPORT_COLUMNS.map((column) => ({
key: column.key,
header: column.header,
width: column.width,
}))
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
worksheet.autoFilter = { from: 'A1', to: 'N1' }
rows.forEach((row) => {
worksheet.addRow({
storeName: row.storeName,
operationOwner: row.operationOwner,
operationDepartmentHead: row.operationDepartmentHead,
operationDirector: row.operationDirector,
profitCenter: row.profitCenter,
accountType: formatAccountType(row.accountType),
account: row.account,
password: decryptStoreAccountPassword(row.passwordCiphertext),
boundPhone: row.boundPhone,
verificationContact: row.verificationContact,
jstOrderService: row.jstOrderService,
accountGroupName: row.accountGroupName,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
})
})
const header = worksheet.getRow(1)
header.height = 24
header.font = { bold: true, color: { argb: 'FFFFFFFF' } }
header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF27272A' } }
header.alignment = { vertical: 'middle', horizontal: 'center' }
worksheet.getColumn('createdAt').numFmt = 'yyyy-mm-dd hh:mm:ss'
worksheet.getColumn('updatedAt').numFmt = 'yyyy-mm-dd hh:mm:ss'
worksheet.eachRow((row, rowNumber) => {
if (rowNumber > 1) row.alignment = { vertical: 'middle' }
})
const content = Buffer.from(await workbook.xlsx.writeBuffer())
const scope = selectedIds ? `已选${rows.length}条` : '全部'
return {
content,
fileName: `店铺账号管理-${scope}-${fileStamp()}.xlsx`,
count: rows.length,
}
}
}
export const storeAccountManagementExportService = new StoreAccountManagementExportService()

View File

@ -22,6 +22,7 @@ import {
import { decryptStoreAccountPassword, encryptStoreAccountPassword } from '@/lib/store-account-crypto'
import { canManageSupplyChainPermissions, resolveSupplyChainPermissionSubjectKeys } from '@/lib/supply-chain-permission-settings'
import { supplyChainPermissionSettingsService } from './supply-chain-permission-settings.service'
import { storeAccountManagementAccessService } from './store-account-management-access.service'
import {
storeAccountManagementRepository,
type StoreAccountManagementRepository,
@ -244,7 +245,7 @@ export class StoreAccountManagementService {
}
async saveFieldPermissions(input: { updates: StoreAccountFieldPermissionUpdate[]; user: AuthUser }) {
if (!canManageSupplyChainPermissions(input.user)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有管理员可以配置字段权限')
await storeAccountManagementAccessService.assertCanManageFieldPermissions(input.user)
const updates = Array.from(new Map(input.updates.map((item) => [`${item.fieldKey}:${item.subjectKey}`, item])).values())
const invalid = updates.some((item) => (
!STORE_ACCOUNT_FIELDS.some((field) => field.key === item.fieldKey && !field.system)
@ -258,13 +259,13 @@ export class StoreAccountManagementService {
}
async getFieldPermissionSettings(user: AuthUser) {
if (!canManageSupplyChainPermissions(user)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有管理员可以查看字段权限配置')
await storeAccountManagementAccessService.assertCanManageFieldPermissions(user)
const [permissions, pagePermissionSettings] = await Promise.all([
this.repository.listFieldPermissions(),
supplyChainPermissionSettingsService.getSettings(user),
this.repository.listPagePermissionOverrides(),
])
const pagePermissionOverrides = Object.fromEntries(
Object.entries(pagePermissionSettings.overrides).filter(([key]) => (
Object.entries(pagePermissionSettings).filter(([key]) => (
key.startsWith(STORE_ACCOUNT_PAGE_PERMISSION_PREFIX)
)),
)