Files
Genarrative/server-node/src/repositories/smsAuthEventRepository.ts
2026-04-22 22:01:07 +08:00

303 lines
7.4 KiB
TypeScript

import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
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) {}
async create(input: {
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;
}) {
const id = `smsev_${crypto.randomBytes(16).toString('hex')}`;
const createdAt = new Date().toISOString();
const result = await this.db.query<SmsAuthEventRow>(
`INSERT INTO sms_auth_events (
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
)
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,
input.scene,
input.action,
input.success,
input.ip,
input.userAgent,
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: {
phoneNumber: string;
action: SmsAuthAction;
success?: boolean;
since: string;
}) {
const result = await this.db.query<SmsAuthEventRow>(
`SELECT COUNT(*)::int AS total
FROM sms_auth_events
WHERE phone_number = $1
AND action = $2
AND ($3::boolean IS NULL OR success = $3)
AND created_at >= $4`,
[
params.phoneNumber,
params.action,
params.success ?? null,
params.since,
],
);
return result.rows[0]?.total ?? 0;
}
async countSinceByIp(params: {
ip: string | null;
action: SmsAuthAction;
success?: boolean;
since: string;
}) {
if (!params.ip) {
return 0;
}
const result = await this.db.query<SmsAuthEventRow>(
`SELECT COUNT(*)::int AS total
FROM sms_auth_events
WHERE ip = $1
AND action = $2
AND ($3::boolean IS NULL OR success = $3)
AND created_at >= $4`,
[
params.ip,
params.action,
params.success ?? null,
params.since,
],
);
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]);
}
}