# 史诗 010: 统一广告管理系统 - Brownfield Enhancement

## 版本信息

| 版本 | 日期 | 描述 | 作者 |
|------|------|------|------|
| 1.0 | 2026-01-02 | 初始版本 | James (Claude Code) |
| 1.1 | 2026-01-03 | 完成任务11：添加广告类型管理路由测试 | James (Claude Code) |
| 1.2 | 2026-01-03 | 添加故事010.003：修复路由路径规范问题 | James (Claude Code) |

## 史诗目标

将多租户广告管理改造为统一广告管理系统，由超级管理员在租户管理后台统一配置广告，所有租户共享相同的广告数据展示。同时保持小程序端广告读取逻辑不受影响。

## 史诗描述

### 现有系统上下文

- **当前相关功能**: 系统使用 `@d8d/advertisements-module-mt` 多租户广告模块，每个租户在自己的admin后台独立管理广告，数据通过 `tenant_id` 字段隔离
- **技术栈**: Hono + TypeORM + PostgreSQL + React + Vite
- **集成点**:
  - Server包: `packages/server/src/index.ts` 注册广告路由
  - Admin后台: `web/src/client/admin/` 使用 `@d8d/advertisement-management-ui-mt`
  - 小程序端: 通过 `/api/v1/advertisements` 读取广告

### 增强详情

**添加/修改内容**:
1. 创建 `packages/unified-advertisements-module` - 统一广告模块（无tenant_id隔离）
2. 创建 `packages/unified-advertisement-management-ui` - 统一广告管理UI包
3. 租户后台集成广告管理功能（`web/src/client/tenant/`）
4. 从各租户admin后台移除广告管理功能
5. **Server包替换模块引用** - 保持API路由和契约不变，仅切换后端模块

**集成方式**:
- **管理员接口**: 使用 `tenantAuthMiddleware`（超级管理员专用，参考租户模块实现）
- **用户展示接口**: 使用 `authMiddleware`（多租户认证，但返回统一的广告数据）
- **数据库**: 新建无 `tenant_id` 字段的表结构
- **关键设计**: API路由路径、请求参数、响应结构完全不变，小程序端无感知

**成功标准**:
- 租户后台可完整管理广告（增删改查）
- 所有租户用户端读取到统一的广告数据
- Admin后台不再显示广告管理入口
- **小程序端无需重新发布**，API兼容性100%

## 用户故事

### Story 1: 创建统一广告模块 ✅ 已完成

**标题**: 创建统一广告后端模块 (unified-advertisements-module)

**描述**: 复制单租户广告模块并改造，移除tenant_id字段，区分管理员和用户接口

**任务**:
- [x] 创建包结构和配置文件
- [x] 定义Entity（无tenant_id字段）
- [x] 实现Service层
- [x] 定义Schema
- [x] 实现管理员路由（使用tenantAuthMiddleware）
- [x] 实现用户展示路由（使用authMiddleware）
- [x] 编写单元测试和集成测试
- [x] **添加广告类型管理路由测试**（测试覆盖率提升 - 2026-01-03完成）
  - [x] 添加广告类型管理员路由测试（CRUD + 权限验证）
  - [x] 添加广告类型用户展示路由测试
  - [x] 验证类型与广告的关联查询

**完成日期**: 2026-01-02（初始），2026-01-03（任务11）
**测试覆盖**: 57个测试全部通过（23个单元测试 + 34个集成测试）
**相关文件**: `docs/stories/010.001.story.md`

### Story 2: 创建统一广告管理UI

**标题**: 创建统一广告管理UI包 (unified-advertisement-management-ui)

**描述**: 复制单租户广告管理UI并改造，API端点指向统一模块

**任务**:
- [ ] 创建UI包结构
- [ ] 实现广告管理组件（列表、创建、编辑、删除）
- [ ] 实现广告类型管理组件
- [ ] 创建API客户端（指向统一模块端点）
- [ ] 编写组件测试

### Story 3: 修复路由路径规范问题 ✅ 已完成

**标题**: 修复统一广告模块路由路径规范问题

