backend-module-package-standards.md 20 KB

后端模块包规范

版本信息

版本 日期 描述 作者
2.0 2025-12-26 基于实际实现重写,修正不准确的描述 James (Claude Code)
1.0 2025-12-02 基于史诗007系列移植经验创建 Claude Code

概述

本文档定义了后端模块包的设计、开发和集成规范,基于项目实际的模块包实现经验总结。

包组织方式

项目采用两种包组织方式:

  1. allin-packages模式: 每个业务模块独立成包

    • 目录: allin-packages/{module-name}-module/
    • 示例: @d8d/allin-channel-module, @d8d/allin-platform-module
  2. core-module聚合模式: 将多个相关模块打包在一起

    • 目录: packages/core-module/{module-name}/
    • 示例: @d8d/core-module/auth-module, @d8d/core-module/user-module

1. 包结构规范

1.1 目录结构

allin-packages模式

allin-packages/{module-name}-module/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── src/
│   ├── entities/
│   │   └── {entity-name}.entity.ts
│   ├── services/
│   │   └── {service-name}.service.ts
│   ├── routes/
│   │   ├── {module}-custom.routes.ts
│   │   ├── {module}-crud.routes.ts
│   │   ├── {module}.routes.ts
│   │   └── index.ts
│   ├── schemas/
│   │   └── {schema-name}.schema.ts
│   ├── types/
│   │   └── index.ts
│   └── index.ts
└── tests/
    ├── integration/
    │   └── {module}.integration.test.ts
    └── utils/
        └── test-data-factory.ts

core-module聚合模式

packages/core-module/
├── auth-module/
│   ├── src/
│   │   ├── entities/
│   │   ├── services/
│   │   ├── routes/
│   │   └── schemas/
│   └── tests/
├── user-module/
│   └── ...
└── file-module/
    └── ...

1.2 包命名规范

包类型 命名模式 示例
allin业务包 @d8d/allin-{name}-module @d8d/allin-channel-module
core子模块 @d8d/core-module 内部按路径区分

1.3 workspace配置

# pnpm-workspace.yaml
packages:
  - 'allin-packages/*'
  - 'packages/*'

2. 实体设计规范

2.1 Entity定义

实际实现模式:

import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm';

@Entity('channel_info')
export class Channel {
  @PrimaryGeneratedColumn({
    name: 'channel_id',
    type: 'int',
    unsigned: true,
    comment: '渠道ID'
  })
  id!: number;

  @Column({
    name: 'channel_name',
    type: 'varchar',
    length: 100,
    nullable: false,
    comment: '渠道名称'
  })
  @Index('idx_channel_name', { unique: true })
  channelName!: string;

  @Column({
    name: 'status',
    type: 'int',
    default: 1,
    comment: '状态:1-正常,0-禁用'
  })
  status!: number;

  @Column({
    name: 'create_time',
    type: 'timestamp',
    default: () => 'CURRENT_TIMESTAMP',
    comment: '创建时间'
  })
  createTime!: Date;

  @Column({
    name: 'update_time',
    type: 'timestamp',
    default: () => 'CURRENT_TIMESTAMP',
    onUpdate: 'CURRENT_TIMESTAMP',
    comment: '更新时间'
  })
  updateTime!: Date;
}

2.2 关键要点

  • 完整的列定义: 包含 type, length, nullable, comment 等属性
  • 索引装饰器: 使用 @Index 定义唯一索引
  • 时间戳字段: 使用 timestamp 类型而非 datetime
  • 默认值: 使用 default: () => 'CURRENT_TIMESTAMP'

2.3 关联关系

多对一关系:

@ManyToOne(() => Platform, { eager: false })
@JoinColumn({ name: 'platform_id', referencedColumnName: 'id' })
platform!: Platform;

避免循环依赖:

