feat(identity): store complete deviceInfo JSON from frontend

- Add deviceInfo JSON field to UserDevice table (Prisma schema)
- Update DeviceInfo value object to use deviceInfo instead of HardwareInfo
- Update repository to save complete JSON with redundant fields for queries
- Update mapper to read deviceInfo from database
- Update aggregate and handlers to pass deviceInfo through
- Allow any fields in DeviceNameInput interface with index signature

100% preserve original device info JSON from frontend without extraction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-07 11:08:37 -08:00
parent 592f13e939
commit fbec0b9112
16 changed files with 207 additions and 104 deletions

View File

@ -22,7 +22,15 @@
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(admin-service): 增强移动端版本上传功能\n\n- 添加 APK/IPA 文件解析器自动提取版本信息\n- 支持从安装包自动读取 versionName 和 versionCode\n- 添加 adbkit-apkreader 依赖解析 APK 文件\n- 添加 plist 依赖解析 IPA 文件\n- 优化上传接口支持自动填充版本信息\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")", "Bash(git commit -m \"$(cat <<''EOF''\nfeat(admin-service): 增强移动端版本上传功能\n\n- 添加 APK/IPA 文件解析器自动提取版本信息\n- 支持从安装包自动读取 versionName 和 versionCode\n- 添加 adbkit-apkreader 依赖解析 APK 文件\n- 添加 plist 依赖解析 IPA 文件\n- 优化上传接口支持自动填充版本信息\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git commit:*)", "Bash(git commit:*)",
"Bash(grep:*)", "Bash(grep:*)",
"Bash(ls:*)" "Bash(ls:*)",
"Bash(wsl -e bash -c:*)",
"Bash(git push:*)",
"Bash(git pull:*)",
"Bash(git stash:*)",
"Bash(cd:*)",
"Bash(curl:*)",
"Bash(git revert:*)",
"Bash(del \"c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\\identity-service\\src\\infrastructure\\persistence\\entities\\user-device.entity.ts\")"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

1
.gitignore vendored
View File

@ -0,0 +1 @@
nul

View File