**描述**: 故事010.001实施时违反了后端模块开发规范，在模块路由定义中添加了`/api/v1`前缀。按照规范，该前缀应该在server包注册路由时添加，而非模块内部。

**问题说明**:
- **错误**: 模块路由路径定义为 `/api/v1/admin/unified-advertisements`
- **正确**: 模块路由路径应为 `/`（相对路径，由server包添加完整前缀）
- **原因**: `/api/v1/admin` 前缀由server包在注册时统一添加，模块内部只需定义相对路径

**任务**:
- [x] 修复 `unified-advertisements.admin.routes.ts` 路由路径（改为 `/` 和 `/:id`）
- [x] 修复 `unified-advertisement-types.admin.routes.ts` 路由路径（改为 `/` 和 `/:id`）
- [x] 修复 `unified-advertisement-types.routes.ts` 用户路由路径（改为 `/`）
- [x] 更新集成测试文件中的API路径调用方式
- [x] 更新史诗010文档

**修复内容**:
1. **管理员广告路由**: `/api/v1/admin/unified-advertisements` → `/`
2. **管理员广告路由**: `/api/v1/admin/unified-advertisements/:id` → `/:id`
3. **管理员广告类型路由**: `/api/v1/admin/unified-advertisement-types` → `/`
4. **管理员广告类型路由**: `/api/v1/admin/unified-advertisement-types/:id` → `/:id`
5. **用户广告类型路由**: `/api/v1/advertisement-types` → `/`
6. **集成测试**: 更新测试调用方式（`adminClient.$get()` 而非 `adminClient['/path'].$get()`）

**完成日期**: 2026-01-03
**相关文件**: `docs/stories/010.003.story.md`

### Story 4: Web集成和Server模块替换

**标题**: 集成到租户后台、移除admin后台广告管理、Server切换模块

**描述**: 将统一广告管理UI集成到租户后台，从admin后台移除广告管理，**关键：Server包切换模块但保持API不变**

**任务**:
- [ ] 租户后台添加广告管理菜单项
- [ ] 租户后台添加路由配置（指向新的管理员API）
- [ ] 租户后台API初始化
- [ ] Admin后台删除广告管理菜单项
- [ ] Admin后台删除广告路由配置
- [ ] **Server包替换模块导入**: `@d8d/advertisements-module-mt` → `@d8d/unified-advertisements-module`
- [ ] **保持路由不变**: `/api/v1/advertisements` 路由保持，只是数据源切换
- [ ] 数据源注册新实体（`UnifiedAdvertisement`, `UnifiedAdvertisementType`）
- [ ] E2E测试验证（重点：验证小程序端API兼容性）

**关键注意事项**:
- API路由路径 `/api/v1/advertisements` 保持不变
- Schema响应结构保持与原模块一致
- 小程序端无需感知后端模块切换

## 兼容性要求

- [x] 现有广告API端点保持向后兼容（或提供适配层）
- [x] 数据库schema变更不影响现有表
- [x] UI变更遵循现有租户后台模式
- [x] 性能影响最小化

## 风险缓解

### 主要风险
1. **权限控制错误**: 管理员接口可能被普通租户访问
2. **现有广告数据迁移**: 如果需要迁移历史数据，可能有数据丢失风险
3. **响应数据结构不一致**: 统一模块的Schema可能与原模块有差异

### 缓解措施
1. **API兼容性**:
   - **关键**: 用户端路由 `/api/v1/advertisements` 保持不变
   - Schema定义保持与原 `advertisements-module-mt` 一致
   - 只在server包中切换模块引用，API契约100%兼容
   - 小程序端无需任何改动，无需重新发布
2. **数据迁移**:
   - 评估现有广告数据价值
   - 如需迁移，创建迁移脚本将精选广告迁移到统一表
3. **权限控制**:
   - 管理员路由严格使用 `tenantAuthMiddleware`（仅超级管理员ID=1可访问）
   - 用户路由使用 `authMiddleware` 进行多租户认证（认证通过但返回统一数据）

### 回滚计划
- 保留 `advertisements-module-mt` 包不动
- 如需回滚，恢复 `web/src/client/admin/` 中的菜单和路由
- 恢复 `packages/server/src/index.ts` 中的模块引用

