feat(api): 开发auth、login等接口,规范格式化
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { loginRequestSchema, registerRequestSchema } from '@atlas/contracts';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { AccessTokenGuard, type AuthenticatedRequest } from './auth.guard.js';
|
||||
import { AuthService, type RequestMetadata } from './auth.service.js';
|
||||
|
||||
const refreshCookie = 'atlas_refresh';
|
||||
type HttpRequest = {
|
||||
header: (name: string) => string | undefined;
|
||||
ip?: string;
|
||||
};
|
||||
type HttpResponse = {
|
||||
cookie: (
|
||||
name: string,
|
||||
value: string,
|
||||
options: Record<string, unknown>,
|
||||
) => void;
|
||||
clearCookie: (name: string, options: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
public constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '注册账户并创建个人工作空间' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: {
|
||||
email: 'owner@example.com',
|
||||
password: 'a-strong-password',
|
||||
password_confirmation: 'a-strong-password',
|
||||
display_name: 'Joey',
|
||||
},
|
||||
},
|
||||
})
|
||||
public async register(
|
||||
@Body() body: unknown,
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
const result = await this.authService.register(
|
||||
parse(registerRequestSchema, body),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return { data: result.payload };
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: '使用邮箱和密码登录' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: {
|
||||
email: 'owner@example.com',
|
||||
password: 'a-strong-password',
|
||||
},
|
||||
},
|
||||
})
|
||||
public async login(
|
||||
@Body() body: unknown,
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
const result = await this.authService.login(
|
||||
parse(loginRequestSchema, body),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return { data: result.payload };
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' })
|
||||
public async refresh(
|
||||
@Req() request: HttpRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
try {
|
||||
const result = await this.authService.refresh(
|
||||
readCookie(request.header('cookie'), refreshCookie),
|
||||
metadata(request),
|
||||
);
|
||||
setRefreshCookie(response, result.refreshToken);
|
||||
return {
|
||||
data: {
|
||||
access_token: result.payload.access_token,
|
||||
access_token_expires_in:
|
||||
result.payload.access_token_expires_in,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
clearRefreshCookie(response);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(204)
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: '撤销当前登录会话' })
|
||||
@ApiNoContentResponse({ description: '会话已撤销' })
|
||||
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||
public async logout(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Res({ passthrough: true }) response: HttpResponse,
|
||||
) {
|
||||
await this.authService.logout(request.auth.sessionId);
|
||||
clearRefreshCookie(response);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' })
|
||||
@ApiOkResponse({ description: '当前身份和工作空间' })
|
||||
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
||||
public async me(@Req() request: AuthenticatedRequest) {
|
||||
return { data: await this.authService.me(request.auth.userId) };
|
||||
}
|
||||
}
|
||||
|
||||
function parse<
|
||||
T extends {
|
||||
safeParse: (value: unknown) => {
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
||||
};
|
||||
},
|
||||
>(schema: T, value: unknown) {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success)
|
||||
return result.data as ReturnType<T['safeParse']> extends {
|
||||
data: infer Data;
|
||||
}
|
||||
? Data
|
||||
: never;
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_FAILED',
|
||||
'请求参数不符合要求',
|
||||
result.error?.issues.map((issue) => ({
|
||||
field: issue.path.join('.'),
|
||||
code: 'INVALID_FIELD',
|
||||
message: issue.message,
|
||||
})),
|
||||
);
|
||||
}
|
||||
function metadata(request: HttpRequest): RequestMetadata {
|
||||
return {
|
||||
ip: request.ip ?? null,
|
||||
ipHash: request.ip
|
||||
? createHash('sha256').update(request.ip).digest('base64url')
|
||||
: null,
|
||||
userAgent: request.header('user-agent') ?? null,
|
||||
};
|
||||
}
|
||||
function cookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/api/auth',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
function setRefreshCookie(response: HttpResponse, token: string) {
|
||||
response.cookie(refreshCookie, token, cookieOptions());
|
||||
}
|
||||
function clearRefreshCookie(response: HttpResponse) {
|
||||
response.clearCookie(refreshCookie, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth',
|
||||
});
|
||||
}
|
||||
function readCookie(header: string | undefined, name: string) {
|
||||
return header
|
||||
?.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${name}=`))
|
||||
?.slice(name.length + 1);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
createHash,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
scrypt as scryptCallback,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const scrypt = promisify(scryptCallback) as (
|
||||
password: string,
|
||||
salt: string,
|
||||
keyLength: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number },
|
||||
) => Promise<Buffer>;
|
||||
const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 };
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
// The random salt and expensive KDF keep a database leak from exposing passwords directly.
|
||||
const salt = randomBytes(16).toString('base64url');
|
||||
const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||
return `scrypt$${salt}$${derived.toString('base64url')}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
encoded: string,
|
||||
): Promise<boolean> {
|
||||
const [algorithm, salt, expected] = encoded.split('$');
|
||||
if (algorithm !== 'scrypt' || !salt || !expected) return false;
|
||||
const actual = (await scrypt(password, salt, 64, scryptOptions)) as Buffer;
|
||||
const expectedBuffer = Buffer.from(expected, 'base64url');
|
||||
return (
|
||||
expectedBuffer.length === actual.length &&
|
||||
timingSafeEqual(expectedBuffer, actual)
|
||||
);
|
||||
}
|
||||
|
||||
export function createOpaqueToken(): string {
|
||||
return randomBytes(48).toString('base64url');
|
||||
}
|
||||
export function tokenHash(token: string): string {
|
||||
return createHash('sha256').update(token).digest('base64url');
|
||||
}
|
||||
|
||||
export type AccessTokenClaims = {
|
||||
sub: string;
|
||||
sid: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
};
|
||||
export function signAccessToken(
|
||||
claims: AccessTokenClaims,
|
||||
secret: string,
|
||||
): string {
|
||||
// Access tokens are short-lived; refresh sessions remain revocable in PostgreSQL.
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
|
||||
).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
||||
const signature = createHmac('sha256', secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest('base64url');
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
export function verifyAccessToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): AccessTokenClaims | null {
|
||||
const [header, payload, signature] = token.split('.');
|
||||
if (!header || !payload || !signature) return null;
|
||||
const expected = createHmac('sha256', secret)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest();
|
||||
const actual = Buffer.from(signature, 'base64url');
|
||||
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
||||
return null;
|
||||
try {
|
||||
const claims = JSON.parse(
|
||||
Buffer.from(payload, 'base64url').toString('utf8'),
|
||||
) as AccessTokenClaims;
|
||||
return typeof claims.sub === 'string' &&
|
||||
typeof claims.sid === 'string' &&
|
||||
typeof claims.exp === 'number' &&
|
||||
claims.exp > Math.floor(Date.now() / 1000)
|
||||
? claims
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service.js';
|
||||
|
||||
export type AuthenticatedRequest = {
|
||||
header: (name: string) => string | undefined;
|
||||
auth: { userId: string; sessionId: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenGuard implements CanActivate {
|
||||
public constructor(private readonly authService: AuthService) {}
|
||||
public async canActivate(context: ExecutionContext) {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<AuthenticatedRequest>();
|
||||
request.auth = await this.authService.authenticate(
|
||||
request.header('authorization'),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { AccessTokenGuard } from './auth.guard.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AccessTokenGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import type {
|
||||
AuthSessionResponse,
|
||||
AuthUser,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { AuthRateLimiter } from '../security/auth-rate-limiter.service.js';
|
||||
import {
|
||||
createOpaqueToken,
|
||||
hashPassword,
|
||||
signAccessToken,
|
||||
tokenHash,
|
||||
verifyAccessToken,
|
||||
verifyPassword,
|
||||
} from './auth.crypto.js';
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
email_verified_at: Date | null;
|
||||
created_at: Date;
|
||||
status: 'active' | 'disabled' | 'pending_review';
|
||||
password_hash: string;
|
||||
};
|
||||
type WorkspaceRow = { id: string; name: string; role: Workspace['role'] };
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly accessTtlSeconds = 900;
|
||||
private readonly refreshTtlDays = 30;
|
||||
|
||||
public constructor(
|
||||
@Inject(DATABASE) private readonly database: Database,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
private readonly rateLimiter: AuthRateLimiter,
|
||||
) {}
|
||||
|
||||
public async register(input: RegisterRequest, metadata: RequestMetadata) {
|
||||
if (input.password !== input.password_confirmation)
|
||||
throw validationError(
|
||||
'password_confirmation',
|
||||
'PASSWORD_CONFIRMATION_MISMATCH',
|
||||
'两次输入的密码不一致',
|
||||
);
|
||||
if (input.password.length < this.config.AUTH_PASSWORD_MIN_LENGTH)
|
||||
throw validationError(
|
||||
'password',
|
||||
'PASSWORD_TOO_SHORT',
|
||||
`密码至少需要 ${this.config.AUTH_PASSWORD_MIN_LENGTH} 个字符`,
|
||||
);
|
||||
const email = input.email.trim().toLowerCase();
|
||||
// Apply the shared limiter before spending CPU on password hashing or querying account existence.
|
||||
await this.rateLimiter.consume('register', { ip: metadata.ip, email });
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
try {
|
||||
return await this.database.transaction(async (transaction) => {
|
||||
// A user must never exist without its initial tenant and owner membership.
|
||||
const user = await insertUser(
|
||||
transaction,
|
||||
email,
|
||||
passwordHash,
|
||||
input.display_name?.trim() || null,
|
||||
);
|
||||
const workspace = await createPersonalWorkspace(
|
||||
transaction,
|
||||
user.id,
|
||||
user.display_name ?? email,
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
user,
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (isUniqueViolation(error))
|
||||
throw new ApiError(
|
||||
409,
|
||||
'EMAIL_ALREADY_REGISTERED',
|
||||
'该邮箱已注册',
|
||||
[
|
||||
{
|
||||
field: 'email',
|
||||
code: 'EMAIL_ALREADY_REGISTERED',
|
||||
message: '该邮箱已注册',
|
||||
},
|
||||
],
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async login(input: LoginRequest, metadata: RequestMetadata) {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
// Use the same normalized value for the limiter and citext account lookup.
|
||||
await this.rateLimiter.consume('login', { ip: metadata.ip, email });
|
||||
const result = await this.database.query<UserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE email = $1',
|
||||
[email],
|
||||
);
|
||||
const user = result.rows[0];
|
||||
if (
|
||||
!user ||
|
||||
!(await verifyPassword(input.password, user.password_hash))
|
||||
)
|
||||
throw new ApiError(401, 'INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||
if (user.status === 'disabled')
|
||||
throw new ApiError(403, 'ACCOUNT_DISABLED', '账户已停用');
|
||||
const workspace = await this.findWorkspace(user.id);
|
||||
return this.database.transaction(async (transaction) => {
|
||||
await transaction.query(
|
||||
'UPDATE atlas.users SET last_login_at = now() WHERE id = $1',
|
||||
[user.id],
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
user,
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public async refresh(
|
||||
refreshToken: string | undefined,
|
||||
metadata: RequestMetadata,
|
||||
) {
|
||||
// Refresh has no trusted email until its opaque token is validated, so it is IP-scoped only.
|
||||
await this.rateLimiter.consume('refresh', { ip: metadata.ip });
|
||||
if (!refreshToken)
|
||||
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const sessions = await transaction.query<
|
||||
UserRow & { session_id: string }
|
||||
>(
|
||||
`SELECT u.id, u.email, u.display_name, u.email_verified_at, u.created_at, u.status, u.password_hash, s.id AS session_id
|
||||
FROM atlas.user_sessions s JOIN atlas.users u ON u.id = s.user_id
|
||||
WHERE s.refresh_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now() FOR UPDATE`,
|
||||
[tokenHash(refreshToken)],
|
||||
);
|
||||
const user = sessions.rows[0];
|
||||
if (!user || user.status !== 'active')
|
||||
throw new ApiError(401, 'SESSION_EXPIRED', '登录会话已过期');
|
||||
// Row locking makes a refresh token single-use even when two browser requests arrive together.
|
||||
await transaction.query(
|
||||
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1',
|
||||
[user.session_id],
|
||||
);
|
||||
const workspace = await findWorkspaceInTransaction(
|
||||
transaction,
|
||||
user.id,
|
||||
);
|
||||
return this.createSessionResponse(
|
||||
transaction,
|
||||
user,
|
||||
workspace,
|
||||
metadata,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public async logout(sessionId: string) {
|
||||
await this.database.query(
|
||||
'UPDATE atlas.user_sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL',
|
||||
[sessionId],
|
||||
);
|
||||
}
|
||||
|
||||
public async authenticate(accessToken: string | undefined) {
|
||||
const token = accessToken?.replace(/^Bearer\s+/i, '');
|
||||
const claims = token
|
||||
? verifyAccessToken(token, this.config.AUTH_JWT_SECRET)
|
||||
: null;
|
||||
if (!claims) throw new UnauthorizedException();
|
||||
const active = await this.database.query(
|
||||
'SELECT 1 FROM atlas.user_sessions WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > now()',
|
||||
[claims.sid, claims.sub],
|
||||
);
|
||||
if (!active.rowCount) throw new UnauthorizedException();
|
||||
return { userId: claims.sub, sessionId: claims.sid };
|
||||
}
|
||||
|
||||
public async me(userId: string) {
|
||||
const workspace = await this.findWorkspace(userId);
|
||||
const users = await this.database.query<UserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, created_at, status, password_hash FROM atlas.users WHERE id = $1',
|
||||
[userId],
|
||||
);
|
||||
const user = users.rows[0];
|
||||
if (!user) throw new UnauthorizedException();
|
||||
const platforms = await this.database.workspaceTransaction(
|
||||
workspace.id,
|
||||
async (transaction) => {
|
||||
const rows = await transaction.query<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}>(
|
||||
"SELECT id, code, name FROM atlas.platforms WHERE status = 'active' ORDER BY CASE WHEN code = 'OZON' THEN 0 ELSE 1 END, name",
|
||||
);
|
||||
return rows.rows;
|
||||
},
|
||||
);
|
||||
return {
|
||||
user: publicUser(user),
|
||||
workspace,
|
||||
platforms,
|
||||
suggested_context: {
|
||||
platform_id: platforms[0]?.id ?? null,
|
||||
default_store: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async findWorkspace(userId: string) {
|
||||
const result = await this.database.query<WorkspaceRow>(
|
||||
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return requireWorkspace(result.rows[0]);
|
||||
}
|
||||
private async createSessionResponse(
|
||||
transaction: DatabaseTransaction,
|
||||
user: UserRow,
|
||||
workspace: WorkspaceRow,
|
||||
metadata: RequestMetadata,
|
||||
) {
|
||||
const refreshToken = createOpaqueToken();
|
||||
const session = await transaction.query<{ id: string }>(
|
||||
`INSERT INTO atlas.user_sessions (user_id, refresh_token_hash, expires_at, ip_hash, user_agent)
|
||||
VALUES ($1, $2, now() + ($3 || ' days')::interval, $4, $5) RETURNING id`,
|
||||
[
|
||||
user.id,
|
||||
tokenHash(refreshToken),
|
||||
this.refreshTtlDays,
|
||||
metadata.ipHash,
|
||||
metadata.userAgent,
|
||||
],
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const accessToken = signAccessToken(
|
||||
{
|
||||
sub: user.id,
|
||||
sid: session.rows[0].id,
|
||||
iat: now,
|
||||
exp: now + this.accessTtlSeconds,
|
||||
},
|
||||
this.config.AUTH_JWT_SECRET,
|
||||
);
|
||||
const payload: AuthSessionResponse = {
|
||||
access_token: accessToken,
|
||||
access_token_expires_in: this.accessTtlSeconds,
|
||||
user: publicUser(user),
|
||||
workspace,
|
||||
};
|
||||
return { payload, refreshToken };
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestMetadata = {
|
||||
ip: string | null;
|
||||
ipHash: string | null;
|
||||
userAgent: string | null;
|
||||
};
|
||||
function validationError(field: string, code: string, message: string) {
|
||||
return new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||
{ field, code, message },
|
||||
]);
|
||||
}
|
||||
function isUniqueViolation(error: unknown): error is { code: string } {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code: string }).code === '23505'
|
||||
);
|
||||
}
|
||||
function publicUser(user: UserRow): AuthUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
email_verified_at: user.email_verified_at?.toISOString() ?? null,
|
||||
created_at: user.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
async function insertUser(
|
||||
transaction: DatabaseTransaction,
|
||||
email: string,
|
||||
passwordHash: string,
|
||||
displayName: string | null,
|
||||
) {
|
||||
const result = await transaction.query<UserRow>(
|
||||
'INSERT INTO atlas.users (email, password_hash, display_name) VALUES ($1, $2, $3) RETURNING id, email, display_name, email_verified_at, created_at, status, password_hash',
|
||||
[email, passwordHash, displayName],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
async function createPersonalWorkspace(
|
||||
transaction: DatabaseTransaction,
|
||||
userId: string,
|
||||
label: string,
|
||||
) {
|
||||
const workspace = await transaction.query<{ id: string; name: string }>(
|
||||
'INSERT INTO atlas.workspaces (name, slug) VALUES ($1, $2) RETURNING id, name',
|
||||
[`${label} 的工作空间`, `personal-${userId}`],
|
||||
);
|
||||
await transaction.query(
|
||||
"INSERT INTO atlas.workspace_memberships (workspace_id, user_id, role) VALUES ($1, $2, 'owner')",
|
||||
[workspace.rows[0].id, userId],
|
||||
);
|
||||
return { ...workspace.rows[0], role: 'owner' as const };
|
||||
}
|
||||
async function findWorkspaceInTransaction(
|
||||
transaction: DatabaseTransaction,
|
||||
userId: string,
|
||||
): Promise<WorkspaceRow> {
|
||||
const result = await transaction.query<WorkspaceRow>(
|
||||
`SELECT w.id, w.name, m.role FROM atlas.workspace_memberships m JOIN atlas.workspaces w ON w.id = m.workspace_id WHERE m.user_id = $1 AND w.status = 'active' ORDER BY m.joined_at LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return requireWorkspace(result.rows[0]);
|
||||
}
|
||||
function requireWorkspace(workspace: WorkspaceRow | undefined): WorkspaceRow {
|
||||
if (!workspace)
|
||||
throw new ApiError(
|
||||
403,
|
||||
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||
'当前账户没有可用工作空间',
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
Reference in New Issue
Block a user