添加短信验证服务
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-18 08:48:04 +00:00
parent 7ce61e9879
commit 680b9a3e1c
14 changed files with 321 additions and 60 deletions

View File

@@ -4,10 +4,13 @@ await esbuild.build({
entryPoints: ['src/server.ts'],
bundle: true,
platform: 'node',
format: 'esm',
format: 'cjs',
target: 'node22',
outfile: 'dist/server.js',
outfile: 'dist/server.cjs',
sourcemap: true,
packages: 'external',
tsconfig: 'tsconfig.json',
define: {
'import.meta.url': 'undefined',
},
});

View File

@@ -2,7 +2,7 @@ module.exports = {
apps: [
{
name: 'genarrative-server',
script: 'dist/server.js',
script: 'dist/server.cjs',
cwd: __dirname,
instances: 1,
exec_mode: 'fork',

View File

@@ -6,7 +6,7 @@
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "node build.mjs",
"start": "node dist/server.js",
"start": "node dist/server.cjs",
"test": "node test.mjs",
"db:migrate": "tsx src/migrate.ts"
},

View File

@@ -7,6 +7,10 @@ import type { AppConfig } from './config.js';
const LOG_RETENTION_DAYS = 7;
function shouldUseTransport(config: AppConfig) {
return config.nodeEnv !== 'test' && config.logLevel !== 'silent';
}
function cleanupExpiredLogs(logsDir: string) {
if (!fs.existsSync(logsDir)) {
return;
@@ -28,6 +32,14 @@ function cleanupExpiredLogs(logsDir: string) {
}
export function createLogger(config: AppConfig): Logger {
if (!shouldUseTransport(config)) {
return pino({
level: config.logLevel,
timestamp: pino.stdTimeFunctions.isoTime,
base: undefined,
});
}
fs.mkdirSync(config.logsDir, { recursive: true });
cleanupExpiredLogs(config.logsDir);

View File

@@ -151,9 +151,23 @@ async function main() {
process.on('SIGTERM', shutdown);
}
const isEntryPoint =
typeof process.argv[1] === 'string' &&
import.meta.url === pathToFileURL(process.argv[1]).href;
function isEntryModule() {
if (typeof process.argv[1] !== 'string') {
return false;
}
const entryHref = pathToFileURL(process.argv[1]).href;
if (typeof import.meta.url === 'string' && import.meta.url === entryHref) {
return true;
}
return (
typeof __filename === 'string' &&
pathToFileURL(__filename).href === entryHref
);
}
const isEntryPoint = isEntryModule();
if (isEntryPoint) {
void main().catch((error) => {

View File

@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import pino from 'pino';
import type { AppConfig } from '../config.js';
import { createSmsVerificationService } from './smsVerificationService.js';
function createAliyunSmsConfig(): AppConfig {
return {
smsAuth: {
enabled: true,
provider: 'aliyun',
endpoint: 'dypnsapi.aliyuncs.com',
accessKeyId: 'test-access-key-id',
accessKeySecret: 'test-access-key-secret',
signName: '测试签名',
templateCode: 'SMS_100001',
templateParamKey: 'code',
countryCode: '86',
schemeName: '',
codeLength: 6,
codeType: 1,
validTimeSeconds: 300,
intervalSeconds: 60,
duplicatePolicy: 1,
caseAuthPolicy: 1,
returnVerifyCode: false,
mockVerifyCode: '123456',
maxSendPerPhonePerDay: 20,
maxSendPerIpPerHour: 30,
maxVerifyFailuresPerPhonePerHour: 12,
maxVerifyFailuresPerIpPerHour: 24,
captchaTtlSeconds: 180,
captchaTriggerVerifyFailuresPerPhone: 3,
captchaTriggerVerifyFailuresPerIp: 5,
blockPhoneFailureThreshold: 6,
blockIpFailureThreshold: 10,
blockPhoneDurationMinutes: 30,
blockIpDurationMinutes: 30,
},
} as AppConfig;
}
test('createSmsVerificationService initializes aliyun sdk client under tsx esm runtime', () => {
const service = createSmsVerificationService(
createAliyunSmsConfig(),
pino({ enabled: false }),
);
assert.equal(typeof service.sendLoginCode, 'function');
assert.equal(typeof service.verifyLoginCode, 'function');
});

View File

@@ -1,6 +1,6 @@
import crypto from 'node:crypto';
import DypnsClient, {
import DypnsApiModule, {
CheckSmsVerifyCodeRequest,
SendSmsVerifyCodeRequest,
} from '@alicloud/dypnsapi20170525';
@@ -29,6 +29,30 @@ export type SmsVerificationService = {
): Promise<void>;
};
type DypnsClientInstance = InstanceType<typeof DypnsApiModule>;
type DypnsClientConstructor = new (
config: OpenApiClient.Config,
) => DypnsClientInstance;
function resolveDypnsClientConstructor(): DypnsClientConstructor {
const directExport = DypnsApiModule as unknown;
if (typeof directExport === 'function') {
return directExport as DypnsClientConstructor;
}
// 兼容 CommonJS SDK 在 ESM/tsx 运行时被包一层 default 的情况。
const nestedDefault = (
DypnsApiModule as unknown as { default?: unknown }
).default;
if (typeof nestedDefault === 'function') {
return nestedDefault as DypnsClientConstructor;
}
throw new Error('阿里云短信 SDK Client 导出异常');
}
const DypnsClient = resolveDypnsClientConstructor();
function isAliyunConfigMissing(config: AppConfig['smsAuth']) {
return !config.accessKeyId || !config.accessKeySecret;
}
@@ -74,6 +98,7 @@ class AliyunSmsVerificationService implements SmsVerificationService {
async sendLoginCode(phoneNumber: NormalizedPhoneNumber) {
const templateParam = JSON.stringify({
[this.config.templateParamKey]: '##code##',
"min": this.config.validTimeSeconds,
});
const request = new SendSmsVerifyCodeRequest({
phoneNumber: phoneNumber.nationalNumber,