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:*)",
"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": [],
"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\" 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(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": [],
"ask": []

View File

@ -54,8 +54,9 @@ model UserDevice {
deviceId String @map("device_id") @db.VarChar(100)
deviceName String? @map("device_name") @db.VarChar(100)
// Hardware Info - 设备硬件信息
platform String? @db.VarChar(20) // ios, android, web
// Hardware Info - 设备硬件信息 (JSON 存储完整前端传递的设备信息)
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
osVersion String? @map("os_version") @db.VarChar(50) // iOS 17.2, Android 14
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 { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
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 { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
@ -46,28 +46,22 @@ export class AutoCreateAccountHandler {
// 4. 生成随机用户名和头像
const identity = generateRandomIdentity();
// 5. 构建设备名称和硬件信息
// 5. 构建设备名称,保存完整的设备信息 JSON
let deviceNameStr = '未命名设备';
let hardwareInfo: HardwareInfo | undefined;
if (command.deviceName) {
const parts: string[] = [];
if (command.deviceName.model) parts.push(command.deviceName.model);
if (command.deviceName.platform) parts.push(command.deviceName.platform);
if (command.deviceName.osVersion) parts.push(command.deviceName.osVersion);
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({
accountSequence,
initialDeviceId: command.deviceId,
deviceName: deviceNameStr,
hardwareInfo,
deviceInfo: command.deviceName, // 100% 保持原样存储
inviterSequence,
province: ProvinceCode.create('DEFAULT'),
city: CityCode.create('DEFAULT'),

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ export class DeviceInfo {
public readonly deviceName: string,
public readonly addedAt: Date,
lastActiveAt: Date,
public readonly deviceInfo?: Record<string, unknown>, // 完整的设备信息 JSON
) {
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: 完整的设备信息 JSON100% 保持前端传递的原样
export class DeviceInfo {
private _lastActiveAt: Date;
private _hardwareInfo: HardwareInfo;
private _deviceInfo: Record<string, unknown>;
constructor(
public readonly deviceId: string,
public readonly deviceName: string,
public readonly addedAt: Date,
lastActiveAt: Date,
hardwareInfo?: HardwareInfo,
deviceInfo?: Record<string, unknown>,
) {
this._lastActiveAt = lastActiveAt;
this._hardwareInfo = hardwareInfo || {};
this._deviceInfo = deviceInfo || {};
}
get lastActiveAt(): Date {
return this._lastActiveAt;
}
get hardwareInfo(): HardwareInfo {
return this._hardwareInfo;
// 100% 保持原样的完整设备信息 JSON
get deviceInfo(): Record<string, unknown> {
return this._deviceInfo;
}
// 便捷访问器
get platform(): string | undefined {
return this._hardwareInfo.platform;
return this._deviceInfo.platform as string | undefined;
}
get deviceModel(): string | undefined {
return this._hardwareInfo.deviceModel;
return (this._deviceInfo.model || this._deviceInfo.deviceModel) as string | undefined;
}
get osVersion(): string | undefined {
return this._hardwareInfo.osVersion;
return this._deviceInfo.osVersion as string | undefined;
}
get appVersion(): string | undefined {
return this._hardwareInfo.appVersion;
return this._deviceInfo.appVersion as string | undefined;
}
updateActivity(): void {
this._lastActiveAt = new Date();
}
updateHardwareInfo(info: HardwareInfo): void {
this._hardwareInfo = { ...this._hardwareInfo, ...info };
updateDeviceInfo(info: Record<string, unknown>): void {
this._deviceInfo = { ...this._deviceInfo, ...info };
}
}

View File

@ -29,7 +29,8 @@ export interface UserDeviceEntity {
userId: bigint;
deviceId: string;
deviceName: string | null;
// Hardware Info
deviceInfo: Record<string, unknown> | null; // 完整的设备信息 JSON
// Hardware Info (冗余字段,便于查询)
platform: string | null;
deviceModel: 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 { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
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 { toMpcSignatureString } from '../entities/wallet-address.entity';
@ -9,22 +9,13 @@ import { toMpcSignatureString } from '../entities/wallet-address.entity';
export class UserAccountMapper {
toDomain(entity: UserAccountEntity): UserAccount {
const devices = (entity.devices || []).map((d) => {
const hardwareInfo: HardwareInfo = {
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,
};
// 直接使用完整的 deviceInfo JSON100% 保持原样
return new DeviceInfo(
d.deviceId,
d.deviceName || '未命名设备',
d.addedAt,
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 {
UserId, AccountSequence, PhoneNumber, ReferralCode, ChainType,
AccountStatus, KYCStatus, DeviceInfo, HardwareInfo, KYCInfo, AddressStatus,
AccountStatus, KYCStatus, DeviceInfo, KYCInfo, AddressStatus,
} from '@/domain/value-objects';
import { toMpcSignatureString, fromMpcSignatureString } from '../entities/wallet-address.entity';
@ -75,21 +75,26 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
await tx.userDevice.deleteMany({ where: { userId: savedUserId } });
if (devices.length > 0) {
await tx.userDevice.createMany({
data: devices.map((d) => ({
userId: savedUserId,
deviceId: d.deviceId,
deviceName: d.deviceName,
platform: d.hardwareInfo.platform || null,
deviceModel: d.hardwareInfo.deviceModel || null,
osVersion: d.hardwareInfo.osVersion || null,
appVersion: d.hardwareInfo.appVersion || null,
screenWidth: d.hardwareInfo.screenWidth || null,
screenHeight: d.hardwareInfo.screenHeight || null,
locale: d.hardwareInfo.locale || null,
timezone: d.hardwareInfo.timezone || null,
addedAt: d.addedAt,
lastActiveAt: d.lastActiveAt,
})),
data: devices.map((d) => {
// 从 deviceInfo JSON 中提取冗余字段便于查询
const info = d.deviceInfo || {};
return {
userId: savedUserId,
deviceId: d.deviceId,
deviceName: d.deviceName,
deviceInfo: d.deviceInfo || null, // 100% 保存完整 JSON
platform: (info as any).platform || null,
deviceModel: (info as any).model || null,
osVersion: (info as any).osVersion || null,
appVersion: (info as any).appVersion || null,
screenWidth: (info as any).screenWidth || null,
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 {
const devices = data.devices.map((d: any) => {
const hardwareInfo: HardwareInfo = {
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,
};
// 优先使用完整的 deviceInfo JSON保持原样
return new DeviceInfo(
d.deviceId,
d.deviceName || '未命名设备',
d.addedAt,
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