| 版本 | 日期 | 描述 | 作者 |
|---|---|---|---|
| 2.0 | 2025-12-26 | 基于实际实现重写,修正不准确的描述 | James (Claude Code) |
| 1.0 | 2025-12-02 | 基于史诗007系列移植经验创建 | Claude Code |
本文档定义了后端模块包的设计、开发和集成规范,基于项目实际的模块包实现经验总结。
项目采用两种包组织方式:
allin-packages模式: 每个业务模块独立成包
allin-packages/{module-name}-module/@d8d/allin-channel-module, @d8d/allin-platform-modulecore-module聚合模式: 将多个相关模块打包在一起
packages/core-module/{module-name}/@d8d/core-module/auth-module, @d8d/core-module/user-moduleallin-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
packages/core-module/
├── auth-module/
│ ├── src/
│ │ ├── entities/
│ │ ├── services/
│ │ ├── routes/
│ │ └── schemas/
│ └── tests/
├── user-module/
│ └── ...
└── file-module/
└── ...
| 包类型 | 命名模式 | 示例 |
|---|---|---|
| allin业务包 | @d8d/allin-{name}-module |
@d8d/allin-channel-module |
| core子模块 | @d8d/core-module |
内部按路径区分 |
# pnpm-workspace.yaml
packages:
- 'allin-packages/*'
- 'packages/*'
实际实现模式:
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;
}
type, length, nullable, comment 等属性@Index 定义唯一索引timestamp 类型而非 datetimedefault: () => 'CURRENT_TIMESTAMP'多对一关系:
@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;
@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;
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);
}
}
实际实现:
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;
}
}
override 关键字: 明确标识覆盖父类方法status 字段而非物理删除createTime 和 updateTime实际实现:
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;
routes/index.ts:
export * from './channel.routes';
export * from './channel-custom.routes';
export * from './channel-crud.routes';
重要: 自定义路由(非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 验证数据z.ZodError 异常createZodErrorResponse: 提供统一的错误响应格式Promise.all: 批量验证数组数据OpenAPIHono: 而非普通的 HonoAuthContext 泛型: 提供类型安全的认证上下文basePath: 在聚合路由时处理路径parseWithAwait: 验证响应数据符合Schema定义实际实现:
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();
重要: 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()
重要: Schema只用于请求参数验证和响应定义,不需要导出推断的TypeScript类型。
// ❌ 不需要:导出推断类型(实际项目中不会被使用)
export type Channel = z.infer<typeof ChannelSchema>;
export type CreateChannelDto = z.infer<typeof CreateChannelSchema>;
export type UpdateChannelDto = z.infer<typeof UpdateChannelSchema>;
原因:
@hono/zod-openapi 自动生成OpenAPI文档hc.rpc() 自动获得类型安全的客户端正确做法:只导出Schema常量,不导出推断类型。
.openapi() 装饰器: 添加描述和示例z.coerce.date<Date>() 和 z.coerce.number<number>(): Zod 4.0需要添加泛型参数.nullable().optional(): 处理可空字段@Column({
name: 'status',
type: 'int',
default: 1,
comment: '状态:1-正常,0-禁用'
})
status!: number;
// 覆盖delete方法实现软删除
override async delete(id: number, userId?: string | number): Promise<boolean> {
const result = await this.repository.update({ id }, { status: 0 });
return result.affected === 1;
}
// 查询时只查询正常状态的记录
const channel = await this.repository.findOne({
where: { id, status: 1 }
});
| 数据库类型 | TypeORM类型 | 备注 |
|---|---|---|
int unsigned |
int + unsigned: true |
主键常用 |
varchar(n) |
varchar + length: n |
字符串 |
text |
text |
长文本 |
timestamp |
timestamp |
时间戳 |
decimal(p,s) |
decimal + precision/scale |
金额 |
int (状态) |
int |
状态枚举 |
// 实体定义
@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),
});
// 正确的错误响应格式
return c.json({
code: 400,
message: '渠道名称已存在'
}, 400);
| 状态码 | 使用场景 |
|---|---|
| 200 | 操作成功(包括删除) |
| 201 | 创建成功 |
| 400 | 请求参数错误 |
| 401 | 未授权 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
// 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); // ❌ 错误
});
{
"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"
}
}
{
"dependencies": {
"@d8d/allin-platform-module": "workspace:*",
"@d8d/file-module": "workspace:*",
"@d8d/allin-enums": "workspace:*"
}
}
详细的测试规范请参考 后端模块包测试规范。
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);
}
}
// 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 // 避免数据库连接冲突
}
});
# 开发过程中运行类型检查
pnpm typecheck
# 针对特定包
cd allin-packages/channel-module && pnpm typecheck
# 进入模块目录
cd allin-packages/channel-module
# 运行测试
pnpm test
# 运行集成测试
pnpm test:integration
# 生成覆盖率报告
pnpm test:coverage
/**
* 创建渠道 - 覆盖父类方法,添加名称唯一性检查
* @param data 渠道数据
* @param userId 操作用户ID
* @returns 创建的渠道
* @throws Error 当渠道名称已存在时
*/
override async create(data: Partial<Channel>, userId?: string | number): Promise<Channel> {
// ...
}
allin-packages:
channel-module: 基础CRUD模块platform-module: 基础依赖包company-module: 模块间依赖disability-module: 文件模块集成core-module:
auth-module: 认证模块user-module: 用户模块file-module: 文件模块override 关键字: 明确标识覆盖父类方法type, comment, nullable 等属性.openapi() 装饰器添加文档z.infer 推断的类型status 字段而非物理删除createTime 和 updateTime@d8d/allin-{name}-module 或 @d8d/core-moduleoverride 关键字status 字段.openapi() 装饰器z.coerce.date<Date>() 和 z.coerce.number<number>() 泛型语法OpenAPIHono 和 AuthContextparseWithAwait 验证响应数据createZodErrorResponse 处理Zod错误fileParallelism: false文档状态: 正式版 基于实际实现: 2025-12-26