fix: 统一推荐码生成逻辑 - 由 identity-service 单点生成

重要变更:
- identity-service 生成用户推荐码,通过 Kafka 事件传递给 referral-service
- referral-service 不再自己生成推荐码,直接使用事件中的推荐码
- 修复两个服务推荐码不一致的问题

涉及服务:
- identity-service: 事件 payload 添加 referralCode 字段
- referral-service: 接收并存储 identity-service 生成的推荐码
- wallet-service: 添加区域账户动态创建接口
- planting-service: 调用区域账户创建接口

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-13 01:14:56 -08:00
parent ebbf2d971a
commit 98d8bee20d
15 changed files with 194 additions and 20 deletions

View File

@ -33,9 +33,9 @@ export class AutoCreateAccountDto {
@IsObject()
deviceName?: DeviceNameDto;
@ApiPropertyOptional({ example: 'ABC123', description: '邀请人推荐码' })
@ApiPropertyOptional({ example: 'RWAABC1234', description: '邀请人推荐码 (6-20位大写字母和数字)' })
@IsOptional()
@IsString()
@Matches(/^[A-Z0-9]{6}$/, { message: '推荐码格式错误' })
@Matches(/^[A-Z0-9]{6,20}$/, { message: '推荐码格式错误' })
inviterReferralCode?: string;
}

View File

