This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -0,0 +1,105 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AuthAuditLogEventType } from '../../../packages/shared/src/contracts/auth.js';
import type { AppDatabase } from '../db.js';
export type AuthAuditLogRecord = {
id: string;
userId: string;
eventType: AuthAuditLogEventType;
detail: string;
ip: string | null;
userAgent: string | null;
metaJson: Record<string, unknown> | null;
createdAt: string;
};
type AuthAuditLogRow = QueryResultRow & {
id: string;
user_id: string;
event_type: AuthAuditLogEventType;
detail: string;
ip: string | null;
user_agent: string | null;
meta_json: Record<string, unknown> | null;
created_at: string;
};
function toAuthAuditLogRecord(
row: AuthAuditLogRow | undefined,
): AuthAuditLogRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
userId: row.user_id,
eventType: row.event_type,
detail: row.detail,
ip: row.ip,
userAgent: row.user_agent,
metaJson: row.meta_json,
createdAt: row.created_at,
};
}
export class AuthAuditLogRepository {
constructor(private readonly db: AppDatabase) {}
async create(input: {
userId: string;
eventType: AuthAuditLogEventType;
detail: string;
ip: string | null;
userAgent: string | null;
metaJson?: Record<string, unknown> | null;
}) {
const id = `audit_${crypto.randomBytes(16).toString('hex')}`;
const createdAt = new Date().toISOString();
const result = await this.db.query<AuthAuditLogRow>(
`INSERT INTO auth_audit_logs (
id,
user_id,
event_type,
detail,
ip,
user_agent,
meta_json,
created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, event_type, detail, ip, user_agent, meta_json, created_at`,
[
id,
input.userId,
input.eventType,
input.detail,
input.ip,
input.userAgent,
input.metaJson ?? null,
createdAt,
],
);
return toAuthAuditLogRecord(result.rows[0]);
}
async listRecentByUserId(userId: string, limit = 20) {
const result = await this.db.query<AuthAuditLogRow>(
`SELECT id, user_id, event_type, detail, ip, user_agent, meta_json, created_at
FROM auth_audit_logs
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[userId, limit],
);
return result.rows
.map((row) => toAuthAuditLogRecord(row))
.filter((row): row is AuthAuditLogRecord => Boolean(row));
}
}

View File

@@ -0,0 +1,156 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AppDatabase } from '../db.js';
export type AuthIdentityProvider = 'wechat';
export type AuthIdentityRecord = {
id: string;
userId: string;
provider: AuthIdentityProvider;
providerUid: string;
providerUnionId: string | null;
displayName: string | null;
avatarUrl: string | null;
isVerified: boolean;
metaJson: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
};
type AuthIdentityRow = QueryResultRow & {
id: string;
user_id: string;
provider: AuthIdentityProvider;
provider_uid: string;
provider_unionid: string | null;
display_name: string | null;
avatar_url: string | null;
is_verified: boolean;
meta_json: Record<string, unknown> | null;
created_at: string;
updated_at: string;
};
function toAuthIdentityRecord(
row: AuthIdentityRow | undefined,
): AuthIdentityRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
userId: row.user_id,
provider: row.provider,
providerUid: row.provider_uid,
providerUnionId: row.provider_unionid,
displayName: row.display_name,
avatarUrl: row.avatar_url,
isVerified: row.is_verified,
metaJson: row.meta_json,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export type CreateWechatIdentityInput = {
userId: string;
providerUid: string;
providerUnionId: string | null;
displayName: string | null;
avatarUrl: string | null;
metaJson?: Record<string, unknown> | null;
};
export class AuthIdentityRepository {
constructor(private readonly db: AppDatabase) {}
async findWechatIdentityByProfile(params: {
providerUid: string;
providerUnionId: string | null;
}) {
const result = params.providerUnionId
? await this.db.query<AuthIdentityRow>(
`SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at
FROM auth_identities
WHERE provider = 'wechat'
AND (provider_unionid = $1 OR provider_uid = $2)
ORDER BY
CASE WHEN provider_unionid = $1 THEN 0 ELSE 1 END
LIMIT 1`,
[params.providerUnionId, params.providerUid],
)
: await this.db.query<AuthIdentityRow>(
`SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at
FROM auth_identities
WHERE provider = 'wechat'
AND provider_uid = $1
LIMIT 1`,
[params.providerUid],
);
return toAuthIdentityRecord(result.rows[0]);
}
async listByUserId(userId: string) {
const result = await this.db.query<AuthIdentityRow>(
`SELECT id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at
FROM auth_identities
WHERE user_id = $1
ORDER BY provider, created_at`,
[userId],
);
return result.rows
.map((row) => toAuthIdentityRecord(row))
.filter((row): row is AuthIdentityRecord => Boolean(row));
}
async createWechatIdentity(input: CreateWechatIdentityInput) {
const now = new Date().toISOString();
const identityId = `authi_${crypto.randomBytes(16).toString('hex')}`;
const result = await this.db.query<AuthIdentityRow>(
`INSERT INTO auth_identities (
id,
user_id,
provider,
provider_uid,
provider_unionid,
display_name,
avatar_url,
is_verified,
meta_json,
created_at,
updated_at
)
VALUES ($1, $2, 'wechat', $3, $4, $5, $6, TRUE, $7, $8, $9)
RETURNING id, user_id, provider, provider_uid, provider_unionid, display_name, avatar_url, is_verified, meta_json, created_at, updated_at`,
[
identityId,
input.userId,
input.providerUid,
input.providerUnionId,
input.displayName,
input.avatarUrl,
input.metaJson ?? null,
now,
now,
],
);
return toAuthIdentityRecord(result.rows[0]);
}
async moveWechatIdentitiesToUser(sourceUserId: string, targetUserId: string) {
await this.db.query(
`UPDATE auth_identities
SET user_id = $1, updated_at = $2
WHERE user_id = $3
AND provider = 'wechat'`,
[targetUserId, new Date().toISOString(), sourceUserId],
);
}
}

View File

@@ -0,0 +1,128 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AppDatabase } from '../db.js';
export type AuthRiskBlockScopeType = 'phone' | 'ip';
export type AuthRiskBlockRecord = {
id: string;
scopeType: AuthRiskBlockScopeType;
scopeKey: string;
reason: string;
expiresAt: string;
liftedAt: string | null;
createdAt: string;
updatedAt: string;
};
type AuthRiskBlockRow = QueryResultRow & {
id: string;
scope_type: AuthRiskBlockScopeType;
scope_key: string;
reason: string;
expires_at: string;
lifted_at: string | null;
created_at: string;
updated_at: string;
};
function toAuthRiskBlockRecord(
row: AuthRiskBlockRow | undefined,
): AuthRiskBlockRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
scopeType: row.scope_type,
scopeKey: row.scope_key,
reason: row.reason,
expiresAt: row.expires_at,
liftedAt: row.lifted_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export class AuthRiskBlockRepository {
constructor(private readonly db: AppDatabase) {}
async findActive(scopeType: AuthRiskBlockScopeType, scopeKey: string) {
const result = await this.db.query<AuthRiskBlockRow>(
`SELECT id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at
FROM auth_risk_blocks
WHERE scope_type = $1
AND scope_key = $2
AND lifted_at IS NULL
AND expires_at > $3
ORDER BY expires_at DESC
LIMIT 1`,
[scopeType, scopeKey, new Date().toISOString()],
);
return toAuthRiskBlockRecord(result.rows[0]);
}
async createOrRefresh(input: {
scopeType: AuthRiskBlockScopeType;
scopeKey: string;
reason: string;
expiresAt: string;
}) {
const existing = await this.findActive(input.scopeType, input.scopeKey);
if (existing) {
const result = await this.db.query<AuthRiskBlockRow>(
`UPDATE auth_risk_blocks
SET reason = $1,
expires_at = $2,
updated_at = $3
WHERE id = $4
RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`,
[input.reason, input.expiresAt, new Date().toISOString(), existing.id],
);
return toAuthRiskBlockRecord(result.rows[0]);
}
const id = `risk_${crypto.randomBytes(16).toString('hex')}`;
const now = new Date().toISOString();
const result = await this.db.query<AuthRiskBlockRow>(
`INSERT INTO auth_risk_blocks (
id,
scope_type,
scope_key,
reason,
expires_at,
lifted_at,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, NULL, $6, $7)
RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`,
[id, input.scopeType, input.scopeKey, input.reason, input.expiresAt, now, now],
);
return toAuthRiskBlockRecord(result.rows[0]);
}
async liftActive(scopeType: AuthRiskBlockScopeType, scopeKey: string) {
const now = new Date().toISOString();
const result = await this.db.query<AuthRiskBlockRow>(
`UPDATE auth_risk_blocks
SET lifted_at = $1,
updated_at = $2
WHERE scope_type = $3
AND scope_key = $4
AND lifted_at IS NULL
AND expires_at > $5
RETURNING id, scope_type, scope_key, reason, expires_at, lifted_at, created_at, updated_at`,
[now, now, scopeType, scopeKey, now],
);
return result.rows
.map((row) => toAuthRiskBlockRecord(row))
.filter((row): row is AuthRiskBlockRecord => Boolean(row));
}
}

View File

@@ -1,10 +1,21 @@
import type { QueryResultRow } from 'pg';
import {
DEFAULT_MUSIC_VOLUME,
SAVE_SNAPSHOT_VERSION,
} from '../../../packages/shared/src/contracts/runtime.js';
import type {
CustomWorldProfileRecord,
RuntimeSettings,
SavedGameSnapshot,
} from '../../../packages/shared/src/contracts/runtime.js';
import type { AppDatabase } from '../db.js';
const SAVE_SNAPSHOT_VERSION = 2;
const DEFAULT_MUSIC_VOLUME = 0.42;
const MAX_CUSTOM_WORLD_PROFILES = 12;
export type SavedSnapshot = {
export type SavedSnapshot = SavedGameSnapshot<unknown, string, unknown>;
type SnapshotRow = QueryResultRow & {
version: number;
savedAt: string;
gameState: unknown;
@@ -12,37 +23,53 @@ export type SavedSnapshot = {
currentStory: unknown;
};
export type RuntimeSettings = {
type SettingsRow = QueryResultRow & {
musicVolume: number;
};
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
type ProfileRow = QueryResultRow & {
payload: CustomWorldProfileRecord;
};
function toJson(value: unknown) {
return JSON.stringify(value ?? null);
}
export type RuntimeRepositoryPort = {
getSnapshot(userId: string): Promise<SavedSnapshot | null>;
putSnapshot(
userId: string,
payload: Omit<SavedSnapshot, 'version'>,
): Promise<SavedSnapshot>;
deleteSnapshot(userId: string): Promise<void>;
getSettings(userId: string): Promise<RuntimeSettings>;
putSettings(
userId: string,
settings: RuntimeSettings,
): Promise<RuntimeSettings>;
listCustomWorldProfiles(userId: string): Promise<CustomWorldProfileRecord[]>;
upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
): Promise<CustomWorldProfileRecord[]>;
deleteCustomWorldProfile(
userId: string,
profileId: string,
): Promise<CustomWorldProfileRecord[]>;
};
export class RuntimeRepository {
export class RuntimeRepository implements RuntimeRepositoryPort {
constructor(private readonly db: AppDatabase) {}
getSnapshot(userId: string) {
const row = this.db
.prepare(
`SELECT version, saved_at, game_state_json, bottom_tab, current_story_json
FROM save_snapshots
WHERE user_id = ?`,
)
.get(userId) as
| {
version: number;
saved_at: string;
game_state_json: string;
bottom_tab: string;
current_story_json: string;
}
| undefined;
async getSnapshot(userId: string) {
const result = await this.db.query<SnapshotRow>(
`SELECT version,
saved_at AS "savedAt",
game_state_json AS "gameState",
bottom_tab AS "bottomTab",
current_story_json AS "currentStory"
FROM save_snapshots
WHERE user_id = $1`,
[userId],
);
const row = result.rows[0];
if (!row) {
return null;
@@ -50,14 +77,14 @@ export class RuntimeRepository {
return {
version: row.version,
savedAt: row.saved_at,
gameState: parseJson(row.game_state_json),
bottomTab: row.bottom_tab,
currentStory: parseJson(row.current_story_json),
savedAt: row.savedAt,
gameState: row.gameState,
bottomTab: row.bottomTab,
currentStory: row.currentStory,
} satisfies SavedSnapshot;
}
putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
async putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
const snapshot = {
version: SAVE_SNAPSHOT_VERSION,
savedAt: payload.savedAt,
@@ -67,115 +94,126 @@ export class RuntimeRepository {
} satisfies SavedSnapshot;
const now = new Date().toISOString();
this.db
.prepare(
`INSERT INTO save_snapshots (
const result = await this.db.query<SnapshotRow>(
`INSERT INTO save_snapshots (
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
version = excluded.version,
saved_at = excluded.saved_at,
bottom_tab = excluded.bottom_tab,
game_state_json = excluded.game_state_json,
current_story_json = excluded.current_story_json,
updated_at = excluded.updated_at`,
)
.run(
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id) DO UPDATE SET
version = EXCLUDED.version,
saved_at = EXCLUDED.saved_at,
bottom_tab = EXCLUDED.bottom_tab,
game_state_json = EXCLUDED.game_state_json,
current_story_json = EXCLUDED.current_story_json,
updated_at = EXCLUDED.updated_at
RETURNING version,
saved_at AS "savedAt",
game_state_json AS "gameState",
bottom_tab AS "bottomTab",
current_story_json AS "currentStory"`,
[
userId,
snapshot.version,
snapshot.savedAt,
snapshot.bottomTab,
toJson(snapshot.gameState),
toJson(snapshot.currentStory),
snapshot.gameState,
snapshot.currentStory,
now,
);
],
);
return snapshot;
const row = result.rows[0];
return {
version: row.version,
savedAt: row.savedAt,
gameState: row.gameState,
bottomTab: row.bottomTab,
currentStory: row.currentStory,
} satisfies SavedSnapshot;
}
deleteSnapshot(userId: string) {
this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(userId);
async deleteSnapshot(userId: string) {
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [userId]);
}
getSettings(userId: string) {
const row = this.db
.prepare(
`SELECT music_volume
FROM runtime_settings
WHERE user_id = ?`,
)
.get(userId) as { music_volume: number } | undefined;
async getSettings(userId: string) {
const result = await this.db.query<SettingsRow>(
`SELECT music_volume AS "musicVolume"
FROM runtime_settings
WHERE user_id = $1`,
[userId],
);
const row = result.rows[0];
return {
musicVolume:
typeof row?.music_volume === 'number'
? row.music_volume
typeof row?.musicVolume === 'number'
? row.musicVolume
: DEFAULT_MUSIC_VOLUME,
} satisfies RuntimeSettings;
}
putSettings(userId: string, settings: RuntimeSettings) {
async putSettings(userId: string, settings: RuntimeSettings) {
const nextSettings = {
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
} satisfies RuntimeSettings;
this.db
.prepare(
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
music_volume = excluded.music_volume,
updated_at = excluded.updated_at`,
)
.run(userId, nextSettings.musicVolume, new Date().toISOString());
const result = await this.db.query<SettingsRow>(
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE SET
music_volume = EXCLUDED.music_volume,
updated_at = EXCLUDED.updated_at
RETURNING music_volume AS "musicVolume"`,
[userId, nextSettings.musicVolume, new Date().toISOString()],
);
return nextSettings;
return {
musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume,
} satisfies RuntimeSettings;
}
listCustomWorldProfiles(userId: string) {
const rows = this.db
.prepare(
`SELECT payload_json
FROM custom_world_profiles
WHERE user_id = ?
ORDER BY updated_at DESC
LIMIT ?`,
)
.all(userId, MAX_CUSTOM_WORLD_PROFILES) as Array<{ payload_json: string }>;
async listCustomWorldProfiles(userId: string) {
const result = await this.db.query<ProfileRow>(
`SELECT payload_json AS payload
FROM custom_world_profiles
WHERE user_id = $1
ORDER BY updated_at DESC
LIMIT $2`,
[userId, MAX_CUSTOM_WORLD_PROFILES],
);
return rows.map((row) => parseJson<Record<string, unknown>>(row.payload_json));
return result.rows.map((row: ProfileRow) => row.payload);
}
upsertCustomWorldProfile(
async upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
profile: CustomWorldProfileRecord,
) {
const payload = {
...profile,
id: profileId,
};
this.db
.prepare(
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = excluded.updated_at`,
)
.run(userId, profileId, JSON.stringify(payload), new Date().toISOString());
await this.db.query(
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, profile_id) DO UPDATE SET
payload_json = EXCLUDED.payload_json,
updated_at = EXCLUDED.updated_at`,
[userId, profileId, payload, new Date().toISOString()],
);
return this.listCustomWorldProfiles(userId);
}
deleteCustomWorldProfile(userId: string, profileId: string) {
this.db
.prepare(
`DELETE FROM custom_world_profiles
WHERE user_id = ? AND profile_id = ?`,
)
.run(userId, profileId);
async deleteCustomWorldProfile(userId: string, profileId: string) {
await this.db.query(
`DELETE FROM custom_world_profiles
WHERE user_id = $1 AND profile_id = $2`,
[userId, profileId],
);
return this.listCustomWorldProfiles(userId);
}

View File

@@ -0,0 +1,102 @@
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';
type SmsAuthEventRow = QueryResultRow & {
total: number;
};
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;
}) {
const id = `smsev_${crypto.randomBytes(16).toString('hex')}`;
await this.db.query(
`INSERT INTO sms_auth_events (
id,
phone_number,
scene,
action,
success,
ip,
user_agent,
created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
id,
input.phoneNumber,
input.scene,
input.action,
input.success,
input.ip,
input.userAgent,
new Date().toISOString(),
],
);
}
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;
}
}