## 详细设计

### API端点设计

#### 管理员接口 (超级管理员专用 - 新增)
```
# 使用 tenantAuthMiddleware (仅超级管理员ID=1可访问)
GET    /api/v1/admin/unified-advertisements       # 广告列表
POST   /api/v1/admin/unified-advertisements       # 创建广告
PUT    /api/v1/admin/unified-advertisements/:id   # 更新广告
DELETE /api/v1/admin/unified-advertisements/:id   # 删除广告

GET    /api/v1/admin/unified-advertisement-types       # 广告类型列表
POST   /api/v1/admin/unified-advertisement-types       # 创建广告类型
PUT    /api/v1/admin/unified-advertisement-types/:id   # 更新广告类型
DELETE /api/v1/admin/unified-advertisement-types/:id   # 删除广告类型
```

#### 用户展示接口 (保持不变 - 小程序端使用)
```
# 关键设计: 路由、参数、响应结构完全不变，仅切换后端模块
# 从: advertisements-module-mt  (多租户，tenant_id隔离)
# 到:   unified-advertisements-module (统一，无tenant_id)
# 使用 authMiddleware (多租户认证，但返回统一的广告数据)

GET    /api/v1/advertisements          # 获取有效广告列表（不变）
GET    /api/v1/advertisements/:id      # 获取单个广告详情（不变）
GET    /api/v1/advertisement-types     # 获取广告类型列表（不变）
```

**重要**: 小程序端无需任何改动，API契约100%兼容。只是后端数据源从多租户切换到统一模块。

### 数据库Schema

```sql
-- 统一广告表（无tenant_id字段，参考advertisements-module-mt的ad_mt表结构）
CREATE TABLE unified_advertisement (
  id SERIAL PRIMARY KEY,
  title VARCHAR(30) COMMENT '标题',
  type_id INT UNSIGNED NULL COMMENT '广告类型ID',
  code VARCHAR(20) COMMENT '调用别名',
  url VARCHAR(255) COMMENT '跳转URL',
  image_file_id INT UNSIGNED NULL COMMENT '图片文件ID（关联file_module）',
  sort INT DEFAULT 0 COMMENT '排序',
  status INT UNSIGNED DEFAULT 0 COMMENT '状态',
  action_type INT DEFAULT 1 COMMENT '跳转类型: 0=不跳转, 1=webview, 2=小程序页面',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  created_by INT UNSIGNED NULL COMMENT '创建用户ID',
  updated_by INT UNSIGNED NULL COMMENT '更新用户ID',
  FOREIGN KEY (type_id) REFERENCES unified_advertisement_type(id),
  FOREIGN KEY (image_file_id) REFERENCES file_mt(id),
  INDEX idx_type_id (type_id),
  INDEX idx_image_file_id (image_file_id),
  INDEX idx_status (status),
  INDEX idx_sort (sort)
) COMMENT='统一广告表';

CREATE TABLE unified_advertisement_type (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) NOT NULL COMMENT '类型名称',
  code VARCHAR(50) NOT NULL UNIQUE COMMENT '类型代码',
  description TEXT COMMENT '描述',
  status INT DEFAULT 1 COMMENT '状态',
  sort_order INT DEFAULT 0 COMMENT '排序',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_code (code)
) COMMENT='统一广告类型表';
```

**关键设计**:
- `image_file_id`: 关联 `file_module` 的文件ID（不是直接存储URL）
- `type_id`: 关联广告类型表
- 无 `tenant_id` 字段（与原多租户模块的主要区别）
- 表结构参考 `packages/advertisements-module-mt/src/entities/advertisement.entity.ts`

### Entity定义（TypeORM）

