fix(contribution-service): 修复CDC同步字段映射,支持完整同步referral数据

主要更改:
1. synced_referrals表增加referrer_user_id和original_user_id字段
   - 1.0的referral_relationships表只有referrer_id(user_id),没有referrer_account_sequence
   - 保存原始user_id以便后续解析推荐人的account_sequence

2. 修复referral-synced.handler字段映射
   - 正确处理1.0的user_id、referrer_id、ancestor_path字段
   - ancestor_path从BigInt[]数组转换为逗号分隔的字符串

3. 修复cdc-event-dispatcher表名注册
   - 使用正确的表名: referral_relationships, planting_orders
   - 移除不需要的user_accounts注册

4. 更新docker-compose.2.0.yml
   - 添加CDC_TOPIC_REFERRALS配置
   - 移除未使用的CDC_TOPIC_PAYMENTS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-11 09:27:01 -08:00
parent 05a8168a31
commit 4b55c63e71
8 changed files with 155 additions and 20 deletions

View File

@ -0,0 +1,12 @@
-- Migration: 添加推荐关系的 user_id 字段
-- 用于完整同步 1.0 referral_relationships 表数据
-- 添加 referrer_user_id 字段 (1.0 的 referrer_id)
ALTER TABLE "synced_referrals" ADD COLUMN "referrer_user_id" BIGINT;
-- 添加 original_user_id 字段 (1.0 的 user_id)
ALTER TABLE "synced_referrals" ADD COLUMN "original_user_id" BIGINT;
-- 创建索引
CREATE INDEX "synced_referrals_referrer_user_id_idx" ON "synced_referrals"("referrer_user_id");
CREATE INDEX "synced_referrals_original_user_id_idx" ON "synced_referrals"("original_user_id");

View File

