Files
TrueGrowth/specs/009-media-library/contracts/asset-storage-service.md

9.3 KiB
Raw Blame History

API Contract: Asset Storage Service

Service: asset-storage-service.ts Feature: 009-media-library Date: 2025-12-11

Overview

定义素材存储服务的接口契约该服务负责与IndexedDB交互管理素材的持久化存储。

Service Interface

export interface AssetStorageService {
  /**
   * 初始化存储服务
   * 创建IndexedDB实例和必要的stores
   */
  initialize(): Promise<void>;

  /**
   * 添加新素材到存储
   * @param data - 素材数据
   * @returns 保存的素材对象
   * @throws QuotaExceededError - 存储空间不足
   * @throws ValidationError - 数据验证失败
   */
  addAsset(data: AddAssetData): Promise<Asset>;

  /**
   * 获取所有素材
   * @returns 所有素材的数组
   */
  getAllAssets(): Promise<Asset[]>;

  /**
   * 根据ID获取单个素材
   * @param id - 素材ID
   * @returns 素材对象不存在则返回null
   */
  getAssetById(id: string): Promise<Asset | null>;

  /**
   * 更新素材名称
   * @param id - 素材ID
   * @param newName - 新名称
   * @throws NotFoundError - 素材不存在
   * @throws ValidationError - 名称验证失败
   */
  renameAsset(id: string, newName: string): Promise<void>;

  /**
   * 删除素材
   * @param id - 素材ID
   * @throws NotFoundError - 素材不存在
   */
  removeAsset(id: string): Promise<void>;

  /**
   * 清空所有素材
   * 慎用,不可恢复
   */
  clearAll(): Promise<void>;

  /**
   * 获取存储统计信息
   * @returns 存储使用情况
   */
  getStorageStats(): Promise<StorageStats>;

  /**
   * 检查存储配额
   * @returns 配额信息
   */
  checkQuota(): Promise<StorageQuota>;

  /**
   * 估算添加新素材后的存储使用量
   * @param blobSize - Blob大小字节
   * @returns 是否有足够空间
   */
  canAddAsset(blobSize: number): Promise<boolean>;
}

Data Types

AddAssetData

export interface AddAssetData {
  type: AssetType;
  source: AssetSource;
  name: string;
  blob: Blob;
  mimeType: string;
  prompt?: string;
  modelName?: string;
}

StorageStats

export interface StorageStats {
  totalAssets: number;
  imageCount: number;
  videoCount: number;
  localCount: number;
  aiGeneratedCount: number;
  totalSize: number;  // 估算的总大小(字节)
}

Error Types

export class AssetStorageError extends Error {
  constructor(message: string, public code: string) {
    super(message);
    this.name = 'AssetStorageError';
  }
}

export class QuotaExceededError extends AssetStorageError {
  constructor() {
    super('存储空间不足', 'QUOTA_EXCEEDED');
  }
}

export class NotFoundError extends AssetStorageError {
  constructor(id: string) {
    super(`素材未找到: ${id}`, 'NOT_FOUND');
  }
}

export class ValidationError extends AssetStorageError {
  constructor(message: string) {
    super(message, 'VALIDATION_ERROR');
  }
}

Usage Examples

Initialize Service

import { assetStorageService } from './services/asset-storage-service';

// 在应用启动时初始化
await assetStorageService.initialize();

Add Asset

// 用户上传文件
const file: File = event.target.files[0];

try {
  const asset = await assetStorageService.addAsset({
    type: AssetType.IMAGE,
    source: AssetSource.LOCAL,
    name: file.name,
    blob: file,
    mimeType: file.type
  });

  console.log('Asset added:', asset.id);
} catch (error) {
  if (error instanceof QuotaExceededError) {
    // 显示存储空间不足错误
  } else if (error instanceof ValidationError) {
    // 显示验证错误
  }
}

Load All Assets

try {
  const assets = await assetStorageService.getAllAssets();
  console.log(`Loaded ${assets.length} assets`);
} catch (error) {
  console.error('Failed to load assets:', error);
}

Rename Asset

try {
  await assetStorageService.renameAsset(assetId, '新名称');
  console.log('Asset renamed successfully');
} catch (error) {
  if (error instanceof NotFoundError) {
    // 素材不存在
  } else if (error instanceof ValidationError) {
    // 名称验证失败
  }
}

Delete Asset

try {
  await assetStorageService.removeAsset(assetId);
  console.log('Asset deleted');
} catch (error) {
  if (error instanceof NotFoundError) {
    // 素材已被删除或不存在
  }
}

Check Storage Quota

const quota = await assetStorageService.checkQuota();

console.log(`Storage usage: ${quota.percentUsed.toFixed(2)}%`);

if (quota.percentUsed > 80) {
  // 显示警告
  console.warn('Storage is nearly full!');
}