```typescript
// src/entities/unified-advertisement.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
import { FileMt } from '@d8d/core-module-mt/file-module-mt';  // 规范: 从核心包路径引用
import { UnifiedAdvertisementType } from './unified-advertisement-type.entity';

@Entity('unified_advertisement')
export class UnifiedAdvertisement {
  @PrimaryGeneratedColumn({ unsigned: true })
  id!: number;

  // 注意: 无 tenantId 字段（与原多租户模块的主要区别）

  @Column({
    name: 'title',
    type: 'varchar',
    length: 30,
    nullable: true,
    comment: '标题'
  })
  title!: string | null;

  @Column({
    name: 'type_id',
    type: 'int',
    nullable: true,
    unsigned: true,
    comment: '广告类型'
  })
  typeId!: number | null;

  @Column({
    name: 'code',
    type: 'varchar',
    length: 20,
    nullable: true,
    comment: '调用别名'
  })
  code!: string | null;

  @Column({
    name: 'url',
    type: 'varchar',
    length: 255,
    nullable: true,
    comment: 'url'
  })
  url!: string | null;

  // 文件模块关联（关键设计）
  @Column({
    name: 'image_file_id',
    type: 'int',
    unsigned: true,
    nullable: true,
    comment: '图片文件ID'
  })
  imageFileId!: number | null;

  @ManyToOne(() => FileMt, { nullable: true })
  @JoinColumn({
    name: 'image_file_id',
    referencedColumnName: 'id'
  })
  imageFile!: FileMt | null;

  @ManyToOne(() => UnifiedAdvertisementType, { nullable: true })
  @JoinColumn({
    name: 'type_id',
    referencedColumnName: 'id'
  })
  advertisementType!: UnifiedAdvertisementType | null;

  @Column({
    name: 'sort',
    type: 'int',
    default: 0,
    comment: '排序'
  })
  sort!: number;

  @CreateDateColumn({
    name: 'created_at',
    type: 'timestamp',
    comment: '创建时间'
  })
  createdAt!: Date;

  @UpdateDateColumn({
    name: 'updated_at',
    type: 'timestamp',
    comment: '更新时间'
  })
  updatedAt!: Date;

  @Column({
    name: 'created_by',
    type: 'int',
    unsigned: true,
    nullable: true,
    comment: '创建用户ID'
  })
  createdBy!: number | null;

  @Column({
    name: 'updated_by',
    type: 'int',
    unsigned: true,
    nullable: true,
    comment: '更新用户ID'
  })
  updatedBy!: number | null;

  @Column({
    name: 'status',
    type: 'int',
    unsigned: true,
    default: 0,
    comment: '状态'
  })
  status!: number;

  @Column({
    name: 'action_type',
    type: 'int',
    default: 1,
    comment: '跳转类型 0 不跳转 1webview 2小程序页面'
  })
  actionType!: number;
}
```

### 路由实现参考

#### 统一广告模块路由

```typescript
// src/routes/admin/advertisements.ts - 管理员路由（新增）
import { createCrudRoutes } from '@d8d/shared-crud';
import { tenantAuthMiddleware } from '@d8d/tenant-module-mt';
import { UnifiedAdvertisement } from '../entities/unified-advertisement.entity';

export const adminUnifiedAdRoutes = createCrudRoutes({
  entity: UnifiedAdvertisement,
  createSchema: CreateUnifiedAdvertisementDto,
  updateSchema: UpdateUnifiedAdvertisementDto,
  getSchema: UnifiedAdvertisementSchema,
  listSchema: UnifiedAdvertisementSchema,
  searchFields: ['title', 'code'],
  relations: ['advertisementType', 'imageFile'],  // 关键: 包含imageFile关联
  middleware: [tenantAuthMiddleware], // 超级管理员认证
  userTracking: {
    createdByField: 'createdBy',
    updatedByField: 'updatedBy'
  },
  dataPermission: {
    enabled: false // 不启用数据权限，所有超级管理员共享
  }
});

// src/routes/advertisements.ts - 用户路由（与原模块保持一致）
import { createCrudRoutes } from '@d8d/shared-crud';
import { authMiddleware } from '@d8d/auth-module-mt';
import { UnifiedAdvertisement } from '../entities/unified-advertisement.entity';

// 关键: 路由结构与原advertisements-module-mt完全一致
// 使用authMiddleware进行多租户认证，但返回统一数据（无tenant_id过滤）
export const advertisementRoutes = createCrudRoutes({
  entity: UnifiedAdvertisement,
  createSchema: CreateAdvertisementDto,
  updateSchema: UpdateAdvertisementDto,
  getSchema: AdvertisementSchema,
  listSchema: AdvertisementSchema,
  searchFields: ['title', 'code'],
  relations: ['advertisementType', 'imageFile'],  // 关键: 包含imageFile关联
  middleware: [authMiddleware],
  // 注意: 不使用tenantOptions，返回统一数据给所有租户
  userTracking: {
    createdByField: 'createdBy',
    updatedByField: 'updatedBy'
  },
  dataPermission: {
    enabled: false // 不启用数据权限控制
  }
});
```