@ -103,6 +103,7 @@ export class UserAccount {
account.addDomainEvent(new UserAccountAutoCreatedEvent({
userId: account.userId.toString(),
accountSequence: params.accountSequence.value,
referralCode: account._referralCode.value, // 用户的推荐码
initialDeviceId: params.initialDeviceId,
inviterSequence: params.inviterSequence?.value || null,
registeredAt: account._registeredAt,
@ -137,6 +138,7 @@ export class UserAccount {
account.addDomainEvent(new UserAccountCreatedEvent({
userId: account.userId.toString(),
accountSequence: params.accountSequence.value,
referralCode: account._referralCode.value, // 用户的推荐码
phoneNumber: params.phoneNumber.value,
initialDeviceId: params.initialDeviceId,
inviterSequence: params.inviterSequence?.value || null,

View File

@ -15,6 +15,7 @@ export class UserAccountAutoCreatedEvent extends DomainEvent {
public readonly payload: {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string; // 用户的推荐码(由 identity-service 生成)
initialDeviceId: string;
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
registeredAt: Date;
@ -33,6 +34,7 @@ export class UserAccountCreatedEvent extends DomainEvent {
public readonly payload: {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string; // 用户的推荐码(由 identity-service 生成)
phoneNumber: string;
initialDeviceId: string;
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号

View File

@ -2,12 +2,15 @@ import { DomainError } from '@/shared/exceptions/domain.exception';
export class ReferralCode {
constructor(public readonly value: string) {
if (!/^[A-Z0-9]{6}$/.test(value)) {
// 兼容 referral-service 的推荐码格式 (6-20位大写字母和数字)
if (!/^[A-Z0-9]{6,20}$/.test(value)) {
throw new DomainError('推荐码格式错误');
}
}
static generate(): ReferralCode {
// 生成6位随机推荐码identity-service 本地生成)
// 注referral-service 会生成10位的推荐码两者都兼容
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = '';
for (let i = 0; i < 6; i++) {

View File

@ -141,6 +141,7 @@ export class PlantingApplicationService {
/**
* (5)
* wallet-service中创建对应的省市区域账户
*/
async confirmProvinceCity(
orderNo: string,
@ -155,9 +156,33 @@ export class PlantingApplicationService {
throw new Error('无权操作此订单');
}
// 获取省市选择信息
const selection = order.provinceCitySelection;
if (!selection) {
throw new Error('请先选择省市');
}
// 确认省市选择
order.confirmProvinceCity();
await this.orderRepository.save(order);
// 确保省市区域账户存在(动态创建)
try {
const result = await this.walletService.ensureRegionAccounts({
provinceCode: selection.provinceCode,
provinceName: selection.provinceName,
cityCode: selection.cityCode,
cityName: selection.cityName,
});
this.logger.log(
`区域账户确保完成: 省=${result.provinceAccount.accountSequence}(created=${result.provinceAccount.created}), ` +
`市=${result.cityAccount.accountSequence}(created=${result.cityAccount.created})`
);
} catch (error) {
// 区域账户创建失败不影响省市确认流程,只记录日志
this.logger.warn(`创建区域账户失败,将在支付时重试: ${error.message}`);
}
return { success: true };
}

View File

@ -386,4 +386,50 @@ export class WalletServiceClient {
throw error;
}
}
/**
*
* /
*/
async ensureRegionAccounts(params: {
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
}): Promise<{
provinceAccount: { accountSequence: string; created: boolean };
cityAccount: { accountSequence: string; created: boolean };
}> {
try {
return await this.withRetry(
`ensureRegionAccounts(${params.provinceCode}, ${params.cityCode})`,
async () => {
const response = await firstValueFrom(
this.httpService.post<{
provinceAccount: { accountSequence: string; created: boolean };
cityAccount: { accountSequence: string; created: boolean };
}>(
`${this.baseUrl}/api/v1/wallets/ensure-region-accounts`,
params,
),
);
return response.data;
},
);
} catch (error) {
this.logger.error(
`Failed to ensure region accounts: province=${params.provinceCode}, city=${params.cityCode}`,
error,
);
// 在开发环境模拟成功
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Development mode: simulating region accounts creation');
return {
provinceAccount: { accountSequence: `9${params.provinceCode}`, created: true },
cityAccount: { accountSequence: `8${params.cityCode}`, created: true },
};
}
throw error;
}
}
}

View File

@ -192,6 +192,7 @@ export class ReferralController {
const command = new CreateReferralRelationshipCommand(
BigInt(dto.userId),
dto.accountSequence,
dto.referralCode,
dto.referrerCode ?? null,
dto.inviterAccountSequence ?? null,
);

View File

@ -18,7 +18,12 @@ export class CreateReferralDto {
@IsString()
accountSequence: string; // 格式: D + YYMMDD + 5位序号
@ApiPropertyOptional({ description: '推荐码', example: 'RWA123ABC' })
@ApiProperty({ description: '用户的推荐码(由 identity-service 生成)', example: 'ABC123' })
@IsString()
@Length(6, 20)
referralCode: string;
@ApiPropertyOptional({ description: '推荐人的推荐码', example: 'RWA123ABC' })
@IsOptional()
@IsString()
@Length(6, 20)

View File

@ -2,6 +2,7 @@ export class CreateReferralRelationshipCommand {
constructor(
public readonly userId: bigint,
public readonly accountSequence: string, // 格式: D + YYMMDD + 5位序号
public readonly referralCode: string, // 用户的推荐码(由 identity-service 生成)
public readonly referrerCode: string | null = null,
public readonly inviterAccountSequence: string | null = null, // 格式: D + YYMMDD + 5位序号
) {}

View File

@ -9,6 +9,7 @@ import { CreateReferralRelationshipCommand } from '../commands';
interface UserAccountCreatedPayload {
userId: string;
accountSequence: string; // 格式: D + YYMMDD + 5位序号
referralCode: string; // 用户的推荐码(由 identity-service 生成)
inviterSequence: string | null; // 格式: D + YYMMDD + 5位序号
registeredAt: string;
// UserAccountCreated 有 phoneNumber, UserAccountAutoCreated 没有
@ -73,6 +74,7 @@ export class UserRegisteredHandler implements OnModuleInit {
const command = new CreateReferralRelationshipCommand(
userIdFromSequence, // 使用从 accountSequence 提取的数值作为 userId
payload.accountSequence, // 完整的 accountSequence 字符串
payload.referralCode, // 用户的推荐码(由 identity-service 生成)
null, // referrerCode - 不通过推荐码查找
payload.inviterSequence, // 通过 accountSequence 查找推荐人
);

View File

@ -81,10 +81,11 @@ export class ReferralService {
}
}
// 创建推荐关系(传入推荐人的推荐码)
// 创建推荐关系(传入用户自己的推荐码和推荐人的推荐码)
const relationship = ReferralRelationship.create(
command.userId,
command.accountSequence,
command.referralCode, // 用户自己的推荐码(由 identity-service 生成)
referrerId,
referrerReferralCode, // 推荐人的推荐码
parentChain,

View File

@ -72,6 +72,7 @@ export class ReferralRelationship {
* ()
* @param userId ID
* @param accountSequence
* @param userReferralCode identity-service
* @param referrerId ID
* @param referrerReferralCode
* @param parentReferralChain
@ -79,13 +80,14 @@ export class ReferralRelationship {
static create(
userId: bigint,
accountSequence: string, // 格式: D + YYMMDD + 5位序号
userReferralCode: string, // 用户的推荐码(由 identity-service 生成)
referrerId: bigint | null,
referrerReferralCode: string | null, // 推荐人的推荐码
parentReferralChain: bigint[] = [],
): ReferralRelationship {
const userIdVo = UserId.create(userId);
const referrerIdVo = referrerId ? UserId.create(referrerId) : null;
const referralCode = ReferralCode.generate(userId);
const referralCode = ReferralCode.create(userReferralCode); // 使用传入的推荐码,不再生成
const usedReferralCode = referrerReferralCode ? ReferralCode.create(referrerReferralCode) : null;
const referralChain = ReferralChain.create(referrerId, parentReferralChain);
const now = new Date();

View File

@ -2,13 +2,18 @@
-- These accounts receive fixed allocations from each planting order
-- Note: Province/City area accounts (9+provinceCode, 8+cityCode) are dynamically created
-- when users select their planting location
--
-- System account user_id convention:
-- - Fixed system accounts: -1 to -100 (negative to avoid conflict with real users)
-- - Province area accounts: 9 + provinceCode (e.g., 9440000 for Guangdong)
-- - City area accounts: 8 + cityCode (e.g., 8440100 for Guangzhou)
-- S0000000001: 总部社区 (Headquarters Community)
-- Receives: 基础费 9 USDT, 无推荐人的分享权益 500 USDT, 无授权的团队权益等
INSERT INTO "wallet_accounts" (
"account_sequence", "user_id", "status", "created_at", "updated_at"
) VALUES (
'S0000000001', 1, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
'S0000000001', -1, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
) ON CONFLICT ("account_sequence") DO NOTHING;
-- S0000000002: 成本费账户 (Cost Fee Account)
@ -16,7 +21,7 @@ INSERT INTO "wallet_accounts" (
INSERT INTO "wallet_accounts" (
"account_sequence", "user_id", "status", "created_at", "updated_at"
) VALUES (
'S0000000002', 2, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
'S0000000002', -2, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
) ON CONFLICT ("account_sequence") DO NOTHING;
-- S0000000003: 运营费账户 (Operation Fee Account)
@ -24,7 +29,7 @@ INSERT INTO "wallet_accounts" (
INSERT INTO "wallet_accounts" (
"account_sequence", "user_id", "status", "created_at", "updated_at"
) VALUES (
'S0000000003', 3, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
'S0000000003', -3, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
) ON CONFLICT ("account_sequence") DO NOTHING;
-- S0000000004: RWAD底池账户 (RWAD Pool Injection Account)
@ -32,5 +37,5 @@ INSERT INTO "wallet_accounts" (
INSERT INTO "wallet_accounts" (
"account_sequence", "user_id", "status", "created_at", "updated_at"
) VALUES (
'S0000000004', 4, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
'S0000000004', -4, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
) ON CONFLICT ("account_sequence") DO NOTHING;

View File

@ -147,4 +147,23 @@ export class InternalWalletController {
const result = await this.walletService.allocateFunds(command);
return { success: result.success };
}
@Post('ensure-region-accounts')
@Public()
@ApiOperation({ summary: '确保区域账户存在(内部API) - 在选择省市确认后调用' })
@ApiResponse({ status: 200, description: '区域账户创建结果' })
async ensureRegionAccounts(
@Body() dto: { provinceCode: string; provinceName: string; cityCode: string; cityName: string },
) {
this.logger.log(`确保区域账户存在: 省=${dto.provinceName}(${dto.provinceCode}), 市=${dto.cityName}(${dto.cityCode})`);
const result = await this.walletService.ensureRegionAccounts({
provinceCode: dto.provinceCode,
provinceName: dto.provinceName,
cityCode: dto.cityCode,
cityName: dto.cityName,
});
return result;
}
}

View File

@ -622,28 +622,27 @@ export class WalletApplicationService {
): Promise<void> {
// targetId 是 accountSequence
// - 用户账户: D2512120001
// - 固定系统账户: S0000000001
// - 省区域账户: 9440000 (9 + provinceCode)
// - 市区域账户: 8440100 (8 + cityCode)
// - 固定系统账户: S0000000001 (userId: -1 to -100, 由migration seed创建)
// - 省区域账户: 9440000 (9 + provinceCode, userId = 9440000)
// - 市区域账户: 8440100 (8 + cityCode, userId = 8440100)
// 为系统账户生成 userId (用于数据库约束)
// 省区域: 使用 9 + provinceCode 作为 userId
// 市区域: 使用 8 + cityCode 作为 userId
let systemUserId = BigInt(0);
const targetId = allocation.targetId;
if (targetId.startsWith('9')) {
// 省区域账户: 9440000 -> userId = 9440000
// 省区域账户: 9440000 -> userId = 9440000 (动态创建)
systemUserId = BigInt(targetId);
} else if (targetId.startsWith('8')) {
// 市区域账户: 8440100 -> userId = 8440100
// 市区域账户: 8440100 -> userId = 8440100 (动态创建)
systemUserId = BigInt(targetId);
} else if (targetId.startsWith('S')) {
// 固定系统账户: S0000000001 -> userId 从后面的数字提取
// 固定系统账户: S0000000001 -> userId = -1 (负数由seed创建直接查找)
const numPart = targetId.slice(1);
systemUserId = BigInt(parseInt(numPart, 10));
systemUserId = BigInt(-parseInt(numPart, 10));
}
// 使用 getOrCreate 自动创建不存在的账户(包括省/市区域账户)
// 使用 getOrCreate 自动创建不存在的账户(仅用于省/市区域账户的动态创建)
// 固定系统账户(S开头)应该已由migration seed创建
const wallet = await this.walletRepo.getOrCreate(targetId, systemUserId);
if (!wallet) {
this.logger.warn(`Failed to get or create wallet for accountSequence ${allocation.targetId}`);
@ -711,6 +710,67 @@ export class WalletApplicationService {
// });
}
// =============== Region Accounts ===============
/**
*
* /
*
* :
* - 省区域账户: 9 + provinceCode (: 9440000)
* - 市区域账户: 8 + cityCode (: 8440100)
*/
async ensureRegionAccounts(params: {
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
}): Promise<{
provinceAccount: { accountSequence: string; created: boolean };
cityAccount: { accountSequence: string; created: boolean };
}> {
const { provinceCode, provinceName, cityCode, cityName } = params;
// 省区域账户: 9 + provinceCode
const provinceAccountSequence = `9${provinceCode}`;
const provinceUserId = BigInt(provinceAccountSequence);
// 市区域账户: 8 + cityCode
const cityAccountSequence = `8${cityCode}`;
const cityUserId = BigInt(cityAccountSequence);
this.logger.log(`确保区域账户存在: 省=${provinceAccountSequence}, 市=${cityAccountSequence}`);
// 检查省账户是否已存在
let provinceWallet = await this.walletRepo.findByAccountSequence(provinceAccountSequence);
let provinceCreated = false;
if (!provinceWallet) {
provinceWallet = await this.walletRepo.getOrCreate(provinceAccountSequence, provinceUserId);
provinceCreated = true;
this.logger.log(`创建省区域账户: ${provinceAccountSequence} (${provinceName})`);
}
// 检查市账户是否已存在
let cityWallet = await this.walletRepo.findByAccountSequence(cityAccountSequence);
let cityCreated = false;
if (!cityWallet) {
cityWallet = await this.walletRepo.getOrCreate(cityAccountSequence, cityUserId);
cityCreated = true;
this.logger.log(`创建市区域账户: ${cityAccountSequence} (${cityName})`);
}
return {
provinceAccount: {
accountSequence: provinceAccountSequence,
created: provinceCreated,
},
cityAccount: {
accountSequence: cityAccountSequence,
created: cityCreated,
},
};
}
// =============== Withdrawal ===============
/**