@ -66,9 +66,13 @@ model SyncedAdoption {
model SyncedReferral { model SyncedReferral {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
accountSequence String @unique @map("account_sequence") @db.VarChar(20) accountSequence String @unique @map("account_sequence") @db.VarChar(20)
// 推荐人信息:优先使用 account_sequence但也保存 user_id 以便后续解析
referrerAccountSequence String? @map("referrer_account_sequence") @db.VarChar(20) referrerAccountSequence String? @map("referrer_account_sequence") @db.VarChar(20)
referrerUserId BigInt? @map("referrer_user_id") // 1.0 的 referrer_id
originalUserId BigInt? @map("original_user_id") // 1.0 的 user_id
// 预计算的层级路径(便于快速查询上下级) // 预计算的层级路径(便于快速查询上下级)
// 1.0 存储的是 BigInt[],这里转换为逗号分隔的字符串
ancestorPath String? @map("ancestor_path") @db.Text ancestorPath String? @map("ancestor_path") @db.Text
depth Int @default(0) depth Int @default(0)
@ -79,6 +83,8 @@ model SyncedReferral {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@@index([referrerAccountSequence]) @@index([referrerAccountSequence])
@@index([referrerUserId])
@@index([originalUserId])
@@map("synced_referrals") @@map("synced_referrals")
} }

View File

@ -21,9 +21,15 @@ export class CDCEventDispatcher implements OnModuleInit {
async onModuleInit() { async onModuleInit() {
// 注册各表的事件处理器 // 注册各表的事件处理器
this.cdcConsumer.registerHandler('users', this.handleUserEvent.bind(this)); // 表名需要与 Debezium topic 中的表名一致
this.cdcConsumer.registerHandler('referrals', this.handleReferralEvent.bind(this)); // topic 格式: cdc.<service>.public.<table_name>
this.cdcConsumer.registerHandler('adoptions', this.handleAdoptionEvent.bind(this)); //
// 注意contribution-service 不需要直接同步用户数据
// - 认种订单 (planting_orders) 包含 account_sequence
// - 推荐关系 (referral_relationships) 包含 account_sequence 和层级信息
// - ContributionAccount 在认种时自动创建
this.cdcConsumer.registerHandler('referral_relationships', this.handleReferralEvent.bind(this)); // referral-service
this.cdcConsumer.registerHandler('planting_orders', this.handleAdoptionEvent.bind(this)); // planting-service
// 启动 CDC 消费者 // 启动 CDC 消费者
try { try {

View File

@ -5,7 +5,20 @@ import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-o
/** /**
* CDC * CDC
* * 1.0 referral-service同步过来的referral_relationships数据
*
* 1.0 (referral_relationships):
* - user_id: BigInt (ID)
* - account_sequence: String ()
* - referrer_id: BigInt (ID, account_sequence)
* - ancestor_path: BigInt[] ( user_id)
* - depth: Int ()
*
* 2.0 :
* - original_user_id (1.0 user_id)
* - referrer_user_id (1.0 referrer_id)
* - referrer account_sequence
* - ancestor_path
*/ */
@Injectable() @Injectable()
export class ReferralSyncedHandler { export class ReferralSyncedHandler {
@ -43,33 +56,77 @@ export class ReferralSyncedHandler {
private async handleCreate(data: any, sequenceNum: bigint): Promise<void> { private async handleCreate(data: any, sequenceNum: bigint): Promise<void> {
if (!data) return; if (!data) return;
// 1.0 字段映射
const accountSequence = data.account_sequence || data.accountSequence;
const originalUserId = data.user_id || data.userId;
const referrerUserId = data.referrer_id || data.referrerId;
const ancestorPathArray = data.ancestor_path || data.ancestorPath;
const depth = data.depth || 0;
// 将 BigInt[] 转换为逗号分隔的字符串
const ancestorPath = this.convertAncestorPath(ancestorPathArray);
// 尝试查找推荐人的 account_sequence
let referrerAccountSequence: string | null = null;
if (referrerUserId) {
const referrer = await this.syncedDataRepository.findSyncedReferralByOriginalUserId(BigInt(referrerUserId));
if (referrer) {
referrerAccountSequence = referrer.accountSequence;
} else {
this.logger.debug(
`Referrer user_id ${referrerUserId} not found yet for ${accountSequence}, will resolve later`,
);
}
}
await this.unitOfWork.executeInTransaction(async () => { await this.unitOfWork.executeInTransaction(async () => {
await this.syncedDataRepository.upsertSyncedReferral({ await this.syncedDataRepository.upsertSyncedReferral({
accountSequence: data.account_sequence || data.accountSequence, accountSequence,
referrerAccountSequence: data.referrer_account_sequence || data.referrerAccountSequence || null, referrerAccountSequence,
ancestorPath: data.ancestor_path || data.ancestorPath || null, referrerUserId: referrerUserId ? BigInt(referrerUserId) : null,
depth: data.depth || 0, originalUserId: originalUserId ? BigInt(originalUserId) : null,
ancestorPath,
depth,
sourceSequenceNum: sequenceNum, sourceSequenceNum: sequenceNum,
}); });
}); });
this.logger.log( this.logger.log(
`Referral synced: ${data.account_sequence || data.accountSequence} -> ${data.referrer_account_sequence || data.referrerAccountSequence || 'none'}`, `Referral synced: ${accountSequence} (user_id: ${originalUserId}) -> referrer_id: ${referrerUserId || 'none'}`,
); );
} }
private async handleUpdate(data: any, sequenceNum: bigint): Promise<void> { private async handleUpdate(data: any, sequenceNum: bigint): Promise<void> {
if (!data) return; if (!data) return;
const accountSequence = data.account_sequence || data.accountSequence;
const originalUserId = data.user_id || data.userId;
const referrerUserId = data.referrer_id || data.referrerId;
const ancestorPathArray = data.ancestor_path || data.ancestorPath;
const depth = data.depth || 0;
const ancestorPath = this.convertAncestorPath(ancestorPathArray);
// 尝试查找推荐人的 account_sequence
let referrerAccountSequence: string | null = null;
if (referrerUserId) {
const referrer = await this.syncedDataRepository.findSyncedReferralByOriginalUserId(BigInt(referrerUserId));
if (referrer) {
referrerAccountSequence = referrer.accountSequence;
}
}
await this.syncedDataRepository.upsertSyncedReferral({ await this.syncedDataRepository.upsertSyncedReferral({
accountSequence: data.account_sequence || data.accountSequence, accountSequence,
referrerAccountSequence: data.referrer_account_sequence || data.referrerAccountSequence || null, referrerAccountSequence,
ancestorPath: data.ancestor_path || data.ancestorPath || null, referrerUserId: referrerUserId ? BigInt(referrerUserId) : null,
depth: data.depth || 0, originalUserId: originalUserId ? BigInt(originalUserId) : null,
ancestorPath,
depth,
sourceSequenceNum: sequenceNum, sourceSequenceNum: sequenceNum,
}); });
this.logger.debug(`Referral updated: ${data.account_sequence || data.accountSequence}`); this.logger.debug(`Referral updated: ${accountSequence}`);
} }
private async handleDelete(data: any): Promise<void> { private async handleDelete(data: any): Promise<void> {
@ -77,4 +134,27 @@ export class ReferralSyncedHandler {
// 引荐关系删除需要特殊处理 // 引荐关系删除需要特殊处理
this.logger.warn(`Referral delete event received: ${data.account_sequence || data.accountSequence}`); this.logger.warn(`Referral delete event received: ${data.account_sequence || data.accountSequence}`);
} }
/**
* BigInt[]
* @param ancestorPath BigInt null
* @returns null
*/
private convertAncestorPath(ancestorPath: any): string | null {
if (!ancestorPath) return null;
// 处理可能的数组格式
if (Array.isArray(ancestorPath)) {
return ancestorPath.map((id) => String(id)).join(',');
}
// 如果已经是字符串 (可能是 PostgreSQL 数组的字符串表示)
if (typeof ancestorPath === 'string') {
// PostgreSQL 数组格式: {1,2,3} 或 [1,2,3]
const cleaned = ancestorPath.replace(/[{}\[\]]/g, '');
return cleaned || null;
}
return null;
}
} }

View File

@ -41,6 +41,8 @@ export interface SyncedReferral {
id: bigint; id: bigint;
accountSequence: string; accountSequence: string;
referrerAccountSequence: string | null; referrerAccountSequence: string | null;
referrerUserId: bigint | null; // 1.0 的 referrer_id
originalUserId: bigint | null; // 1.0 的 user_id
ancestorPath: string | null; ancestorPath: string | null;
depth: number; depth: number;
sourceSequenceNum: bigint; sourceSequenceNum: bigint;
@ -128,11 +130,18 @@ export interface ISyncedDataRepository {
upsertSyncedReferral(data: { upsertSyncedReferral(data: {
accountSequence: string; accountSequence: string;
referrerAccountSequence?: string | null; referrerAccountSequence?: string | null;
referrerUserId?: bigint | null;
originalUserId?: bigint | null;
ancestorPath?: string | null; ancestorPath?: string | null;
depth?: number; depth?: number;
sourceSequenceNum: bigint; sourceSequenceNum: bigint;
}): Promise<SyncedReferral>; }): Promise<SyncedReferral>;
/**
* ID查找推荐关系
*/
findSyncedReferralByOriginalUserId(originalUserId: bigint): Promise<SyncedReferral | null>;
/** /**
* *
*/ */

View File

@ -79,12 +79,12 @@ export class CDCConsumerService implements OnModuleInit {
await this.consumer.connect(); await this.consumer.connect();
this.logger.log('CDC consumer connected'); this.logger.log('CDC consumer connected');
// 订阅 Debezium CDC topics (从1.0 planting-service同步) // 订阅 Debezium CDC topics (从1.0服务同步)
const topics = [ const topics = [
// 认种订单表 (planting_orders) // 认种订单表 (planting-service: planting_orders)
this.configService.get<string>('CDC_TOPIC_ADOPTIONS', 'cdc.planting.public.planting_orders'), this.configService.get<string>('CDC_TOPIC_ADOPTIONS', 'cdc.planting.public.planting_orders'),
// 资金分配表 (fund_allocations) // 推荐关系表 (referral-service: referral_relationships)
this.configService.get<string>('CDC_TOPIC_PAYMENTS', 'cdc.planting.public.fund_allocations'), this.configService.get<string>('CDC_TOPIC_REFERRALS', 'cdc.referral.public.referral_relationships'),
]; ];
await this.consumer.subscribe({ await this.consumer.subscribe({

View File

@ -171,6 +171,8 @@ export class SyncedDataRepository implements ISyncedDataRepository {
async upsertSyncedReferral(data: { async upsertSyncedReferral(data: {
accountSequence: string; accountSequence: string;
referrerAccountSequence?: string | null; referrerAccountSequence?: string | null;
referrerUserId?: bigint | null;
originalUserId?: bigint | null;
ancestorPath?: string | null; ancestorPath?: string | null;
depth?: number; depth?: number;
sourceSequenceNum: bigint; sourceSequenceNum: bigint;
@ -180,6 +182,8 @@ export class SyncedDataRepository implements ISyncedDataRepository {
create: { create: {
accountSequence: data.accountSequence, accountSequence: data.accountSequence,
referrerAccountSequence: data.referrerAccountSequence ?? null, referrerAccountSequence: data.referrerAccountSequence ?? null,
referrerUserId: data.referrerUserId ?? null,
originalUserId: data.originalUserId ?? null,
ancestorPath: data.ancestorPath ?? null, ancestorPath: data.ancestorPath ?? null,
depth: data.depth ?? 0, depth: data.depth ?? 0,
sourceSequenceNum: data.sourceSequenceNum, sourceSequenceNum: data.sourceSequenceNum,
@ -187,6 +191,8 @@ export class SyncedDataRepository implements ISyncedDataRepository {
}, },
update: { update: {
referrerAccountSequence: data.referrerAccountSequence ?? undefined, referrerAccountSequence: data.referrerAccountSequence ?? undefined,
referrerUserId: data.referrerUserId ?? undefined,
originalUserId: data.originalUserId ?? undefined,
ancestorPath: data.ancestorPath ?? undefined, ancestorPath: data.ancestorPath ?? undefined,
depth: data.depth ?? undefined, depth: data.depth ?? undefined,
sourceSequenceNum: data.sourceSequenceNum, sourceSequenceNum: data.sourceSequenceNum,
@ -197,6 +203,18 @@ export class SyncedDataRepository implements ISyncedDataRepository {
return this.toSyncedReferral(record); return this.toSyncedReferral(record);
} }
async findSyncedReferralByOriginalUserId(originalUserId: bigint): Promise<SyncedReferral | null> {
const record = await this.client.syncedReferral.findFirst({
where: { originalUserId },
});
if (!record) {
return null;
}
return this.toSyncedReferral(record);
}
async findSyncedReferralByAccountSequence(accountSequence: string): Promise<SyncedReferral | null> { async findSyncedReferralByAccountSequence(accountSequence: string): Promise<SyncedReferral | null> {
const record = await this.client.syncedReferral.findUnique({ const record = await this.client.syncedReferral.findUnique({
where: { accountSequence }, where: { accountSequence },
@ -368,6 +386,8 @@ export class SyncedDataRepository implements ISyncedDataRepository {
id: record.id, id: record.id,
accountSequence: record.accountSequence, accountSequence: record.accountSequence,
referrerAccountSequence: record.referrerAccountSequence, referrerAccountSequence: record.referrerAccountSequence,
referrerUserId: record.referrerUserId,
originalUserId: record.originalUserId,
ancestorPath: record.ancestorPath, ancestorPath: record.ancestorPath,
depth: record.depth, depth: record.depth,
sourceSequenceNum: record.sourceSequenceNum, sourceSequenceNum: record.sourceSequenceNum,

View File

@ -37,10 +37,12 @@ services:
REDIS_PORT: 6379 REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-} REDIS_PASSWORD: ${REDIS_PASSWORD:-}
REDIS_DB: 10 REDIS_DB: 10
# Kafka - 消费 CDC 事件 (从1.0 planting-service同步认种数据) # Kafka - 消费 CDC 事件 (从1.0服务同步数据)
KAFKA_BROKERS: kafka:29092 KAFKA_BROKERS: kafka:29092
# 认种订单 (planting-service)
CDC_TOPIC_ADOPTIONS: ${CDC_TOPIC_ADOPTIONS:-cdc.planting.public.planting_orders} CDC_TOPIC_ADOPTIONS: ${CDC_TOPIC_ADOPTIONS:-cdc.planting.public.planting_orders}
CDC_TOPIC_PAYMENTS: ${CDC_TOPIC_PAYMENTS:-cdc.planting.public.fund_allocations} # 推荐关系 (referral-service)
CDC_TOPIC_REFERRALS: ${CDC_TOPIC_REFERRALS:-cdc.referral.public.referral_relationships}
CDC_CONSUMER_GROUP: contribution-service-cdc-group CDC_CONSUMER_GROUP: contribution-service-cdc-group
ports: ports:
- "3020:3020" - "3020:3020"