View File

@@ -1,21 +1,33 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AppDatabase } from '../db.js';
export type UserRecord = {
id: string;
username: string;
username: string | null;
passwordHash: string;
tokenVersion: number;
displayName: string;
loginProvider: 'password' | 'phone' | 'wechat';
accountStatus: 'active' | 'pending_bind_phone' | 'disabled';
phoneNumber: string | null;
phoneVerifiedAt: string | null;
createdAt: string;
updatedAt: string;
};
type UserRow = {
type UserRow = QueryResultRow & {
id: string;
username: string;
username: string | null;
password_hash: string;
token_version: number;
display_name: string;
login_provider: 'password' | 'phone' | 'wechat';
account_status: 'active' | 'pending_bind_phone' | 'disabled';
phone_number: string | null;
phone_verified_at: string | null;
created_at: string;
updated_at: string;
};
@@ -30,59 +42,249 @@ function toUserRecord(row: UserRow | undefined): UserRecord | null {
username: row.username,
passwordHash: row.password_hash,
tokenVersion: row.token_version,
displayName: row.display_name,
loginProvider: row.login_provider,
accountStatus: row.account_status,
phoneNumber: row.phone_number,
phoneVerifiedAt: row.phone_verified_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export class UserRepository {
export type CreatePhoneUserInput = {
username: string;
passwordHash: string;
displayName: string;
phoneNumber: string;
phoneVerifiedAt: string;
};
export type CreateWechatPendingUserInput = {
username: string;
passwordHash: string;
displayName: string;
};
export type UserRepositoryPort = {
findByUsername(username: string): Promise<UserRecord | null>;
findByPhoneNumber(phoneNumber: string): Promise<UserRecord | null>;
findById(userId: string): Promise<UserRecord | null>;
create(username: string, passwordHash: string): Promise<UserRecord | null>;
createPhoneUser(input: CreatePhoneUserInput): Promise<UserRecord | null>;
createWechatPendingUser(
input: CreateWechatPendingUserInput,
): Promise<UserRecord | null>;
activatePendingWechatUser(
userId: string,
params: {
displayName: string;
phoneNumber: string;
phoneVerifiedAt: string;
},
): Promise<UserRecord | null>;
updatePhoneInfo(
userId: string,
params: {
phoneNumber: string;
phoneVerifiedAt: string;
displayName?: string;
},
): Promise<UserRecord | null>;
deleteUser(userId: string): Promise<void>;
incrementTokenVersion(userId: string): Promise<UserRecord | null>;
};
export class UserRepository implements UserRepositoryPort {
constructor(private readonly db: AppDatabase) {}
findByUsername(username: string) {
const row = this.db
.prepare(
`SELECT id, username, password_hash, token_version, created_at, updated_at
FROM users
WHERE username = ?`,
)
.get(username) as UserRow | undefined;
return toUserRecord(row);
async findByUsername(username: string) {
const result = await this.db.query<UserRow>(
`SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at
FROM users
WHERE username = $1`,
[username],
);
return toUserRecord(result.rows[0]);
}
findById(userId: string) {
const row = this.db
.prepare(
`SELECT id, username, password_hash, token_version, created_at, updated_at
FROM users
WHERE id = ?`,
)
.get(userId) as UserRow | undefined;
return toUserRecord(row);
async findByPhoneNumber(phoneNumber: string) {
const result = await this.db.query<UserRow>(
`SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at
FROM users
WHERE phone_number = $1`,
[phoneNumber],
);
return toUserRecord(result.rows[0]);
}
create(username: string, passwordHash: string) {
async findById(userId: string) {
const result = await this.db.query<UserRow>(
`SELECT id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at
FROM users
WHERE id = $1`,
[userId],
);
return toUserRecord(result.rows[0]);
}
async create(username: string, passwordHash: string) {
const now = new Date().toISOString();
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
this.db
.prepare(
`INSERT INTO users (id, username, password_hash, token_version, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)`,
)
.run(id, username, passwordHash, now, now);
const result = await this.db.query<UserRow>(
`INSERT INTO users (
id,
username,
password_hash,
token_version,
display_name,
login_provider,
account_status,
created_at,
updated_at
)
VALUES ($1, $2, $3, 1, $4, 'password', 'active', $5, $6)
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[id, username, passwordHash, username, now, now],
);
return this.findById(id);
return toUserRecord(result.rows[0]);
}
incrementTokenVersion(userId: string) {
this.db
.prepare(
`UPDATE users
SET token_version = token_version + 1, updated_at = ?
WHERE id = ?`,
)
.run(new Date().toISOString(), userId);
async createPhoneUser(input: CreatePhoneUserInput) {
const now = new Date().toISOString();
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
return this.findById(userId);
const result = await this.db.query<UserRow>(
`INSERT INTO users (
id,
username,
password_hash,
token_version,
display_name,
login_provider,
account_status,
phone_number,
phone_verified_at,
created_at,
updated_at
)
VALUES ($1, $2, $3, 1, $4, 'phone', 'active', $5, $6, $7, $8)
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[
id,
input.username,
input.passwordHash,
input.displayName,
input.phoneNumber,
input.phoneVerifiedAt,
now,
now,
],
);
return toUserRecord(result.rows[0]);
}
async createWechatPendingUser(input: CreateWechatPendingUserInput) {
const now = new Date().toISOString();
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
const result = await this.db.query<UserRow>(
`INSERT INTO users (
id,
username,
password_hash,
token_version,
display_name,
login_provider,
account_status,
created_at,
updated_at
)
VALUES ($1, $2, $3, 1, $4, 'wechat', 'pending_bind_phone', $5, $6)
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[id, input.username, input.passwordHash, input.displayName, now, now],
);
return toUserRecord(result.rows[0]);
}
async activatePendingWechatUser(
userId: string,
params: {
displayName: string;
phoneNumber: string;
phoneVerifiedAt: string;
},
) {
const result = await this.db.query<UserRow>(
`UPDATE users
SET account_status = 'active',
phone_number = $1,
phone_verified_at = $2,
display_name = $3,
updated_at = $4
WHERE id = $5
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[
params.phoneNumber,
params.phoneVerifiedAt,
params.displayName,
new Date().toISOString(),
userId,
],
);
return toUserRecord(result.rows[0]);
}
async updatePhoneInfo(
userId: string,
params: {
phoneNumber: string;
phoneVerifiedAt: string;
displayName?: string;
},
) {
const result = await this.db.query<UserRow>(
`UPDATE users
SET phone_number = $1,
phone_verified_at = $2,
display_name = COALESCE($3, display_name),
updated_at = $4
WHERE id = $5
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[
params.phoneNumber,
params.phoneVerifiedAt,
params.displayName ?? null,
new Date().toISOString(),
userId,
],
);
return toUserRecord(result.rows[0]);
}
async deleteUser(userId: string) {
await this.db.query(
`DELETE FROM users
WHERE id = $1`,
[userId],
);
}
async incrementTokenVersion(userId: string) {
const result = await this.db.query<UserRow>(
`UPDATE users
SET token_version = token_version + 1, updated_at = $1
WHERE id = $2
RETURNING id, username, password_hash, token_version, display_name, login_provider, account_status, phone_number, phone_verified_at, created_at, updated_at`,
[new Date().toISOString(), userId],
);
return toUserRecord(result.rows[0]);
}
}

View File

@@ -0,0 +1,214 @@
import crypto from 'node:crypto';
import type { QueryResultRow } from 'pg';
import type { AppDatabase } from '../db.js';
export type UserSessionRecord = {
id: string;
userId: string;
refreshTokenHash: string;
clientType: string;
userAgent: string | null;
ip: string | null;
expiresAt: string;
revokedAt: string | null;
createdAt: string;
updatedAt: string;
lastSeenAt: string;
};
type UserSessionRow = QueryResultRow & {
id: string;
user_id: string;
refresh_token_hash: string;
client_type: string;
user_agent: string | null;
ip: string | null;
expires_at: string;
revoked_at: string | null;
created_at: string;
updated_at: string;
last_seen_at: string;
};
function toUserSessionRecord(
row: UserSessionRow | undefined,
): UserSessionRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
userId: row.user_id,
refreshTokenHash: row.refresh_token_hash,
clientType: row.client_type,
userAgent: row.user_agent,
ip: row.ip,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastSeenAt: row.last_seen_at,
};
}
export type CreateUserSessionInput = {
userId: string;
refreshTokenHash: string;
clientType: string;
userAgent: string | null;
ip: string | null;
expiresAt: string;
};
export class UserSessionRepository {
constructor(private readonly db: AppDatabase) {}
async create(input: CreateUserSessionInput) {
const now = new Date().toISOString();
const sessionId = `usess_${crypto.randomBytes(16).toString('hex')}`;
const result = await this.db.query<UserSessionRow>(
`INSERT INTO user_sessions (
id,
user_id,
refresh_token_hash,
client_type,
user_agent,
ip,
expires_at,
revoked_at,
created_at,
updated_at,
last_seen_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9, $10)
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
[
sessionId,
input.userId,
input.refreshTokenHash,
input.clientType,
input.userAgent,
input.ip,
input.expiresAt,
now,
now,
now,
],
);
return toUserSessionRecord(result.rows[0]);
}
async findActiveByRefreshTokenHash(refreshTokenHash: string) {
const result = await this.db.query<UserSessionRow>(
`SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at
FROM user_sessions
WHERE refresh_token_hash = $1
LIMIT 1`,
[refreshTokenHash],
);
return toUserSessionRecord(result.rows[0]);
}
async rotate(
sessionId: string,
input: {
refreshTokenHash: string;
expiresAt: string;
lastSeenAt: string;
},
) {
const result = await this.db.query<UserSessionRow>(
`UPDATE user_sessions
SET refresh_token_hash = $1,
expires_at = $2,
last_seen_at = $3,
updated_at = $4
WHERE id = $5
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
[
input.refreshTokenHash,
input.expiresAt,
input.lastSeenAt,
new Date().toISOString(),
sessionId,
],
);
return toUserSessionRecord(result.rows[0]);
}
async revoke(sessionId: string) {
const now = new Date().toISOString();
const result = await this.db.query<UserSessionRow>(
`UPDATE user_sessions
SET revoked_at = $1,
updated_at = $2
WHERE id = $3
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
[now, now, sessionId],
);
return toUserSessionRecord(result.rows[0]);
}
async findById(sessionId: string) {
const result = await this.db.query<UserSessionRow>(
`SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at
FROM user_sessions
WHERE id = $1
LIMIT 1`,
[sessionId],
);
return toUserSessionRecord(result.rows[0]);
}
async listActiveByUserId(userId: string) {
const result = await this.db.query<UserSessionRow>(
`SELECT id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at
FROM user_sessions
WHERE user_id = $1
AND revoked_at IS NULL
ORDER BY last_seen_at DESC, created_at DESC`,
[userId],
);
return result.rows
.map((row) => toUserSessionRecord(row))
.filter((row): row is UserSessionRecord => Boolean(row));
}
async revokeAllByUserId(userId: string) {
const now = new Date().toISOString();
await this.db.query(
`UPDATE user_sessions
SET revoked_at = $1,
updated_at = $2
WHERE user_id = $3
AND revoked_at IS NULL`,
[now, now, userId],
);
}
async revokeByUserIdAndSessionId(userId: string, sessionId: string) {
const now = new Date().toISOString();
const result = await this.db.query<UserSessionRow>(
`UPDATE user_sessions
SET revoked_at = $1,
updated_at = $2
WHERE user_id = $3
AND id = $4
AND revoked_at IS NULL
RETURNING id, user_id, refresh_token_hash, client_type, user_agent, ip, expires_at, revoked_at, created_at, updated_at, last_seen_at`,
[now, now, userId, sessionId],
);
return toUserSessionRecord(result.rows[0]);
}
}