#### Server包模块替换

```typescript
// packages/server/src/index.ts - 变更对比

// ===== 旧代码 (删除) =====
import { Advertisement, AdvertisementType } from '@d8d/advertisements-module-mt';
import { advertisementRoutes, advertisementTypeRoutes } from '@d8d/advertisements-module-mt';

// ===== 新代码 (使用) =====
import { UnifiedAdvertisement, UnifiedAdvertisementType } from '@d8d/unified-advertisements-module';
import { advertisementRoutes, advertisementTypeRoutes } from '@d8d/unified-advertisements-module';
import { adminUnifiedAdRoutes } from '@d8d/unified-advertisements-module';

// ===== 数据源注册 =====
initializeDataSource([
  // ...
  // 旧: Advertisement, AdvertisementType,
  新: UnifiedAdvertisement, UnifiedAdvertisementType,
]);

// ===== 路由注册 - 用户端保持不变 =====
export const advertisementApiRoutes = api.route('/api/v1/advertisements', advertisementRoutes);
export const advertisementTypeApiRoutes = api.route('/api/v1/advertisement-types', advertisementTypeRoutes);

// ===== 路由注册 - 管理员端（新增） =====
export const adminUnifiedAdApiRoutes = api.route('/api/v1/admin/unified-advertisements', adminUnifiedAdRoutes);
```

## 验收标准

### 完成定义 (Definition of Done)
- [ ] 所有故事完成且验收标准满足
- [ ] 现有功能通过测试验证
- [ ] 集成点正常工作
- [ ] 文档适当更新
- [ ] 现有功能无回归

### 功能验收
1. [ ] 租户后台（超级管理员）可以管理广告（创建、编辑、删除、查看）
2. [ ] 所有租户用户可以读取到统一的广告数据
3. [ ] Admin后台不再显示广告管理入口
4. [ ] API端点正常工作且返回正确数据
5. [ ] 权限控制正确（只有超级管理员可管理）

### 技术验收
1. [ ] 所有单元测试通过
2. [ ] 集成测试通过
3. [ ] 代码符合项目编码规范
4. [ ] 无TypeScript类型错误
5. [ ] ESLint检查通过

## 参考文档

- [后端模块包开发规范](../architecture/backend-module-package-standards.md)
- [UI包开发规范](../architecture/ui-package-standards.md)
- [源码树和文件组织](../architecture/source-tree.md)
- 租户模块实现: `packages/tenant-module-mt/`
- 单租户广告模块: `packages/advertisements-module/`

---

**Story Manager Handoff**:

"请为这个brownfield史诗开发详细的用户故事。关键考虑事项：

- 这是对现有运行系统的增强，技术栈: Hono + TypeORM + React + Vite
- 集成点:
  - Server包: `packages/server/src/index.ts`
  - 租户后台: `web/src/client/tenant/`
  - Admin后台: `web/src/client/admin/`
- 需要遵循的现有模式:
  - 管理员接口使用 `tenantAuthMiddleware` (参考 `packages/tenant-module-mt/`)
  - 用户接口使用 `authMiddleware` (参考 `packages/advertisements-module-mt/`)
- 关键兼容性要求:
  - 保持现有API端点兼容
  - 不影响小程序端广告读取
  - 权限控制严格区分管理员和用户
- 每个故事必须包含验证现有功能完整性的测试

史诗应在保持系统完整性的同时交付统一广告管理功能。"
