feat: 三栏比例缩放 + SeedanceContent视频上传 + 本地存储基建 (0.0.22)

三栏布局
- ResizeObserver 等比例分配替代固定默认值恢复
- 修复拖拽+窗口调整竞态卡死(needsRecalcRef 延迟补算)
- containerRef.current 动态读取 DOM 替代闭包 el

Seedance2Content
- 从占位文本重构为内容编排控件(文本+图片/视频/音频上传)
- 根据 spec.image_files/video_files/audio_files 动态渲染上传区域
- 值 JSON 序列化 → 提交时转 ContentItem[](text/image_url/video_url/audio_url)

图片上传兼容视频文件
- buildAccept 根据 file_filter 区分 image/video 前缀
- createMediaBeforeUpload 动态放行 video/* MIME
- 公共模块 media-upload-utils.ts

本地存储基建
- sql.js (WASM SQLite) + safeStorage 加密持久化
- 8个IPC文件操作通道 + resolveSafe 路径穿越防护
- <userData>/heixiu-data/ 沙箱 + JWT用户隔离
- 7表DDL + 游标分页 + LRU媒体淘汰

其他
- 修复 errorMessageRef 渲染期写入警告
- 媒体预览回退直接URL(<img>无CORS限制)+ output_type类型补充
- CSP wasm-unsafe-eval + https: CDN媒体源
This commit is contained in:
2026-06-18 19:22:28 +08:00
parent e1c8eff946
commit e4c9ae8fc6
42 changed files with 2640 additions and 126 deletions

View File

@@ -0,0 +1,176 @@
// ============================================================
// infrastructure/storage/db-core.ts — sql.js 数据库核心封装
//
// 职责:
// - 初始化 sql.js WASM 运行时 + 创建/加载 Database
// - 执行 DDL 建表 / 迁移
// - 提供通用 CRUD 操作execute / query / run / get
// - 导出数据库为 Uint8Array供加密落盘
//
// 安全:
// - 数据库完全在内存中运行,不直接操作磁盘
// - 数据导出由 db-encrypt.ts 加密后写入磁盘
// ============================================================
import initSqlJs, { type Database, type SqlJsStatic, type SqlValue } from 'sql.js';
import { DDL_STATEMENTS, INIT_VERSION_SQL } from './schema';
import { logger } from '@/utils/logger';
// ---------- 内部状态 ----------
let SQL: SqlJsStatic | null = null;
let db: Database | null = null;
// ---------- 初始化 ----------
/**
* 初始化 sql.js 运行时WASM 加载),仅需调用一次。
*/
async function ensureRuntime(): Promise<SqlJsStatic> {
if (SQL) return SQL;
try {
SQL = await initSqlJs({
locateFile: (file: string) => {
const url = import.meta.env.DEV ? `/${file}` : `./${file}`;
console.log(`[storage] 加载 sql.js WASM${url}`);
return url;
},
});
console.log('[storage] sql.js 运行时初始化成功');
return SQL;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[storage] sql.js WASM 加载失败:${msg}`);
logger.error('storage', `sql.js WASM 加载失败:${msg}`, err instanceof Error ? err : undefined);
throw err;
}
}
/**
* 打开或创建数据库。
*
* @param encryptedData - 从磁盘读取的加密数据(由 db-encrypt.ts 解密后提供),
* 未提供则创建空库。
* @returns true 表示成功
*/
export async function openDatabase(encryptedData?: Uint8Array): Promise<boolean> {
try {
const sql = await ensureRuntime();
db = new sql.Database(encryptedData ?? undefined);
// 执行建表 DDL
db.run('PRAGMA journal_mode=OFF');
db.run('PRAGMA synchronous=0');
for (const stmt of DDL_STATEMENTS) {
db.run(stmt);
}
db.run(INIT_VERSION_SQL);
const size = (db.export()?.byteLength ?? 0);
console.log(`[storage] 数据库已打开(${encryptedData ? '从磁盘恢复' : '新建'}${DDL_STATEMENTS.length} 条 DDL${size} bytes`);
logger.info('storage', `数据库已打开(${encryptedData ? '从磁盘恢复' : '新建'}`);
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[storage] 数据库打开失败:${msg}`);
logger.error('storage', `数据库打开失败:${msg}`, err instanceof Error ? err : undefined);
return false;
}
}
// ---------- 导出 ----------
/**
* 导出当前数据库为 Uint8Array供加密后写入磁盘
*/
export function exportDatabase(): Uint8Array | null {
if (!db) return null;
try {
return db.export();
} catch (err) {
logger.error('storage', '数据库导出失败', err instanceof Error ? err : undefined);
return null;
}
}
// ---------- 关闭 ----------
export function closeDatabase(): void {
if (db) {
db.close();
db = null;
logger.info('storage', '数据库已关闭');
}
}
export function isOpen(): boolean {
return db !== null;
}
// ---------- 查询 API ----------
/** 确保数据库已打开 */
function guard(): Database {
if (!db) throw new Error('数据库未初始化 — 请确认已调用 initStorage() 并等待其完成');
return db;
}
/** 执行一条写操作 SQLINSERT / UPDATE / DELETE返回受影响行数 */
export function execute(sql: string, params?: SqlValue[]): number {
try {
guard().run(sql, params);
return guard().getRowsModified();
} catch (err) {
logger.error('storage', `SQL 执行失败:${sql.substring(0, 80)}`, err instanceof Error ? err : undefined);
throw err;
}
}
/** 查询多条记录 */
export function queryAll<T = Record<string, unknown>>(sql: string, params?: SqlValue[]): T[] {
try {
const stmt = guard().prepare(sql);
if (params) stmt.bind(params);
const rows: T[] = [];
while (stmt.step()) {
rows.push(stmt.getAsObject() as T);
}
stmt.free();
return rows;
} catch (err) {
logger.error('storage', `SQL 查询失败:${sql.substring(0, 80)}`, err instanceof Error ? err : undefined);
return [];
}
}
/** 查询单条记录 */
export function queryOne<T = Record<string, unknown>>(sql: string, params?: SqlValue[]): T | null {
try {
const stmt = guard().prepare(sql);
if (params) stmt.bind(params);
let row: T | null = null;
if (stmt.step()) {
row = stmt.getAsObject() as T;
}
stmt.free();
return row;
} catch (err) {
logger.error('storage', `SQL 查询失败:${sql.substring(0, 80)}`, err instanceof Error ? err : undefined);
return null;
}
}
/** 执行多条 SQL事务包裹 */
export function executeBatch(statements: string[]): void {
const d = guard();
try {
d.run('BEGIN');
for (const stmt of statements) {
d.run(stmt);
}
d.run('COMMIT');
} catch (err) {
try { d.run('ROLLBACK'); } catch { /* ignore */ }
throw err;
}
}

View File

@@ -0,0 +1,123 @@
// ============================================================
// infrastructure/storage/db-encrypt.ts — 数据库加密持久化
//
// 职责:
// - 将 sql.js 数据库序列化 → safeStorage 加密 → IPC 写入磁盘
// - 从磁盘读取 → safeStorage 解密 → 反序列化为 sql.js 可用的 Uint8Array
// - 降级方案:非 Electron 环境回退 localStorage仅开发用
//
// 流程:
// 保存DB.export() → Uint8Array → base64 → safeStorage.encrypt() → IPC writeFile
// 加载IPC readFile → base64 → safeStorage.decrypt() → Uint8Array → DB
// ============================================================
import { hasIpc } from '@/utils/ipc';
import { exportDatabase } from './db-core';
import { logger } from '@/utils/logger';
/** 数据库文件相对路径(相对于 heixiu-data/ */
const DB_RELATIVE_PATH = 'heixiu.db';
// ---------- 数据编解码 ----------
function uint8ArrayToBase64(data: Uint8Array): string {
let binary = '';
for (let i = 0; i < data.byteLength; i++) {
binary += String.fromCharCode(data[i]);
}
return btoa(binary);
}
function base64ToUint8Array(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
// ---------- 公开 API ----------
/** 保存数据库到磁盘(加密 + IPC 写文件) */
export async function saveDatabase(): Promise<boolean> {
const data = exportDatabase();
if (!data) {
console.warn('[storage] saveDatabaseexportDatabase 返回 null数据库未打开');
return false;
}
console.log(`[storage] saveDatabase导出 ${data.byteLength} bytes`);
const base64 = uint8ArrayToBase64(data);
console.log(`[storage] saveDatabaseBase64 编码后 ${base64.length} chars`);
// Electron 环境:加密后写磁盘
if (hasIpc() && window.fileStorage && window.safeStorage) {
console.log('[storage] saveDatabaseElectron 环境,准备加密...');
try {
const encrypted = await window.safeStorage.encrypt(base64);
if (!encrypted) {
console.warn('[storage] saveDatabasesafeStorage.encrypt 返回 null');
return false;
}
console.log(`[storage] saveDatabase加密后 ${encrypted.length} chars准备写入磁盘...`);
const result = await window.fileStorage.writeFile(DB_RELATIVE_PATH, encrypted, 'base64');
console.log(`[storage] saveDatabase写入结果 = ${result}`);
return result !== null;
} catch (err) {
console.error('[storage] saveDatabase 异常:', err);
logger.error('storage', '数据库加密保存失败', err instanceof Error ? err : undefined);
return false;
}
}
// 降级localStorage 明文(仅开发用)
console.log('[storage] saveDatabase非 Electron 环境,使用 localStorage 降级');
try {
localStorage.setItem('ele-heixiu-db', base64);
return true;
} catch {
return false;
}
}
/** 从磁盘加载数据库IPC 读文件 + 解密) */
export async function loadDatabase(): Promise<Uint8Array | undefined> {
// Electron 环境:读文件 + 解密
if (hasIpc() && window.fileStorage && window.safeStorage) {
console.log('[storage] loadDatabaseElectron 环境,读取磁盘...');
try {
const encrypted = await window.fileStorage.readFile(DB_RELATIVE_PATH);
if (!encrypted) {
console.log('[storage] loadDatabase磁盘文件不存在首次启动');
return undefined;
}
console.log(`[storage] loadDatabase读取加密数据 ${encrypted.length} chars准备解密...`);
const base64 = await window.safeStorage.decrypt(encrypted);
if (!base64) {
console.warn('[storage] loadDatabase解密失败');
return undefined;
}
console.log(`[storage] loadDatabase解密后 ${base64.length} chars`);
return base64ToUint8Array(base64);
} catch (err) {
console.warn('[storage] loadDatabase 异常:', err);
logger.warn('storage', '数据库解密加载失败', err instanceof Error ? err : undefined);
return undefined;
}
}
// 降级localStorage 明文
console.log('[storage] loadDatabase非 Electron 环境,尝试 localStorage...');
try {
const raw = localStorage.getItem('ele-heixiu-db');
if (!raw) {
console.log('[storage] loadDatabaselocalStorage 无数据');
return undefined;
}
console.log(`[storage] loadDatabaselocalStorage 有数据 ${raw.length} chars`);
return base64ToUint8Array(raw);
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,128 @@
// ============================================================
// infrastructure/storage/index.ts — 本地存储模块统一入口
//
// 初始化流程:
// await initStorage() // AppProvider 挂载后调用一次
// await clearUserStorage() // 退出登录时调用
// await saveDatabase() // 定期或退出前调用(保存到磁盘)
//
// 目录结构(自动管理):
// <userData>/heixiu-data/
// ├── heixiu.db ← 加密的 sql.js 持久化镜像
// └── media-cache/<userId>/<date>/ ← 媒体文件缓存
//
// 使用示例:
// import { initStorage, getTasks, syncTasks, saveParamSnapshot } from '@/infrastructure/storage';
// ============================================================
import { openDatabase, closeDatabase, isOpen } from './db-core';
import { loadDatabase, saveDatabase } from './db-encrypt';
import { clearUserIdCache } from './user-scope';
import { clearUserTasks, getTaskCount as _getTaskCount } from './task-store';
import { clearUserOrders, getOrderCount as _getOrderCount } from './order-store';
import { clearUserParams } from './param-store';
import { clearUserMedia, getMediaCount as _getMediaCount, getMediaTotalSize as _getMediaTotalSize } from './media-store';
import { logger } from '@/utils/logger';
// ---------- 重新导出 ----------
export { openDatabase, closeDatabase, isOpen, exportDatabase, execute, queryAll, queryOne } from './db-core';
export { loadDatabase, saveDatabase } from './db-encrypt';
export { getCurrentUserId, clearUserIdCache, getTodayKey, hashURL, formatBytes } from './user-scope';
export { upsertTask, syncTasks, getTasks, getTaskById, deleteTask, getTaskCount, clearUserTasks, rowToTask } from './task-store';
export { upsertOrder, syncRechargeOrders, syncVipOrders, getOrders, getOrderById, deleteOrder, getOrderCount, clearUserOrders } from './order-store';
export { saveParamSnapshot, fillOutputData, getParamByTaskId, getParamHistory, parseParams, deleteParamHistory, clearUserParams } from './param-store';
export { upsertMediaCache, getMediaByUrl, hasMedia, getMediaCount, getMediaTotalSize, deleteMediaByUrl, clearExpiredMedia, clearUserMedia } from './media-store';
export { setSetting, getSetting, getAllSettings, deleteSetting, getOutputPath, setOutputPath, getTeamRepoPath, setTeamRepoPath } from './settings-store';
export { getCursor, updateCursor, hasMore, nextPage, syncedCount, totalCount, getPageSize } from './sync-manager';
export { writeFile, readFile, deleteFile, makeDir, showItemInFolder, fileExists, saveBlobToFile, readFileAsBlobUrl, getDataDir } from './ipc-file';
export { loadMedia, preloadMedia, isVideoMime, isImageMime, isAudioMime, revokeMediaUrl, isMediaSuccess, isMediaFailure } from './media-loader';
export type { MediaLoadResult, MediaLoadSuccess, MediaLoadFailure } from './media-loader';
export type {
TaskRecordEntry, TaskQueryFilter,
OrderRecordEntry, OrderType, OrderQueryFilter,
ParamHistoryEntry, MediaCacheEntry,
SyncCursor, SyncDataType, AppSetting,
} from './types';
// ---------- 初始化 ----------
/**
* 初始化本地存储模块。
*
* 流程:从磁盘加载加密数据 → 解密 → sql.js 打开 → 建表
*
* @returns true 表示就绪
*/
export async function initStorage(): Promise<boolean> {
// 幂等:已经打开就跳过
if (isOpen()) {
console.log('[storage] 数据库已打开,跳过重复初始化');
return true;
}
console.log('[storage] 开始初始化...');
// 1. 尝试从磁盘加载已有数据
let data: Uint8Array | undefined;
try {
data = await loadDatabase();
console.log(`[storage] 加载磁盘数据:${data ? `${data.byteLength} bytes` : '无(首次启动)'}`);
} catch (err) {
console.warn('[storage] 加载持久化数据失败,将创建新数据库', err);
logger.warn('storage', '加载持久化数据失败,将创建新数据库', err instanceof Error ? err : undefined);
}
// 2. 打开数据库
const ok = await openDatabase(data);
if (!ok) {
console.error('[storage] 数据库打开失败,本地存储不可用');
logger.warn('storage', '数据库打开失败,本地存储不可用');
return false;
}
// 3. 清理过期媒体缓存
try {
const { clearExpiredMedia } = await import('./media-store');
clearExpiredMedia();
} catch { /* ignore */ }
console.log('[storage] 本地存储模块初始化完成');
logger.info('storage', '本地存储模块初始化完成');
return true;
}
// ---------- 清理 ----------
/** 清除当前用户的所有本地数据 + 关闭数据库 */
export async function clearUserStorage(): Promise<void> {
// 1. 清空所有表数据
try {
await Promise.allSettled([
Promise.resolve(clearUserTasks()),
Promise.resolve(clearUserOrders()),
Promise.resolve(clearUserParams()),
Promise.resolve(clearUserMedia()),
]);
} catch { /* ignore */ }
// 2. 保存空库到磁盘(覆盖旧数据)
await saveDatabase();
// 3. 关闭数据库
closeDatabase();
clearUserIdCache();
logger.info('storage', '用户存储数据已清理');
}
// ---------- 统计 ----------
/** 获取存储统计信息 */
export function getStorageStats() {
return {
taskCount: _getTaskCount(),
orderCount: _getOrderCount(),
mediaCount: _getMediaCount(),
mediaSize: _getMediaTotalSize(),
dbReady: isOpen(),
};
}

View File

@@ -0,0 +1,76 @@
// ============================================================
// infrastructure/storage/ipc-file.ts — 渲染进程侧文件操作桥接
//
// 职责:
// - 封装 window.fileStorage 调用(由 preload 注入)
// - 降级方案:非 Electron 环境使用 localStorage 模拟
// - 提供统一的文件操作 API 供上层 Store 使用
//
// 使用模式:
// 本模块是唯一直接访问 window.fileStorage 的地方,
// media-store / db-encrypt 等均通过本模块操作文件。
// ============================================================
import { hasIpc } from '@/utils/ipc';
import { logger } from '@/utils/logger';
// ---------- 文件 API ----------
export async function writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
if (!hasIpc() || !window.fileStorage) {
logger.warn('storage', 'fileStorage 不可用,文件写入跳过');
return null;
}
return window.fileStorage.writeFile(relativePath, data, encoding);
}
export async function readFile(relativePath: string): Promise<string | null> {
if (!hasIpc() || !window.fileStorage) return null;
return window.fileStorage.readFile(relativePath);
}
export async function deleteFile(relativePath: string): Promise<boolean> {
if (!hasIpc() || !window.fileStorage) return false;
return window.fileStorage.deleteFile(relativePath);
}
export async function makeDir(relativePath: string): Promise<string | null> {
if (!hasIpc() || !window.fileStorage) return null;
return window.fileStorage.makeDir(relativePath);
}
export async function showItemInFolder(filePath: string): Promise<boolean> {
if (!hasIpc() || !window.fileStorage) return false;
return window.fileStorage.showItemInFolder(filePath);
}
export async function fileExists(relativePath: string): Promise<boolean> {
if (!hasIpc() || !window.fileStorage) return false;
return window.fileStorage.fileExists(relativePath);
}
/** Blob → Base64 → IPC 写入磁盘。返回写入的绝对路径 */
export async function saveBlobToFile(relativePath: string, blob: Blob): Promise<string | null> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1]; // 去掉 data:mime;base64, 前缀
writeFile(relativePath, base64, 'base64').then(resolve);
};
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
}
/** 读取文件并返回 Blob URL */
export async function readFileAsBlobUrl(relativePath: string, mimeType: string): Promise<string | null> {
const base64 = await readFile(relativePath);
if (!base64) return null;
return `data:${mimeType};base64,${base64}`;
}
/** 获取 heixiu-data 根目录绝对路径 */
export async function getDataDir(): Promise<string | null> {
if (!hasIpc() || !window.fileStorage) return null;
return window.fileStorage.getDataDir();
}

