feat(api): 开发平台、门店相关代码

This commit is contained in:
R524809
2026-09-03 16:04:44 +08:00
parent 35fcf8776e
commit ada02e9135
40 changed files with 7014 additions and 5807 deletions
@@ -1,4 +1,9 @@
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import {
Inject,
Injectable,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { createHmac } from 'node:crypto';
import { Redis } from 'ioredis';
import { ApiError } from '../common/api-error.js';
@@ -8,17 +13,23 @@ type AuthAction = 'login' | 'register' | 'refresh';
type Limit = { ip: number; email?: number };
@Injectable()
export class AuthRateLimiter implements OnModuleDestroy {
export class AuthRateLimiter implements OnModuleInit, OnModuleDestroy {
private readonly redis: Redis;
public constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
// Redis makes counters shared across API processes instead of resetting per instance.
// Redis 让限流计数在多个 API 实例间共享,不会随单个实例重启清零。
this.redis = new Redis(config.REDIS_URL, {
enableOfflineQueue: false,
lazyConnect: true,
maxRetriesPerRequest: 1,
});
}
public async onModuleInit(): Promise<void> {
// 在开始接收认证请求前完成连接,避免首个请求命中 Redis 连接竞态而被误判为 503。
await this.redis.connect();
}
public async consume(
action: AuthAction,
identifiers: { ip: string | null; email?: string },
@@ -41,6 +52,10 @@ export class AuthRateLimiter implements OnModuleDestroy {
}
public async onModuleDestroy(): Promise<void> {
if (this.redis.status !== 'ready') {
this.redis.disconnect();
return;
}
await this.redis.quit();
}
@@ -56,10 +71,10 @@ export class AuthRateLimiter implements OnModuleDestroy {
)
.update(identifier)
.digest('base64url');
// The raw IP/email never becomes part of a Redis key or a loggable identifier.
// Redis key 只保存 IP/邮箱的 HMAC,不暴露可被日志或运维工具读取的原文。
const key = `atlas:auth-rate-limit:${action}:${dimension}:${digest}`;
try {
// INCR + first-write EXPIRE must be atomic or concurrent requests could leave a key unexpired.
// INCR 与首次 EXPIRE 必须原子执行,避免并发下产生永不过期的限流 key。
const count = await this.redis.eval(
"local value = redis.call('INCR', KEYS[1]); if value == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]); end; return value;",
1,
@@ -75,7 +90,7 @@ export class AuthRateLimiter implements OnModuleDestroy {
}
} catch (error) {
if (error instanceof ApiError) throw error;
// Authentication fails closed: silently bypassing a broken shared limiter enables brute-force attacks.
// 认证限流故障时必须拒绝请求;静默跳过会扩大暴力破解风险。
throw new ApiError(
503,
'AUTH_RATE_LIMIT_UNAVAILABLE',