010.009.story.md 15 KB

Story 010.009: 创建统一文件后端模块 (unified-file-module)

Status

Approved

Story

As a 开发者, I want 从单租户文件模块复制并改造创建统一文件后端模块(unified-file-module), so that 统一广告模块可以使用无租户隔离的文件实体,确保架构一致性。

Acceptance Criteria

  1. 创建 packages/unified-file-module 包(使用 cp -r 命令直接复制 packages/file-module 整个文件夹
  2. 修改包名和模块引用(从 @d8d/file-module 改为 @d8d/unified-file-module),移除用户模块和认证模块依赖
  3. 定义Entity(确认无tenant_id字段,单租户file-module本身就没有tenant_id)
  4. 实现Service层和文件上传逻辑(MinIO,保持与原模块一致)
  5. 实现管理员路由(所有路由都使用 tenantAuthMiddleware,只有超级管理员可访问,不需要用户展示路由
  6. 编写完整的单元测试和集成测试
  7. 测试覆盖率达到70%以上

Tasks / Subtasks

  • [ ] 任务1: 复制文件模块创建统一文件模块 (AC: 1)

    • 使用 cp -r packages/file-module packages/unified-file-module 命令直接复制整个文件夹
    • 验证复制后的目录结构完整
    • 验证所有文件都已复制
  • [ ] 任务2: 修改包配置文件 (AC: 2)

    • 修改 package.json 包名:@d8d/file-module@d8d/unified-file-module
    • 修改 package.json 描述:添加"unified"相关描述
    • 删除依赖:从 package.json 中移除 @d8d/user-module@d8d/auth-module(本模块只在超级管理员后台使用,不需要用户和认证依赖)
    • 添加依赖:添加 @d8d/tenant-module-mt(需要使用 tenantAuthMiddleware
  • [ ] 任务3: 修改模块导出文件 (AC: 2)

    • 修改 src/index.ts 导出的实体名:FileUnifiedFile
    • 修改 src/index.ts 导出的服务名:FileServiceUnifiedFileServiceMinioService 保持不变
    • 更新 src/entities/index.ts 导出
  • [ ] 任务4: 修改Entity定义 (AC: 3)

    • 重命名文件:entities/file.entity.tsentities/unified-file.entity.ts
    • 修改类名:export class Fileexport class UnifiedFile
    • 确认无 tenant_id 字段(单租户模块本身就没有,验证确认即可)
    • 修改表名:@Entity('file')@Entity('unified_file')
    • 删除 UserEntity 关联:移除 @ManyToOne('UserEntity')uploadUser 字段(因为不需要用户模块依赖)
    • 保持 uploadUserId 字段不变(只保留ID,不关联UserEntity)
    • 保持其他所有字段定义不变(id, name, type, size, path, description, uploadTime, lastUpdated, createdAt, updatedAt)
  • [ ] 任务5: 修改Service层 (AC: 4)

    • 重命名文件:services/file.service.tsservices/unified-file.service.ts
    • 修改类名:export class FileServiceexport class UnifiedFileService
    • 更新构造函数中的Entity引用:FileUnifiedFile
    • MinioService 保持不变(复用原有实现)
    • 保持所有方法逻辑不变
  • [ ] 任务6: 修改Schema定义 (AC: 4)

    • 修改 schemas/file.schema.ts 中所有Schema名称:FileSchemaUnifiedFileSchemaCreateFileSchemaCreateUnifiedFileSchema,等等
    • 更新Schema中的描述,添加"unified"相关说明
    • 保持所有字段验证规则不变
  • [ ] 任务7: 修改路由层 (AC: 5)

    • 所有路由都使用 tenantAuthMiddleware(只有超级管理员ID=1可访问,不需要用户展示路由
    • 删除原有的用户展示路由(如果存在)
    • 确认路由路径设置为相对路径(不包含 /api/v1 前缀)
    • 添加 request.params 定义,使用 z.coerce.number<number>() 进行类型转换
  • [ ] 任务8: 编写单元测试 (AC: 7, 8)

    • 重命名测试文件:tests/unit/file.service.test.tstests/unit/unified-file.service.test.ts
    • 更新测试中的实体名:FileUnifiedFile
    • 更新测试中的服务名:FileServiceUnifiedFileService
    • 验证所有单元测试通过
  • [ ] 任务9: 编写集成测试 (AC: 6, 7)

    • 重命名测试文件:tests/integration/file.integration.test.tstests/integration/unified-file.integration.test.ts
    • 更新测试中的实体名和服务名
    • 添加管理员路由权限测试(验证只有超级管理员ID=1可访问)
    • 删除用户路由测试(不需要用户展示路由)
    • 验证所有集成测试通过
  • [ ] 任务10: 类型检查和代码质量 (AC: 8)

    • 运行 pnpm typecheck 确保无TypeScript类型错误
    • 运行 pnpm test 确保所有测试通过
    • 运行 pnpm test:coverage 确保测试覆盖率达到70%以上

Dev Notes

前一故事关键要点(来自 010.006, 010.007, 010.008)

史诗010的统一设计模式:

  • ⚠️ 统一文件模块只在超级管理员后台使用,不需要用户展示路由
  • 所有路由都使用 tenantAuthMiddleware(只有超级管理员ID=1可访问)
  • 不需要 authMiddleware(不需要用户展示路由)
  • 不需要依赖用户模块和认证模块
  • 统一模块的Entity没有 tenant_id 字段
  • API路由路径使用相对路径(不包含 /api/v1 前缀)

路由规范 [Source: docs/prd/epic-010-unified-ad-management.md, docs/architecture/backend-module-package-standards.md]:

  • 路由路径必须使用相对路径(如 //:id),不包含 /api/v1 前缀
  • 路由 request.params 必须明确定义,使用 z.coerce.number<number>() 进行类型转换
  • 使用 c.req.valid('param') 获取参数,而非 parseInt(c.req.param('id'))

当前问题说明

架构不一致性 [Source: docs/prd/epic-010-unified-ad-management.md]:

  • 统一广告模块 (unified-advertisements-module) 当前使用 @d8d/core-module-mt/file-module-mtFileMt 实体
  • FileMt 实体有 tenant_id 字段,是多租户隔离的
  • 统一广告本身是无租户隔离的,但关联的文件却是多租户隔离的
  • 这造成了架构不一致性

源模块:单租户文件模块 (file-module)

模块结构 [Source: packages/file-module/]:

packages/file-module/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── src/
│   ├── entities/
│   │   └── file.entity.ts
│   ├── services/
│   │   ├── file.service.ts
│   │   └── minio.service.ts
│   ├── routes/
│   │   ├── file.routes.ts
│   │   ├── file-crud.routes.ts
│   │   ├── file-custom.routes.ts
│   │   └── index.ts
│   ├── schemas/
│   │   └── file.schema.ts
│   └── index.ts
└── tests/
    ├── unit/
    │   └── file.service.test.ts
    └── integration/
        └── file.integration.test.ts

Entity定义 [Source: packages/file-module/src/entities/file.entity.ts]:

@Entity('file')
export class File {
  @PrimaryGeneratedColumn({ name: 'id', type: 'int', unsigned: true })
  id!: number;

  @Column({ name: 'name', type: 'varchar', length: 255 })
  name!: string;

  @Column({ name: 'type', type: 'varchar', length: 50, nullable: true, comment: '文件类型' })
  type!: string | null;

  @Column({ name: 'size', type: 'int', unsigned: true, nullable: true, comment: '文件大小,单位字节' })
  size!: number | null;

  @Column({ name: 'path', type: 'varchar', length: 512, comment: '文件存储路径' })
  path!: string;

  @Column({ name: 'description', type: 'text', nullable: true, comment: '文件描述' })
  description!: string | null;

  @Column({ name: 'upload_user_id', type: 'int', unsigned: true })
  uploadUserId!: number;

  // ⚠️ 统一文件模块删除此关联(不需要用户模块依赖)
  // @ManyToOne('UserEntity')
  // @JoinColumn({ name: 'upload_user_id', referencedColumnName: 'id' })
  // uploadUser!: UserEntity;

  @Column({ name: 'upload_time', type: 'timestamp' })
  uploadTime!: Date;

  @Column({ name: 'last_updated', type: 'timestamp', nullable: true, comment: '最后更新时间' })
  lastUpdated!: Date | null;

  @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
  createdAt!: Date;

  @Column({
    name: 'updated_at',
    type: 'timestamp',
    default: () => 'CURRENT_TIMESTAMP',
    onUpdate: 'CURRENT_TIMESTAMP'
  })
  updatedAt!: Date;

  // 注意:没有 tenant_id 字段(单租户模块)
}

包名: @d8d/file-module [Source: packages/file-module/package.json]

依赖 [Source: packages/file-module/package.json]:

{
  "dependencies": {
    "@d8d/shared-types": "workspace:*",
    "@d8d/shared-utils": "workspace:*",
    "@d8d/shared-crud": "workspace:*",
    "@d8d/tenant-module-mt": "workspace:*",
    "hono": "^4.8.5",
    "@hono/zod-openapi": "1.0.2",
    "minio": "^8.0.5",
    "typeorm": "^0.3.20",
    "uuid": "^11.1.0",
    "zod": "^4.1.12"
  }
}

⚠️ 依赖变更说明:

  • 删除: @d8d/user-module@d8d/auth-module(本模块只在超级管理员后台使用,不需要这些依赖)
  • 添加: @d8d/tenant-module-mt(需要使用 tenantAuthMiddleware

关键实施要点

⚠️ 重要:使用 CP 命令直接复制

根据用户明确要求,必须使用以下命令直接复制整个文件模块文件夹:

cp -r packages/file-module packages/unified-file-module

为什么使用 cp 命令直接复制:

  1. 保证完整性: 复制整个文件夹确保所有文件(包括测试、配置、源码)都被包含
  2. 减少错误: 避免手动创建文件时遗漏某些文件或配置
  3. 保持一致性: 确保新模块的结构与原模块完全一致
  4. 提高效率: 一次性复制后只需修改必要的部分,而不是从头创建

复制后的修改清单:

  1. 包名修改: @d8d/file-module@d8d/unified-file-module
  2. 依赖修改: 删除 @d8d/user-module@d8d/auth-module,添加 @d8d/tenant-module-mt
  3. 实体名修改: FileUnifiedFile,表名 fileunified_file
  4. 实体关联修改: 删除 UserEntity 关联,只保留 uploadUserId 字段
  5. 服务名修改: FileServiceUnifiedFileService
  6. Schema名修改: 所有Schema添加 Unified 前缀
  7. 文件名修改: 测试文件和服务文件重命名
  8. 路由配置: 所有路由都使用 tenantAuthMiddleware不需要用户展示路由

后端模块包开发规范

包结构规范 [Source: docs/architecture/backend-module-package-standards.md]:

packages/{module-name}/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── src/
│   ├── entities/
│   ├── services/
│   ├── routes/
│   ├── schemas/
│   └── index.ts
└── tests/
    ├── integration/
    └── utils/

Entity规范 [Source: docs/architecture/backend-module-package-standards.md]:

  • 使用 @Entity() 装饰器定义表名
  • 完整的列定义:包含 type, length, nullable, comment 等属性
  • 主键使用 @PrimaryGeneratedColumn,设置 unsigned: true
  • 时间戳使用 timestamp 类型,默认值为 () => 'CURRENT_TIMESTAMP'

Service规范 [Source: docs/architecture/backend-module-package-standards.md]:

  • 继承 GenericCrudService 基类
  • 使用 override 关键字覆盖父类方法
  • 软删除使用 status 字段(如果需要)

路由规范 [Source: docs/architecture/backend-module-package-standards.md]:

  • 使用 OpenAPIHonoAuthContext 泛型
  • 路由路径使用相对路径,不包含 /api/v1 前缀
  • 自定义路由使用 createRoute 定义
  • 响应数据使用 parseWithAwait 验证
  • request.params 使用 z.coerce.number<number>() 进行类型转换

Schema规范 [Source: docs/architecture/backend-module-package-standards.md]:

  • 使用 z.object() 定义Schema
  • 使用 .openapi() 装饰器添加描述和示例
  • 使用 z.coerce.date<Date>()z.coerce.number<number>() 泛型语法
  • 不导出推断类型(类型由RPC自动推断)

核心模块引用规范

⚠️ 重要: 新模块必须从正确路径引用基础模块

模块类型 ✅ 正确引用 说明
租户模块(多租户) @d8d/tenant-module-mt 独立包,位于 packages/tenant-module-mt/

⚠️ 本模块不需要的依赖:

  • ❌ 不需要 @d8d/user-module@d8d/user-module-mt(不在用户模块中关联用户信息)
  • ❌ 不需要 @d8d/auth-module@d8d/auth-module-mt(不需要用户认证中间件)
  • ✅ 只需要 @d8d/tenant-module-mt(使用 tenantAuthMiddleware

MinIO集成

MinIO配置 [Source: docs/architecture/tech-stack.md]:

  • 对象存储服务:MinIO
  • 客户端SDK:MinIO JavaScript SDK
  • 默认存储桶:d8dai

环境变量:

  • MINIO_HOST: MinIO服务器地址
  • MINIO_PORT: MinIO端口
  • MINIO_USE_SSL: 是否使用SSL
  • MINIO_BUCKET_NAME: 存储桶名称

项目位置

新模块位置: packages/unified-file-module/ [Source: docs/architecture/source-tree.md]

测试标准: Vitest [Source: docs/architecture/tech-stack.md]

Testing

测试文件位置 [Source: docs/architecture/backend-module-package-standards.md]:

  • 单元测试: packages/unified-file-module/tests/unit/
  • 集成测试: packages/unified-file-module/tests/integration/

测试框架:

  • Vitest [Source: docs/architecture/tech-stack.md]
  • hono/testing (API端点测试)

测试配置 [Source: docs/architecture/backend-module-package-standards.md]:

// vitest.config.ts
export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['tests/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    fileParallelism: false // 避免数据库连接冲突
  }
});

测试覆盖要求:

  • 测试覆盖率达到70%以上
  • 单元测试:覆盖Service层核心逻辑
  • 集成测试:覆盖路由端点和权限控制

测试命令:

# 进入模块目录
cd packages/unified-file-module

# 运行所有测试
pnpm test

# 运行单元测试
pnpm test:unit

# 运行集成测试
pnpm test:integration

# 生成覆盖率报告
pnpm test:coverage

# 类型检查
pnpm typecheck

Change Log

Date Version Description Author
2026-01-04 1.0 初始故事创建 Bob (Scrum Master)
2026-01-04 1.1 修正:统一文件模块只在超级管理员后台使用,移除用户模块和认证模块依赖,所有路由使用 tenantAuthMiddleware Bob (Scrum Master)
2026-01-04 1.2 修正:租户模块是独立包 @d8d/tenant-module-mt,不在 core-module-mt 里面 Bob (Scrum Master)
2026-01-04 1.3 批准故事 Bob (Scrum Master)

Dev Agent Record

Agent Model Used

开发代理实施时填写

Debug Log References

开发代理实施时填写

Completion Notes List

开发代理实施时填写

File List

开发代理实施时填写

QA Results

QA代理待填写