Check Before Adding

const fileSize = file.size;
const canAdd = await assetStorageService.canAddAsset(fileSize);

if (!canAdd) {
  alert('存储空间不足,请删除一些旧素材');
  return;
}

// 继续添加
await assetStorageService.addAsset({...});

Implementation Notes

1. localforage Configuration

import localforage from 'localforage';

const assetStore = localforage.createInstance({
  name: 'aitu-assets',
  storeName: 'assets',
  description: 'Media library assets storage'
});

2. Blob URL Management

  • 创建时使用 URL.createObjectURL(blob)
  • 组件卸载时调用 URL.revokeObjectURL(url) 释放内存
  • 不直接存储 Blob URL 到 IndexedDB存储 Blob 对象本身)

3. Error Handling Strategy

async function wrapStorageOperation<T>(
  operation: () => Promise<T>,
  errorContext: string
): Promise<T> {
  try {
    return await operation();
  } catch (error) {
    if (error.name === 'QuotaExceededError') {
      throw new QuotaExceededError();
    }
    if (error.name === 'NotFoundError') {
      throw new NotFoundError(errorContext);
    }
    throw new AssetStorageError(
      `Storage operation failed: ${error.message}`,
      'UNKNOWN_ERROR'
    );
  }
}

4. Performance Considerations

  • 批量加载: 使用 getAllAssets() 一次加载所有元数据
  • 懒加载Blob: 网格项只在可见时加载图片
  • 索引: 考虑为 type, source, createdAt 添加索引如使用原生IndexedDB
  • 缓存: 在内存中缓存最近加载的assets减少IndexedDB查询

Testing Contract

Unit Tests

describe('AssetStorageService', () => {
  beforeEach(async () => {
    await assetStorageService.initialize();
    await assetStorageService.clearAll();
  });

  it('should add asset successfully', async () => {
    const blob = new Blob(['test'], { type: 'image/png' });
    const asset = await assetStorageService.addAsset({
      type: AssetType.IMAGE,
      source: AssetSource.LOCAL,
      name: 'test.png',
      blob,
      mimeType: 'image/png'
    });

    expect(asset.id).toBeDefined();
    expect(asset.type).toBe(AssetType.IMAGE);
  });

  it('should throw ValidationError for invalid name', async () => {
    const blob = new Blob(['test'], { type: 'image/png' });

    await expect(
      assetStorageService.addAsset({
        type: AssetType.IMAGE,
        source: AssetSource.LOCAL,
        name: '',  // Invalid
        blob,
        mimeType: 'image/png'
      })
    ).rejects.toThrow(ValidationError);
  });

  it('should get all assets', async () => {
    // Add multiple assets
    const blob = new Blob(['test'], { type: 'image/png' });
    await assetStorageService.addAsset({ /* ... */ });
    await assetStorageService.addAsset({ /* ... */ });

    const assets = await assetStorageService.getAllAssets();
    expect(assets).toHaveLength(2);
  });

  it('should rename asset', async () => {
    const blob = new Blob(['test'], { type: 'image/png' });
    const asset = await assetStorageService.addAsset({ /* ... */ });

    await assetStorageService.renameAsset(asset.id, 'new-name.png');

    const updated = await assetStorageService.getAssetById(asset.id);
    expect(updated?.name).toBe('new-name.png');
  });

  it('should delete asset', async () => {
    const blob = new Blob(['test'], { type: 'image/png' });
    const asset = await assetStorageService.addAsset({ /* ... */ });

    await assetStorageService.removeAsset(asset.id);

    const deleted = await assetStorageService.getAssetById(asset.id);
    expect(deleted).toBeNull();
  });

  it('should throw NotFoundError when deleting non-existent asset', async () => {
    await expect(
      assetStorageService.removeAsset('non-existent-id')
    ).rejects.toThrow(NotFoundError);
  });
});

Security Considerations

  1. Input Validation: 所有用户输入name, mimeType必须验证
  2. MIME Type Verification: 验证文件实际内容与声明的MIME类型匹配
  3. Size Limits: 虽然没有硬性限制,但应警告大文件
  4. Error Messages: 不暴露内部实现细节给用户

Migration Strategy

如果将来需要更改存储格式:

export interface StorageVersion {
  version: number;
  migrateFrom(oldVersion: number): Promise<void>;
}

// 例如从v1迁移到v2
async function migrateV1ToV2(): Promise<void> {
  const allAssets = await assetStore.keys();
  for (const key of allAssets) {
    const old = await assetStore.getItem(key);
    const migrated = transformV1ToV2(old);
    await assetStore.setItem(key, migrated);
  }
}

契约完成日期: 2025-12-11 实现文件: packages/drawnix/src/services/asset-storage-service.ts 下一步: 生成 quickstart.md 开发指南