@ -0,0 +1,103 @@
# =============================================================================
# RWA Platform - Shared Infrastructure
# =============================================================================
# This file defines shared infrastructure services (PostgreSQL, Redis, Kafka)
# that are used by all microservices.
#
# Usage:
# docker compose -f docker-compose.infra.yml up -d
# =============================================================================
services:
# PostgreSQL - Shared database server (each service uses different database)
postgres:
image: postgres:16-alpine
container_name: rwa-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
# Create multiple databases on startup
POSTGRES_MULTIPLE_DATABASES: rwa_identity,rwa_mpc,rwa_blockchain
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-multiple-dbs.sh:/docker-entrypoint-initdb.d/init-multiple-dbs.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
networks:
- rwa-network
# Redis - Shared cache/session store
redis:
image: redis:7-alpine
container_name: rwa-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
networks:
- rwa-network
# Zookeeper - Required by Kafka
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
container_name: rwa-zookeeper
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "2181"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- rwa-network
# Kafka - Event streaming platform
kafka:
image: confluentinc/cp-kafka:7.5.0
container_name: rwa-kafka
depends_on:
zookeeper:
condition: service_healthy
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,PLAINTEXT_INTERNAL://kafka:29092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_INTERNAL:PLAINTEXT
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,PLAINTEXT_INTERNAL://0.0.0.0:29092
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT_INTERNAL
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
healthcheck:
test: ["CMD", "kafka-topics", "--bootstrap-server", "localhost:9092", "--list"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
restart: unless-stopped
networks:
- rwa-network
networks:
rwa-network:
name: rwa-network
driver: bridge
volumes:
postgres_data:
redis_data:

View File

@ -36,7 +36,9 @@
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" show cf308ef --stat)", "Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" show cf308ef --stat)",
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"$(cat <<''EOF''\nrefactor: move mnemonic verification from identity-service to blockchain-service\n\n- Add /internal/verify-mnemonic API to blockchain-service\n- Add /internal/derive-from-mnemonic API to blockchain-service \n- Create MnemonicDerivationAdapter for BIP39 mnemonic address derivation\n- Create BlockchainClientService in identity-service to call blockchain-service\n- Remove WalletGeneratorService from identity-service\n- Update recover-by-mnemonic handler to use blockchain-service API\n\nThis enforces proper domain boundaries - all blockchain/crypto operations\nare now handled by blockchain-service.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")", "Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"$(cat <<''EOF''\nrefactor: move mnemonic verification from identity-service to blockchain-service\n\n- Add /internal/verify-mnemonic API to blockchain-service\n- Add /internal/derive-from-mnemonic API to blockchain-service \n- Create MnemonicDerivationAdapter for BIP39 mnemonic address derivation\n- Create BlockchainClientService in identity-service to call blockchain-service\n- Remove WalletGeneratorService from identity-service\n- Update recover-by-mnemonic handler to use blockchain-service API\n\nThis enforces proper domain boundaries - all blockchain/crypto operations\nare now handled by blockchain-service.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"fix(identity-service): remove WalletGeneratorService from app.module.ts\")", "Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"fix(identity-service): remove WalletGeneratorService from app.module.ts\")",
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"fix(blockchain-service): add @scure/bip39 dependency\")" "Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"fix(blockchain-service): add @scure/bip39 dependency\")",
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" add backend/services/blockchain-service/src/main.ts backend/services/blockchain-service/Dockerfile backend/services/docker-compose.yml)",
"Bash(git -C \"c:\\Users\\dong\\Desktop\\rwadurian\" commit -m \"$(cat <<''EOF''\nfix(blockchain-service): add global API prefix and increase healthcheck start_period\n\n- Add app.setGlobalPrefix(''api/v1'') to main.ts so health endpoint\n is at /api/v1/health consistent with other services\n- Increase healthcheck start_period to 60s to allow time for\n Prisma migrations on first startup\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)\")"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@ -54,8 +54,9 @@ model UserDevice {
deviceId String @map("device_id") @db.VarChar(100) deviceId String @map("device_id") @db.VarChar(100)
deviceName String? @map("device_name") @db.VarChar(100) deviceName String? @map("device_name") @db.VarChar(100)
// Hardware Info - 设备硬件信息 // Hardware Info - 设备硬件信息 (JSON 存储完整前端传递的设备信息)
platform String? @db.VarChar(20) // ios, android, web deviceInfo Json? @map("device_info") // 完整的设备信息 JSON
platform String? @db.VarChar(20) // ios, android, web (冗余字段,便于查询)
deviceModel String? @map("device_model") @db.VarChar(100) // iPhone 15 Pro, Pixel 8 deviceModel String? @map("device_model") @db.VarChar(100) // iPhone 15 Pro, Pixel 8
osVersion String? @map("os_version") @db.VarChar(50) // iOS 17.2, Android 14 osVersion String? @map("os_version") @db.VarChar(50) // iOS 17.2, Android 14
appVersion String? @map("app_version") @db.VarChar(20) // 1.0.0 appVersion String? @map("app_version") @db.VarChar(20) // 1.0.0

View File

@ -3,7 +3,7 @@ import { AutoCreateAccountCommand } from './auto-create-account.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface'; import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services'; import { AccountSequenceGeneratorService, UserValidatorService } from '@/domain/services';
import { ReferralCode, AccountSequence, ProvinceCode, CityCode, HardwareInfo } from '@/domain/value-objects'; import { ReferralCode, AccountSequence, ProvinceCode, CityCode } from '@/domain/value-objects';
import { TokenService } from '@/application/services/token.service'; import { TokenService } from '@/application/services/token.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service'; import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception'; import { ApplicationError } from '@/shared/exceptions/domain.exception';
@ -46,28 +46,22 @@ export class AutoCreateAccountHandler {
// 4. 生成随机用户名和头像 // 4. 生成随机用户名和头像
const identity = generateRandomIdentity(); const identity = generateRandomIdentity();
// 5. 构建设备名称和硬件信息 // 5. 构建设备名称,保存完整的设备信息 JSON
let deviceNameStr = '未命名设备'; let deviceNameStr = '未命名设备';
let hardwareInfo: HardwareInfo | undefined;
if (command.deviceName) { if (command.deviceName) {
const parts: string[] = []; const parts: string[] = [];
if (command.deviceName.model) parts.push(command.deviceName.model); if (command.deviceName.model) parts.push(command.deviceName.model);
if (command.deviceName.platform) parts.push(command.deviceName.platform); if (command.deviceName.platform) parts.push(command.deviceName.platform);
if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion); if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion);
if (parts.length > 0) deviceNameStr = parts.join(' '); if (parts.length > 0) deviceNameStr = parts.join(' ');
hardwareInfo = {
platform: command.deviceName.platform,
deviceModel: command.deviceName.model,
osVersion: command.deviceName.osVersion,
};
} }
// 6. 创建账户 // 6. 创建账户 - 传递完整的 deviceName JSON
const account = UserAccount.createAutomatic({ const account = UserAccount.createAutomatic({
accountSequence, accountSequence,
initialDeviceId: command.deviceId, initialDeviceId: command.deviceId,
deviceName: deviceNameStr, deviceName: deviceNameStr,
hardwareInfo, deviceInfo: command.deviceName, // 100% 保持原样存储
inviterSequence, inviterSequence,
province: ProvinceCode.create('DEFAULT'), province: ProvinceCode.create('DEFAULT'),
city: CityCode.create('DEFAULT'), city: CityCode.create('DEFAULT'),

View File

@ -1,8 +1,10 @@
// ============ Types ============ // ============ Types ============
// 设备信息输入 - 100% 保持前端传递的原样存储
export interface DeviceNameInput { export interface DeviceNameInput {
model?: string; // iPhone 15 Pro, Pixel 8 model?: string; // iPhone 15 Pro, Pixel 8
platform?: string; // ios, android, web platform?: string; // ios, android, web
osVersion?: string; // iOS 17.2, Android 14 osVersion?: string; // iOS 17.2, Android 14
[key: string]: unknown; // 允许任意其他字段
} }
// ============ Commands ============ // ============ Commands ============

View File

@ -8,7 +8,7 @@ import {
} from '@/domain/services'; } from '@/domain/services';
import { import {
UserId, PhoneNumber, ReferralCode, AccountSequence, ProvinceCode, CityCode, UserId, PhoneNumber, ReferralCode, AccountSequence, ProvinceCode, CityCode,
ChainType, KYCInfo, HardwareInfo, ChainType, KYCInfo,
} from '@/domain/value-objects'; } from '@/domain/value-objects';
import { TokenService } from './token.service'; import { TokenService } from './token.service';
import { RedisService } from '@/infrastructure/redis/redis.service'; import { RedisService } from '@/infrastructure/redis/redis.service';
@ -89,28 +89,22 @@ export class UserApplicationService {
// 4. 生成随机用户名和头像 // 4. 生成随机用户名和头像
const identity = generateRandomIdentity(); const identity = generateRandomIdentity();
// 5. 构建设备名称字符串和硬件信息 // 5. 构建设备名称字符串
let deviceNameStr = '未命名设备'; let deviceNameStr = '未命名设备';
let hardwareInfo: HardwareInfo | undefined;
if (command.deviceName) { if (command.deviceName) {
const parts: string[] = []; const parts: string[] = [];
if (command.deviceName.model) parts.push(command.deviceName.model); if (command.deviceName.model) parts.push(command.deviceName.model);
if (command.deviceName.platform) parts.push(command.deviceName.platform); if (command.deviceName.platform) parts.push(command.deviceName.platform);
if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion); if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion);
if (parts.length > 0) deviceNameStr = parts.join(' '); if (parts.length > 0) deviceNameStr = parts.join(' ');
hardwareInfo = {
platform: command.deviceName.platform,
deviceModel: command.deviceName.model,
osVersion: command.deviceName.osVersion,
};
} }
// 6. 创建用户账户 // 6. 创建用户账户 - deviceInfo 100% 保持前端传递的原样
const account = UserAccount.createAutomatic({ const account = UserAccount.createAutomatic({
accountSequence, accountSequence,
initialDeviceId: command.deviceId, initialDeviceId: command.deviceId,
deviceName: deviceNameStr, deviceName: deviceNameStr,
hardwareInfo, deviceInfo: command.deviceName, // 100% 保持原样存储
inviterSequence, inviterSequence,
province: ProvinceCode.create('DEFAULT'), province: ProvinceCode.create('DEFAULT'),
city: CityCode.create('DEFAULT'), city: CityCode.create('DEFAULT'),

View File

@ -1,7 +1,7 @@
import { DomainError } from '@/shared/exceptions/domain.exception'; import { DomainError } from '@/shared/exceptions/domain.exception';
import { import {
UserId, AccountSequence, PhoneNumber, ReferralCode, ProvinceCode, CityCode, UserId, AccountSequence, PhoneNumber, ReferralCode, ProvinceCode, CityCode,
DeviceInfo, HardwareInfo, ChainType, KYCInfo, KYCStatus, AccountStatus, DeviceInfo, ChainType, KYCInfo, KYCStatus, AccountStatus,
} from '@/domain/value-objects'; } from '@/domain/value-objects';
import { WalletAddress } from '@/domain/entities/wallet-address.entity'; import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { import {
@ -87,7 +87,7 @@ export class UserAccount {
accountSequence: AccountSequence; accountSequence: AccountSequence;
initialDeviceId: string; initialDeviceId: string;
deviceName?: string; deviceName?: string;
hardwareInfo?: HardwareInfo; deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
inviterSequence: AccountSequence | null; inviterSequence: AccountSequence | null;
province: ProvinceCode; province: ProvinceCode;
city: CityCode; city: CityCode;
@ -97,7 +97,7 @@ export class UserAccount {
const devices = new Map<string, DeviceInfo>(); const devices = new Map<string, DeviceInfo>();
devices.set(params.initialDeviceId, new DeviceInfo( devices.set(params.initialDeviceId, new DeviceInfo(
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(), params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
params.hardwareInfo, params.deviceInfo, // 传递完整的 JSON
)); ));
// UserID将由数据库自动生成(autoincrement)这里使用临时值0 // UserID将由数据库自动生成(autoincrement)这里使用临时值0
@ -130,7 +130,7 @@ export class UserAccount {
phoneNumber: PhoneNumber; phoneNumber: PhoneNumber;
initialDeviceId: string; initialDeviceId: string;
deviceName?: string; deviceName?: string;
hardwareInfo?: HardwareInfo; deviceInfo?: Record<string, unknown>; // 完整的设备信息 JSON
inviterSequence: AccountSequence | null; inviterSequence: AccountSequence | null;
province: ProvinceCode; province: ProvinceCode;
city: CityCode; city: CityCode;
@ -138,7 +138,7 @@ export class UserAccount {
const devices = new Map<string, DeviceInfo>(); const devices = new Map<string, DeviceInfo>();
devices.set(params.initialDeviceId, new DeviceInfo( devices.set(params.initialDeviceId, new DeviceInfo(
params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(), params.initialDeviceId, params.deviceName || '未命名设备', new Date(), new Date(),
params.hardwareInfo, params.deviceInfo,
)); ));
// UserID将由数据库自动生成(autoincrement)这里使用临时值0 // UserID将由数据库自动生成(autoincrement)这里使用临时值0
@ -201,7 +201,7 @@ export class UserAccount {
); );
} }
addDevice(deviceId: string, deviceName?: string, hardwareInfo?: HardwareInfo): void { addDevice(deviceId: string, deviceName?: string, deviceInfo?: Record<string, unknown>): void {
this.ensureActive(); this.ensureActive();
if (this._devices.size >= 5 && !this._devices.has(deviceId)) { if (this._devices.size >= 5 && !this._devices.has(deviceId)) {
throw new DomainError('最多允许5个设备同时登录'); throw new DomainError('最多允许5个设备同时登录');
@ -209,12 +209,12 @@ export class UserAccount {
if (this._devices.has(deviceId)) { if (this._devices.has(deviceId)) {
const device = this._devices.get(deviceId)!; const device = this._devices.get(deviceId)!;
device.updateActivity(); device.updateActivity();
if (hardwareInfo) { if (deviceInfo) {
device.updateHardwareInfo(hardwareInfo); device.updateDeviceInfo(deviceInfo);
} }
} else { } else {
this._devices.set(deviceId, new DeviceInfo( this._devices.set(deviceId, new DeviceInfo(
deviceId, deviceName || '未命名设备', new Date(), new Date(), hardwareInfo, deviceId, deviceName || '未命名设备', new Date(), new Date(), deviceInfo,
)); ));
this.addDomainEvent(new DeviceAddedEvent({ this.addDomainEvent(new DeviceAddedEvent({
userId: this.userId.toString(), userId: this.userId.toString(),

View File

@ -6,6 +6,7 @@ export class DeviceInfo {
public readonly deviceName: string, public readonly deviceName: string,
public readonly addedAt: Date, public readonly addedAt: Date,
lastActiveAt: Date, lastActiveAt: Date,
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
) { ) {
this._lastActiveAt = lastActiveAt; this._lastActiveAt = lastActiveAt;
} }

View File

@ -144,64 +144,55 @@ export class Mnemonic {
} }
} }
// ============ HardwareInfo ============
export interface HardwareInfo {
platform?: string; // ios, android, web
deviceModel?: string; // iPhone 15 Pro, Pixel 8
osVersion?: string; // iOS 17.2, Android 14
appVersion?: string; // 1.0.0
screenWidth?: number;
screenHeight?: number;
locale?: string; // zh-CN, en-US
timezone?: string; // Asia/Shanghai
}
// ============ DeviceInfo ============ // ============ DeviceInfo ============
// deviceInfo: 完整的设备信息 JSON100% 保持前端传递的原样
export class DeviceInfo { export class DeviceInfo {
private _lastActiveAt: Date; private _lastActiveAt: Date;
private _hardwareInfo: HardwareInfo; private _deviceInfo: Record<string, unknown>;
constructor( constructor(
public readonly deviceId: string, public readonly deviceId: string,
public readonly deviceName: string, public readonly deviceName: string,
public readonly addedAt: Date, public readonly addedAt: Date,
lastActiveAt: Date, lastActiveAt: Date,
hardwareInfo?: HardwareInfo, deviceInfo?: Record<string, unknown>,
) { ) {
this._lastActiveAt = lastActiveAt; this._lastActiveAt = lastActiveAt;
this._hardwareInfo = hardwareInfo || {}; this._deviceInfo = deviceInfo || {};
} }
get lastActiveAt(): Date { get lastActiveAt(): Date {
return this._lastActiveAt; return this._lastActiveAt;
} }
get hardwareInfo(): HardwareInfo { // 100% 保持原样的完整设备信息 JSON
return this._hardwareInfo; get deviceInfo(): Record<string, unknown> {
return this._deviceInfo;
} }
// 便捷访问器
get platform(): string | undefined { get platform(): string | undefined {
return this._hardwareInfo.platform; return this._deviceInfo.platform as string | undefined;
} }
get deviceModel(): string | undefined { get deviceModel(): string | undefined {
return this._hardwareInfo.deviceModel; return (this._deviceInfo.model || this._deviceInfo.deviceModel) as string | undefined;
} }
get osVersion(): string | undefined { get osVersion(): string | undefined {
return this._hardwareInfo.osVersion; return this._deviceInfo.osVersion as string | undefined;
} }
get appVersion(): string | undefined { get appVersion(): string | undefined {
return this._hardwareInfo.appVersion; return this._deviceInfo.appVersion as string | undefined;
} }
updateActivity(): void { updateActivity(): void {
this._lastActiveAt = new Date(); this._lastActiveAt = new Date();
} }
updateHardwareInfo(info: HardwareInfo): void { updateDeviceInfo(info: Record<string, unknown>): void {
this._hardwareInfo = { ...this._hardwareInfo, ...info }; this._deviceInfo = { ...this._deviceInfo, ...info };
} }
} }

View File

@ -29,7 +29,8 @@ export interface UserDeviceEntity {
userId: bigint; userId: bigint;
deviceId: string; deviceId: string;
deviceName: string | null; deviceName: string | null;
// Hardware Info deviceInfo: Record<string, unknown> | null; // 完整的设备信息 JSON
// Hardware Info (冗余字段,便于查询)
platform: string | null; platform: string | null;
deviceModel: string | null; deviceModel: string | null;
osVersion: string | null; osVersion: string | null;

View File

@ -1,8 +0,0 @@
export interface UserDeviceEntity {
id: bigint;
userId: bigint;
deviceId: string;
deviceName: string | null;
addedAt: Date;
lastActiveAt: Date;
}

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate'; import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { WalletAddress } from '@/domain/entities/wallet-address.entity'; import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { DeviceInfo, HardwareInfo, KYCInfo, KYCStatus, AccountStatus, ChainType, AddressStatus } from '@/domain/value-objects'; import { DeviceInfo, KYCInfo, KYCStatus, AccountStatus, ChainType, AddressStatus } from '@/domain/value-objects';
import { UserAccountEntity } from '../entities/user-account.entity'; import { UserAccountEntity } from '../entities/user-account.entity';
import { toMpcSignatureString } from '../entities/wallet-address.entity'; import { toMpcSignatureString } from '../entities/wallet-address.entity';
@ -9,22 +9,13 @@ import { toMpcSignatureString } from '../entities/wallet-address.entity';
export class UserAccountMapper { export class UserAccountMapper {
toDomain(entity: UserAccountEntity): UserAccount { toDomain(entity: UserAccountEntity): UserAccount {
const devices = (entity.devices || []).map((d) => { const devices = (entity.devices || []).map((d) => {
const hardwareInfo: HardwareInfo = { // 直接使用完整的 deviceInfo JSON100% 保持原样
platform: d.platform || undefined,
deviceModel: d.deviceModel || undefined,
osVersion: d.osVersion || undefined,
appVersion: d.appVersion || undefined,
screenWidth: d.screenWidth || undefined,
screenHeight: d.screenHeight || undefined,
locale: d.locale || undefined,
timezone: d.timezone || undefined,
};
return new DeviceInfo( return new DeviceInfo(
d.deviceId, d.deviceId,
d.deviceName || '未命名设备', d.deviceName || '未命名设备',
d.addedAt, d.addedAt,
d.lastActiveAt, d.lastActiveAt,
hardwareInfo, d.deviceInfo || undefined,
); );
}); });

View File

@ -7,7 +7,7 @@ import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggre
import { WalletAddress } from '@/domain/entities/wallet-address.entity'; import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { import {
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType, UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType,
AccountStatus, KYCStatus, DeviceInfo, HardwareInfo, KYCInfo, AddressStatus, AccountStatus, KYCStatus, DeviceInfo, KYCInfo, AddressStatus,
} from '@/domain/value-objects'; } from '@/domain/value-objects';
import { toMpcSignatureString, fromMpcSignatureString } from '../entities/wallet-address.entity'; import { toMpcSignatureString, fromMpcSignatureString } from '../entities/wallet-address.entity';
@ -75,21 +75,26 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
await tx.userDevice.deleteMany({ where: { userId: savedUserId } }); await tx.userDevice.deleteMany({ where: { userId: savedUserId } });
if (devices.length > 0) { if (devices.length > 0) {
await tx.userDevice.createMany({ await tx.userDevice.createMany({
data: devices.map((d) => ({ data: devices.map((d) => {
userId: savedUserId, // 从 deviceInfo JSON 中提取冗余字段便于查询
deviceId: d.deviceId, const info = d.deviceInfo || {};
deviceName: d.deviceName, return {
platform: d.hardwareInfo.platform || null, userId: savedUserId,
deviceModel: d.hardwareInfo.deviceModel || null, deviceId: d.deviceId,
osVersion: d.hardwareInfo.osVersion || null, deviceName: d.deviceName,
appVersion: d.hardwareInfo.appVersion || null, deviceInfo: d.deviceInfo || null, // 100% 保存完整 JSON
screenWidth: d.hardwareInfo.screenWidth || null, platform: (info as any).platform || null,
screenHeight: d.hardwareInfo.screenHeight || null, deviceModel: (info as any).model || null,
locale: d.hardwareInfo.locale || null, osVersion: (info as any).osVersion || null,
timezone: d.hardwareInfo.timezone || null, appVersion: (info as any).appVersion || null,
addedAt: d.addedAt, screenWidth: (info as any).screenWidth || null,
lastActiveAt: d.lastActiveAt, screenHeight: (info as any).screenHeight || null,
})), locale: (info as any).locale || null,
timezone: (info as any).timezone || null,
addedAt: d.addedAt,
lastActiveAt: d.lastActiveAt,
};
}),
}); });
} }
}); });
@ -214,22 +219,13 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
private toDomain(data: any): UserAccount { private toDomain(data: any): UserAccount {
const devices = data.devices.map((d: any) => { const devices = data.devices.map((d: any) => {
const hardwareInfo: HardwareInfo = { // 优先使用完整的 deviceInfo JSON保持原样
platform: d.platform || undefined,
deviceModel: d.deviceModel || undefined,
osVersion: d.osVersion || undefined,
appVersion: d.appVersion || undefined,
screenWidth: d.screenWidth || undefined,
screenHeight: d.screenHeight || undefined,
locale: d.locale || undefined,
timezone: d.timezone || undefined,
};
return new DeviceInfo( return new DeviceInfo(
d.deviceId, d.deviceId,
d.deviceName || '未命名设备', d.deviceName || '未命名设备',
d.addedAt, d.addedAt,
d.lastActiveAt, d.lastActiveAt,
hardwareInfo, d.deviceInfo || undefined, // 100% 保持原样
); );
}); });

View File

@ -0,0 +1,26 @@
#!/bin/bash
# =============================================================================
# PostgreSQL - Initialize Multiple Databases
# =============================================================================
# This script creates multiple databases from POSTGRES_MULTIPLE_DATABASES env var
# =============================================================================
set -e
set -u
function create_database() {
local database=$1
echo "Creating database '$database'"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
SELECT 'CREATE DATABASE $database'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$database')\gexec
EOSQL
}
if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then
echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES"
for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do
create_database $db
done
echo "Multiple databases created"
fi