fix(iam): 修复 TS 编译错误、ESM 依赖注入与身份头读取

P1 端到端验证中发现 IAM 服务存在 14 个 TS 编译错误与运行时 DI 失败:

- 移除 typeorm/ioredis/kafkajs 依赖(IAM 用 Drizzle)
- health.controller.ts 改用 db.execute(sql SELECT 1) 校验连接
- lifecycle.service.ts 简化为只关闭 Drizzle 连接池
- Drizzle API 修正:r.roles -> r.iam_roles,.in() -> inArray()
- ESM 模式下 DI 必须显式 @Inject(IamRepository)(参考 classes 黄金模板)
- iam.controller.ts 直接读 req.headers[x-user-id],不依赖未注册的 AuthMiddleware
- health.module.ts 补 .js 后缀
- package.json 补 @types/express

验证:register -> JWT -> Gateway /iam/me 200 -> /classes CRUD 200
This commit is contained in:
SpecialX
2026-07-09 00:25:37 +08:00
parent 68ddff1065
commit f658571726
10 changed files with 142 additions and 110 deletions

View File

@@ -33,7 +33,7 @@ jobs:
runs-on: ubuntu-latest
container: node:22-alpine
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
ref: ${{ github.event.inputs.commit_sha || github.ref }}

3
pnpm-lock.yaml generated
View File

@@ -376,6 +376,9 @@ importers:
'@types/bcrypt':
specifier: ^5.0.0
version: 5.0.2
'@types/express':
specifier: ^4.17.0
version: 4.17.25
'@types/jsonwebtoken':
specifier: ^9.0.0
version: 9.0.10

View File

@@ -32,6 +32,7 @@
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.0",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^22.0.0",
"@types/uuid": "^10.0.0",

View File

@@ -1,38 +1,41 @@
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { IamService } from './iam.service.js';
import { registerSchema, loginSchema, refreshTokenSchema } from './iam.dto.js';
import type { AuthenticatedRequest } from '../middleware/auth.middleware.js';
import { Body, Controller, Get, Post, Req } from "@nestjs/common";
import type { Request } from "express";
import { IamService } from "./iam.service.js";
import { registerSchema, loginSchema, refreshTokenSchema } from "./iam.dto.js";
import { UnauthorizedError } from "../shared/errors/application-error.js";
@Controller('iam')
@Controller("iam")
export class IamController {
constructor(private readonly service: IamService) {}
@Post('register')
@Post("register")
async register(@Body() body: unknown) {
const dto = registerSchema.parse(body);
const result = await this.service.register(dto);
return { success: true as const, data: result };
}
@Post('login')
@Post("login")
async login(@Body() body: unknown) {
const dto = loginSchema.parse(body);
const result = await this.service.login(dto);
return { success: true as const, data: result };
}
@Post('refresh')
@Post("refresh")
async refresh(@Body() body: unknown) {
const dto = refreshTokenSchema.parse(body);
const tokens = await this.service.refresh(dto.refreshToken);
return { success: true as const, data: tokens };
}
@Get('me')
@Get("me")
async me(@Req() req: Request) {
const authReq = req as AuthenticatedRequest;
const user = await this.service.getUserInfo(authReq.userId as string);
const userId = req.headers["x-user-id"] as string;
if (!userId) {
throw new UnauthorizedError("Missing x-user-id header");
}
const user = await this.service.getUserInfo(userId);
return { success: true as const, data: user };
}
}

View File

@@ -1,13 +1,10 @@
import { Module } from '@nestjs/common';
import { IamController } from './iam.controller.js';
import { IamService } from './iam.service.js';
import { IamRepository } from './iam.repository.js';
import { Module } from "@nestjs/common";
import { IamController } from "./iam.controller.js";
import { IamService } from "./iam.service.js";
import { IamRepository } from "./iam.repository.js";
@Module({
controllers: [IamController],
providers: [
IamService,
{ provide: IamRepository, useFactory: () => new IamRepository() },
],
providers: [IamService, IamRepository],
})
export class IamModule {}

View File