View File

@@ -0,0 +1,267 @@
// ============================================================
// infrastructure/storage/media-loader.ts — 媒体加载与缓存
//
// 职责:
// - 通过认证 axios 下载媒体文件(携带 Authorization 头)
// - 从 HTTP Content-Type 检测真实 MIME 类型
// → 解决 CDN 直连链接无文件扩展名导致 isVideoUrl() 失效的问题
// - 本地磁盘缓存 + 数据库元数据记录
// - 返回 blob URL 供 <img>/<video> 标签使用
//
// 流程:
// 加载:检查本地缓存 → 命中 → 读文件 → blob URL
// → 未命中 → axios 下载 → 保存磁盘 → 记录 DB → blob URL
// 显示blob URL高效支持视频 seek无需 data: URI 编码开销)
// ============================================================
import axios from 'axios';
import { axiosInstance } from '@/services/request';
import { logger } from '@/utils/logger';
import { hasIpc } from '@/utils/ipc';
import { getCurrentUserId, hashURL, getTodayKey } from './user-scope';
import { getMediaByUrl, upsertMediaCache, deleteMediaByUrl } from './media-store';
import { saveBlobToFile, readFile, fileExists } from './ipc-file';
// ---------- 类型 ----------
/** 加载成功 */
export interface MediaLoadSuccess {
ok: true;
/** 可直传 <img src> / <video src> 的 blob URL */
blobUrl: string;
/** HTTP 响应 Content-Type如 "image/png"、"video/mp4" */
mimeType: string;
/** 是否来自本地缓存false = 本次网络下载) */
fromCache: boolean;
}
/** 加载失败(含可区分的原因,供 UI 展示不同提示) */
export interface MediaLoadFailure {
ok: false;
reason: 'signature_expired' | 'network_error' | 'cancelled';
message: string;
}
/** 判别联合:调用方通过 result.ok 收窄类型 */
export type MediaLoadResult = MediaLoadSuccess | MediaLoadFailure;
// ---------- 类型守卫 ----------
export function isMediaSuccess(r: MediaLoadResult): r is MediaLoadSuccess {
return r.ok;
}
export function isMediaFailure(r: MediaLoadResult): r is MediaLoadFailure {
return !r.ok;
}
// ---------- 内部工具 ----------
/**
* base64 → Blob → blob URL
*
* 相比 data: URIreadFileAsBlobUrl 的做法blob URL 优势:
* - 无 base64 33% 体积膨胀(内存友好)
* - <video> 可正常 seek/stream
* - 通过 URL.revokeObjectURL() 显式释放
*/
function base64ToBlobUrl(base64: string, mimeType: string): string {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
return URL.createObjectURL(blob);
}
/**
* 从本地磁盘缓存加载媒体,返回 blob URL。
* 若文件不存在或 IPC 不可用则返回 null。
*/
async function loadFromDisk(localPath: string, mimeType: string): Promise<string | null> {
if (!hasIpc()) return null;
try {
const exists = await fileExists(localPath);
if (!exists) return null;
const base64 = await readFile(localPath);
if (!base64) return null;
return base64ToBlobUrl(base64, mimeType);
} catch {
return null;
}
}
/**
* 从远程下载媒体 → 保存到本地磁盘缓存 → 返回 blob URL。
*
* 降级策略:
* - 磁盘写入失败 → 仍返回 blob URL本次会话可用不持久化
* - CDN 签名过期 (403 SignatureExpired) → 返回 MediaLoadFailurereason: 'signature_expired'
* - 其他网络错误 → 返回 MediaLoadFailurereason: 'network_error'
* - 请求取消 → 返回 MediaLoadFailurereason: 'cancelled'
*/
async function downloadAndCache(
url: string,
signal?: AbortSignal,
): Promise<MediaLoadSuccess | MediaLoadFailure> {
try {
// ---- 下载axiosInstance 自动携带 Authorization ----
const response = await axiosInstance.get(url, {
responseType: 'arraybuffer',
signal,
timeout: 120_000, // 媒体文件可能较大
});
// ---- 从 Content-Type 提取 MIME ----
const rawContentType = String(response.headers['content-type'] || '');
const mimeType = rawContentType.split(';')[0]?.trim() || 'application/octet-stream';
const buffer = response.data as ArrayBuffer;
const blob = new Blob([buffer], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
// ---- 写入磁盘缓存 ----
if (hasIpc() && window.fileStorage) {
const uid = getCurrentUserId();
const ext = mimeType.split('/')[1] || 'bin';
const urlHash = hashURL(url);
const localPath = `media-cache/${uid}/${getTodayKey()}/${urlHash}.${ext}`;
try {
const savedPath = await saveBlobToFile(localPath, blob);
if (savedPath) {
upsertMediaCache(url, mimeType, buffer.byteLength, localPath);
logger.debug('storage', `媒体已缓存:${mimeType} ${buffer.byteLength} bytes → ${localPath}`);
}
} catch (err) {
logger.warn('storage', '媒体磁盘缓存写入失败(降级为内存模式)', err instanceof Error ? err : undefined);
}
}
return { ok: true, blobUrl, mimeType, fromCache: false };
} catch (err) {
// 请求取消
if (axios.isCancel(err) || (err as Record<string, unknown>)?.code === 'ERR_CANCELED') {
return { ok: false, reason: 'cancelled', message: '请求已取消' };
}
// CDN 签名过期 — 403 + JSON body { code: "SignatureExpired", message: "..." }
if (axios.isAxiosError(err) && err.response?.status === 403) {
try {
const raw = err.response.data;
// responseType 为 arraybuffer错误响应体也是 ArrayBuffer需解码
let text: string;
if (raw instanceof ArrayBuffer) {
text = new TextDecoder().decode(raw);
} else if (typeof raw === 'string') {
text = raw;
} else {
text = JSON.stringify(raw);
}
const body = JSON.parse(text) as Record<string, unknown>;
if (body.code === 'SignatureExpired') {
logger.debug('storage', `CDN 签名已过期:${url}`);
return {
ok: false,
reason: 'signature_expired',
message: '媒体链接签名已过期有效期24小时请重新生成任务',
};
}
} catch {
// 解析失败 → 按普通 403 处理
}
logger.warn('storage', `CDN 访问拒绝 (403)${url}`);
return {
ok: false,
reason: 'network_error',
message: 'CDN 访问被拒绝403链接可能已失效',
};
}
logger.warn('storage', `媒体下载失败:${url}`, err instanceof Error ? err : undefined);
return {
ok: false,
reason: 'network_error',
message: '媒体文件下载失败,请检查网络连接',
};
}
}
// ---------- 公开 API ----------
/**
* 加载媒体文件,返回 blob URL 或可区分的错误信息。
*
* 策略:
* 1. 查本地 DB 缓存 → 命中则读磁盘返回 blob URL
* 2. 未命中则通过认证 HTTP 下载 → 写磁盘 + 记 DB → 返回 blob URL
* 3. CDN 签名过期(403 SignatureExpired) → 返回 reason='signature_expired'
* 4. 其他网络错误 → 返回 reason='network_error'
*
* @param url - 媒体文件的完整 URL可以是 CDN 直连链接)
* @param signal - 取消信号(组件卸载时中止下载)
*/
export async function loadMedia(url: string, signal?: AbortSignal): Promise<MediaLoadResult> {
// ---- 1. 检查本地数据库缓存 ----
const cached = getMediaByUrl(url);
if (cached) {
const blobUrl = await loadFromDisk(cached.local_path, cached.mime_type);
if (blobUrl) {
logger.debug('storage', `媒体缓存命中:${url}`);
return { ok: true, blobUrl, mimeType: cached.mime_type, fromCache: true };
}
// 数据库有记录但磁盘文件丢失 → 清理脏记录,走下载
deleteMediaByUrl(url);
logger.debug('storage', `媒体缓存文件丢失,重新下载:${url}`);
}
// ---- 2. 网络下载 + 缓存 ----
return downloadAndCache(url, signal);
}
/**
* 预加载一批媒体 URL并行下载不阻塞 UI
* 适用于任务列表打开时预加载缩略图等场景。
*/
export function preloadMedia(urls: string[], signal?: AbortSignal): void {
Promise.allSettled(urls.map(url => loadMedia(url, signal))).then(results => {
const loaded = results.filter(r => r.status === 'fulfilled' && r.value.ok).length;
if (loaded > 0) {
logger.debug('storage', `预加载完成:${loaded}/${urls.length} 个媒体文件`);
}
});
}
// ---------- MIME 类型判断 ----------
/** 判断 MIME 是否为视频 */
export function isVideoMime(mimeType: string): boolean {
return mimeType.startsWith('video/');
}
/** 判断 MIME 是否为图片 */
export function isImageMime(mimeType: string): boolean {
return mimeType.startsWith('image/');
}
/** 判断 MIME 是否为音频 */
export function isAudioMime(mimeType: string): boolean {
return mimeType.startsWith('audio/');
}
// ---------- 资源释放 ----------
/**
* 释放 blob URL 占用的内存。
* 应在组件卸载或 URL 不再使用时调用。
*/
export function revokeMediaUrl(blobUrl: string): void {
if (blobUrl.startsWith('blob:')) {
URL.revokeObjectURL(blobUrl);
}
}

View File

@@ -0,0 +1,80 @@
// ============================================================
// infrastructure/storage/media-store.ts — 媒体缓存存储
//
// 职责:
// - 媒体缓存元数据 CRUDBlob 实体由主进程管理)
// - URL → local_path 映射 + LRU 淘汰
// - 读取时更新访问时间LRU 排序依据)
// ============================================================
import { execute, queryAll, queryOne } from './db-core';
import { getCurrentUserId, getTodayKey, hashURL } from './user-scope';
import type { MediaCacheEntry } from './types';
import { logger } from '@/utils/logger';
// ---------- API ----------
export function upsertMediaCache(
url: string,
mimeType: string,
fileSize: number,
localPath: string,
): MediaCacheEntry | null {
const uid = getCurrentUserId();
const urlHash = hashURL(url);
const now = Date.now();
// 删除旧条目(同 URL 覆盖)
execute(`DELETE FROM media_cache WHERE url_hash = ?`, [urlHash]);
execute(
`INSERT INTO media_cache (user_id, url_hash, original_url, local_path, mime_type, file_size, created_date, accessed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[uid, urlHash, url, localPath, mimeType, fileSize, getTodayKey(), now],
);
const row = queryOne<MediaCacheEntry>(`SELECT * FROM media_cache WHERE url_hash = ?`, [urlHash]);
return row ?? null;
}
export function getMediaByUrl(url: string): MediaCacheEntry | null {
const row = queryOne<MediaCacheEntry>(`SELECT * FROM media_cache WHERE url_hash = ?`, [hashURL(url)]);
if (row) {
// 更新访问时间LRU
execute(`UPDATE media_cache SET accessed_at = ? WHERE id = ?`, [Date.now(), row.id!]);
}
return row;
}
export function hasMedia(url: string): boolean {
const row = queryOne<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM media_cache WHERE url_hash = ?`, [hashURL(url)]);
return (row?.cnt ?? 0) > 0;
}
export function getMediaCount(): number {
const row = queryOne<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM media_cache WHERE user_id = ?`, [getCurrentUserId()]);
return row?.cnt ?? 0;
}
export function getMediaTotalSize(): number {
const row = queryOne<{ total: number }>(`SELECT COALESCE(SUM(file_size), 0) as total FROM media_cache WHERE user_id = ?`, [getCurrentUserId()]);
return row?.total ?? 0;
}
export function deleteMediaByUrl(url: string): void {
execute(`DELETE FROM media_cache WHERE url_hash = ?`, [hashURL(url)]);
}
export function clearExpiredMedia(maxAgeDays = 30): number {
const cutoff = Date.now() - maxAgeDays * 86400000;
const rows = queryAll<MediaCacheEntry>(`SELECT id, local_path FROM media_cache WHERE accessed_at < ?`, [cutoff]);
for (const r of rows) {
if (r.id) execute(`DELETE FROM media_cache WHERE id = ?`, [r.id]);
}
if (rows.length > 0) logger.info('storage', `已清理 ${rows.length} 条过期媒体缓存`);
return rows.length;
}
export function clearUserMedia(): void {
execute(`DELETE FROM media_cache WHERE user_id = ?`, [getCurrentUserId()]);
}

View File

@@ -0,0 +1,104 @@
// ============================================================
// infrastructure/storage/order-store.ts — 订单记录本地存储
// ============================================================
import { execute, queryAll, queryOne } from './db-core';
import { getCurrentUserId } from './user-scope';
import type { OrderRecordEntry, OrderType, OrderQueryFilter } from './types';
import { logger } from '@/utils/logger';
// ---------- 通用接口 ----------
interface OrderLike {
id: string;
status: string;
created_at?: string | Date;
}
// ---------- API ----------
export function upsertOrder<T extends OrderLike>(orderType: OrderType, order: T): void {
const now = Date.now();
const row: OrderRecordEntry = {
id: order.id,
user_id: getCurrentUserId(),
order_type: orderType,
status: order.status,
data_json: JSON.stringify(order),
created_at: typeof order.created_at === 'string'
? Date.parse(order.created_at)
: (order.created_at instanceof Date ? order.created_at.getTime() : now),
};
execute(
`INSERT OR REPLACE INTO order_records (id, user_id, order_type, status, data_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[row.id, row.user_id, row.order_type, row.status, row.data_json, row.created_at],
);
}
export function syncRechargeOrders<T extends OrderLike>(orders: T[]): number {
let n = 0;
for (const o of orders) {
try { upsertOrder('recharge', o); n++; } catch { /* skip */ }
}
logger.debug('storage', `充值订单同步:${n}/${orders.length}`);
return n;
}
export function syncVipOrders<T extends OrderLike>(orders: T[]): number {
let n = 0;
for (const o of orders) {
try { upsertOrder('vip', o); n++; } catch { /* skip */ }
}
logger.debug('storage', `VIP 订单同步:${n}/${orders.length}`);
return n;
}
export function getOrders<T extends OrderLike>(filter: OrderQueryFilter = {}): T[] {
const uid = getCurrentUserId();
let rows: OrderRecordEntry[];
if (filter.order_type) {
rows = queryAll<OrderRecordEntry>(
`SELECT * FROM order_records WHERE user_id = ? AND order_type = ? ORDER BY created_at DESC LIMIT ?`,
[uid, filter.order_type, filter.limit || 100],
);
} else {
rows = queryAll<OrderRecordEntry>(
`SELECT * FROM order_records WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
[uid, filter.limit || 100],
);
}
return rows.map(r => {
try { return JSON.parse(r.data_json) as T; } catch { return { id: r.id, status: r.status } as unknown as T; }
});
}
export function getOrderById<T extends OrderLike>(orderId: string): T | null {
const row = queryOne<OrderRecordEntry>(`SELECT * FROM order_records WHERE id = ?`, [orderId]);
if (!row) return null;
try { return JSON.parse(row.data_json) as T; } catch { return null; }
}
export function deleteOrder(orderId: string): void {
execute(`DELETE FROM order_records WHERE id = ?`, [orderId]);
}
export function getOrderCount(orderType?: OrderType): number {
const uid = getCurrentUserId();
if (orderType) {
const row = queryOne<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM order_records WHERE user_id = ? AND order_type = ?`,
[uid, orderType],
);
return row?.cnt ?? 0;
}
const row = queryOne<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM order_records WHERE user_id = ?`,
[uid],
);
return row?.cnt ?? 0;
}
export function clearUserOrders(): void {
execute(`DELETE FROM order_records WHERE user_id = ?`, [getCurrentUserId()]);
}

View File

@@ -0,0 +1,80 @@
// ============================================================
// infrastructure/storage/param-store.ts — 参数历史本地存储
//
// 职责:
// - 任务提交后保存输入参数快照task_id 主键)
// - 任务完成后回填输出数据
// - 按模型查询历史参数列表(回填菜单数据源)
// ============================================================
import { execute, queryAll, queryOne } from './db-core';
import { getCurrentUserId } from './user-scope';
import type { ParamHistoryEntry } from './types';
const MAX_PER_MODEL = 20;
// ---------- API ----------
export function saveParamSnapshot(
taskId: string,
modelId: string,
modelName: string,
inputParams: Record<string, unknown>,
tag?: string,
): void {
const uid = getCurrentUserId();
execute(
`INSERT OR REPLACE INTO param_history (task_id, user_id, model_id, model_name, input_params_json, tag, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[taskId, uid, modelId, modelName, JSON.stringify(inputParams), tag || null, Date.now()],
);
// 裁剪旧记录
prune(modelId, uid);
}
export function fillOutputData(taskId: string, outputData: Record<string, unknown>): void {
execute(
`UPDATE param_history SET output_data_json = ? WHERE task_id = ?`,
[JSON.stringify(outputData), taskId],
);
}
export function getParamByTaskId(taskId: string): ParamHistoryEntry | null {
return queryOne<ParamHistoryEntry>(`SELECT * FROM param_history WHERE task_id = ?`, [taskId]);
}
export function getParamHistory(modelId: string, limit = 10): ParamHistoryEntry[] {
const uid = getCurrentUserId();
return queryAll<ParamHistoryEntry>(
`SELECT * FROM param_history WHERE user_id = ? AND model_id = ? ORDER BY created_at DESC LIMIT ?`,
[uid, modelId, limit],
);
}
export function parseParams(entry: ParamHistoryEntry): Record<string, unknown> {
try { return JSON.parse(entry.input_params_json) as Record<string, unknown>; } catch { return {}; }
}
export function deleteParamHistory(id: number): void {
execute(`DELETE FROM param_history WHERE id = ?`, [id]);
}
export function clearUserParams(): void {
execute(`DELETE FROM param_history WHERE user_id = ?`, [getCurrentUserId()]);
}
// ---------- 内部 ----------
function prune(modelId: string, userId: string): void {
try {
const rows = queryAll<ParamHistoryEntry>(
`SELECT id FROM param_history WHERE user_id = ? AND model_id = ? ORDER BY created_at DESC`,
[userId, modelId],
);
if (rows.length <= MAX_PER_MODEL) return;
const toDelete = rows.slice(MAX_PER_MODEL);
for (const r of toDelete) {
if (r.id) execute(`DELETE FROM param_history WHERE id = ?`, [r.id]);
}
} catch { /* ignore */ }
}

View File

@@ -0,0 +1,82 @@
// ============================================================
// infrastructure/storage/schema.ts — 数据库 Schema 定义
// ============================================================
/** 当前 schema 版本 */
export const SCHEMA_VERSION = 1;
/** 建表 DDL所有 CREATE TABLE IF NOT EXISTS */
export const DDL_STATEMENTS = [
// ---- 任务记录 ----
`CREATE TABLE IF NOT EXISTS task_records (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
model_id TEXT NOT NULL,
status TEXT NOT NULL,
data_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_task_user_status ON task_records(user_id, status)`,
`CREATE INDEX IF NOT EXISTS idx_task_user_model ON task_records(user_id, model_id)`,
// ---- 订单记录 ----
`CREATE TABLE IF NOT EXISTS order_records (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
order_type TEXT NOT NULL,
status TEXT NOT NULL,
data_json TEXT NOT NULL,
created_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_order_user_type ON order_records(user_id, order_type)`,
// ---- 参数历史 ----
`CREATE TABLE IF NOT EXISTS param_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT UNIQUE NOT NULL,
user_id TEXT NOT NULL,
model_id TEXT NOT NULL,
model_name TEXT NOT NULL,
input_params_json TEXT NOT NULL,
output_data_json TEXT,
tag TEXT,
created_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_param_user_model ON param_history(user_id, model_id, created_at)`,
// ---- 媒体缓存 ----
`CREATE TABLE IF NOT EXISTS media_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
url_hash TEXT UNIQUE NOT NULL,
original_url TEXT NOT NULL,
local_path TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
created_date TEXT NOT NULL,
accessed_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_media_user_hash ON media_cache(user_id, url_hash)`,
// ---- 同步游标 ----
`CREATE TABLE IF NOT EXISTS sync_cursor (
data_type TEXT PRIMARY KEY,
last_page INTEGER NOT NULL,
total_count INTEGER NOT NULL,
synced_at INTEGER NOT NULL
)`,
// ---- 应用设置 ----
`CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)`,
// ---- Schema 版本 ----
`CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY)`,
];
/** 初始化 schema 版本记录 */
export const INIT_VERSION_SQL = `INSERT OR IGNORE INTO _schema_version (version) VALUES (${SCHEMA_VERSION})`;

View File

@@ -0,0 +1,52 @@
// ============================================================
// infrastructure/storage/settings-store.ts — 应用设置持久化
//
// 职责:
// - 读写 key-value 格式的应用设置(如 outputPath
// - 提供同步读取方法(无 async overhead
// - 兼容现有的 localStorage 设置逻辑
// ============================================================
import { execute, queryAll, queryOne } from './db-core';
import type { AppSetting } from './types';
import { SETTING_KEYS } from './types';
// ---------- API ----------
export function setSetting(key: string, value: string): void {
execute(
`INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)`,
[key, value, Date.now()],
);
}
export function getSetting(key: string): string | null {
const row = queryOne<AppSetting>(`SELECT value FROM app_settings WHERE key = ?`, [key]);
return row?.value ?? null;
}
export function getAllSettings(): AppSetting[] {
return queryAll<AppSetting>(`SELECT * FROM app_settings ORDER BY key`);
}
export function deleteSetting(key: string): void {
execute(`DELETE FROM app_settings WHERE key = ?`, [key]);
}
// ---------- 便捷方法(兼容 SettingsProvider ----------
export function getOutputPath(): string {
return getSetting(SETTING_KEYS.OUTPUT_PATH) || '';
}
export function setOutputPath(path: string): void {
setSetting(SETTING_KEYS.OUTPUT_PATH, path);
}
export function getTeamRepoPath(): string {
return getSetting(SETTING_KEYS.TEAM_REPO_PATH) || '';
}
export function setTeamRepoPath(path: string): void {
setSetting(SETTING_KEYS.TEAM_REPO_PATH, path);
}

View File

@@ -0,0 +1,60 @@
// ============================================================
// infrastructure/storage/sync-manager.ts — 数据同步控制
//
// 职责:
// - 管理分页加载游标(每类数据拉到第几页)
// - 判断是否需要"下一页"按钮
// - "加载历史数据"按钮的逻辑入口
//
// 策略:
// - 每次加载 100 条
// - last_page * 100 < total_count 时显示"继续加载更多"
// - 不自动拉取,完全由用户手动触发
// ============================================================
import { execute, queryOne } from './db-core';
import type { SyncCursor, SyncDataType } from './types';
const PAGE_SIZE = 100;
// ---------- API ----------
export function getPageSize(): number {
return PAGE_SIZE;
}
export function getCursor(dataType: SyncDataType): SyncCursor | null {
return queryOne<SyncCursor>(`SELECT * FROM sync_cursor WHERE data_type = ?`, [dataType]);
}
export function updateCursor(dataType: SyncDataType, lastPage: number, totalCount: number): void {
execute(
`INSERT OR REPLACE INTO sync_cursor (data_type, last_page, total_count, synced_at) VALUES (?, ?, ?, ?)`,
[dataType, lastPage, totalCount, Date.now()],
);
}
/** 判断是否还有更多数据可加载 */
export function hasMore(dataType: SyncDataType): boolean {
const c = getCursor(dataType);
if (!c) return true; // 从未加载过 → 显示按钮
return c.last_page * PAGE_SIZE < c.total_count;
}
/** 下次应加载的页码 */
export function nextPage(dataType: SyncDataType): number {
const c = getCursor(dataType);
return c ? c.last_page + 1 : 1;
}
/** 已缓存的总条数 */
export function syncedCount(dataType: SyncDataType): number {
const c = getCursor(dataType);
return c ? Math.min(c.last_page * PAGE_SIZE, c.total_count) : 0;
}
/** 服务端返回的总数 */
export function totalCount(dataType: SyncDataType): number {
const c = getCursor(dataType);
return c?.total_count ?? 0;
}

View File

@@ -0,0 +1,97 @@
// ============================================================
// infrastructure/storage/task-store.ts — 任务记录本地存储
//
// 职责:
// - 插入/更新/查询任务记录
// - 批量同步 API 数据
// - 按用户 + 状态/模型筛选
// ============================================================
import { execute, queryAll, queryOne } from './db-core';
import { getCurrentUserId } from './user-scope';
import type { TaskRecordEntry, TaskQueryFilter } from './types';
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
import { logger } from '@/utils/logger';
// ---------- 序列化 ----------
function taskToRow(task: TaskInfoItemResponseBody): TaskRecordEntry {
return {
id: task.id,
user_id: getCurrentUserId(),
model_id: task.model_id,
status: task.status,
data_json: JSON.stringify(task),
created_at: typeof task.created_at === 'string' ? Date.parse(task.created_at) : Date.now(),
updated_at: Date.now(),
};
}
export function rowToTask(row: TaskRecordEntry): TaskInfoItemResponseBody {
try {
return JSON.parse(row.data_json) as TaskInfoItemResponseBody;
} catch {
return { id: row.id, model_id: row.model_id, status: row.status, created_at: new Date(row.created_at).toISOString() } as unknown as TaskInfoItemResponseBody;
}
}
// ---------- API ----------
export function upsertTask(task: TaskInfoItemResponseBody): void {
const r = taskToRow(task);
execute(
`INSERT OR REPLACE INTO task_records (id, user_id, model_id, status, data_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[r.id, r.user_id, r.model_id, r.status, r.data_json, r.created_at, r.updated_at],
);
}
export function syncTasks(tasks: TaskInfoItemResponseBody[]): number {
let n = 0;
for (const t of tasks) {
try { upsertTask(t); n++; } catch { /* skip */ }
}
logger.debug('storage', `任务同步:${n}/${tasks.length}`);
return n;
}
export function getTasks(filter: TaskQueryFilter = {}): TaskRecordEntry[] {
const uid = getCurrentUserId();
if (filter.status) {
return queryAll<TaskRecordEntry>(
`SELECT * FROM task_records WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?`,
[uid, filter.status, filter.limit || 100],
);
}
if (filter.model_id) {
return queryAll<TaskRecordEntry>(
`SELECT * FROM task_records WHERE user_id = ? AND model_id = ? ORDER BY created_at DESC LIMIT ?`,
[uid, filter.model_id, filter.limit || 100],
);
}
return queryAll<TaskRecordEntry>(
`SELECT * FROM task_records WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
[uid, filter.limit || 100],
);
}
export function getTaskById(taskId: string): TaskRecordEntry | null {
return queryOne<TaskRecordEntry>(`SELECT * FROM task_records WHERE id = ?`, [taskId]);
}
export function deleteTask(taskId: string): void {
execute(`DELETE FROM task_records WHERE id = ?`, [taskId]);
}
export function getTaskCount(userId?: string): number {
const row = queryOne<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM task_records WHERE user_id = ?`,
[userId || getCurrentUserId()],
);
return row?.cnt ?? 0;
}
export function clearUserTasks(): void {
const uid = getCurrentUserId();
execute(`DELETE FROM task_records WHERE user_id = ?`, [uid]);
}

View File

@@ -0,0 +1,94 @@
// ============================================================
// infrastructure/storage/types.ts — 本地存储模块共享类型
// ============================================================
// ---------- 任务记录 ----------
export interface TaskRecordEntry {
id: string;
user_id: string;
model_id: string;
status: string;
data_json: string;
created_at: number;
updated_at: number;
}
export interface TaskQueryFilter {
status?: string;
model_id?: string;
limit?: number;
offset?: number;
}
// ---------- 订单记录 ----------
export type OrderType = 'recharge' | 'vip';
export interface OrderRecordEntry {
id: string;
user_id: string;
order_type: OrderType;
status: string;
data_json: string;
created_at: number;
}
export interface OrderQueryFilter {
order_type?: OrderType;
status?: string;
limit?: number;
offset?: number;
}
// ---------- 参数历史 ----------
export interface ParamHistoryEntry {
id?: number;
task_id: string;
user_id: string;
model_id: string;
model_name: string;
input_params_json: string;
output_data_json?: string;
tag?: string;
created_at: number;
}
// ---------- 媒体缓存 ----------
export interface MediaCacheEntry {
id?: number;
user_id: string;
url_hash: string;
original_url: string;
local_path: string;
mime_type: string;
file_size: number;
created_date: string;
accessed_at: number;
}
// ---------- 同步游标 ----------
export type SyncDataType = 'tasks' | 'recharge_orders' | 'vip_orders';
export interface SyncCursor {
data_type: SyncDataType;
last_page: number;
total_count: number;
synced_at: number;
}
// ---------- 应用设置 ----------
export interface AppSetting {
key: string;
value: string;
updated_at: number;
}
export const SETTING_KEYS = {
OUTPUT_PATH: 'output_path',
TEAM_REPO_PATH: 'team_repo_path',
} as const;

View File

@@ -0,0 +1,80 @@
// ============================================================
// infrastructure/storage/user-scope.ts — 用户作用域工具
//
// 职责:
// - 从 JWT refresh_token 提取当前用户 ID
// - 生成日期键YYYY-MM-DD用于按日期分区
// - URL 哈希djb2用于去重查询
// ============================================================
import { getSafeStore } from '@/utils/safe-storage';
const REFRESH_KEY = 'ele-heixiu-refresh-token';
// ---------- 用户 ID ----------
let cachedUserId: string | null = null;
let cacheTs = 0;
const CACHE_MS = 30_000;
function extractIdFromJWT(token: string): string | null {
try {
const p = JSON.parse(atob(token.split('.')[1]));
return (p.user_id as string) || (p.sub as string) || null;
} catch {
return null;
}
}
/** 获取当前用户 IDJWT 解码 + 30s 缓存),未登录返回 'anonymous' */
export function getCurrentUserId(): string {
const now = Date.now();
if (cachedUserId !== null && now - cacheTs < CACHE_MS) return cachedUserId;
const token = getSafeStore(REFRESH_KEY);
if (token) {
const id = extractIdFromJWT(token);
if (id) { cachedUserId = id; cacheTs = now; return id; }
}
cachedUserId = 'anonymous';
cacheTs = now;
return 'anonymous';
}
export function clearUserIdCache(): void {
cachedUserId = null;
cacheTs = 0;
}
// ---------- 日期键 ----------
export function getTodayKey(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function dateKeyFromTs(ts: number): string {
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
// ---------- URL 哈希 ----------
export function hashURL(url: string): string {
let h = 5381;
for (let i = 0; i < url.length; i++) {
h = ((h << 5) + h) ^ url.charCodeAt(i);
h = h >>> 0;
}
return h.toString(16).padStart(8, '0');
}
// ---------- 格式化 ----------
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${(bytes / 1073741824).toFixed(2)} GB`;
}