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:
176
src/infrastructure/storage/db-core.ts
Normal file
176
src/infrastructure/storage/db-core.ts
Normal 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;
|
||||
}
|
||||
|
||||
/** 执行一条写操作 SQL(INSERT / 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;
|
||||
}
|
||||
}
|
||||
123
src/infrastructure/storage/db-encrypt.ts
Normal file
123
src/infrastructure/storage/db-encrypt.ts
Normal 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] saveDatabase:exportDatabase 返回 null(数据库未打开?)');
|
||||
return false;
|
||||
}
|
||||
console.log(`[storage] saveDatabase:导出 ${data.byteLength} bytes`);
|
||||
|
||||
const base64 = uint8ArrayToBase64(data);
|
||||
console.log(`[storage] saveDatabase:Base64 编码后 ${base64.length} chars`);
|
||||
|
||||
// Electron 环境:加密后写磁盘
|
||||
if (hasIpc() && window.fileStorage && window.safeStorage) {
|
||||
console.log('[storage] saveDatabase:Electron 环境,准备加密...');
|
||||
try {
|
||||
const encrypted = await window.safeStorage.encrypt(base64);
|
||||
if (!encrypted) {
|
||||
console.warn('[storage] saveDatabase:safeStorage.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] loadDatabase:Electron 环境,读取磁盘...');
|
||||
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] loadDatabase:localStorage 无数据');
|
||||
return undefined;
|
||||
}
|
||||
console.log(`[storage] loadDatabase:localStorage 有数据 ${raw.length} chars`);
|
||||
return base64ToUint8Array(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
128
src/infrastructure/storage/index.ts
Normal file
128
src/infrastructure/storage/index.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
76
src/infrastructure/storage/ipc-file.ts
Normal file
76
src/infrastructure/storage/ipc-file.ts
Normal 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();
|
||||
}
|
||||
267
src/infrastructure/storage/media-loader.ts
Normal file
267
src/infrastructure/storage/media-loader.ts
Normal 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: URI(readFileAsBlobUrl 的做法),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) → 返回 MediaLoadFailure(reason: 'signature_expired')
|
||||
* - 其他网络错误 → 返回 MediaLoadFailure(reason: 'network_error')
|
||||
* - 请求取消 → 返回 MediaLoadFailure(reason: '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);
|
||||
}
|
||||
}
|
||||
80
src/infrastructure/storage/media-store.ts
Normal file
80
src/infrastructure/storage/media-store.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// ============================================================
|
||||
// infrastructure/storage/media-store.ts — 媒体缓存存储
|
||||
//
|
||||
// 职责:
|
||||
// - 媒体缓存元数据 CRUD(Blob 实体由主进程管理)
|
||||
// - 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()]);
|
||||
}
|
||||
104
src/infrastructure/storage/order-store.ts
Normal file
104
src/infrastructure/storage/order-store.ts
Normal 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()]);
|
||||
}
|
||||
80
src/infrastructure/storage/param-store.ts
Normal file
80
src/infrastructure/storage/param-store.ts
Normal 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 */ }
|
||||
}
|
||||
82
src/infrastructure/storage/schema.ts
Normal file
82
src/infrastructure/storage/schema.ts
Normal 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})`;
|
||||
52
src/infrastructure/storage/settings-store.ts
Normal file
52
src/infrastructure/storage/settings-store.ts
Normal 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);
|
||||
}
|
||||
60
src/infrastructure/storage/sync-manager.ts
Normal file
60
src/infrastructure/storage/sync-manager.ts
Normal 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;
|
||||
}
|
||||
97
src/infrastructure/storage/task-store.ts
Normal file
97
src/infrastructure/storage/task-store.ts
Normal 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]);
|
||||
}
|
||||
94
src/infrastructure/storage/types.ts
Normal file
94
src/infrastructure/storage/types.ts
Normal 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;
|
||||
80
src/infrastructure/storage/user-scope.ts
Normal file
80
src/infrastructure/storage/user-scope.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前用户 ID(JWT 解码 + 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user