This commit is contained in:
2026-04-22 22:01:07 +08:00
parent d8716d70b0
commit b317c2a8ea
37 changed files with 1821 additions and 515 deletions

View File

@@ -6,6 +6,13 @@ CREATE TABLE IF NOT EXISTS sms_auth_events (
success BOOLEAN NOT NULL,
ip TEXT,
user_agent TEXT,
provider TEXT,
provider_request_id TEXT,
provider_biz_id TEXT,
provider_out_id TEXT,
delivery_status TEXT NOT NULL DEFAULT 'pending',
delivery_report_raw_json JSONB,
delivery_reported_at TEXT,
created_at TEXT NOT NULL
);
@@ -14,3 +21,9 @@ CREATE INDEX IF NOT EXISTS sms_auth_events_phone_created_idx
CREATE INDEX IF NOT EXISTS sms_auth_events_ip_created_idx
ON sms_auth_events (ip, created_at DESC);
CREATE INDEX IF NOT EXISTS sms_auth_events_provider_biz_id_idx
ON sms_auth_events (provider_biz_id);
CREATE INDEX IF NOT EXISTS sms_auth_events_provider_out_id_idx
ON sms_auth_events (provider_out_id);

View File

@@ -238,6 +238,7 @@ async function sendPhoneCode(
ok: true;
cooldownSeconds: number;
expiresInSeconds: number;
providerRequestId: string | null;
};
assert.equal(response.status, 200);
@@ -1012,6 +1013,166 @@ test('phone login reuses the same account for repeated verification', async () =
});
});
test('mock sms send code records delivered tracking metadata', async () => {
await withTestServer('phone-send-code-mock-tracking', async ({ baseUrl, context }) => {
const sendResult = await sendPhoneCode(baseUrl, '13800138009');
assert.equal(sendResult.providerRequestId, 'mock-request-id');
const rows = await context.db.query<{
provider: string | null;
provider_request_id: string | null;
provider_biz_id: string | null;
provider_out_id: string | null;
delivery_status: string;
}>(
`SELECT
provider,
provider_request_id,
provider_biz_id,
provider_out_id,
delivery_status
FROM sms_auth_events
WHERE phone_number = $1
AND action = 'send_code'
ORDER BY created_at DESC
LIMIT 1`,
['+8613800138009'],
);
assert.equal(rows.rows.length, 1);
assert.equal(rows.rows[0]?.provider, 'mock');
assert.equal(rows.rows[0]?.provider_request_id, 'mock-request-id');
assert.equal(rows.rows[0]?.provider_biz_id, null);
assert.equal(rows.rows[0]?.provider_out_id, 'mock-out-id');
assert.equal(rows.rows[0]?.delivery_status, 'delivered');
});
});
test('aliyun delivery report updates sms event delivery status', async () => {
await withTestServer(
'phone-delivery-report-aliyun',
async ({ baseUrl, context }) => {
await context.smsAuthEventRepository.create({
phoneNumber: '+8613800138007',
scene: 'login',
action: 'send_code',
success: true,
ip: '127.0.0.1',
userAgent: 'test-agent',
provider: 'aliyun',
providerRequestId: 'aliyun-request-id',
providerBizId: 'aliyun-biz-id-report',
providerOutId: 'login_out_id_report',
deliveryStatus: 'pending',
});
const response = await httpRequest(
`${baseUrl}/api/auth/phone/delivery-report/aliyun`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
BizId: 'aliyun-biz-id-report',
OutId: 'login_out_id_report',
ReceiveStatus: 'SUCCESS',
}).toString(),
},
);
const payload = (await response.json()) as {
ok: true;
matched: boolean;
};
assert.equal(response.status, 200);
assert.equal(payload.ok, true);
assert.equal(payload.matched, true);
const rows = await context.db.query<{
delivery_status: string;
delivery_report_raw_json: {
BizId?: string;
ReceiveStatus?: string;
} | null;
delivery_reported_at: string | null;
}>(
`SELECT
delivery_status,
delivery_report_raw_json,
delivery_reported_at
FROM sms_auth_events
WHERE provider_biz_id = $1
ORDER BY created_at DESC
LIMIT 1`,
['aliyun-biz-id-report'],
);
assert.equal(rows.rows.length, 1);
assert.equal(rows.rows[0]?.delivery_status, 'delivered');
assert.equal(
rows.rows[0]?.delivery_report_raw_json?.BizId,
'aliyun-biz-id-report',
);
assert.equal(
rows.rows[0]?.delivery_report_raw_json?.ReceiveStatus,
'SUCCESS',
);
assert.ok(rows.rows[0]?.delivery_reported_at);
},
);
});
test('aliyun-tracked send events can persist pending delivery metadata', async () => {
await withTestServer(
'phone-send-code-aliyun-tracking',
async ({ context }) => {
await context.smsAuthEventRepository.create({
phoneNumber: '+8613800138008',
scene: 'login',
action: 'send_code',
success: true,
ip: '127.0.0.1',
userAgent: 'test-agent',
provider: 'aliyun',
providerRequestId: 'aliyun-request-id',
providerBizId: 'aliyun-biz-id',
providerOutId: 'login_out_id_001',
deliveryStatus: 'pending',
});
const rows = await context.db.query<{
provider: string | null;
provider_request_id: string | null;
provider_biz_id: string | null;
provider_out_id: string | null;
delivery_status: string;
}>(
`SELECT
provider,
provider_request_id,
provider_biz_id,
provider_out_id,
delivery_status
FROM sms_auth_events
WHERE phone_number = $1
AND action = 'send_code'
ORDER BY created_at DESC
LIMIT 1`,
['+8613800138008'],
);
assert.equal(rows.rows.length, 1);
assert.equal(rows.rows[0]?.provider, 'aliyun');
assert.equal(rows.rows[0]?.provider_request_id, 'aliyun-request-id');
assert.equal(rows.rows[0]?.provider_biz_id, 'aliyun-biz-id');
assert.equal(rows.rows[0]?.provider_out_id, 'login_out_id_001');
assert.equal(rows.rows[0]?.delivery_status, 'pending');
},
);
});
test('phone login rejects incorrect verification codes', async () => {
await withTestServer('phone-login-invalid-code', async ({ baseUrl }) => {
await sendPhoneCode(baseUrl, '13700137000');

View File

@@ -314,15 +314,29 @@ async function recordSmsAuthEvent(
action: 'send_code' | 'verify_code';
success: boolean;
requestContext: RefreshSessionRequestContext | null;
provider?: string | null;
providerRequestId?: string | null;
providerBizId?: string | null;
providerOutId?: string | null;
deliveryStatus?: 'pending' | 'delivered' | 'failed' | 'unknown';
deliveryReportRawJson?: Record<string, unknown> | null;
deliveryReportedAt?: string | null;
},
) {
await context.smsAuthEventRepository.create({
return context.smsAuthEventRepository.create({
phoneNumber: input.phoneNumber,
scene: input.scene,
action: input.action,
success: input.success,
ip: input.requestContext?.ip ?? null,
userAgent: input.requestContext?.userAgent ?? null,
provider: input.provider ?? null,
providerRequestId: input.providerRequestId ?? null,
providerBizId: input.providerBizId ?? null,
providerOutId: input.providerOutId ?? null,
deliveryStatus: input.deliveryStatus,
deliveryReportRawJson: input.deliveryReportRawJson ?? null,
deliveryReportedAt: input.deliveryReportedAt ?? null,
});
}
@@ -1014,13 +1028,30 @@ export async function sendPhoneLoginCode(
const result = await context.smsVerificationService.sendLoginCode(
normalizedPhone,
);
await recordSmsAuthEvent(context, {
const smsEvent = await recordSmsAuthEvent(context, {
phoneNumber: normalizedPhone.e164,
scene,
action: 'send_code',
success: true,
requestContext,
provider: result.provider,
providerRequestId: result.providerRequestId,
providerBizId: result.providerBizId,
providerOutId: result.providerOutId,
deliveryStatus: result.deliveryStatus,
});
context.logger.info(
{
phone_suffix: normalizedPhone.nationalNumber.slice(-4),
sms_event_id: smsEvent?.id ?? null,
provider: result.provider,
provider_request_id: result.providerRequestId,
provider_biz_id: result.providerBizId,
provider_out_id: result.providerOutId,
delivery_status: result.deliveryStatus,
},
'sms verify code accepted by provider',
);
return {
ok: true,
@@ -1030,6 +1061,98 @@ export async function sendPhoneLoginCode(
};
}
function normalizeAliyunDeliveryStatus(rawValue: string) {
const normalizedValue = rawValue.trim().toLowerCase();
if (!normalizedValue) {
return 'unknown' as const;
}
if (
normalizedValue === 'success' ||
normalizedValue === 'delivered' ||
normalizedValue === 'pass' ||
normalizedValue === 'ok'
) {
return 'delivered' as const;
}
if (
normalizedValue === 'fail' ||
normalizedValue === 'failed' ||
normalizedValue === 'failure' ||
normalizedValue === 'undelivered'
) {
return 'failed' as const;
}
return 'unknown' as const;
}
export async function handleAliyunSmsDeliveryReport(
context: AppContext,
reportPayload: Record<string, unknown>,
) {
const bizId =
typeof reportPayload.BizId === 'string' ? reportPayload.BizId.trim() : '';
const outId =
typeof reportPayload.OutId === 'string' ? reportPayload.OutId.trim() : '';
const deliveryStatus = normalizeAliyunDeliveryStatus(
typeof reportPayload.ReceiveStatus === 'string'
? reportPayload.ReceiveStatus
: typeof reportPayload.Status === 'string'
? reportPayload.Status
: '',
);
const matchedEvent = bizId
? await context.smsAuthEventRepository.findLatestByProviderBizId(bizId)
: outId
? await context.smsAuthEventRepository.findLatestByProviderOutId(outId)
: null;
if (!matchedEvent) {
context.logger.warn(
{
provider: 'aliyun',
provider_biz_id: bizId || null,
provider_out_id: outId || null,
delivery_status: deliveryStatus,
report_payload: reportPayload,
},
'aliyun sms delivery report did not match any local event',
);
return {
ok: true as const,
matched: false as const,
};
}
const reportedAt = new Date().toISOString();
const updatedEvent = await context.smsAuthEventRepository.updateDeliveryStatus({
id: matchedEvent.id,
deliveryStatus,
deliveryReportRawJson: reportPayload,
deliveryReportedAt: reportedAt,
});
context.logger.info(
{
sms_event_id: matchedEvent.id,
provider: matchedEvent.provider,
provider_biz_id: matchedEvent.providerBizId,
provider_out_id: matchedEvent.providerOutId,
delivery_status: updatedEvent?.deliveryStatus ?? deliveryStatus,
},
'aliyun sms delivery report applied',
);
return {
ok: true as const,
matched: true as const,
};
}
export async function entryWithPhoneCode(
context: AppContext,
phoneInput: string,

View File

@@ -120,6 +120,7 @@ test('createDatabase applies runtime baseline migrations for pg-mem', async () =
'20260417_013_custom_world_profile_soft_delete',
'20260419_014_profile_save_archives',
'20260419_015_runtime_settings_platform_theme',
'20260422_016_sms_auth_delivery_tracking',
],
);

View File

@@ -351,4 +351,38 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
ADD COLUMN IF NOT EXISTS platform_theme TEXT NOT NULL DEFAULT 'light'`,
],
},
{
id: '20260422_016_sms_auth_delivery_tracking',
name: 'sms auth delivery tracking',
statements: [
`ALTER TABLE sms_auth_events
ADD COLUMN IF NOT EXISTS provider TEXT`,
`ALTER TABLE sms_auth_events
ADD COLUMN IF NOT EXISTS provider_request_id TEXT`,
`ALTER TABLE sms_auth_events
ADD COLUMN IF NOT EXISTS provider_biz_id TEXT`,
`ALTER TABLE sms_auth_events
ADD COLUMN IF NOT EXISTS provider_out_id TEXT`,
`ALTER TABLE sms_auth_events
ADD COLUMN IF NOT EXISTS delivery_status TEXT NOT NULL DEFAULT 'pending'`,
`ALTER TABLE sms_auth_events
ADD COLUMN IF NOT EXISTS delivery_report_raw_json JSONB`,
`ALTER TABLE sms_auth_events
ADD COLUMN IF NOT EXISTS delivery_reported_at TEXT`,
`CREATE INDEX IF NOT EXISTS sms_auth_events_provider_biz_id_idx
ON sms_auth_events (provider_biz_id)`,
`CREATE INDEX IF NOT EXISTS sms_auth_events_provider_out_id_idx
ON sms_auth_events (provider_out_id)`,
`UPDATE sms_auth_events
SET provider = COALESCE(provider, 'mock'),
delivery_status = CASE
WHEN delivery_status IS NOT NULL AND delivery_status <> '' THEN delivery_status
WHEN success THEN 'delivered'
ELSE 'unknown'
END
WHERE provider IS NULL
OR delivery_status IS NULL
OR delivery_status = ''`,
],
},
];

View File

@@ -6,11 +6,71 @@ import type { AppDatabase } from '../db.js';
export type SmsAuthScene = 'login' | 'bind_phone' | 'change_phone';
export type SmsAuthAction = 'send_code' | 'verify_code';
export type SmsDeliveryStatus = 'pending' | 'delivered' | 'failed' | 'unknown';
export type SmsAuthEventRecord = {
id: string;
phoneNumber: string;
scene: SmsAuthScene;
action: SmsAuthAction;
success: boolean;
ip: string | null;
userAgent: string | null;
provider: string | null;
providerRequestId: string | null;
providerBizId: string | null;
providerOutId: string | null;
deliveryStatus: SmsDeliveryStatus;
deliveryReportRawJson: Record<string, unknown> | null;
deliveryReportedAt: string | null;
createdAt: string;
};
type SmsAuthEventRow = QueryResultRow & {
id: string;
phone_number: string;
scene: SmsAuthScene;
action: SmsAuthAction;
success: boolean;
ip: string | null;
user_agent: string | null;
provider: string | null;
provider_request_id: string | null;
provider_biz_id: string | null;
provider_out_id: string | null;
delivery_status: SmsDeliveryStatus;
delivery_report_raw_json: Record<string, unknown> | null;
delivery_reported_at: string | null;
created_at: string;
total: number;
};
function toSmsAuthEventRecord(
row: SmsAuthEventRow | undefined,
): SmsAuthEventRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
phoneNumber: row.phone_number,
scene: row.scene,
action: row.action,
success: row.success,
ip: row.ip,
userAgent: row.user_agent,
provider: row.provider,
providerRequestId: row.provider_request_id,
providerBizId: row.provider_biz_id,
providerOutId: row.provider_out_id,
deliveryStatus: row.delivery_status,
deliveryReportRawJson: row.delivery_report_raw_json,
deliveryReportedAt: row.delivery_reported_at,
createdAt: row.created_at,
};
}
export class SmsAuthEventRepository {
constructor(private readonly db: AppDatabase) {}
@@ -21,9 +81,17 @@ export class SmsAuthEventRepository {
success: boolean;
ip: string | null;
userAgent: string | null;
provider?: string | null;
providerRequestId?: string | null;
providerBizId?: string | null;
providerOutId?: string | null;
deliveryStatus?: SmsDeliveryStatus;
deliveryReportRawJson?: Record<string, unknown> | null;
deliveryReportedAt?: string | null;
}) {
const id = `smsev_${crypto.randomBytes(16).toString('hex')}`;
await this.db.query(
const createdAt = new Date().toISOString();
const result = await this.db.query<SmsAuthEventRow>(
`INSERT INTO sms_auth_events (
id,
phone_number,
@@ -32,9 +100,34 @@ export class SmsAuthEventRepository {
success,
ip,
user_agent,
provider,
provider_request_id,
provider_biz_id,
provider_out_id,
delivery_status,
delivery_report_raw_json,
delivery_reported_at,
created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
)
RETURNING
id,
phone_number,
scene,
action,
success,
ip,
user_agent,
provider,
provider_request_id,
provider_biz_id,
provider_out_id,
delivery_status,
delivery_report_raw_json,
delivery_reported_at,
created_at`,
[
id,
input.phoneNumber,
@@ -43,9 +136,18 @@ export class SmsAuthEventRepository {
input.success,
input.ip,
input.userAgent,
new Date().toISOString(),
input.provider ?? null,
input.providerRequestId ?? null,
input.providerBizId ?? null,
input.providerOutId ?? null,
input.deliveryStatus ?? 'pending',
input.deliveryReportRawJson ?? null,
input.deliveryReportedAt ?? null,
createdAt,
],
);
return toSmsAuthEventRecord(result.rows[0]);
}
async countSinceByPhone(params: {
@@ -99,4 +201,102 @@ export class SmsAuthEventRepository {
return result.rows[0]?.total ?? 0;
}
async findLatestByProviderBizId(providerBizId: string) {
const result = await this.db.query<SmsAuthEventRow>(
`SELECT
id,
phone_number,
scene,
action,
success,
ip,
user_agent,
provider,
provider_request_id,
provider_biz_id,
provider_out_id,
delivery_status,
delivery_report_raw_json,
delivery_reported_at,
created_at,
0::int AS total
FROM sms_auth_events
WHERE provider_biz_id = $1
ORDER BY created_at DESC
LIMIT 1`,
[providerBizId],
);
return toSmsAuthEventRecord(result.rows[0]);
}
async findLatestByProviderOutId(providerOutId: string) {
const result = await this.db.query<SmsAuthEventRow>(
`SELECT
id,
phone_number,
scene,
action,
success,
ip,
user_agent,
provider,
provider_request_id,
provider_biz_id,
provider_out_id,
delivery_status,
delivery_report_raw_json,
delivery_reported_at,
created_at,
0::int AS total
FROM sms_auth_events
WHERE provider_out_id = $1
ORDER BY created_at DESC
LIMIT 1`,
[providerOutId],
);
return toSmsAuthEventRecord(result.rows[0]);
}
async updateDeliveryStatus(params: {
id: string;
deliveryStatus: SmsDeliveryStatus;
deliveryReportRawJson: Record<string, unknown> | null;
deliveryReportedAt: string;
}) {
const result = await this.db.query<SmsAuthEventRow>(
`UPDATE sms_auth_events
SET delivery_status = $1,
delivery_report_raw_json = $2,
delivery_reported_at = $3
WHERE id = $4
RETURNING
id,
phone_number,
scene,
action,
success,
ip,
user_agent,
provider,
provider_request_id,
provider_biz_id,
provider_out_id,
delivery_status,
delivery_report_raw_json,
delivery_reported_at,
created_at,
0::int AS total`,
[
params.deliveryStatus,
params.deliveryReportRawJson,
params.deliveryReportedAt,
params.id,
],
);
return toSmsAuthEventRecord(result.rows[0]);
}
}

View File

@@ -1,4 +1,4 @@
import { type Request, type Response, Router } from 'express';
import express, { type Request, type Response, Router } from 'express';
import { z } from 'zod';
import type {
@@ -28,6 +28,7 @@ import {
revokeRefreshSession,
revokeUserSession,
sendPhoneLoginCode,
handleAliyunSmsDeliveryReport,
startWechatLogin,
} from '../auth/authService.js';
import {
@@ -116,6 +117,8 @@ export function createAuthRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use('/phone/delivery-report/aliyun', express.urlencoded({ extended: false }));
router.get(
'/login-options',
routeMeta({ operation: 'auth.login_options' }),
@@ -178,6 +181,22 @@ export function createAuthRoutes(context: AppContext) {
}),
);
router.post(
'/phone/delivery-report/aliyun',
routeMeta({ operation: 'auth.phone.delivery_report.aliyun' }),
asyncHandler(async (request, response) => {
const payload =
request.body && typeof request.body === 'object'
? (request.body as Record<string, unknown>)
: {};
sendApiResponse(
response,
await handleAliyunSmsDeliveryReport(context, payload),
);
}),
);
router.post(
'/phone/change',
routeMeta({ operation: 'auth.phone.change' }),

View File

@@ -51,3 +51,26 @@ test('createSmsVerificationService initializes aliyun sdk client under tsx esm r
assert.equal(typeof service.sendLoginCode, 'function');
assert.equal(typeof service.verifyLoginCode, 'function');
});
test('mock sms service reports delivered tracking metadata', async () => {
const config = createAliyunSmsConfig();
config.smsAuth.provider = 'mock';
config.smsAuth.accessKeyId = '';
config.smsAuth.accessKeySecret = '';
const service = createSmsVerificationService(
config,
pino({ enabled: false }),
);
const result = await service.sendLoginCode({
e164: '+8613800138000',
nationalNumber: '13800138000',
maskedNationalNumber: '138****8000',
});
assert.equal(result.provider, 'mock');
assert.equal(result.deliveryStatus, 'delivered');
assert.equal(result.providerRequestId, 'mock-request-id');
assert.equal(result.providerOutId, 'mock-out-id');
});

View File

@@ -19,6 +19,10 @@ export type SendLoginCodeResult = {
cooldownSeconds: number;
expiresInSeconds: number;
providerRequestId: string | null;
providerBizId: string | null;
providerOutId: string | null;
provider: 'aliyun' | 'mock';
deliveryStatus: 'pending' | 'delivered';
};
export type SmsVerificationService = {
@@ -131,6 +135,10 @@ class AliyunSmsVerificationService implements SmsVerificationService {
cooldownSeconds: this.config.intervalSeconds,
expiresInSeconds: this.config.validTimeSeconds,
providerRequestId: body.requestId ?? body.model?.requestId ?? null,
providerBizId: body.model?.bizId ?? null,
providerOutId: body.model?.outId ?? null,
provider: 'aliyun',
deliveryStatus: 'pending',
} satisfies SendLoginCodeResult;
} catch (error) {
if (error instanceof Error && error.name === 'HttpError') {
@@ -240,6 +248,10 @@ class MockSmsVerificationService implements SmsVerificationService {
cooldownSeconds: this.config.intervalSeconds,
expiresInSeconds: this.config.validTimeSeconds,
providerRequestId: 'mock-request-id',
providerBizId: null,
providerOutId: 'mock-out-id',
provider: 'mock',
deliveryStatus: 'delivered',
} satisfies SendLoginCodeResult;
}