// 使用字符串引用避免循环依赖
@ManyToOne('DisabledPerson', { nullable: true })
@JoinColumn({ name: 'person_id', referencedColumnName: 'id' })
person!: import('@d8d/allin-disability-module/entities').DisabledPerson | null;

2.4 文件关联模式

@Column({ name: 'avatar_file_id', type: 'int', unsigned: true, nullable: true })
avatarFileId!: number | null;

@ManyToOne(() => File)
@JoinColumn({ name: 'avatar_file_id', referencedColumnName: 'id' })
avatarFile!: File | null;

3. 服务层规范

3.1 GenericCrudService继承

import { GenericCrudService } from '@d8d/shared-crud';
import { DataSource } from 'typeorm';
import { Channel } from '../entities/channel.entity';

export class ChannelService extends GenericCrudService<Channel> {
  constructor(dataSource: DataSource) {
    super(dataSource, Channel);
  }
}

3.2 方法覆盖模式(使用override)

实际实现:

export class ChannelService extends GenericCrudService<Channel> {
  constructor(dataSource: DataSource) {
    super(dataSource, Channel);
  }

  /**
   * 创建渠道 - 覆盖父类方法,添加名称唯一性检查
   */
  override async create(data: Partial<Channel>, userId?: string | number): Promise<Channel> {
    // 检查渠道名称是否已存在(只检查正常状态的渠道)
    if (data.channelName) {
      const existingChannel = await this.repository.findOne({
        where: { channelName: data.channelName, status: 1 }
      });
      if (existingChannel) {
        throw new Error('渠道名称已存在');
      }
    }

    // 设置默认值
    const channelData = {
      contactPerson: '',
      contactPhone: '',
      channelType: '',
      description: '',
      ...data,
      status: 1,
      createTime: new Date(),
      updateTime: new Date()
    };

    return super.create(channelData, userId);
  }

  /**
   * 更新渠道 - 覆盖父类方法,添加存在性和名称重复检查
   */
  override async update(id: number, data: Partial<Channel>, userId?: string | number): Promise<Channel | null> {
    // 检查渠道是否存在
    const channel = await this.repository.findOne({ where: { id, status: 1 } });
    if (!channel) {
      throw new Error('渠道不存在');
    }

    // 检查名称是否与其他渠道重复
    if (data.channelName && data.channelName !== channel.channelName) {
      const existingChannel = await this.repository.findOne({
        where: { channelName: data.channelName, id: Not(id), status: 1 }
      });
      if (existingChannel) {
        throw new Error('渠道名称已存在');
      }
    }

    const updateData = {
      ...data,
      updateTime: new Date()
    };

    return super.update(id, updateData, userId);
  }

  /**
   * 删除渠道 - 覆盖父类方法,改为软删除
   */
  override async delete(id: number, userId?: string | number): Promise<boolean> {
    // 软删除:设置status为0
    const result = await this.repository.update({ id }, { status: 0 });
    return result.affected === 1;
  }
}

3.3 关键要点

  • 使用 override 关键字: 明确标识覆盖父类方法
  • 软删除逻辑: 使用 status 字段而非物理删除
  • 业务逻辑检查: 在调用父类方法前进行验证
  • 设置默认值: 为可选字段设置合理的默认值
  • 时间戳管理: 自动设置 createTimeupdateTime

4. 路由层规范

4.1 使用OpenAPIHono

实际实现:

import { OpenAPIHono } from '@hono/zod-openapi';
import { AuthContext } from '@d8d/shared-types';
import channelCustomRoutes from './channel-custom.routes';
import { channelCrudRoutes } from './channel-crud.routes';

// 创建路由实例 - 聚合自定义路由和CRUD路由
const channelRoutes = new OpenAPIHono<AuthContext>()
  .route('/', channelCustomRoutes)
  .route('/', channelCrudRoutes);

export { channelRoutes };
export default channelRoutes;

4.2 导出模式

routes/index.ts:

export * from './channel.routes';
export * from './channel-custom.routes';
export * from './channel-crud.routes';

