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:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
* 健康检查模块。
|
||||
|
||||
@@ -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 / SIGINT),NestJS 会依次调用 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 ORM(MySQL),无 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user