Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务: - 安全审计全部修复(docs/security-audit-2026-09-05.md): bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、 全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、 管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、 会话默认 7 天、限流缓存故障降级、robots.txt 端点等 - 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固 - Redis 支持库号(FCB_REDIS_DB / redis://…/db URL) - 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、 提码 5 位起、chunk 32MiB 上限、admin 审计动作等) 验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
158 lines
6.9 KiB
TypeScript
158 lines
6.9 KiB
TypeScript
/** 管理后台 API(对齐 t3 契约:login/verify/logout/file list/update/delete/batch-delete/config/audit) */
|
||
import { request } from './http'
|
||
import { paths } from './paths'
|
||
import type {
|
||
AdminConfig,
|
||
AdminFileListResult,
|
||
AdminFileUpdatePayload,
|
||
AdminLoginResult,
|
||
AuditListResult,
|
||
} from './types'
|
||
import { pick } from '@/utils/format'
|
||
|
||
/** 管理员登录(密码错误 → 401 ApiError) */
|
||
export function adminLogin(password: string): Promise<AdminLoginResult> {
|
||
return request<AdminLoginResult>(paths.adminLogin, { method: 'POST', json: { password } })
|
||
}
|
||
|
||
export function adminVerify(): Promise<Record<string, unknown>> {
|
||
return request<Record<string, unknown>>(paths.adminVerify)
|
||
}
|
||
|
||
export async function adminLogout(): Promise<void> {
|
||
await request<void>(paths.adminLogout, { method: 'POST' })
|
||
}
|
||
|
||
export interface AdminFileListQuery {
|
||
page: number
|
||
size: number
|
||
keyword?: string
|
||
}
|
||
|
||
/** 文件列表(宽松解析:兼容 data 字段或 list 字段、total 顶层) */
|
||
export async function adminFileList(q: AdminFileListQuery): Promise<AdminFileListResult> {
|
||
const raw = await request<Record<string, unknown>>(paths.adminFileList, {
|
||
query: { page: q.page, size: q.size, keyword: q.keyword || undefined },
|
||
})
|
||
const rows = (pick<unknown[]>(raw, ['data', 'list', 'items', 'files']) ?? []) as Record<string, unknown>[]
|
||
const total = Number(pick<number>(raw, ['total', 'count']) ?? rows.length)
|
||
return {
|
||
page: Number(pick<number>(raw, ['page']) ?? q.page),
|
||
size: Number(pick<number>(raw, ['size']) ?? q.size),
|
||
total,
|
||
data: rows.map((r) => ({
|
||
id: Number(pick<number>(r, ['id']) ?? 0),
|
||
code: String(pick<string>(r, ['code']) ?? ''),
|
||
name: String(pick<string>(r, ['name']) ?? `${pick<string>(r, ['prefix']) ?? ''}${pick<string>(r, ['suffix']) ?? ''}`),
|
||
suffix: String(pick<string>(r, ['suffix']) ?? ''),
|
||
size: Number(pick<number>(r, ['size']) ?? 0),
|
||
isText: Boolean(pick<boolean>(r, ['isText', 'is_text']) ?? false),
|
||
expiredAt: (pick<string>(r, ['expiredAt', 'expired_at', 'expires_at']) as string) ?? null,
|
||
expiredCount: (pick<number>(r, ['expiredCount', 'expired_count']) as number) ?? null,
|
||
usedCount: Number(pick<number>(r, ['usedCount', 'used_count']) ?? 0),
|
||
createdAt: (pick<string>(r, ['createdAt', 'created_at']) as string) ?? null,
|
||
isExpired: Boolean(pick<boolean>(r, ['isExpired', 'is_expired']) ?? false),
|
||
})),
|
||
}
|
||
}
|
||
|
||
/** 删除单个文件 */
|
||
export async function adminFileDelete(id: number): Promise<void> {
|
||
await request<void>(paths.adminFileDelete, { method: 'DELETE', json: { id } })
|
||
}
|
||
|
||
/** 批量删除(契约:POST /admin/file/batch-delete,body {ids:number[]}) */
|
||
export async function adminFileBatchDelete(ids: number[]): Promise<void> {
|
||
await request<void>(paths.adminFileBatchDelete, { method: 'POST', json: { ids } })
|
||
}
|
||
|
||
/** 更新分享(code/过期时间/次数;go-api 定稿 PATCH /admin/file/update) */
|
||
export async function adminFileUpdate(payload: AdminFileUpdatePayload): Promise<void> {
|
||
await request<void>(paths.adminFileUpdate, { method: 'PATCH', json: payload })
|
||
}
|
||
|
||
/** 读取管理端配置(KV 全量,含 site_name/logo_url/favicon_url) */
|
||
export async function adminConfigGet(): Promise<AdminConfig> {
|
||
const raw = await request<unknown>(paths.adminConfigGet)
|
||
// 兼容:后端可能直接返回 KV 对象,或包一层 config/data
|
||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||
const obj = raw as Record<string, unknown>
|
||
const inner = (pick<Record<string, unknown>>(obj, ['config', 'data', 'settings']) ?? obj) as Record<string, unknown>
|
||
return inner
|
||
}
|
||
return {}
|
||
}
|
||
|
||
/** 更新管理端配置(PATCH 部分字段 JSON;提交后全站生效,含 site_name/logo_url/favicon_url) */
|
||
export async function adminConfigUpdate(patch: Record<string, unknown>): Promise<void> {
|
||
await request<void>(paths.adminConfigUpdate, { method: 'PATCH', json: patch })
|
||
}
|
||
|
||
/** v3 存储引擎热切换:成功返回当前引擎名;失败(503)服务端保持原引擎并抛 ApiError */
|
||
export async function adminStorageSwitch(engine: 'local' | 's3' | 'webdav'): Promise<string> {
|
||
const data = await request<{ ok: boolean; engine: string }>(paths.adminStorageSwitch, {
|
||
method: 'POST',
|
||
json: { engine },
|
||
})
|
||
return String(data?.engine ?? engine)
|
||
}
|
||
|
||
/** 修改管理员密码(成功后旧 token 全部失效,需重新登录) */
|
||
export async function adminPasswordUpdate(oldPassword: string, newPassword: string): Promise<void> {
|
||
await request<void>(paths.adminPasswordUpdate, {
|
||
method: 'PATCH',
|
||
json: { old_password: oldPassword, new_password: newPassword },
|
||
})
|
||
}
|
||
|
||
export interface AuditListQuery {
|
||
page: number
|
||
size: number
|
||
action?: string
|
||
result?: string
|
||
ip?: string
|
||
/** ISO 或 YYYY-MM-DD;由调用方格式化 */
|
||
startTime?: string
|
||
endTime?: string
|
||
}
|
||
|
||
/** 审计日志列表(宽松解析兼容 data/list 字段与 snake_case/camelCase 行字段) */
|
||
export async function adminAuditList(q: AuditListQuery): Promise<AuditListResult> {
|
||
const raw = await request<Record<string, unknown>>(paths.adminAuditList, {
|
||
query: {
|
||
page: q.page,
|
||
size: q.size,
|
||
action: q.action || undefined,
|
||
result: q.result || undefined,
|
||
ip: q.ip || undefined,
|
||
start_time: q.startTime || undefined,
|
||
end_time: q.endTime || undefined,
|
||
},
|
||
})
|
||
const rows = (pick<unknown[]>(raw, ['data', 'list', 'items', 'logs']) ?? []) as Record<string, unknown>[]
|
||
const total = Number(pick<number>(raw, ['total', 'count']) ?? rows.length)
|
||
return {
|
||
page: Number(pick<number>(raw, ['page']) ?? q.page),
|
||
size: Number(pick<number>(raw, ['size']) ?? q.size),
|
||
total,
|
||
data: rows.map((r, idx) => ({
|
||
id: Number(pick<number>(r, ['id']) ?? idx + 1),
|
||
action: String(pick<string>(r, ['action']) ?? ''),
|
||
result: String(pick<string>(r, ['result']) ?? ''),
|
||
fileCode: String(pick<string>(r, ['file_code', 'fileCode', 'code']) ?? ''),
|
||
fileName: String(pick<string>(r, ['file_name', 'fileName', 'name']) ?? ''),
|
||
sizeBytes: (pick<number>(r, ['size_bytes', 'sizeBytes', 'size']) as number) ?? null,
|
||
transferredBytes: (pick<number>(r, ['transferred_bytes', 'transferredBytes', 'bytes']) as number) ?? null,
|
||
ip: String(pick<string>(r, ['ip', 'client_ip', 'clientIp']) ?? ''),
|
||
userAgent: String(pick<string>(r, ['user_agent', 'userAgent']) ?? ''),
|
||
deviceOs: String(pick<string>(r, ['device_os', 'deviceOs', 'os']) ?? ''),
|
||
deviceBrowser: String(pick<string>(r, ['device_browser', 'deviceBrowser', 'browser']) ?? ''),
|
||
deviceType: String(pick<string>(r, ['device_type', 'deviceType']) ?? ''),
|
||
actor: String(pick<string>(r, ['actor']) ?? ''),
|
||
errorMsg: String(pick<string>(r, ['error_msg', 'errorMsg', 'error']) ?? ''),
|
||
durationMs: (pick<number>(r, ['duration_ms', 'durationMs', 'duration']) as number) ?? null,
|
||
createdAt: (pick<string>(r, ['created_at', 'createdAt', 'time']) as string) ?? null,
|
||
})),
|
||
}
|
||
}
|