4.3 自定义路由响应规范

重要: 自定义路由(非CRUD路由)在返回响应前必须使用 parseWithAwait 验证和转换数据。

实际实现:

import { parseWithAwait, createZodErrorResponse } from '@d8d/shared-utils';
import { z } from '@hono/zod-openapi';
import { ChannelSchema } from '../schemas/channel.schema';

// 自定义路由
channelCustomRoutes.get('/statistics/:id', async (c) => {
  try {
    const id = c.req.param('id');
    const channelService = new ChannelService(AppDataSource);
    const result = await channelService.getStatistics(Number(id));

    // ✅ 必须:使用 parseWithAwait 验证和转换响应数据
    const validatedResult = await parseWithAwait(ChannelSchema, result);
    return c.json(validatedResult, 200);
  } catch (error) {
    if (error instanceof z.ZodError) {
      // ✅ 推荐:使用 createZodErrorResponse 处理Zod验证错误
      return c.json(createZodErrorResponse(error), 400);
    }
    return c.json({ code: 500, message: error.message }, 500);
  }
});

数组响应处理:

// 处理数组数据
const validatedData = await Promise.all(
  result.data.map(item => parseWithAwait(ChannelSchema, item))
);
return c.json({ data: validatedData, total: result.total }, 200);

关键要点:

  • 必须使用 parseWithAwait: 所有自定义路由返回前必须使用 parseWithAwait 验证数据
  • 捕获 ZodError: 在catch块中处理 z.ZodError 异常
  • 使用 createZodErrorResponse: 提供统一的错误响应格式
  • 数组使用 Promise.all: 批量验证数组数据

4.4 关键要点

  • 使用 OpenAPIHono: 而非普通的 Hono
  • 使用 AuthContext 泛型: 提供类型安全的认证上下文
  • 路由聚合: 分别定义自定义路由和CRUD路由,然后聚合
  • 不设置 basePath: 在聚合路由时处理路径
  • 自定义路由必须使用 parseWithAwait: 验证响应数据符合Schema定义

5. Schema规范

5.1 使用Zod + OpenAPI装饰器

实际实现:

import { z } from '@hono/zod-openapi';

// 渠道实体Schema
export const ChannelSchema = z.object({
  id: z.number().int().positive().openapi({
    description: '渠道ID',
    example: 1
  }),
  channelName: z.string().max(100).openapi({
    description: '渠道名称',
    example: '微信小程序'
  }),
  channelType: z.string().max(50).openapi({
    description: '渠道类型',
    example: '小程序'
  }),
  contactPerson: z.string().max(50).openapi({
    description: '联系人',
    example: '张三'
  }),
  contactPhone: z.string().max(20).openapi({
    description: '联系电话',
    example: '13800138000'
  }),
  description: z.string().nullable().optional().openapi({
    description: '描述',
    example: '微信小程序渠道'
  }),
  status: z.number().int().min(0).max(1).default(1).openapi({
    description: '状态:1-正常,0-禁用',
    example: 1
  }),
  createTime: z.coerce.date<Date>().openapi({
    description: '创建时间',
    example: '2024-01-01T00:00:00Z'
  }),
  updateTime: z.coerce.date<Date>().openapi({
    description: '更新时间',
    example: '2024-01-01T00:00:00Z'
  })
});

// 创建渠道DTO
export const CreateChannelSchema = z.object({
  channelName: z.string().min(1).max(100).openapi({
    description: '渠道名称',
    example: '微信小程序'
  }),
  channelType: z.string().max(50).optional().openapi({
    description: '渠道类型',
    example: '小程序'
  }),
  contactPerson: z.string().max(50).optional().openapi({
    description: '联系人',
    example: '张三'
  }),
  contactPhone: z.string().max(20).optional().openapi({
    description: '联系电话',
    example: '13800138000'
  }),
  description: z.string().optional().openapi({
    description: '描述',
    example: '微信小程序渠道'
  })
});

