1
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 = ''`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user