@@ -1,19 +1,37 @@
import { eq } from 'drizzle-orm';
import { getDb } from '../config/database.js';
import { users, roles, userRoles, permissions, rolePermissions, refreshTokens } from './iam.schema.js';
import type { User, Role, Permission } from './iam.schema.js';
import { eq, inArray } from "drizzle-orm";
import { getDb } from "../config/database.js";
import {
users,
roles,
userRoles,
permissions,
rolePermissions,
refreshTokens,
} from "./iam.schema.js";
import type { User, Role, Permission } from "./iam.schema.js";
export class IamRepository {
async createUser(data: { id: string; email: string; passwordHash: string; name: string }): Promise<User> {
async createUser(data: {
id: string;
email: string;
passwordHash: string;
name: string;
}): Promise<User> {
const db = getDb();
await db.insert(users).values(data);
const [result] = await db.select().from(users).where(eq(users.id, data.id));
if (!result) {
throw new Error("Failed to create user");
}
return result;
}
async findUserByEmail(email: string): Promise<User | undefined> {
const db = getDb();
const [result] = await db.select().from(users).where(eq(users.email, email));
const [result] = await db
.select()
.from(users)
.where(eq(users.email, email));
return result;
}
@@ -30,20 +48,26 @@ export class IamRepository {
.from(roles)
.innerJoin(userRoles, eq(roles.id, userRoles.roleId))
.where(eq(userRoles.userId, userId));
return result.map((r) => r.roles);
return result.map((r) => r.iam_roles);
}
async getUserPermissions(userId: string): Promise<Permission[]> {
const db = getDb();
const userRoleRows = await db.select().from(userRoles).where(eq(userRoles.userId, userId));
const userRoleRows = await db
.select()
.from(userRoles)
.where(eq(userRoles.userId, userId));
const roleIds = userRoleRows.map((r) => r.roleId);
if (roleIds.length === 0) return [];
const result = await db
.select()
.from(permissions)
.innerJoin(rolePermissions, eq(permissions.id, rolePermissions.permissionId))
.where(rolePermissions.roleId.in(roleIds));
return result.map((r) => r.permissions);
.innerJoin(
rolePermissions,
eq(permissions.id, rolePermissions.permissionId),
)
.where(inArray(rolePermissions.roleId, roleIds));
return result.map((r) => r.iam_permissions);
}
async assignRole(userId: string, roleId: string): Promise<void> {
@@ -51,13 +75,21 @@ export class IamRepository {
await db.insert(userRoles).values({ userId, roleId });
}
async createRefreshToken(data: { id: string; userId: string; tokenHash: string; expiresAt: Date }): Promise<void> {
async createRefreshToken(data: {
id: string;
userId: string;
tokenHash: string;
expiresAt: Date;
}): Promise<void> {
const db = getDb();
await db.insert(refreshTokens).values(data);
}
async revokeRefreshToken(id: string): Promise<void> {
const db = getDb();
await db.update(refreshTokens).set({ revokedAt: new Date() }).where(eq(refreshTokens.id, id));
await db
.update(refreshTokens)
.set({ revokedAt: new Date() })
.where(eq(refreshTokens.id, id));
}
}

View File

@@ -1,11 +1,16 @@
import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { IamRepository } from './iam.repository.js';
import { ConflictError, UnauthorizedError, NotFoundError } from '../shared/errors/application-error.js';
import { env } from '../config/env.js';
import type { RegisterDto, LoginDto } from './iam.dto.js';
import type { User } from './iam.schema.js';
import { v4 as uuidv4 } from "uuid";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { Inject } from "@nestjs/common";
import { IamRepository } from "./iam.repository.js";
import {
ConflictError,
UnauthorizedError,
NotFoundError,
} from "../shared/errors/application-error.js";
import { env } from "../config/env.js";
import type { RegisterDto, LoginDto } from "./iam.dto.js";
import type { User } from "./iam.schema.js";
export interface TokenPair {
accessToken: string;
@@ -22,12 +27,16 @@ export interface UserInfo {
}
export class IamService {
constructor(private readonly repository: IamRepository) {}
constructor(
@Inject(IamRepository) private readonly repository: IamRepository,
) {}
async register(dto: RegisterDto): Promise<{ user: UserInfo; tokens: TokenPair }> {
async register(
dto: RegisterDto,
): Promise<{ user: UserInfo; tokens: TokenPair }> {
const existing = await this.repository.findUserByEmail(dto.email);
if (existing) {
throw new ConflictError('Email already registered');
throw new ConflictError("Email already registered");
}
const userId = uuidv4();
@@ -50,16 +59,16 @@ export class IamService {
async login(dto: LoginDto): Promise<{ user: UserInfo; tokens: TokenPair }> {
const user = await this.repository.findUserByEmail(dto.email);
if (!user) {
throw new UnauthorizedError('Invalid credentials');
throw new UnauthorizedError("Invalid credentials");
}
const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) {
throw new UnauthorizedError('Invalid credentials');
throw new UnauthorizedError("Invalid credentials");
}
if (user.status !== 'active') {
throw new UnauthorizedError('Account is not active');
if (user.status !== "active") {
throw new UnauthorizedError("Account is not active");
}
const { tokens } = await this.issueTokens(user);
@@ -72,16 +81,16 @@ export class IamService {
try {
payload = jwt.verify(refreshToken, env.JWT_SECRET) as jwt.JwtPayload;
} catch {
throw new UnauthorizedError('Invalid refresh token');
throw new UnauthorizedError("Invalid refresh token");
}
if (payload.type !== 'refresh') {
throw new UnauthorizedError('Invalid token type');
if (payload.type !== "refresh") {
throw new UnauthorizedError("Invalid token type");
}
const user = await this.repository.findUserById(payload.sub as string);
if (!user) {
throw new NotFoundError('User', payload.sub as string);
throw new NotFoundError("User", payload.sub as string);
}
return this.issueTokens(user).then((r) => r.tokens);
@@ -90,7 +99,7 @@ export class IamService {
async getUserInfo(userId: string): Promise<UserInfo> {
const user = await this.repository.findUserById(userId);
if (!user) {
throw new NotFoundError('User', userId);
throw new NotFoundError("User", userId);
}
return this.buildUserInfo(user);
}
@@ -100,15 +109,15 @@ export class IamService {
const roleNames = roles.map((r) => r.name);
const accessToken = jwt.sign(
{ sub: user.id, email: user.email, roles: roleNames, type: 'access' },
{ sub: user.id, email: user.email, roles: roleNames, type: "access" },
env.JWT_SECRET,
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: '15m' }
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: "15m" },
);
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
{ sub: user.id, type: "refresh" },
env.JWT_SECRET,
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: '7d' }
{ issuer: env.JWT_ISSUER, audience: env.JWT_AUDIENCE, expiresIn: "7d" },
);
// 存储 refresh token hash

View File

@@ -1,7 +1,8 @@
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { getDb } from "../../config/database.js";
const SERVICE_NAME = 'iam';
const SERVICE_NAME = "iam";
/**
* 健康检查端点。
@@ -14,33 +15,37 @@ const SERVICE_NAME = 'iam';
*/
@Controller()
export class HealthController {
constructor(private readonly dataSource: DataSource) {}
@Get('healthz')
@Get("healthz")
liveness(): { status: string; service: string; timestamp: string } {
return {
status: 'ok',
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
}
@Get('readyz')
async readiness(): Promise<{ status: string; service: string; timestamp: string }> {
@Get("readyz")
async readiness(): Promise<{
status: string;
service: string;
timestamp: string;
}> {
try {
await this.dataSource.query('SELECT 1');
const db = getDb();
await db.execute(sql`SELECT 1`);
return {
status: 'ok',
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
} catch (error) {
throw new HttpException(
{
status: 'error',
status: "error",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'database unreachable',
error:
error instanceof Error ? error.message : "database unreachable",
},
HttpStatus.SERVICE_UNAVAILABLE,
);

View File

@@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller.js";
/**
* 健康检查模块。

View File

@@ -1,9 +1,12 @@
import { Inject, Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { Redis } from 'ioredis';
import type { Producer } from 'kafkajs';
import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from "@nestjs/common";
import { closeDb } from "../../config/database.js";
const SERVICE_NAME = 'iam';
const SERVICE_NAME = "iam";
/**
* 优雅停机服务。
@@ -12,52 +15,31 @@ const SERVICE_NAME = 'iam';
* 触发SIGTERM / SIGINTNestJS 会依次调用 OnApplicationShutdown 钩子。
* K8s 配置 `terminationGracePeriodSeconds=60` 给予足够时间清理。
*
* 关闭顺序Kafka producer → Redis → DataSource
* 先停外部消息生产避免新事件,再关缓存,最后关 DB
*
* 集成说明(不修改 app.module.ts仅在 README 注释说明):
* - 在 `app.module.ts` 的 providers 中加入 `LifecycleService`。
* - 在 `main.ts` 中 `app.listen` 之前调用 `app.enableShutdownHooks()`。
*
* 依赖注入 token 约定(需与各服务 provider 注册一致):
* - DataSource由 `TypeOrmModule.forRoot()` 提供。
* - 'REDIS_CLIENT':需在对应模块注册 `{ provide: 'REDIS_CLIENT', useFactory: ... }`。
* - 'KAFKA_PRODUCER':需在对应模块注册 `{ provide: 'KAFKA_PRODUCER', useFactory: ... }`。
* IAM 服务仅使用 Drizzle ORMMySQL无 Kafka / Redis 依赖
* 关闭时仅需关闭数据库连接池
*/
@Injectable()
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(LifecycleService.name);
constructor(
private readonly dataSource: DataSource,
@Inject('REDIS_CLIENT') private readonly redis: Redis,
@Inject('KAFKA_PRODUCER') private readonly kafkaProducer: Producer,
) {}
onModuleInit(): void {
this.logger.log(`service ${SERVICE_NAME} module initialized`);
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(
`service ${SERVICE_NAME} shutting down (signal=${signal ?? 'unknown'})`,
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
);
await this.safeDisconnect('kafka producer', () => this.kafkaProducer.disconnect());
await this.safeDisconnect('redis', () => this.redis.quit());
await this.safeDisconnect('datasource', () => this.dataSource.destroy());
try {
await closeDb();
this.logger.log("database connection closed");
} catch (error) {
this.logger.error(
`database close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
}
private async safeDisconnect(name: string, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
this.logger.log(`${name} closed`);
} catch (error) {
this.logger.error(
`${name} close failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}