// 更新渠道DTO(所有字段可选)
export const UpdateChannelSchema = CreateChannelSchema.partial();

5.2 Zod 4.0 coerce使用说明

重要: Zod 4.0 中,z.coerce.date()z.coerce.number() 需要添加泛型参数来指定类型。

// ✅ 正确:Zod 4.0 - 使用泛型指定类型
z.coerce.date<Date>()       // 转换为Date类型
z.coerce.number<number>()    // 转换为number类型

// ❌ 错误:不指定泛型(Zod 4.0中类型推断可能不准确)
z.coerce.date()
z.coerce.number()

5.3 类型使用说明

重要: Schema只用于请求参数验证和响应定义,不需要导出推断的TypeScript类型

// ❌ 不需要:导出推断类型(实际项目中不会被使用)
export type Channel = z.infer<typeof ChannelSchema>;
export type CreateChannelDto = z.infer<typeof CreateChannelSchema>;
export type UpdateChannelDto = z.infer<typeof UpdateChannelSchema>;

原因

  • UI包通过RPC直接从API路由推断类型
  • @hono/zod-openapi 自动生成OpenAPI文档
  • 前端使用 hc.rpc() 自动获得类型安全的客户端

正确做法:只导出Schema常量,不导出推断类型。

5.4 关键要点

  • 使用 .openapi() 装饰器: 添加描述和示例
  • 使用 z.coerce.date<Date>()z.coerce.number<number>(): Zod 4.0需要添加泛型参数
  • 使用 .nullable().optional(): 处理可空字段
  • 不导出推断类型: 类型由RPC自动推断,不需要手动导出

6. 软删除规范

6.1 字段定义

@Column({
  name: 'status',
  type: 'int',
  default: 1,
  comment: '状态:1-正常,0-禁用'
})
status!: number;

6.2 Service层实现

// 覆盖delete方法实现软删除
override async delete(id: number, userId?: string | number): Promise<boolean> {
  const result = await this.repository.update({ id }, { status: 0 });
  return result.affected === 1;
}

6.3 查询时过滤

// 查询时只查询正常状态的记录
const channel = await this.repository.findOne({
  where: { id, status: 1 }
});

7. 数据库类型规范

7.1 PostgreSQL类型映射

数据库类型 TypeORM类型 备注
int unsigned int + unsigned: true 主键常用
varchar(n) varchar + length: n 字符串
text text 长文本
timestamp timestamp 时间戳
decimal(p,s) decimal + precision/scale 金额
int (状态) int 状态枚举

7.2 Decimal字段处理

// 实体定义
@Column({
  name: 'total_amount',
  type: 'decimal',
  precision: 10,
  scale: 2
})
totalAmount!: number;

// Schema验证(使用z.coerce.number<number>()处理字符串)
const CreateSchema = z.object({
  totalAmount: z.coerce.number<number>().min(0),
});

8. 错误处理规范

8.1 标准错误响应格式

// 正确的错误响应格式
return c.json({
  code: 400,
  message: '渠道名称已存在'
}, 400);

8.2 HTTP状态码使用

状态码 使用场景
200 操作成功(包括删除)
201 创建成功
400 请求参数错误
401 未授权
404 资源不存在
500 服务器内部错误

8.3 DELETE操作响应

// DELETE成功应返回200,而不是404
app.delete('/:id', async (c) => {
  const result = await service.delete(id);
  return c.json({ success: true }, 200); // ✅ 正确
  // return c.json({ success: true }, 404); // ❌ 错误
});

9. 包配置规范

9.1 package.json

{
  "name": "@d8d/allin-channel-module",
  "version": "1.0.0",
  "type": "module",
  "main": "src/index.ts",
  "types": "src/index.ts",
  "scripts": {
    "test": "vitest run",
    "test:coverage": "vitest run --coverage",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@d8d/shared-crud": "workspace:*",
    "@d8d/shared-types": "workspace:*",
    "@d8d/shared-utils": "workspace:*",
    "typeorm": "^0.3.20"
  },
  "devDependencies": {
    "@hono/zod-openapi": "latest",
    "vitest": "latest"
  }
}

9.2 依赖配置

{
  "dependencies": {
    "@d8d/allin-platform-module": "workspace:*",
    "@d8d/file-module": "workspace:*",
    "@d8d/allin-enums": "workspace:*"
  }
}

10. 测试规范

详细的测试规范请参考 后端模块包测试规范

10.1 测试数据工厂

export class TestDataFactory {
  static createChannelData(overrides: Partial<Channel> = {}): Partial<Channel> {
    const timestamp = Date.now();
    return {
      channelName: `测试渠道_${timestamp}`,
      channelType: '测试类型',
      contactPerson: '测试联系人',
      contactPhone: '13800138000',
      status: 1,
      ...overrides
    };
  }

  static async createTestChannel(
    dataSource: DataSource,
    overrides: Partial<Channel> = {}
  ): Promise<Channel> {
    const channelData = this.createChannelData(overrides);
    const channelRepo = dataSource.getRepository(Channel);
    const channel = channelRepo.create(channelData);
    return await channelRepo.save(channel);
  }
}

10.2 测试配置

// 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 // 避免数据库连接冲突
  }
});

11. 开发流程

11.1 类型检查

# 开发过程中运行类型检查
pnpm typecheck

# 针对特定包
cd allin-packages/channel-module && pnpm typecheck

11.2 运行测试

# 进入模块目录
cd allin-packages/channel-module

# 运行测试
pnpm test

# 运行集成测试
pnpm test:integration

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

11.3 注释规范

/**
 * 创建渠道 - 覆盖父类方法,添加名称唯一性检查
 * @param data 渠道数据
 * @param userId 操作用户ID
 * @returns 创建的渠道
 * @throws Error 当渠道名称已存在时
 */
override async create(data: Partial<Channel>, userId?: string | number): Promise<Channel> {
  // ...
}

12. 参考实现

12.1 已完成模块

allin-packages:

  • channel-module: 基础CRUD模块
  • platform-module: 基础依赖包
  • company-module: 模块间依赖
  • disability-module: 文件模块集成

core-module:

  • auth-module: 认证模块
  • user-module: 用户模块
  • file-module: 文件模块

12.2 最佳实践

  1. 使用 override 关键字: 明确标识覆盖父类方法
  2. 完整的列定义: 包含 type, comment, nullable 等属性
  3. OpenAPI文档: 使用 .openapi() 装饰器添加文档
  4. 类型推断导出: 导出 z.infer 推断的类型
  5. 软删除实现: 使用 status 字段而非物理删除
  6. 时间戳管理: 自动设置 createTimeupdateTime
  7. 错误处理: 提供明确的错误消息

附录:检查清单

新模块包创建检查清单

  • 包名符合规范:@d8d/allin-{name}-module@d8d/core-module
  • 目录结构完整:entities, services, routes, schemas, tests
  • Entity包含完整列定义:type, comment, nullable等
  • Service使用 override 关键字
  • 软删除实现:使用 status 字段
  • Schema使用 .openapi() 装饰器
  • Schema不导出推断类型(类型由RPC自动推断)
  • Schema使用 z.coerce.date<Date>()z.coerce.number<number>() 泛型语法
  • 路由使用 OpenAPIHonoAuthContext
  • 自定义路由使用 parseWithAwait 验证响应数据
  • 自定义路由使用 createZodErrorResponse 处理Zod错误
  • 测试数据工厂使用时间戳保证唯一性
  • vitest.config.ts 设置 fileParallelism: false
  • 类型检查通过
  • 所有测试通过

相关文档


文档状态: 正式版 基于实际实现: 2025-12-26