feat(contract-signing): 添加电子合同签署功能及单点配置优化
- planting-service: 添加合同签署任务管理和超时检测 - 新增 ContractSigningTask 领域模型 - 添加 24 小时合同签署超时定时任务 - 支付后资金保持冻结,由 referral-service 统一确认扣款 - referral-service: 单点配置 CONTRACT_SIGNING_ENABLED - 新增 ContractSigningHandler 处理合同签署/超时事件 - 新增 WalletServiceClient 调用钱包服务确认扣款 - planting.created 处理后根据配置决定是否等待合同签署 - reward-service: 移除 CONTRACT_SIGNING_ENABLED 配置 - 扣款确认由 referral-service 在发送事件前完成 - 保持奖励分配逻辑不变 配置说明: - CONTRACT_SIGNING_ENABLED=true (默认): 等待合同签署后分配奖励 - CONTRACT_SIGNING_ENABLED=false: 立即分配奖励(原有流程) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1b3d545c0d
commit
714ce42e4f
|
|
@ -71,6 +71,9 @@
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.1.3"
|
"typescript": "^5.1.3"
|
||||||
},
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
"js",
|
"js",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "contract_templates" (
|
||||||
|
"template_id" SERIAL NOT NULL,
|
||||||
|
"version" VARCHAR(20) NOT NULL,
|
||||||
|
"title" VARCHAR(200) NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"effective_from" TIMESTAMP(3) NOT NULL,
|
||||||
|
"effective_to" TIMESTAMP(3),
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "contract_templates_pkey" PRIMARY KEY ("template_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "contract_signing_tasks" (
|
||||||
|
"task_id" BIGSERIAL NOT NULL,
|
||||||
|
"order_no" VARCHAR(50) NOT NULL,
|
||||||
|
"user_id" BIGINT NOT NULL,
|
||||||
|
"account_sequence" VARCHAR(20) NOT NULL,
|
||||||
|
"template_id" INTEGER NOT NULL,
|
||||||
|
"contract_version" VARCHAR(20) NOT NULL,
|
||||||
|
"contract_content" TEXT NOT NULL,
|
||||||
|
"user_phone_number" VARCHAR(20),
|
||||||
|
"user_real_name" VARCHAR(50),
|
||||||
|
"user_id_card_number" VARCHAR(50),
|
||||||
|
"tree_count" INTEGER NOT NULL,
|
||||||
|
"total_amount" DECIMAL(20,8) NOT NULL,
|
||||||
|
"province_code" VARCHAR(10) NOT NULL,
|
||||||
|
"province_name" VARCHAR(50) NOT NULL,
|
||||||
|
"city_code" VARCHAR(10) NOT NULL,
|
||||||
|
"city_name" VARCHAR(50) NOT NULL,
|
||||||
|
"status" VARCHAR(30) NOT NULL DEFAULT 'PENDING',
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"scrolled_to_bottom_at" TIMESTAMP(3),
|
||||||
|
"acknowledged_at" TIMESTAMP(3),
|
||||||
|
"signed_at" TIMESTAMP(3),
|
||||||
|
"signature_cloud_url" VARCHAR(500),
|
||||||
|
"signature_hash" VARCHAR(64),
|
||||||
|
"signing_ip_address" VARCHAR(50),
|
||||||
|
"signing_device_info" TEXT,
|
||||||
|
"signing_user_agent" VARCHAR(500),
|
||||||
|
"signing_latitude" DECIMAL(10,8),
|
||||||
|
"signing_longitude" DECIMAL(11,8),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "contract_signing_tasks_pkey" PRIMARY KEY ("task_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "contract_templates_version_key" ON "contract_templates"("version");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "contract_templates_is_active_effective_from_idx" ON "contract_templates"("is_active", "effective_from");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "contract_signing_tasks_order_no_key" ON "contract_signing_tasks"("order_no");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "contract_signing_tasks_user_id_idx" ON "contract_signing_tasks"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "contract_signing_tasks_status_idx" ON "contract_signing_tasks"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "contract_signing_tasks_expires_at_idx" ON "contract_signing_tasks"("expires_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "contract_signing_tasks_status_expires_at_idx" ON "contract_signing_tasks"("status", "expires_at");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "contract_signing_tasks" ADD CONSTRAINT "contract_signing_tasks_template_id_fkey" FOREIGN KEY ("template_id") REFERENCES "contract_templates"("template_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
@ -277,3 +277,99 @@ model PaymentCompensation {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("payment_compensations")
|
@@map("payment_compensations")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 合同模板表
|
||||||
|
// 存储电子认种合同的模板内容,支持版本管理
|
||||||
|
// ============================================
|
||||||
|
model ContractTemplate {
|
||||||
|
id Int @id @default(autoincrement()) @map("template_id")
|
||||||
|
version String @unique @map("version") @db.VarChar(20) // 版本号: v1.0.0
|
||||||
|
|
||||||
|
// 合同内容
|
||||||
|
title String @map("title") @db.VarChar(200) // 合同标题
|
||||||
|
content String @map("content") @db.Text // 合同HTML内容,包含占位符
|
||||||
|
|
||||||
|
// 生效时间
|
||||||
|
effectiveFrom DateTime @map("effective_from")
|
||||||
|
effectiveTo DateTime? @map("effective_to") // null表示永久有效
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// 关联
|
||||||
|
signingTasks ContractSigningTask[]
|
||||||
|
|
||||||
|
@@index([isActive, effectiveFrom])
|
||||||
|
@@map("contract_templates")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 合同签署任务表
|
||||||
|
// 独立模块,不影响现有认种流程
|
||||||
|
// 通过订阅 OrderPaid 事件创建签署任务
|
||||||
|
// ============================================
|
||||||
|
model ContractSigningTask {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("task_id")
|
||||||
|
|
||||||
|
// 关联信息
|
||||||
|
orderNo String @unique @map("order_no") @db.VarChar(50)
|
||||||
|
userId BigInt @map("user_id")
|
||||||
|
accountSequence String @map("account_sequence") @db.VarChar(20)
|
||||||
|
|
||||||
|
// 合同信息
|
||||||
|
templateId Int @map("template_id")
|
||||||
|
contractVersion String @map("contract_version") @db.VarChar(20)
|
||||||
|
contractContent String @map("contract_content") @db.Text // 已填入用户信息的完整合同
|
||||||
|
|
||||||
|
// 用户信息快照 (签署时填入合同的信息)
|
||||||
|
userPhoneNumber String? @map("user_phone_number") @db.VarChar(20)
|
||||||
|
userRealName String? @map("user_real_name") @db.VarChar(50)
|
||||||
|
userIdCardNumber String? @map("user_id_card_number") @db.VarChar(50) // 脱敏存储
|
||||||
|
|
||||||
|
// 订单信息快照
|
||||||
|
treeCount Int @map("tree_count")
|
||||||
|
totalAmount Decimal @map("total_amount") @db.Decimal(20, 8)
|
||||||
|
provinceCode String @map("province_code") @db.VarChar(10)
|
||||||
|
provinceName String @map("province_name") @db.VarChar(50)
|
||||||
|
cityCode String @map("city_code") @db.VarChar(10)
|
||||||
|
cityName String @map("city_name") @db.VarChar(50)
|
||||||
|
|
||||||
|
// 签署状态
|
||||||
|
// PENDING: 待签署
|
||||||
|
// SCROLLED: 已滚动到底部
|
||||||
|
// ACKNOWLEDGED: 已确认法律效力
|
||||||
|
// SIGNED: 已签署完成
|
||||||
|
// UNSIGNED_TIMEOUT: 超时未签署
|
||||||
|
status String @default("PENDING") @map("status") @db.VarChar(30)
|
||||||
|
expiresAt DateTime @map("expires_at") // 24小时过期时间
|
||||||
|
|
||||||
|
// 签署进度时间戳
|
||||||
|
scrolledToBottomAt DateTime? @map("scrolled_to_bottom_at")
|
||||||
|
acknowledgedAt DateTime? @map("acknowledged_at")
|
||||||
|
signedAt DateTime? @map("signed_at")
|
||||||
|
|
||||||
|
// 签名数据
|
||||||
|
signatureCloudUrl String? @map("signature_cloud_url") @db.VarChar(500)
|
||||||
|
signatureHash String? @map("signature_hash") @db.VarChar(64) // SHA256
|
||||||
|
|
||||||
|
// 法律合规证据链
|
||||||
|
signingIpAddress String? @map("signing_ip_address") @db.VarChar(50)
|
||||||
|
signingDeviceInfo String? @map("signing_device_info") @db.Text // JSON格式
|
||||||
|
signingUserAgent String? @map("signing_user_agent") @db.VarChar(500)
|
||||||
|
signingLatitude Decimal? @map("signing_latitude") @db.Decimal(10, 8)
|
||||||
|
signingLongitude Decimal? @map("signing_longitude") @db.Decimal(11, 8)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
// 关联
|
||||||
|
template ContractTemplate @relation(fields: [templateId], references: [id])
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@map("contract_signing_tasks")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 创建初始合同模板(基于真实合同文档)
|
||||||
|
const contractTemplate = await prisma.contractTemplate.upsert({
|
||||||
|
where: { version: 'v1.0.0' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
version: 'v1.0.0',
|
||||||
|
title: '榴莲树联合种植协议',
|
||||||
|
content: `
|
||||||
|
<div class="contract-container" style="font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; padding: 20px; line-height: 1.8;">
|
||||||
|
<h1 style="text-align: center; font-size: 24px; font-weight: bold; margin-bottom: 30px; letter-spacing: 8px;">榴莲树联合种植协议</h1>
|
||||||
|
|
||||||
|
<div class="contract-info" style="margin-bottom: 30px; padding: 15px; background: #f9f9f9; border-radius: 8px;">
|
||||||
|
<p style="margin: 5px 0;"><strong>协议编号:</strong>{{ORDER_NO}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="font-size: 16px; font-weight: bold; margin: 20px 0 15px; border-bottom: 1px solid #ddd; padding-bottom: 10px;">甲方(发起方/公司)</h2>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px; width: 30%;">名称:</td>
|
||||||
|
<td style="padding: 10px;">海南民垦农业有限公司</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px;">地址:</td>
|
||||||
|
<td style="padding: 10px;">海南省海口市秀英区石山镇美社村313号F203</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px;">法定代表人:</td>
|
||||||
|
<td style="padding: 10px;">肖建</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 style="font-size: 16px; font-weight: bold; margin: 20px 0 15px; border-bottom: 1px solid #ddd; padding-bottom: 10px;">乙方(合伙人/投资人)</h2>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px; width: 30%;">姓名/名称:</td>
|
||||||
|
<td style="padding: 10px;">{{USER_REAL_NAME}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px;">身份证号:</td>
|
||||||
|
<td style="padding: 10px;">{{USER_ID_CARD}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px;">联系方式:</td>
|
||||||
|
<td style="padding: 10px;">{{USER_PHONE}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px;">账户编号:</td>
|
||||||
|
<td style="padding: 10px;">{{ACCOUNT_SEQUENCE}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="text-indent: 2em; margin: 20px 0;">鉴于甲方在榴莲种植、管理和生产销售方面具备专业能力,乙方看好榴莲市场未来的无限价值,甲、乙双方经友好协商,本着平等、互利、共赢的原则,就联合种植榴莲事宜达成如下协议:</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">一、合作事项</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">1、双方共同合作种植榴莲,种植地点为海南省白沙县榴莲种植基地及其他基地,首期种植面积4000亩,二期种植面积预计100000亩。</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">二、种植周期</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">1、种植周期:五年。</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">三、合作模式</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">甲方:负责种植、销售等产业具体实施及运营管理。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">乙方:负责榴莲种植所需资金。</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">四、认种数量</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">乙方认种 <strong style="color: #e74c3c; font-size: 18px;">{{TREE_COUNT}}</strong> 棵榴莲树苗。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">认种金额:<strong style="color: #e74c3c; font-size: 18px;">{{TOTAL_AMOUNT}} USDT</strong></p>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">种植区域:{{PROVINCE_NAME}} {{CITY_NAME}}</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">五、种植标准</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">1、本项目联合种植地点为海南省白沙县榴莲种植基地及周边地区。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">2、甲方按照每亩地10棵榴莲的标准进行种植。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">3、种植期限以5年为标准周期。</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">六、收益分配方案</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">1、收益计算标准:榴莲结果产量作为销售标准。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">2、分配比例:榴莲树产果后,乙方享有40%的榴莲果分配比例。</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">七、权利与义务</h3>
|
||||||
|
<p style="font-weight: bold; margin: 15px 0 5px;">(一)甲方权利与义务</p>
|
||||||
|
<p style="text-indent: 2em; margin: 5px 0;">1、甲方有权按照本协议约定获取相应收益。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 5px 0;">2、甲方保证种植技术达标。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 5px 0;">3、甲方完成种植、养护、生产、销售等一系列流程的顺利进行。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 5px 0;">4、甲方确保乙方的榴莲树在幼苗期因某些原因生长不良时补给乙方同期种植且正常生长的榴莲树。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 5px 0;">5、甲方确保乙方的榴莲树在第五年因某些原因不产果时,补给乙方同期种植且正常产果的榴莲树。</p>
|
||||||
|
|
||||||
|
<p style="font-weight: bold; margin: 15px 0 5px;">(二)乙方权利与义务</p>
|
||||||
|
<p style="text-indent: 2em; margin: 5px 0;">1、乙方有权按照本协议约定获取20年的榴莲果收益。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 5px 0;">2、乙方在种植期间不干涉正常种植管理与组织运营。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 5px 0;">3、乙方对公司采用的联合种植方案进行保密,不得泄露。</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">八、保密条款</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">双方应对本协议涉及的商业秘密、技术秘密等予以保密,未经对方书面同意,不得向任何第三方透露。如有违反保密条款,违约方应向对方赔偿因此造成的全部损失。</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">九、违约责任</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">若甲方未能履行本协议约定的技术、种植、管理或运营责任导致榴莲产量减少或质量下降,影响项目收益,双方应承担相应的赔偿责任,收益不足由甲方补偿。</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">十、协议变更与解除</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">1、本协议的变更或补充需经双方书面协商一致,并签署相关协议。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">2、在履行本协议过程中,如因不可抗力等不可预见、不可避免的因素导致本协议无法继续履行,双方可协商解除本协议,互不承担违约责任。</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">十一、争议解决</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">如双方在本协议履行过程中发生争议,应首先通过友好协商解决;协商不成的,任何一方均有权向甲方所在地的人民法院提起诉讼。</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 15px; font-weight: bold; margin: 25px 0 10px;">十二、其他条款</h3>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0;">1、本协议自双方签字(或盖章)之日起生效,有效期至本项目收益分配完毕之日止。</p>
|
||||||
|
<p style="text-indent: 2em; margin: 10px 0; color: #e74c3c; font-weight: bold;">2、电子合同具有同等法律效力。</p>
|
||||||
|
|
||||||
|
<div class="signature-section" style="margin-top: 50px; padding-top: 30px; border-top: 2px solid #333;">
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 40px;">
|
||||||
|
<div style="width: 45%;">
|
||||||
|
<p style="margin-bottom: 10px; font-weight: bold;">甲方(签字/盖章):</p>
|
||||||
|
<p style="margin-bottom: 5px;">海南民垦农业有限公司</p>
|
||||||
|
<div style="width: 150px; height: 60px; border: 1px dashed #ccc; margin: 10px 0; display: flex; align-items: center; justify-content: center; color: #999;">
|
||||||
|
[公司电子章]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="width: 45%;">
|
||||||
|
<p style="margin-bottom: 10px; font-weight: bold;">乙方(签字/盖章):</p>
|
||||||
|
<p style="margin-bottom: 5px;">{{USER_REAL_NAME}}</p>
|
||||||
|
<div style="width: 200px; height: 80px; border: 1px dashed #ccc; margin: 10px 0;">
|
||||||
|
{{USER_SIGNATURE}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="text-align: center; margin-top: 30px;"><strong>签约日期:</strong>{{SIGNING_DATE}}</p>
|
||||||
|
<p style="text-align: center; color: #666; font-size: 12px; margin-top: 10px;">签署时间戳:{{SIGNING_TIMESTAMP}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`.trim(),
|
||||||
|
effectiveFrom: new Date('2024-01-01'),
|
||||||
|
effectiveTo: null,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Created contract template:', contractTemplate.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||||
import { PlantingOrderController } from './controllers/planting-order.controller';
|
import { PlantingOrderController } from './controllers/planting-order.controller';
|
||||||
import { PlantingPositionController } from './controllers/planting-position.controller';
|
import { PlantingPositionController } from './controllers/planting-position.controller';
|
||||||
import { HealthController } from './controllers/health.controller';
|
import { HealthController } from './controllers/health.controller';
|
||||||
|
import { ContractSigningController } from './controllers/contract-signing.controller';
|
||||||
import { ApplicationModule } from '../application/application.module';
|
import { ApplicationModule } from '../application/application.module';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
|
@ -11,6 +12,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
PlantingOrderController,
|
PlantingOrderController,
|
||||||
PlantingPositionController,
|
PlantingPositionController,
|
||||||
HealthController,
|
HealthController,
|
||||||
|
ContractSigningController,
|
||||||
],
|
],
|
||||||
providers: [JwtAuthGuard],
|
providers: [JwtAuthGuard],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../infrastructure/auth/jwt-auth.guard';
|
||||||
|
import { ContractSigningService } from '../../application/services/contract-signing.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签署合同请求DTO
|
||||||
|
*/
|
||||||
|
interface SignContractDto {
|
||||||
|
signatureBase64: string; // Base64编码的签名图片
|
||||||
|
signatureHash: string; // SHA256哈希
|
||||||
|
deviceInfo: {
|
||||||
|
deviceId?: string;
|
||||||
|
deviceModel?: string;
|
||||||
|
osVersion?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
};
|
||||||
|
location?: {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同签署控制器
|
||||||
|
*
|
||||||
|
* 独立模块,提供合同签署相关的API
|
||||||
|
*/
|
||||||
|
@Controller('planting/contract-signing')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ContractSigningController {
|
||||||
|
private readonly logger = new Logger(ContractSigningController.name);
|
||||||
|
|
||||||
|
constructor(private readonly contractSigningService: ContractSigningService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户待签署的合同任务列表
|
||||||
|
*/
|
||||||
|
@Get('pending')
|
||||||
|
async getPendingTasks(@Request() req: { user: { userId: string } }) {
|
||||||
|
const userId = BigInt(req.user.userId);
|
||||||
|
const tasks = await this.contractSigningService.getPendingTasks(userId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: tasks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所有未签署的合同任务(包括超时的)
|
||||||
|
* 用于App启动时检查
|
||||||
|
*/
|
||||||
|
@Get('unsigned')
|
||||||
|
async getUnsignedTasks(@Request() req: { user: { userId: string } }) {
|
||||||
|
const userId = BigInt(req.user.userId);
|
||||||
|
const tasks = await this.contractSigningService.getUnsignedTasks(userId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: tasks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签署任务详情
|
||||||
|
*/
|
||||||
|
@Get('tasks/:orderNo')
|
||||||
|
async getTask(
|
||||||
|
@Param('orderNo') orderNo: string,
|
||||||
|
@Request() req: { user: { userId: string } },
|
||||||
|
) {
|
||||||
|
const userId = BigInt(req.user.userId);
|
||||||
|
const task = await this.contractSigningService.getTask(orderNo, userId);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '签署任务不存在',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: task,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录用户已滚动到底部
|
||||||
|
*/
|
||||||
|
@Post('tasks/:orderNo/scroll-complete')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async markScrollComplete(
|
||||||
|
@Param('orderNo') orderNo: string,
|
||||||
|
@Request() req: { user: { userId: string } },
|
||||||
|
) {
|
||||||
|
const userId = BigInt(req.user.userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.contractSigningService.markScrollComplete(orderNo, userId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '已记录滚动到底部',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to mark scroll complete: ${error.message}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录用户确认法律效力
|
||||||
|
*/
|
||||||
|
@Post('tasks/:orderNo/acknowledge')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async acknowledgeContract(
|
||||||
|
@Param('orderNo') orderNo: string,
|
||||||
|
@Request() req: { user: { userId: string } },
|
||||||
|
) {
|
||||||
|
const userId = BigInt(req.user.userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.contractSigningService.acknowledgeContract(orderNo, userId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '已确认法律效力',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to acknowledge contract: ${error.message}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传签名并完成签署
|
||||||
|
*/
|
||||||
|
@Post('tasks/:orderNo/sign')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async signContract(
|
||||||
|
@Param('orderNo') orderNo: string,
|
||||||
|
@Body() dto: SignContractDto,
|
||||||
|
@Request() req: { user: { userId: string }; ip: string; headers: { 'user-agent'?: string } },
|
||||||
|
) {
|
||||||
|
const userId = BigInt(req.user.userId);
|
||||||
|
const ipAddress = req.ip || 'unknown';
|
||||||
|
const userAgent = req.headers['user-agent'] || 'unknown';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: 上传签名图片到云存储,获取URL
|
||||||
|
// 目前暂时使用base64数据作为URL占位
|
||||||
|
const signatureCloudUrl = `data:image/png;base64,${dto.signatureBase64.slice(0, 100)}...`;
|
||||||
|
|
||||||
|
await this.contractSigningService.signContract(orderNo, userId, {
|
||||||
|
signatureCloudUrl,
|
||||||
|
signatureHash: dto.signatureHash,
|
||||||
|
ipAddress,
|
||||||
|
deviceInfo: dto.deviceInfo,
|
||||||
|
userAgent,
|
||||||
|
location: dto.location,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '合同签署成功',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to sign contract: ${error.message}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 补签合同(超时后用户仍可补签)
|
||||||
|
*/
|
||||||
|
@Post('tasks/:orderNo/late-sign')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async lateSignContract(
|
||||||
|
@Param('orderNo') orderNo: string,
|
||||||
|
@Body() dto: SignContractDto,
|
||||||
|
@Request() req: { user: { userId: string }; ip: string; headers: { 'user-agent'?: string } },
|
||||||
|
) {
|
||||||
|
const userId = BigInt(req.user.userId);
|
||||||
|
const ipAddress = req.ip || 'unknown';
|
||||||
|
const userAgent = req.headers['user-agent'] || 'unknown';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: 上传签名图片到云存储,获取URL
|
||||||
|
const signatureCloudUrl = `data:image/png;base64,${dto.signatureBase64.slice(0, 100)}...`;
|
||||||
|
|
||||||
|
await this.contractSigningService.lateSignContract(orderNo, userId, {
|
||||||
|
signatureCloudUrl,
|
||||||
|
signatureHash: dto.signatureHash,
|
||||||
|
ipAddress,
|
||||||
|
deviceInfo: dto.deviceInfo,
|
||||||
|
userAgent,
|
||||||
|
location: dto.location,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '合同补签成功',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to late-sign contract: ${error.message}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前有效的合同模板(供前端预览)
|
||||||
|
*/
|
||||||
|
@Get('template')
|
||||||
|
async getActiveTemplate() {
|
||||||
|
const template = await this.contractSigningService.getActiveTemplate();
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '没有可用的合同模板',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
version: template.version,
|
||||||
|
title: template.title,
|
||||||
|
content: template.content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './planting-order.controller';
|
export * from './planting-order.controller';
|
||||||
export * from './planting-position.controller';
|
export * from './planting-position.controller';
|
||||||
export * from './health.controller';
|
export * from './health.controller';
|
||||||
|
export * from './contract-signing.controller';
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { PlantingApplicationService } from './services/planting-application.service';
|
import { PlantingApplicationService } from './services/planting-application.service';
|
||||||
import { PoolInjectionService } from './services/pool-injection.service';
|
import { PoolInjectionService } from './services/pool-injection.service';
|
||||||
|
import { ContractSigningService } from './services/contract-signing.service';
|
||||||
|
import { ContractSigningTimeoutJob } from './jobs/contract-signing-timeout.job';
|
||||||
import { DomainModule } from '../domain/domain.module';
|
import { DomainModule } from '../domain/domain.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DomainModule],
|
imports: [DomainModule, ScheduleModule.forRoot()],
|
||||||
providers: [PlantingApplicationService, PoolInjectionService],
|
providers: [
|
||||||
exports: [PlantingApplicationService, PoolInjectionService],
|
PlantingApplicationService,
|
||||||
|
PoolInjectionService,
|
||||||
|
ContractSigningService,
|
||||||
|
ContractSigningTimeoutJob,
|
||||||
|
],
|
||||||
|
exports: [PlantingApplicationService, PoolInjectionService, ContractSigningService],
|
||||||
})
|
})
|
||||||
export class ApplicationModule {}
|
export class ApplicationModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { ContractSigningService } from '../services/contract-signing.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同签署超时定时任务
|
||||||
|
*
|
||||||
|
* 每5分钟扫描一次过期未签署的合同任务
|
||||||
|
* 将其状态标记为 UNSIGNED_TIMEOUT
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContractSigningTimeoutJob {
|
||||||
|
private readonly logger = new Logger(ContractSigningTimeoutJob.name);
|
||||||
|
|
||||||
|
constructor(private readonly contractSigningService: ContractSigningService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每5分钟执行一次
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||||
|
async handleTimeout(): Promise<void> {
|
||||||
|
this.logger.debug('[CONTRACT-TIMEOUT] Starting timeout check...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = await this.contractSigningService.handleExpiredTasks();
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
this.logger.log(`[CONTRACT-TIMEOUT] Processed ${count} expired signing tasks`);
|
||||||
|
} else {
|
||||||
|
this.logger.debug('[CONTRACT-TIMEOUT] No expired tasks found');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('[CONTRACT-TIMEOUT] Error processing expired tasks:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './contract-signing-timeout.job';
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
IContractTemplateRepository,
|
||||||
|
CONTRACT_TEMPLATE_REPOSITORY,
|
||||||
|
IContractSigningTaskRepository,
|
||||||
|
CONTRACT_SIGNING_TASK_REPOSITORY,
|
||||||
|
} from '../../domain/repositories';
|
||||||
|
import {
|
||||||
|
ContractTemplate,
|
||||||
|
ContractSigningTask,
|
||||||
|
SignContractParams,
|
||||||
|
} from '../../domain/aggregates';
|
||||||
|
import { ContractSigningStatus } from '../../domain/value-objects';
|
||||||
|
import { EventPublisherService } from '../../infrastructure/kafka/event-publisher.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建签署任务的参数
|
||||||
|
*/
|
||||||
|
export interface CreateSigningTaskParams {
|
||||||
|
orderNo: string;
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
provinceName: string;
|
||||||
|
cityCode: string;
|
||||||
|
cityName: string;
|
||||||
|
userPhoneNumber?: string;
|
||||||
|
userRealName?: string;
|
||||||
|
userIdCardNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签署任务DTO
|
||||||
|
*/
|
||||||
|
export interface ContractSigningTaskDto {
|
||||||
|
orderNo: string;
|
||||||
|
contractVersion: string;
|
||||||
|
contractContent: string;
|
||||||
|
status: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
treeCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceName: string;
|
||||||
|
cityName: string;
|
||||||
|
userRealName?: string;
|
||||||
|
scrolledToBottomAt?: Date;
|
||||||
|
acknowledgedAt?: Date;
|
||||||
|
signedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同签署应用服务
|
||||||
|
*
|
||||||
|
* 独立模块,不影响现有认种流程
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContractSigningService {
|
||||||
|
private readonly logger = new Logger(ContractSigningService.name);
|
||||||
|
|
||||||
|
// 签署有效期(24小时)
|
||||||
|
private static readonly SIGNING_EXPIRY_HOURS = 24;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(CONTRACT_TEMPLATE_REPOSITORY)
|
||||||
|
private readonly templateRepo: IContractTemplateRepository,
|
||||||
|
@Inject(CONTRACT_SIGNING_TASK_REPOSITORY)
|
||||||
|
private readonly taskRepo: IContractSigningTaskRepository,
|
||||||
|
private readonly eventPublisher: EventPublisherService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为已支付订单创建合同签署任务
|
||||||
|
* 由事件消费者调用
|
||||||
|
*/
|
||||||
|
async createSigningTask(params: CreateSigningTaskParams): Promise<ContractSigningTask> {
|
||||||
|
this.logger.log(`Creating contract signing task for order: ${params.orderNo}`);
|
||||||
|
|
||||||
|
// 1. 检查是否已存在签署任务
|
||||||
|
const exists = await this.taskRepo.existsByOrderNo(params.orderNo);
|
||||||
|
if (exists) {
|
||||||
|
this.logger.warn(`Signing task already exists for order: ${params.orderNo}`);
|
||||||
|
const existing = await this.taskRepo.findByOrderNo(params.orderNo);
|
||||||
|
return existing!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取当前有效的合同模板
|
||||||
|
const template = await this.templateRepo.findActiveTemplate();
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('没有可用的合同模板');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 生成填充用户信息后的合同内容
|
||||||
|
const contractContent = template.generateContractContent({
|
||||||
|
userPhoneNumber: params.userPhoneNumber,
|
||||||
|
userRealName: params.userRealName,
|
||||||
|
userIdCardNumber: params.userIdCardNumber,
|
||||||
|
treeCount: params.treeCount,
|
||||||
|
totalAmount: params.totalAmount,
|
||||||
|
provinceName: params.provinceName,
|
||||||
|
cityName: params.cityName,
|
||||||
|
orderNo: params.orderNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 计算过期时间(24小时后)
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + ContractSigningService.SIGNING_EXPIRY_HOURS);
|
||||||
|
|
||||||
|
// 5. 创建签署任务
|
||||||
|
const task = ContractSigningTask.create({
|
||||||
|
orderNo: params.orderNo,
|
||||||
|
userId: params.userId,
|
||||||
|
accountSequence: params.accountSequence,
|
||||||
|
templateId: template.id!,
|
||||||
|
contractVersion: template.version,
|
||||||
|
contractContent,
|
||||||
|
userPhoneNumber: params.userPhoneNumber,
|
||||||
|
userRealName: params.userRealName,
|
||||||
|
userIdCardNumber: params.userIdCardNumber,
|
||||||
|
treeCount: params.treeCount,
|
||||||
|
totalAmount: params.totalAmount,
|
||||||
|
provinceCode: params.provinceCode,
|
||||||
|
provinceName: params.provinceName,
|
||||||
|
cityCode: params.cityCode,
|
||||||
|
cityName: params.cityName,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedTask = await this.taskRepo.save(task);
|
||||||
|
this.logger.log(`Created signing task for order: ${params.orderNo}, expires at: ${expiresAt}`);
|
||||||
|
|
||||||
|
return savedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户待签署的合同任务
|
||||||
|
*/
|
||||||
|
async getPendingTasks(userId: bigint): Promise<ContractSigningTaskDto[]> {
|
||||||
|
const tasks = await this.taskRepo.findPendingByUserId(userId);
|
||||||
|
return tasks.map((t) => this.toDto(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所有未签署的合同任务(包括超时的)
|
||||||
|
*/
|
||||||
|
async getUnsignedTasks(userId: bigint): Promise<ContractSigningTaskDto[]> {
|
||||||
|
const tasks = await this.taskRepo.findUnsignedByUserId(userId);
|
||||||
|
return tasks.map((t) => this.toDto(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签署任务详情
|
||||||
|
*/
|
||||||
|
async getTask(orderNo: string, userId: bigint): Promise<ContractSigningTaskDto | null> {
|
||||||
|
const task = await this.taskRepo.findByOrderNo(orderNo);
|
||||||
|
if (!task || task.userId !== userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.toDto(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录用户已滚动到底部
|
||||||
|
*/
|
||||||
|
async markScrollComplete(orderNo: string, userId: bigint): Promise<void> {
|
||||||
|
const task = await this.taskRepo.findByOrderNo(orderNo);
|
||||||
|
if (!task || task.userId !== userId) {
|
||||||
|
throw new Error('签署任务不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
task.markScrolledToBottom();
|
||||||
|
await this.taskRepo.save(task);
|
||||||
|
this.logger.log(`User ${userId} scrolled to bottom for order: ${orderNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录用户确认法律效力
|
||||||
|
*/
|
||||||
|
async acknowledgeContract(orderNo: string, userId: bigint): Promise<void> {
|
||||||
|
const task = await this.taskRepo.findByOrderNo(orderNo);
|
||||||
|
if (!task || task.userId !== userId) {
|
||||||
|
throw new Error('签署任务不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
task.acknowledge();
|
||||||
|
await this.taskRepo.save(task);
|
||||||
|
this.logger.log(`User ${userId} acknowledged contract for order: ${orderNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成签署
|
||||||
|
* 发布 contract.signed 事件,触发 reward-service 执行奖励分配
|
||||||
|
*/
|
||||||
|
async signContract(
|
||||||
|
orderNo: string,
|
||||||
|
userId: bigint,
|
||||||
|
params: SignContractParams,
|
||||||
|
): Promise<void> {
|
||||||
|
const task = await this.taskRepo.findByOrderNo(orderNo);
|
||||||
|
if (!task || task.userId !== userId) {
|
||||||
|
throw new Error('签署任务不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
task.sign(params);
|
||||||
|
await this.taskRepo.save(task);
|
||||||
|
this.logger.log(`User ${userId} signed contract for order: ${orderNo}`);
|
||||||
|
|
||||||
|
// 发布合同签署完成事件,触发奖励分配
|
||||||
|
await this.eventPublisher.publishContractSigned({
|
||||||
|
orderNo: task.orderNo,
|
||||||
|
userId: task.userId.toString(),
|
||||||
|
accountSequence: task.accountSequence,
|
||||||
|
treeCount: task.treeCount,
|
||||||
|
totalAmount: task.totalAmount,
|
||||||
|
provinceCode: task.provinceCode,
|
||||||
|
cityCode: task.cityCode,
|
||||||
|
signedAt: task.signedAt?.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 补签合同(超时后用户仍可补签)
|
||||||
|
*/
|
||||||
|
async lateSignContract(
|
||||||
|
orderNo: string,
|
||||||
|
userId: bigint,
|
||||||
|
params: SignContractParams,
|
||||||
|
): Promise<void> {
|
||||||
|
const task = await this.taskRepo.findByOrderNo(orderNo);
|
||||||
|
if (!task || task.userId !== userId) {
|
||||||
|
throw new Error('签署任务不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
task.lateSign(params);
|
||||||
|
await this.taskRepo.save(task);
|
||||||
|
this.logger.log(`User ${userId} late-signed contract for order: ${orderNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理过期未签署的任务
|
||||||
|
* 由定时任务调用
|
||||||
|
* 发布 contract.expired 事件,触发 reward-service 执行系统账户分配
|
||||||
|
*/
|
||||||
|
async handleExpiredTasks(): Promise<number> {
|
||||||
|
const expiredTasks = await this.taskRepo.findExpiredPendingTasks();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const task of expiredTasks) {
|
||||||
|
try {
|
||||||
|
task.markAsTimeout();
|
||||||
|
await this.taskRepo.save(task);
|
||||||
|
|
||||||
|
// 发布合同超时事件,触发系统账户奖励分配
|
||||||
|
await this.eventPublisher.publishContractExpired({
|
||||||
|
orderNo: task.orderNo,
|
||||||
|
userId: task.userId.toString(),
|
||||||
|
accountSequence: task.accountSequence,
|
||||||
|
treeCount: task.treeCount,
|
||||||
|
totalAmount: task.totalAmount,
|
||||||
|
provinceCode: task.provinceCode,
|
||||||
|
cityCode: task.cityCode,
|
||||||
|
expiredAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
count++;
|
||||||
|
this.logger.log(`Marked task as timeout and published contract.expired: orderNo=${task.orderNo}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to handle expired task: orderNo=${task.orderNo}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
this.logger.log(`Processed ${count} expired signing tasks`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前有效的合同模板
|
||||||
|
*/
|
||||||
|
async getActiveTemplate(): Promise<ContractTemplate | null> {
|
||||||
|
return this.templateRepo.findActiveTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建合同模板(管理后台使用)
|
||||||
|
*/
|
||||||
|
async createTemplate(params: {
|
||||||
|
version: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
effectiveFrom: Date;
|
||||||
|
effectiveTo?: Date;
|
||||||
|
}): Promise<ContractTemplate> {
|
||||||
|
const template = ContractTemplate.create(params);
|
||||||
|
return this.templateRepo.save(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(task: ContractSigningTask): ContractSigningTaskDto {
|
||||||
|
return {
|
||||||
|
orderNo: task.orderNo,
|
||||||
|
contractVersion: task.contractVersion,
|
||||||
|
contractContent: task.contractContent,
|
||||||
|
status: task.status,
|
||||||
|
expiresAt: task.expiresAt,
|
||||||
|
treeCount: task.treeCount,
|
||||||
|
totalAmount: task.totalAmount,
|
||||||
|
provinceName: task.provinceName,
|
||||||
|
cityName: task.cityName,
|
||||||
|
userRealName: task.userRealName,
|
||||||
|
scrolledToBottomAt: task.scrolledToBottomAt,
|
||||||
|
acknowledgedAt: task.acknowledgedAt,
|
||||||
|
signedAt: task.signedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './planting-application.service';
|
export * from './planting-application.service';
|
||||||
export * from './pool-injection.service';
|
export * from './pool-injection.service';
|
||||||
|
export * from './contract-signing.service';
|
||||||
|
|
|
||||||
|
|
@ -288,20 +288,12 @@ export class PlantingApplicationService {
|
||||||
|
|
||||||
this.logger.log(`Local database transaction committed for order ${order.orderNo}`);
|
this.logger.log(`Local database transaction committed for order ${order.orderNo}`);
|
||||||
|
|
||||||
// ==================== 确认阶段 ====================
|
// 资金保持冻结状态,由 referral-service 在发送 planting.order.paid 前统一确认扣款
|
||||||
// 9. 确认扣款(从冻结金额中正式扣除)
|
// 这样 planting-service 无需关心 CONTRACT_SIGNING_ENABLED 配置
|
||||||
// 钱会进入"待分配"状态,由 reward-service 通过事件触发后执行真正的分配
|
this.logger.log(
|
||||||
await this.walletService.confirmPlantingDeduction({
|
`Order ${order.orderNo} paid, funds remain FROZEN. ` +
|
||||||
userId: userId.toString(),
|
`Deduction will be confirmed by referral-service before reward distribution.`,
|
||||||
accountSequence: accountSequence,
|
);
|
||||||
orderId: order.orderNo,
|
|
||||||
});
|
|
||||||
this.logger.log(`Wallet deduction confirmed for order ${order.orderNo}`);
|
|
||||||
|
|
||||||
// 注意:资金分配已移至 reward-service
|
|
||||||
// reward-service 收到 planting.order.paid 事件后,会:
|
|
||||||
// 1. 调用 authorization-service 获取考核后的分配方案
|
|
||||||
// 2. 调用 wallet-service 执行真正的资金分配
|
|
||||||
|
|
||||||
this.logger.log(`Order paid successfully: ${order.orderNo}`);
|
this.logger.log(`Order paid successfully: ${order.orderNo}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,440 @@
|
||||||
|
/**
|
||||||
|
* 合同签署任务聚合根
|
||||||
|
*
|
||||||
|
* 独立模块,不影响现有认种流程
|
||||||
|
* 通过订阅 OrderPaid 事件创建签署任务
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ContractSigningStatus } from '../value-objects/contract-signing-status.enum';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备信息
|
||||||
|
*/
|
||||||
|
export interface DeviceInfo {
|
||||||
|
deviceId?: string;
|
||||||
|
deviceModel?: string;
|
||||||
|
osVersion?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签署位置信息
|
||||||
|
*/
|
||||||
|
export interface SigningLocation {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建合同签署任务的参数
|
||||||
|
*/
|
||||||
|
export interface CreateContractSigningTaskParams {
|
||||||
|
orderNo: string;
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
templateId: number;
|
||||||
|
contractVersion: string;
|
||||||
|
contractContent: string;
|
||||||
|
userPhoneNumber?: string;
|
||||||
|
userRealName?: string;
|
||||||
|
userIdCardNumber?: string;
|
||||||
|
treeCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
provinceName: string;
|
||||||
|
cityCode: string;
|
||||||
|
cityName: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签署合同的参数
|
||||||
|
*/
|
||||||
|
export interface SignContractParams {
|
||||||
|
signatureCloudUrl: string;
|
||||||
|
signatureHash: string;
|
||||||
|
ipAddress: string;
|
||||||
|
deviceInfo: DeviceInfo;
|
||||||
|
userAgent: string;
|
||||||
|
location?: SigningLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContractSigningTask {
|
||||||
|
private _id?: bigint;
|
||||||
|
private _orderNo: string;
|
||||||
|
private _userId: bigint;
|
||||||
|
private _accountSequence: string;
|
||||||
|
private _templateId: number;
|
||||||
|
private _contractVersion: string;
|
||||||
|
private _contractContent: string;
|
||||||
|
private _userPhoneNumber?: string;
|
||||||
|
private _userRealName?: string;
|
||||||
|
private _userIdCardNumber?: string;
|
||||||
|
private _treeCount: number;
|
||||||
|
private _totalAmount: number;
|
||||||
|
private _provinceCode: string;
|
||||||
|
private _provinceName: string;
|
||||||
|
private _cityCode: string;
|
||||||
|
private _cityName: string;
|
||||||
|
private _status: ContractSigningStatus;
|
||||||
|
private _expiresAt: Date;
|
||||||
|
private _scrolledToBottomAt?: Date;
|
||||||
|
private _acknowledgedAt?: Date;
|
||||||
|
private _signedAt?: Date;
|
||||||
|
private _signatureCloudUrl?: string;
|
||||||
|
private _signatureHash?: string;
|
||||||
|
private _signingIpAddress?: string;
|
||||||
|
private _signingDeviceInfo?: DeviceInfo;
|
||||||
|
private _signingUserAgent?: string;
|
||||||
|
private _signingLatitude?: number;
|
||||||
|
private _signingLongitude?: number;
|
||||||
|
private _createdAt: Date;
|
||||||
|
private _updatedAt: Date;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this._createdAt = new Date();
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 工厂方法
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的合同签署任务
|
||||||
|
*/
|
||||||
|
static create(params: CreateContractSigningTaskParams): ContractSigningTask {
|
||||||
|
const task = new ContractSigningTask();
|
||||||
|
task._orderNo = params.orderNo;
|
||||||
|
task._userId = params.userId;
|
||||||
|
task._accountSequence = params.accountSequence;
|
||||||
|
task._templateId = params.templateId;
|
||||||
|
task._contractVersion = params.contractVersion;
|
||||||
|
task._contractContent = params.contractContent;
|
||||||
|
task._userPhoneNumber = params.userPhoneNumber;
|
||||||
|
task._userRealName = params.userRealName;
|
||||||
|
task._userIdCardNumber = params.userIdCardNumber;
|
||||||
|
task._treeCount = params.treeCount;
|
||||||
|
task._totalAmount = params.totalAmount;
|
||||||
|
task._provinceCode = params.provinceCode;
|
||||||
|
task._provinceName = params.provinceName;
|
||||||
|
task._cityCode = params.cityCode;
|
||||||
|
task._cityName = params.cityName;
|
||||||
|
task._status = ContractSigningStatus.PENDING;
|
||||||
|
task._expiresAt = params.expiresAt;
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久化数据重建
|
||||||
|
*/
|
||||||
|
static reconstitute(data: {
|
||||||
|
id: bigint;
|
||||||
|
orderNo: string;
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
templateId: number;
|
||||||
|
contractVersion: string;
|
||||||
|
contractContent: string;
|
||||||
|
userPhoneNumber?: string;
|
||||||
|
userRealName?: string;
|
||||||
|
userIdCardNumber?: string;
|
||||||
|
treeCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
provinceName: string;
|
||||||
|
cityCode: string;
|
||||||
|
cityName: string;
|
||||||
|
status: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
scrolledToBottomAt?: Date;
|
||||||
|
acknowledgedAt?: Date;
|
||||||
|
signedAt?: Date;
|
||||||
|
signatureCloudUrl?: string;
|
||||||
|
signatureHash?: string;
|
||||||
|
signingIpAddress?: string;
|
||||||
|
signingDeviceInfo?: DeviceInfo;
|
||||||
|
signingUserAgent?: string;
|
||||||
|
signingLatitude?: number;
|
||||||
|
signingLongitude?: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): ContractSigningTask {
|
||||||
|
const task = new ContractSigningTask();
|
||||||
|
task._id = data.id;
|
||||||
|
task._orderNo = data.orderNo;
|
||||||
|
task._userId = data.userId;
|
||||||
|
task._accountSequence = data.accountSequence;
|
||||||
|
task._templateId = data.templateId;
|
||||||
|
task._contractVersion = data.contractVersion;
|
||||||
|
task._contractContent = data.contractContent;
|
||||||
|
task._userPhoneNumber = data.userPhoneNumber;
|
||||||
|
task._userRealName = data.userRealName;
|
||||||
|
task._userIdCardNumber = data.userIdCardNumber;
|
||||||
|
task._treeCount = data.treeCount;
|
||||||
|
task._totalAmount = data.totalAmount;
|
||||||
|
task._provinceCode = data.provinceCode;
|
||||||
|
task._provinceName = data.provinceName;
|
||||||
|
task._cityCode = data.cityCode;
|
||||||
|
task._cityName = data.cityName;
|
||||||
|
task._status = data.status as ContractSigningStatus;
|
||||||
|
task._expiresAt = data.expiresAt;
|
||||||
|
task._scrolledToBottomAt = data.scrolledToBottomAt;
|
||||||
|
task._acknowledgedAt = data.acknowledgedAt;
|
||||||
|
task._signedAt = data.signedAt;
|
||||||
|
task._signatureCloudUrl = data.signatureCloudUrl;
|
||||||
|
task._signatureHash = data.signatureHash;
|
||||||
|
task._signingIpAddress = data.signingIpAddress;
|
||||||
|
task._signingDeviceInfo = data.signingDeviceInfo;
|
||||||
|
task._signingUserAgent = data.signingUserAgent;
|
||||||
|
task._signingLatitude = data.signingLatitude;
|
||||||
|
task._signingLongitude = data.signingLongitude;
|
||||||
|
task._createdAt = data.createdAt;
|
||||||
|
task._updatedAt = data.updatedAt;
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Getters
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
get id(): bigint | undefined {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get orderNo(): string {
|
||||||
|
return this._orderNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userId(): bigint {
|
||||||
|
return this._userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get accountSequence(): string {
|
||||||
|
return this._accountSequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
get templateId(): number {
|
||||||
|
return this._templateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get contractVersion(): string {
|
||||||
|
return this._contractVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
get contractContent(): string {
|
||||||
|
return this._contractContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userPhoneNumber(): string | undefined {
|
||||||
|
return this._userPhoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userRealName(): string | undefined {
|
||||||
|
return this._userRealName;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userIdCardNumber(): string | undefined {
|
||||||
|
return this._userIdCardNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
get treeCount(): number {
|
||||||
|
return this._treeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalAmount(): number {
|
||||||
|
return this._totalAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get provinceCode(): string {
|
||||||
|
return this._provinceCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
get provinceName(): string {
|
||||||
|
return this._provinceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cityCode(): string {
|
||||||
|
return this._cityCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cityName(): string {
|
||||||
|
return this._cityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): ContractSigningStatus {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
|
||||||
|
get expiresAt(): Date {
|
||||||
|
return this._expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get scrolledToBottomAt(): Date | undefined {
|
||||||
|
return this._scrolledToBottomAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get acknowledgedAt(): Date | undefined {
|
||||||
|
return this._acknowledgedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signedAt(): Date | undefined {
|
||||||
|
return this._signedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signatureCloudUrl(): string | undefined {
|
||||||
|
return this._signatureCloudUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signatureHash(): string | undefined {
|
||||||
|
return this._signatureHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signingIpAddress(): string | undefined {
|
||||||
|
return this._signingIpAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signingDeviceInfo(): DeviceInfo | undefined {
|
||||||
|
return this._signingDeviceInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signingUserAgent(): string | undefined {
|
||||||
|
return this._signingUserAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signingLatitude(): number | undefined {
|
||||||
|
return this._signingLatitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
get signingLongitude(): number | undefined {
|
||||||
|
return this._signingLongitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
get createdAt(): Date {
|
||||||
|
return this._createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get updatedAt(): Date {
|
||||||
|
return this._updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 业务方法
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已过期
|
||||||
|
*/
|
||||||
|
isExpired(): boolean {
|
||||||
|
return new Date() > this._expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否可以继续签署流程
|
||||||
|
*/
|
||||||
|
canContinueSigning(): boolean {
|
||||||
|
return (
|
||||||
|
!this.isExpired() &&
|
||||||
|
this._status !== ContractSigningStatus.SIGNED &&
|
||||||
|
this._status !== ContractSigningStatus.UNSIGNED_TIMEOUT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记已滚动到底部
|
||||||
|
*/
|
||||||
|
markScrolledToBottom(): void {
|
||||||
|
if (!this.canContinueSigning()) {
|
||||||
|
throw new Error('无法继续签署流程:任务已过期或已完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._status === ContractSigningStatus.PENDING) {
|
||||||
|
this._status = ContractSigningStatus.SCROLLED;
|
||||||
|
this._scrolledToBottomAt = new Date();
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认法律效力
|
||||||
|
*/
|
||||||
|
acknowledge(): void {
|
||||||
|
if (!this.canContinueSigning()) {
|
||||||
|
throw new Error('无法继续签署流程:任务已过期或已完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this._status !== ContractSigningStatus.SCROLLED &&
|
||||||
|
this._status !== ContractSigningStatus.PENDING
|
||||||
|
) {
|
||||||
|
throw new Error('请先滚动到合同底部');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._status = ContractSigningStatus.ACKNOWLEDGED;
|
||||||
|
this._acknowledgedAt = new Date();
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成签署
|
||||||
|
*/
|
||||||
|
sign(params: SignContractParams): void {
|
||||||
|
if (!this.canContinueSigning()) {
|
||||||
|
throw new Error('无法继续签署流程:任务已过期或已完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._status !== ContractSigningStatus.ACKNOWLEDGED) {
|
||||||
|
throw new Error('请先确认法律效力');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._status = ContractSigningStatus.SIGNED;
|
||||||
|
this._signedAt = new Date();
|
||||||
|
this._signatureCloudUrl = params.signatureCloudUrl;
|
||||||
|
this._signatureHash = params.signatureHash;
|
||||||
|
this._signingIpAddress = params.ipAddress;
|
||||||
|
this._signingDeviceInfo = params.deviceInfo;
|
||||||
|
this._signingUserAgent = params.userAgent;
|
||||||
|
if (params.location) {
|
||||||
|
this._signingLatitude = params.location.latitude;
|
||||||
|
this._signingLongitude = params.location.longitude;
|
||||||
|
}
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记为超时未签署
|
||||||
|
*/
|
||||||
|
markAsTimeout(): void {
|
||||||
|
if (
|
||||||
|
this._status === ContractSigningStatus.SIGNED ||
|
||||||
|
this._status === ContractSigningStatus.UNSIGNED_TIMEOUT
|
||||||
|
) {
|
||||||
|
return; // 已完成或已超时,不做处理
|
||||||
|
}
|
||||||
|
|
||||||
|
this._status = ContractSigningStatus.UNSIGNED_TIMEOUT;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 补签合同(超时后用户仍可补签)
|
||||||
|
*/
|
||||||
|
lateSign(params: SignContractParams): void {
|
||||||
|
if (this._status === ContractSigningStatus.SIGNED) {
|
||||||
|
throw new Error('合同已签署');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._status = ContractSigningStatus.SIGNED;
|
||||||
|
this._signedAt = new Date();
|
||||||
|
this._signatureCloudUrl = params.signatureCloudUrl;
|
||||||
|
this._signatureHash = params.signatureHash;
|
||||||
|
this._signingIpAddress = params.ipAddress;
|
||||||
|
this._signingDeviceInfo = params.deviceInfo;
|
||||||
|
this._signingUserAgent = params.userAgent;
|
||||||
|
if (params.location) {
|
||||||
|
this._signingLatitude = params.location.latitude;
|
||||||
|
this._signingLongitude = params.location.longitude;
|
||||||
|
}
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
/**
|
||||||
|
* 合同模板聚合根
|
||||||
|
*/
|
||||||
|
export class ContractTemplate {
|
||||||
|
private _id?: number;
|
||||||
|
private _version: string;
|
||||||
|
private _title: string;
|
||||||
|
private _content: string;
|
||||||
|
private _effectiveFrom: Date;
|
||||||
|
private _effectiveTo?: Date;
|
||||||
|
private _isActive: boolean;
|
||||||
|
private _createdAt: Date;
|
||||||
|
private _updatedAt: Date;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this._createdAt = new Date();
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 工厂方法
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的合同模板
|
||||||
|
*/
|
||||||
|
static create(params: {
|
||||||
|
version: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
effectiveFrom: Date;
|
||||||
|
effectiveTo?: Date;
|
||||||
|
}): ContractTemplate {
|
||||||
|
const template = new ContractTemplate();
|
||||||
|
template._version = params.version;
|
||||||
|
template._title = params.title;
|
||||||
|
template._content = params.content;
|
||||||
|
template._effectiveFrom = params.effectiveFrom;
|
||||||
|
template._effectiveTo = params.effectiveTo;
|
||||||
|
template._isActive = true;
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久化数据重建
|
||||||
|
*/
|
||||||
|
static reconstitute(data: {
|
||||||
|
id: number;
|
||||||
|
version: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
effectiveFrom: Date;
|
||||||
|
effectiveTo?: Date;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): ContractTemplate {
|
||||||
|
const template = new ContractTemplate();
|
||||||
|
template._id = data.id;
|
||||||
|
template._version = data.version;
|
||||||
|
template._title = data.title;
|
||||||
|
template._content = data.content;
|
||||||
|
template._effectiveFrom = data.effectiveFrom;
|
||||||
|
template._effectiveTo = data.effectiveTo;
|
||||||
|
template._isActive = data.isActive;
|
||||||
|
template._createdAt = data.createdAt;
|
||||||
|
template._updatedAt = data.updatedAt;
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Getters
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
get id(): number | undefined {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get version(): string {
|
||||||
|
return this._version;
|
||||||
|
}
|
||||||
|
|
||||||
|
get title(): string {
|
||||||
|
return this._title;
|
||||||
|
}
|
||||||
|
|
||||||
|
get content(): string {
|
||||||
|
return this._content;
|
||||||
|
}
|
||||||
|
|
||||||
|
get effectiveFrom(): Date {
|
||||||
|
return this._effectiveFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
get effectiveTo(): Date | undefined {
|
||||||
|
return this._effectiveTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isActive(): boolean {
|
||||||
|
return this._isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
get createdAt(): Date {
|
||||||
|
return this._createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get updatedAt(): Date {
|
||||||
|
return this._updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 业务方法
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查模板在指定时间是否有效
|
||||||
|
*/
|
||||||
|
isEffectiveAt(date: Date): boolean {
|
||||||
|
if (!this._isActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date < this._effectiveFrom) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._effectiveTo && date > this._effectiveTo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成填充用户信息后的合同内容
|
||||||
|
*
|
||||||
|
* 占位符:
|
||||||
|
* - {{USER_PHONE}}: 用户手机号
|
||||||
|
* - {{USER_REAL_NAME}}: 用户真实姓名
|
||||||
|
* - {{USER_ID_CARD}}: 用户身份证号 (脱敏)
|
||||||
|
* - {{TREE_COUNT}}: 认种数量
|
||||||
|
* - {{TOTAL_AMOUNT}}: 总金额
|
||||||
|
* - {{PROVINCE_NAME}}: 省份名称
|
||||||
|
* - {{CITY_NAME}}: 城市名称
|
||||||
|
* - {{CURRENT_DATE}}: 当前日期
|
||||||
|
* - {{ORDER_NO}}: 订单号
|
||||||
|
*/
|
||||||
|
generateContractContent(params: {
|
||||||
|
userPhoneNumber?: string;
|
||||||
|
userRealName?: string;
|
||||||
|
userIdCardNumber?: string;
|
||||||
|
treeCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceName: string;
|
||||||
|
cityName: string;
|
||||||
|
orderNo: string;
|
||||||
|
}): string {
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`;
|
||||||
|
|
||||||
|
// 身份证号脱敏: 只显示前6位和后4位
|
||||||
|
const maskedIdCard = params.userIdCardNumber
|
||||||
|
? `${params.userIdCardNumber.slice(0, 6)}********${params.userIdCardNumber.slice(-4)}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
let content = this._content;
|
||||||
|
content = content.replace(/\{\{USER_PHONE\}\}/g, params.userPhoneNumber || '未认证');
|
||||||
|
content = content.replace(/\{\{USER_REAL_NAME\}\}/g, params.userRealName || '未认证');
|
||||||
|
content = content.replace(/\{\{USER_ID_CARD\}\}/g, maskedIdCard || '未认证');
|
||||||
|
content = content.replace(/\{\{TREE_COUNT\}\}/g, params.treeCount.toString());
|
||||||
|
content = content.replace(/\{\{TOTAL_AMOUNT\}\}/g, params.totalAmount.toFixed(2));
|
||||||
|
content = content.replace(/\{\{PROVINCE_NAME\}\}/g, params.provinceName);
|
||||||
|
content = content.replace(/\{\{CITY_NAME\}\}/g, params.cityName);
|
||||||
|
content = content.replace(/\{\{CURRENT_DATE\}\}/g, dateStr);
|
||||||
|
content = content.replace(/\{\{ORDER_NO\}\}/g, params.orderNo);
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停用模板
|
||||||
|
*/
|
||||||
|
deactivate(): void {
|
||||||
|
this._isActive = false;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置失效时间
|
||||||
|
*/
|
||||||
|
setEffectiveTo(date: Date): void {
|
||||||
|
this._effectiveTo = date;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
export * from './planting-order.aggregate';
|
export * from './planting-order.aggregate';
|
||||||
export * from './planting-position.aggregate';
|
export * from './planting-position.aggregate';
|
||||||
export * from './pool-injection-batch.aggregate';
|
export * from './pool-injection-batch.aggregate';
|
||||||
|
export * from './contract-template.aggregate';
|
||||||
|
export * from './contract-signing-task.aggregate';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { ContractSigningTask } from '../aggregates';
|
||||||
|
import { ContractSigningStatus } from '../value-objects';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同签署任务仓储接口
|
||||||
|
*/
|
||||||
|
export interface IContractSigningTaskRepository {
|
||||||
|
/**
|
||||||
|
* 保存签署任务
|
||||||
|
*/
|
||||||
|
save(task: ContractSigningTask): Promise<ContractSigningTask>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查找
|
||||||
|
*/
|
||||||
|
findById(id: bigint): Promise<ContractSigningTask | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据订单号查找
|
||||||
|
*/
|
||||||
|
findByOrderNo(orderNo: string): Promise<ContractSigningTask | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所有待签署的任务
|
||||||
|
*/
|
||||||
|
findPendingByUserId(userId: bigint): Promise<ContractSigningTask[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所有未完成签署的任务(包括超时的)
|
||||||
|
*/
|
||||||
|
findUnsignedByUserId(userId: bigint): Promise<ContractSigningTask[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有过期但未标记为超时的任务
|
||||||
|
*/
|
||||||
|
findExpiredPendingTasks(): Promise<ContractSigningTask[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据状态查找
|
||||||
|
*/
|
||||||
|
findByStatus(status: ContractSigningStatus): Promise<ContractSigningTask[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查订单是否已有签署任务
|
||||||
|
*/
|
||||||
|
existsByOrderNo(orderNo: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTRACT_SIGNING_TASK_REPOSITORY = Symbol('IContractSigningTaskRepository');
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { ContractTemplate } from '../aggregates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同模板仓储接口
|
||||||
|
*/
|
||||||
|
export interface IContractTemplateRepository {
|
||||||
|
/**
|
||||||
|
* 保存合同模板
|
||||||
|
*/
|
||||||
|
save(template: ContractTemplate): Promise<ContractTemplate>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID查找
|
||||||
|
*/
|
||||||
|
findById(id: number): Promise<ContractTemplate | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据版本号查找
|
||||||
|
*/
|
||||||
|
findByVersion(version: string): Promise<ContractTemplate | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前有效的模板
|
||||||
|
*/
|
||||||
|
findActiveTemplate(): Promise<ContractTemplate | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有模板
|
||||||
|
*/
|
||||||
|
findAll(): Promise<ContractTemplate[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTRACT_TEMPLATE_REPOSITORY = Symbol('IContractTemplateRepository');
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
export * from './planting-order.repository.interface';
|
export * from './planting-order.repository.interface';
|
||||||
export * from './planting-position.repository.interface';
|
export * from './planting-position.repository.interface';
|
||||||
export * from './pool-injection-batch.repository.interface';
|
export * from './pool-injection-batch.repository.interface';
|
||||||
|
export * from './contract-template.repository.interface';
|
||||||
|
export * from './contract-signing-task.repository.interface';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* 合同签署状态枚举
|
||||||
|
*/
|
||||||
|
export enum ContractSigningStatus {
|
||||||
|
/** 待签署 */
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
|
||||||
|
/** 已滚动到底部 */
|
||||||
|
SCROLLED = 'SCROLLED',
|
||||||
|
|
||||||
|
/** 已确认法律效力 */
|
||||||
|
ACKNOWLEDGED = 'ACKNOWLEDGED',
|
||||||
|
|
||||||
|
/** 已签署完成 */
|
||||||
|
SIGNED = 'SIGNED',
|
||||||
|
|
||||||
|
/** 超时未签署 */
|
||||||
|
UNSIGNED_TIMEOUT = 'UNSIGNED_TIMEOUT',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否需要用户签署
|
||||||
|
*/
|
||||||
|
export function needsUserSigning(status: ContractSigningStatus): boolean {
|
||||||
|
return (
|
||||||
|
status === ContractSigningStatus.PENDING ||
|
||||||
|
status === ContractSigningStatus.SCROLLED ||
|
||||||
|
status === ContractSigningStatus.ACKNOWLEDGED ||
|
||||||
|
status === ContractSigningStatus.UNSIGNED_TIMEOUT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否已完成签署
|
||||||
|
*/
|
||||||
|
export function isSigningCompleted(status: ContractSigningStatus): boolean {
|
||||||
|
return status === ContractSigningStatus.SIGNED;
|
||||||
|
}
|
||||||
|
|
@ -5,3 +5,4 @@ export * from './tree-count.vo';
|
||||||
export * from './province-city-selection.vo';
|
export * from './province-city-selection.vo';
|
||||||
export * from './fund-allocation.vo';
|
export * from './fund-allocation.vo';
|
||||||
export * from './money.vo';
|
export * from './money.vo';
|
||||||
|
export * from './contract-signing-status.enum';
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { PrismaService } from './persistence/prisma/prisma.service';
|
||||||
import { PlantingOrderRepositoryImpl } from './persistence/repositories/planting-order.repository.impl';
|
import { PlantingOrderRepositoryImpl } from './persistence/repositories/planting-order.repository.impl';
|
||||||
import { PlantingPositionRepositoryImpl } from './persistence/repositories/planting-position.repository.impl';
|
import { PlantingPositionRepositoryImpl } from './persistence/repositories/planting-position.repository.impl';
|
||||||
import { PoolInjectionBatchRepositoryImpl } from './persistence/repositories/pool-injection-batch.repository.impl';
|
import { PoolInjectionBatchRepositoryImpl } from './persistence/repositories/pool-injection-batch.repository.impl';
|
||||||
|
import { ContractTemplateRepositoryImpl } from './persistence/repositories/contract-template.repository.impl';
|
||||||
|
import { ContractSigningTaskRepositoryImpl } from './persistence/repositories/contract-signing-task.repository.impl';
|
||||||
import { OutboxRepository } from './persistence/repositories/outbox.repository';
|
import { OutboxRepository } from './persistence/repositories/outbox.repository';
|
||||||
import { PaymentCompensationRepository } from './persistence/repositories/payment-compensation.repository';
|
import { PaymentCompensationRepository } from './persistence/repositories/payment-compensation.repository';
|
||||||
import { UnitOfWork, UNIT_OF_WORK } from './persistence/unit-of-work';
|
import { UnitOfWork, UNIT_OF_WORK } from './persistence/unit-of-work';
|
||||||
|
|
@ -12,10 +14,14 @@ import { ReferralServiceClient } from './external/referral-service.client';
|
||||||
import { KafkaModule } from './kafka/kafka.module';
|
import { KafkaModule } from './kafka/kafka.module';
|
||||||
import { OutboxPublisherService } from './kafka/outbox-publisher.service';
|
import { OutboxPublisherService } from './kafka/outbox-publisher.service';
|
||||||
import { EventAckController } from './kafka/event-ack.controller';
|
import { EventAckController } from './kafka/event-ack.controller';
|
||||||
|
import { ContractSigningEventConsumer } from './kafka/contract-signing-event.consumer';
|
||||||
import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
|
import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface';
|
||||||
import { PLANTING_POSITION_REPOSITORY } from '../domain/repositories/planting-position.repository.interface';
|
import { PLANTING_POSITION_REPOSITORY } from '../domain/repositories/planting-position.repository.interface';
|
||||||
import { POOL_INJECTION_BATCH_REPOSITORY } from '../domain/repositories/pool-injection-batch.repository.interface';
|
import { POOL_INJECTION_BATCH_REPOSITORY } from '../domain/repositories/pool-injection-batch.repository.interface';
|
||||||
|
import { CONTRACT_TEMPLATE_REPOSITORY } from '../domain/repositories/contract-template.repository.interface';
|
||||||
|
import { CONTRACT_SIGNING_TASK_REPOSITORY } from '../domain/repositories/contract-signing-task.repository.interface';
|
||||||
import { PaymentCompensationService } from '../application/services/payment-compensation.service';
|
import { PaymentCompensationService } from '../application/services/payment-compensation.service';
|
||||||
|
import { ContractSigningService } from '../application/services/contract-signing.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -26,7 +32,7 @@ import { PaymentCompensationService } from '../application/services/payment-comp
|
||||||
}),
|
}),
|
||||||
KafkaModule,
|
KafkaModule,
|
||||||
],
|
],
|
||||||
controllers: [EventAckController],
|
controllers: [EventAckController, ContractSigningEventConsumer],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
{
|
{
|
||||||
|
|
@ -41,6 +47,14 @@ import { PaymentCompensationService } from '../application/services/payment-comp
|
||||||
provide: POOL_INJECTION_BATCH_REPOSITORY,
|
provide: POOL_INJECTION_BATCH_REPOSITORY,
|
||||||
useClass: PoolInjectionBatchRepositoryImpl,
|
useClass: PoolInjectionBatchRepositoryImpl,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: CONTRACT_TEMPLATE_REPOSITORY,
|
||||||
|
useClass: ContractTemplateRepositoryImpl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CONTRACT_SIGNING_TASK_REPOSITORY,
|
||||||
|
useClass: ContractSigningTaskRepositoryImpl,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: UNIT_OF_WORK,
|
provide: UNIT_OF_WORK,
|
||||||
useClass: UnitOfWork,
|
useClass: UnitOfWork,
|
||||||
|
|
@ -49,6 +63,7 @@ import { PaymentCompensationService } from '../application/services/payment-comp
|
||||||
PaymentCompensationRepository,
|
PaymentCompensationRepository,
|
||||||
OutboxPublisherService,
|
OutboxPublisherService,
|
||||||
PaymentCompensationService,
|
PaymentCompensationService,
|
||||||
|
ContractSigningService,
|
||||||
WalletServiceClient,
|
WalletServiceClient,
|
||||||
ReferralServiceClient,
|
ReferralServiceClient,
|
||||||
],
|
],
|
||||||
|
|
@ -57,11 +72,14 @@ import { PaymentCompensationService } from '../application/services/payment-comp
|
||||||
PLANTING_ORDER_REPOSITORY,
|
PLANTING_ORDER_REPOSITORY,
|
||||||
PLANTING_POSITION_REPOSITORY,
|
PLANTING_POSITION_REPOSITORY,
|
||||||
POOL_INJECTION_BATCH_REPOSITORY,
|
POOL_INJECTION_BATCH_REPOSITORY,
|
||||||
|
CONTRACT_TEMPLATE_REPOSITORY,
|
||||||
|
CONTRACT_SIGNING_TASK_REPOSITORY,
|
||||||
UNIT_OF_WORK,
|
UNIT_OF_WORK,
|
||||||
OutboxRepository,
|
OutboxRepository,
|
||||||
PaymentCompensationRepository,
|
PaymentCompensationRepository,
|
||||||
OutboxPublisherService,
|
OutboxPublisherService,
|
||||||
PaymentCompensationService,
|
PaymentCompensationService,
|
||||||
|
ContractSigningService,
|
||||||
WalletServiceClient,
|
WalletServiceClient,
|
||||||
ReferralServiceClient,
|
ReferralServiceClient,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { Controller, Logger, Inject } from '@nestjs/common';
|
||||||
|
import { EventPattern, Payload, Ctx, KafkaContext } from '@nestjs/microservices';
|
||||||
|
import { ContractSigningService } from '../../application/services/contract-signing.service';
|
||||||
|
import {
|
||||||
|
IPlantingOrderRepository,
|
||||||
|
PLANTING_ORDER_REPOSITORY,
|
||||||
|
} from '../../domain/repositories';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认种事件消息结构
|
||||||
|
*/
|
||||||
|
interface PlantingEventMessage {
|
||||||
|
eventName: string;
|
||||||
|
aggregateId: string;
|
||||||
|
occurredAt: string;
|
||||||
|
data: {
|
||||||
|
orderId: string;
|
||||||
|
orderNo?: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence?: string;
|
||||||
|
treeCount: number;
|
||||||
|
totalAmount?: number;
|
||||||
|
provinceCode?: string;
|
||||||
|
cityCode?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity Service 的用户信息响应
|
||||||
|
*/
|
||||||
|
interface UserKycInfo {
|
||||||
|
phoneNumber?: string;
|
||||||
|
realName?: string;
|
||||||
|
idCardNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同签署事件消费者
|
||||||
|
*
|
||||||
|
* 独立模块,监听 OrderPaid 事件来创建合同签署任务
|
||||||
|
* 不影响现有的 planting-service 流程
|
||||||
|
*/
|
||||||
|
@Controller()
|
||||||
|
export class ContractSigningEventConsumer {
|
||||||
|
private readonly logger = new Logger(ContractSigningEventConsumer.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly contractSigningService: ContractSigningService,
|
||||||
|
@Inject(PLANTING_ORDER_REPOSITORY)
|
||||||
|
private readonly orderRepo: IPlantingOrderRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听认种事件
|
||||||
|
* 当订单支付完成后(PlantingOrderPaid 事件),创建合同签署任务
|
||||||
|
*/
|
||||||
|
@EventPattern('planting-events')
|
||||||
|
async handlePlantingEvent(
|
||||||
|
@Payload() message: PlantingEventMessage,
|
||||||
|
@Ctx() context: KafkaContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const eventName = message.eventName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.debug(`[CONTRACT-SIGNING] Received event: ${eventName}`);
|
||||||
|
|
||||||
|
// 处理 PlantingOrderPaid 事件(订单支付完成后触发)
|
||||||
|
// 支持多种事件名称格式以确保兼容性
|
||||||
|
if (
|
||||||
|
eventName === 'PlantingOrderPaid' ||
|
||||||
|
eventName === 'planting.order.paid' ||
|
||||||
|
eventName === 'planting.tree.planted'
|
||||||
|
) {
|
||||||
|
await this.handleOrderPaid(message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[CONTRACT-SIGNING] Error processing event ${eventName}:`, error);
|
||||||
|
// 不抛出异常,避免影响其他消费者
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理订单支付完成事件
|
||||||
|
* 创建合同签署任务
|
||||||
|
*/
|
||||||
|
private async handleOrderPaid(message: PlantingEventMessage): Promise<void> {
|
||||||
|
const { data } = message;
|
||||||
|
const orderNo = data.orderNo || data.orderId;
|
||||||
|
|
||||||
|
this.logger.log(`[CONTRACT-SIGNING] Processing PlantingOrderPaid for order: ${orderNo}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取订单详情
|
||||||
|
const order = await this.orderRepo.findByOrderNo(orderNo);
|
||||||
|
if (!order) {
|
||||||
|
this.logger.warn(`[CONTRACT-SIGNING] Order not found: ${orderNo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取用户 KYC 信息
|
||||||
|
// TODO: 调用 identity-service 获取用户信息
|
||||||
|
// 目前使用占位数据
|
||||||
|
const kycInfo = await this.getUserKycInfo(order.userId);
|
||||||
|
|
||||||
|
// 3. 创建合同签署任务
|
||||||
|
const provinceCitySelection = order.provinceCitySelection;
|
||||||
|
if (!provinceCitySelection) {
|
||||||
|
this.logger.warn(`[CONTRACT-SIGNING] Order ${orderNo} has no province/city selection`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractSigningService.createSigningTask({
|
||||||
|
orderNo,
|
||||||
|
userId: order.userId,
|
||||||
|
accountSequence: data.accountSequence || order.userId.toString(),
|
||||||
|
treeCount: order.treeCount.value,
|
||||||
|
totalAmount: order.totalAmount,
|
||||||
|
provinceCode: provinceCitySelection.provinceCode,
|
||||||
|
provinceName: provinceCitySelection.provinceName,
|
||||||
|
cityCode: provinceCitySelection.cityCode,
|
||||||
|
cityName: provinceCitySelection.cityName,
|
||||||
|
userPhoneNumber: kycInfo?.phoneNumber,
|
||||||
|
userRealName: kycInfo?.realName,
|
||||||
|
userIdCardNumber: kycInfo?.idCardNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[CONTRACT-SIGNING] Created signing task for order: ${orderNo}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[CONTRACT-SIGNING] Failed to create signing task for order ${orderNo}:`, error);
|
||||||
|
// 不抛出异常,合同签署任务创建失败不应影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户 KYC 信息
|
||||||
|
* TODO: 实际实现应调用 identity-service
|
||||||
|
*/
|
||||||
|
private async getUserKycInfo(userId: bigint): Promise<UserKycInfo | null> {
|
||||||
|
// TODO: 调用 identity-service 获取用户 KYC 信息
|
||||||
|
// 目前返回 null,合同中会显示"未认证"
|
||||||
|
this.logger.debug(`[CONTRACT-SIGNING] Getting KYC info for user: ${userId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,9 +7,11 @@ import { DomainEvent } from '../../domain/events/domain-event.interface';
|
||||||
* Kafka Topic 映射
|
* Kafka Topic 映射
|
||||||
*
|
*
|
||||||
* 消费者:
|
* 消费者:
|
||||||
* - reward-service: 监听 planting.order.paid
|
* - reward-service: 监听 contract.signed, contract.expired
|
||||||
* - authorization-service: 监听 planting-events
|
* - authorization-service: 监听 planting-events
|
||||||
* - referral-service: 监听 planting.planting.created
|
* - referral-service: 监听 planting.planting.created
|
||||||
|
*
|
||||||
|
* 注意:planting.order.paid 不再使用,奖励分配改为由合同签署结果触发
|
||||||
*/
|
*/
|
||||||
const EVENT_TOPIC_MAP: Record<string, string> = {
|
const EVENT_TOPIC_MAP: Record<string, string> = {
|
||||||
PlantingOrderCreated: 'planting.order.created',
|
PlantingOrderCreated: 'planting.order.created',
|
||||||
|
|
@ -18,8 +20,26 @@ const EVENT_TOPIC_MAP: Record<string, string> = {
|
||||||
FundsAllocated: 'planting.order.funds-allocated',
|
FundsAllocated: 'planting.order.funds-allocated',
|
||||||
PoolInjected: 'planting.pool.injected',
|
PoolInjected: 'planting.pool.injected',
|
||||||
MiningEnabled: 'planting.mining.enabled',
|
MiningEnabled: 'planting.mining.enabled',
|
||||||
|
// 合同签署事件
|
||||||
|
ContractSigned: 'contract.signed',
|
||||||
|
ContractExpired: 'contract.expired',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同签署事件数据
|
||||||
|
*/
|
||||||
|
export interface ContractSigningEventData {
|
||||||
|
orderNo: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
signedAt?: string; // contract.signed
|
||||||
|
expiredAt?: string; // contract.expired
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(EventPublisherService.name);
|
private readonly logger = new Logger(EventPublisherService.name);
|
||||||
|
|
@ -209,6 +229,74 @@ export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布合同签署完成事件 (reward-service 消费)
|
||||||
|
* 触发正常奖励分配(系统费用 + 用户权益)
|
||||||
|
*/
|
||||||
|
async publishContractSigned(data: ContractSigningEventData): Promise<void> {
|
||||||
|
const topic = 'contract.signed';
|
||||||
|
const message = {
|
||||||
|
key: data.orderNo,
|
||||||
|
value: JSON.stringify({
|
||||||
|
eventName: 'contract.signed',
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.debug(`[PUBLISH] Publishing ContractSigned for reward-service:
|
||||||
|
- OrderNo: ${data.orderNo}
|
||||||
|
- UserId: ${data.userId}
|
||||||
|
- TreeCount: ${data.treeCount}
|
||||||
|
- SignedAt: ${data.signedAt}`);
|
||||||
|
|
||||||
|
if (!this.isConnected) {
|
||||||
|
this.logger.warn(`[PUBLISH] Kafka not connected, skipping contract.signed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.kafkaClient.emit(topic, message);
|
||||||
|
this.logger.log(`[PUBLISH] ✓ ContractSigned published for order ${data.orderNo}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[PUBLISH] ✗ Failed to publish ContractSigned:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布合同超时未签署事件 (reward-service 消费)
|
||||||
|
* 触发系统账户奖励分配(系统费用正常 + 用户权益进系统默认账户)
|
||||||
|
*/
|
||||||
|
async publishContractExpired(data: ContractSigningEventData): Promise<void> {
|
||||||
|
const topic = 'contract.expired';
|
||||||
|
const message = {
|
||||||
|
key: data.orderNo,
|
||||||
|
value: JSON.stringify({
|
||||||
|
eventName: 'contract.expired',
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.debug(`[PUBLISH] Publishing ContractExpired for reward-service:
|
||||||
|
- OrderNo: ${data.orderNo}
|
||||||
|
- UserId: ${data.userId}
|
||||||
|
- TreeCount: ${data.treeCount}
|
||||||
|
- ExpiredAt: ${data.expiredAt}`);
|
||||||
|
|
||||||
|
if (!this.isConnected) {
|
||||||
|
this.logger.warn(`[PUBLISH] Kafka not connected, skipping contract.expired`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.kafkaClient.emit(topic, message);
|
||||||
|
this.logger.log(`[PUBLISH] ✓ ContractExpired published for order ${data.orderNo}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[PUBLISH] ✗ Failed to publish ContractExpired:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getTopicForEvent(event: DomainEvent): string {
|
private getTopicForEvent(event: DomainEvent): string {
|
||||||
const topic = EVENT_TOPIC_MAP[event.type] || 'planting.events';
|
const topic = EVENT_TOPIC_MAP[event.type] || 'planting.events';
|
||||||
this.logger.debug(`[TOPIC] Mapped event type ${event.type} to topic ${topic}`);
|
this.logger.debug(`[TOPIC] Mapped event type ${event.type} to topic ${topic}`);
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './kafka.module';
|
export * from './kafka.module';
|
||||||
export * from './event-publisher.service';
|
export * from './event-publisher.service';
|
||||||
|
export * from './contract-signing-event.consumer';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { IContractSigningTaskRepository } from '../../../domain/repositories/contract-signing-task.repository.interface';
|
||||||
|
import {
|
||||||
|
ContractSigningTask,
|
||||||
|
DeviceInfo,
|
||||||
|
} from '../../../domain/aggregates/contract-signing-task.aggregate';
|
||||||
|
import { ContractSigningStatus } from '../../../domain/value-objects/contract-signing-status.enum';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractSigningTaskRepositoryImpl implements IContractSigningTaskRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async save(task: ContractSigningTask): Promise<ContractSigningTask> {
|
||||||
|
const deviceInfoJson = task.signingDeviceInfo
|
||||||
|
? JSON.stringify(task.signingDeviceInfo)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (task.id) {
|
||||||
|
// 更新
|
||||||
|
const updated = await this.prisma.contractSigningTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: task.status,
|
||||||
|
scrolledToBottomAt: task.scrolledToBottomAt,
|
||||||
|
acknowledgedAt: task.acknowledgedAt,
|
||||||
|
signedAt: task.signedAt,
|
||||||
|
signatureCloudUrl: task.signatureCloudUrl,
|
||||||
|
signatureHash: task.signatureHash,
|
||||||
|
signingIpAddress: task.signingIpAddress,
|
||||||
|
signingDeviceInfo: deviceInfoJson,
|
||||||
|
signingUserAgent: task.signingUserAgent,
|
||||||
|
signingLatitude: task.signingLatitude
|
||||||
|
? new Prisma.Decimal(task.signingLatitude)
|
||||||
|
: null,
|
||||||
|
signingLongitude: task.signingLongitude
|
||||||
|
? new Prisma.Decimal(task.signingLongitude)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapToDomain(updated);
|
||||||
|
} else {
|
||||||
|
// 创建
|
||||||
|
const created = await this.prisma.contractSigningTask.create({
|
||||||
|
data: {
|
||||||
|
orderNo: task.orderNo,
|
||||||
|
userId: task.userId,
|
||||||
|
accountSequence: task.accountSequence,
|
||||||
|
templateId: task.templateId,
|
||||||
|
contractVersion: task.contractVersion,
|
||||||
|
contractContent: task.contractContent,
|
||||||
|
userPhoneNumber: task.userPhoneNumber,
|
||||||
|
userRealName: task.userRealName,
|
||||||
|
userIdCardNumber: task.userIdCardNumber,
|
||||||
|
treeCount: task.treeCount,
|
||||||
|
totalAmount: new Prisma.Decimal(task.totalAmount),
|
||||||
|
provinceCode: task.provinceCode,
|
||||||
|
provinceName: task.provinceName,
|
||||||
|
cityCode: task.cityCode,
|
||||||
|
cityName: task.cityName,
|
||||||
|
status: task.status,
|
||||||
|
expiresAt: task.expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapToDomain(created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: bigint): Promise<ContractSigningTask | null> {
|
||||||
|
const task = await this.prisma.contractSigningTask.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
return task ? this.mapToDomain(task) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByOrderNo(orderNo: string): Promise<ContractSigningTask | null> {
|
||||||
|
const task = await this.prisma.contractSigningTask.findUnique({
|
||||||
|
where: { orderNo },
|
||||||
|
});
|
||||||
|
return task ? this.mapToDomain(task) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPendingByUserId(userId: bigint): Promise<ContractSigningTask[]> {
|
||||||
|
const tasks = await this.prisma.contractSigningTask.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
ContractSigningStatus.PENDING,
|
||||||
|
ContractSigningStatus.SCROLLED,
|
||||||
|
ContractSigningStatus.ACKNOWLEDGED,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expiresAt: { gte: new Date() },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return tasks.map((t) => this.mapToDomain(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUnsignedByUserId(userId: bigint): Promise<ContractSigningTask[]> {
|
||||||
|
const tasks = await this.prisma.contractSigningTask.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: {
|
||||||
|
notIn: [ContractSigningStatus.SIGNED],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return tasks.map((t) => this.mapToDomain(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findExpiredPendingTasks(): Promise<ContractSigningTask[]> {
|
||||||
|
const now = new Date();
|
||||||
|
const tasks = await this.prisma.contractSigningTask.findMany({
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
ContractSigningStatus.PENDING,
|
||||||
|
ContractSigningStatus.SCROLLED,
|
||||||
|
ContractSigningStatus.ACKNOWLEDGED,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
expiresAt: { lt: now },
|
||||||
|
},
|
||||||
|
orderBy: { expiresAt: 'asc' },
|
||||||
|
});
|
||||||
|
return tasks.map((t) => this.mapToDomain(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByStatus(status: ContractSigningStatus): Promise<ContractSigningTask[]> {
|
||||||
|
const tasks = await this.prisma.contractSigningTask.findMany({
|
||||||
|
where: { status },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return tasks.map((t) => this.mapToDomain(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsByOrderNo(orderNo: string): Promise<boolean> {
|
||||||
|
const count = await this.prisma.contractSigningTask.count({
|
||||||
|
where: { orderNo },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToDomain(data: {
|
||||||
|
id: bigint;
|
||||||
|
orderNo: string;
|
||||||
|
userId: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
templateId: number;
|
||||||
|
contractVersion: string;
|
||||||
|
contractContent: string;
|
||||||
|
userPhoneNumber: string | null;
|
||||||
|
userRealName: string | null;
|
||||||
|
userIdCardNumber: string | null;
|
||||||
|
treeCount: number;
|
||||||
|
totalAmount: Prisma.Decimal;
|
||||||
|
provinceCode: string;
|
||||||
|
provinceName: string;
|
||||||
|
cityCode: string;
|
||||||
|
cityName: string;
|
||||||
|
status: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
scrolledToBottomAt: Date | null;
|
||||||
|
acknowledgedAt: Date | null;
|
||||||
|
signedAt: Date | null;
|
||||||
|
signatureCloudUrl: string | null;
|
||||||
|
signatureHash: string | null;
|
||||||
|
signingIpAddress: string | null;
|
||||||
|
signingDeviceInfo: string | null;
|
||||||
|
signingUserAgent: string | null;
|
||||||
|
signingLatitude: Prisma.Decimal | null;
|
||||||
|
signingLongitude: Prisma.Decimal | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): ContractSigningTask {
|
||||||
|
let deviceInfo: DeviceInfo | undefined;
|
||||||
|
if (data.signingDeviceInfo) {
|
||||||
|
try {
|
||||||
|
deviceInfo = JSON.parse(data.signingDeviceInfo);
|
||||||
|
} catch {
|
||||||
|
deviceInfo = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContractSigningTask.reconstitute({
|
||||||
|
id: data.id,
|
||||||
|
orderNo: data.orderNo,
|
||||||
|
userId: data.userId,
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
templateId: data.templateId,
|
||||||
|
contractVersion: data.contractVersion,
|
||||||
|
contractContent: data.contractContent,
|
||||||
|
userPhoneNumber: data.userPhoneNumber ?? undefined,
|
||||||
|
userRealName: data.userRealName ?? undefined,
|
||||||
|
userIdCardNumber: data.userIdCardNumber ?? undefined,
|
||||||
|
treeCount: data.treeCount,
|
||||||
|
totalAmount: data.totalAmount.toNumber(),
|
||||||
|
provinceCode: data.provinceCode,
|
||||||
|
provinceName: data.provinceName,
|
||||||
|
cityCode: data.cityCode,
|
||||||
|
cityName: data.cityName,
|
||||||
|
status: data.status,
|
||||||
|
expiresAt: data.expiresAt,
|
||||||
|
scrolledToBottomAt: data.scrolledToBottomAt ?? undefined,
|
||||||
|
acknowledgedAt: data.acknowledgedAt ?? undefined,
|
||||||
|
signedAt: data.signedAt ?? undefined,
|
||||||
|
signatureCloudUrl: data.signatureCloudUrl ?? undefined,
|
||||||
|
signatureHash: data.signatureHash ?? undefined,
|
||||||
|
signingIpAddress: data.signingIpAddress ?? undefined,
|
||||||
|
signingDeviceInfo: deviceInfo,
|
||||||
|
signingUserAgent: data.signingUserAgent ?? undefined,
|
||||||
|
signingLatitude: data.signingLatitude?.toNumber(),
|
||||||
|
signingLongitude: data.signingLongitude?.toNumber(),
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
updatedAt: data.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { IContractTemplateRepository } from '../../../domain/repositories/contract-template.repository.interface';
|
||||||
|
import { ContractTemplate } from '../../../domain/aggregates/contract-template.aggregate';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractTemplateRepositoryImpl implements IContractTemplateRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async save(template: ContractTemplate): Promise<ContractTemplate> {
|
||||||
|
if (template.id) {
|
||||||
|
// 更新
|
||||||
|
const updated = await this.prisma.contractTemplate.update({
|
||||||
|
where: { id: template.id },
|
||||||
|
data: {
|
||||||
|
version: template.version,
|
||||||
|
title: template.title,
|
||||||
|
content: template.content,
|
||||||
|
effectiveFrom: template.effectiveFrom,
|
||||||
|
effectiveTo: template.effectiveTo,
|
||||||
|
isActive: template.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapToDomain(updated);
|
||||||
|
} else {
|
||||||
|
// 创建
|
||||||
|
const created = await this.prisma.contractTemplate.create({
|
||||||
|
data: {
|
||||||
|
version: template.version,
|
||||||
|
title: template.title,
|
||||||
|
content: template.content,
|
||||||
|
effectiveFrom: template.effectiveFrom,
|
||||||
|
effectiveTo: template.effectiveTo,
|
||||||
|
isActive: template.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapToDomain(created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: number): Promise<ContractTemplate | null> {
|
||||||
|
const template = await this.prisma.contractTemplate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
return template ? this.mapToDomain(template) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByVersion(version: string): Promise<ContractTemplate | null> {
|
||||||
|
const template = await this.prisma.contractTemplate.findUnique({
|
||||||
|
where: { version },
|
||||||
|
});
|
||||||
|
return template ? this.mapToDomain(template) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveTemplate(): Promise<ContractTemplate | null> {
|
||||||
|
const now = new Date();
|
||||||
|
const template = await this.prisma.contractTemplate.findFirst({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
effectiveFrom: { lte: now },
|
||||||
|
OR: [
|
||||||
|
{ effectiveTo: null },
|
||||||
|
{ effectiveTo: { gte: now } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
|
});
|
||||||
|
return template ? this.mapToDomain(template) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<ContractTemplate[]> {
|
||||||
|
const templates = await this.prisma.contractTemplate.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return templates.map((t) => this.mapToDomain(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToDomain(data: {
|
||||||
|
id: number;
|
||||||
|
version: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
effectiveFrom: Date;
|
||||||
|
effectiveTo: Date | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): ContractTemplate {
|
||||||
|
return ContractTemplate.reconstitute({
|
||||||
|
id: data.id,
|
||||||
|
version: data.version,
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
effectiveFrom: data.effectiveFrom,
|
||||||
|
effectiveTo: data.effectiveTo ?? undefined,
|
||||||
|
isActive: data.isActive,
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
updatedAt: data.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
export * from './planting-order.repository.impl';
|
export * from './planting-order.repository.impl';
|
||||||
export * from './planting-position.repository.impl';
|
export * from './planting-position.repository.impl';
|
||||||
export * from './pool-injection-batch.repository.impl';
|
export * from './pool-injection-batch.repository.impl';
|
||||||
|
export * from './contract-template.repository.impl';
|
||||||
|
export * from './contract-signing-task.repository.impl';
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,11 @@ async function bootstrap() {
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
// Kafka 微服务 - 用于接收 ACK 确认消息
|
// Kafka 微服务配置
|
||||||
const kafkaBrokers = process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'];
|
const kafkaBrokers = process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'];
|
||||||
const kafkaGroupId = process.env.KAFKA_GROUP_ID || 'planting-service-group';
|
const kafkaGroupId = process.env.KAFKA_GROUP_ID || 'planting-service-group';
|
||||||
|
|
||||||
|
// 微服务 1: 用于接收 ACK 确认消息
|
||||||
app.connectMicroservice<MicroserviceOptions>({
|
app.connectMicroservice<MicroserviceOptions>({
|
||||||
transport: Transport.KAFKA,
|
transport: Transport.KAFKA,
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -59,9 +60,24 @@ async function bootstrap() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动 Kafka 微服务
|
// 微服务 2: 用于合同签署事件消费(监听 planting-events)
|
||||||
|
// 注意:planting-service 自己发布事件,也自己消费(松耦合的合同签署模块)
|
||||||
|
app.connectMicroservice<MicroserviceOptions>({
|
||||||
|
transport: Transport.KAFKA,
|
||||||
|
options: {
|
||||||
|
client: {
|
||||||
|
clientId: 'planting-service-contract-signing',
|
||||||
|
brokers: kafkaBrokers,
|
||||||
|
},
|
||||||
|
consumer: {
|
||||||
|
groupId: `${kafkaGroupId}-contract-signing`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动所有 Kafka 微服务
|
||||||
await app.startAllMicroservices();
|
await app.startAllMicroservices();
|
||||||
logger.log('Kafka microservice started for ACK consumption');
|
logger.log('Kafka microservices started (ACK + Contract Signing)');
|
||||||
|
|
||||||
const port = process.env.APP_PORT || 3003;
|
const port = process.env.APP_PORT || 3003;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { KafkaService, WalletServiceClient } from '../../infrastructure';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同签署事件数据结构
|
||||||
|
*/
|
||||||
|
interface ContractSigningEvent {
|
||||||
|
eventName: string;
|
||||||
|
data: {
|
||||||
|
orderNo: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
signedAt?: string; // contract.signed
|
||||||
|
expiredAt?: string; // contract.expired
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同签署事件处理器
|
||||||
|
* 监听 planting-service 发出的合同签署结果事件
|
||||||
|
*
|
||||||
|
* 处理流程:
|
||||||
|
* - contract.signed → 发送 planting.order.paid → reward-service 正常奖励分配
|
||||||
|
* - contract.expired → 发送 planting.order.expired → reward-service 系统账户分配
|
||||||
|
*
|
||||||
|
* 为什么由 referral-service 转发?
|
||||||
|
* 1. 保持原有架构一致性:referral-service 始终是 planting.order.paid 的发布者
|
||||||
|
* 2. 确保团队统计已更新后再触发奖励计算(虽然此时统计已在 planting.created 时更新)
|
||||||
|
* 3. reward-service 的 handlePlantingOrderPaid 逻辑 100% 不变
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContractSigningHandler implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ContractSigningHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly kafkaService: KafkaService,
|
||||||
|
private readonly walletService: WalletServiceClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// 订阅合同签署完成事件
|
||||||
|
await this.kafkaService.subscribe(
|
||||||
|
'referral-service-contract-signed',
|
||||||
|
['contract.signed'],
|
||||||
|
this.handleContractSigned.bind(this),
|
||||||
|
);
|
||||||
|
this.logger.log('Subscribed to contract.signed events');
|
||||||
|
|
||||||
|
// 订阅合同超时事件
|
||||||
|
await this.kafkaService.subscribe(
|
||||||
|
'referral-service-contract-expired',
|
||||||
|
['contract.expired'],
|
||||||
|
this.handleContractExpired.bind(this),
|
||||||
|
);
|
||||||
|
this.logger.log('Subscribed to contract.expired events');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理合同签署完成事件
|
||||||
|
* 转发为 planting.order.paid 事件,触发正常奖励分配
|
||||||
|
*/
|
||||||
|
private async handleContractSigned(
|
||||||
|
topic: string,
|
||||||
|
message: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const event = message as unknown as ContractSigningEvent;
|
||||||
|
|
||||||
|
if (event.eventName !== 'contract.signed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = event.data;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Received contract.signed for order ${eventData.orderNo}, ` +
|
||||||
|
`accountSequence: ${eventData.accountSequence}, signedAt: ${eventData.signedAt}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 发送 planting.order.paid 事件给 reward-service
|
||||||
|
// 这会触发原有的奖励分配流程(100% 不变)
|
||||||
|
await this.publishOrderPaidEvent(eventData);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully forwarded contract.signed to planting.order.paid for order ${eventData.orderNo}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to forward contract.signed for order ${eventData.orderNo}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理合同超时事件
|
||||||
|
* 转发为 planting.order.expired 事件,触发系统账户奖励分配
|
||||||
|
*/
|
||||||
|
private async handleContractExpired(
|
||||||
|
topic: string,
|
||||||
|
message: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const event = message as unknown as ContractSigningEvent;
|
||||||
|
|
||||||
|
if (event.eventName !== 'contract.expired') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData = event.data;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Received contract.expired for order ${eventData.orderNo}, ` +
|
||||||
|
`accountSequence: ${eventData.accountSequence}, expiredAt: ${eventData.expiredAt}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 发送 planting.order.expired 事件给 reward-service
|
||||||
|
// 这会触发系统账户奖励分配流程
|
||||||
|
await this.publishOrderExpiredEvent(eventData);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully forwarded contract.expired to planting.order.expired for order ${eventData.orderNo}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to forward contract.expired for order ${eventData.orderNo}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认扣款并发送 planting.order.paid 事件给 reward-service
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 调用 wallet-service 确认扣款(将冻结资金正式扣除)
|
||||||
|
* 2. 发送 planting.order.paid 事件
|
||||||
|
*/
|
||||||
|
private async publishOrderPaidEvent(
|
||||||
|
eventData: ContractSigningEvent['data'],
|
||||||
|
): Promise<void> {
|
||||||
|
// 步骤1:确认扣款
|
||||||
|
const confirmResult = await this.walletService.confirmPlantingDeduction({
|
||||||
|
userId: eventData.userId,
|
||||||
|
accountSequence: eventData.accountSequence,
|
||||||
|
orderId: eventData.orderNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmResult.success) {
|
||||||
|
throw new Error(`确认扣款失败: ${confirmResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Deduction confirmed for order ${eventData.orderNo}`);
|
||||||
|
|
||||||
|
// 步骤2:发送 planting.order.paid 事件
|
||||||
|
const maxRetries = 3;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await this.kafkaService.publish({
|
||||||
|
topic: 'planting.order.paid',
|
||||||
|
key: eventData.accountSequence,
|
||||||
|
value: {
|
||||||
|
eventName: 'planting.order.paid',
|
||||||
|
data: {
|
||||||
|
orderId: eventData.orderNo,
|
||||||
|
userId: eventData.userId,
|
||||||
|
accountSequence: eventData.accountSequence,
|
||||||
|
treeCount: eventData.treeCount,
|
||||||
|
provinceCode: eventData.provinceCode,
|
||||||
|
cityCode: eventData.cityCode,
|
||||||
|
paidAt: eventData.signedAt || new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Published planting.order.paid for order ${eventData.orderNo}, ` +
|
||||||
|
`accountSequence ${eventData.accountSequence}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to publish planting.order.paid (attempt ${attempt}/${maxRetries}): ${lastError.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
await this.sleep(1000 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Failed to publish planting.order.paid after ${maxRetries} attempts: ${lastError?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认扣款并发送 planting.order.expired 事件给 reward-service
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 调用 wallet-service 确认扣款(将冻结资金正式扣除)
|
||||||
|
* 2. 发送 planting.order.expired 事件触发系统账户奖励分配
|
||||||
|
*/
|
||||||
|
private async publishOrderExpiredEvent(
|
||||||
|
eventData: ContractSigningEvent['data'],
|
||||||
|
): Promise<void> {
|
||||||
|
// 步骤1:确认扣款(即使合同超时,资金也要正式扣除用于分配)
|
||||||
|
const confirmResult = await this.walletService.confirmPlantingDeduction({
|
||||||
|
userId: eventData.userId,
|
||||||
|
accountSequence: eventData.accountSequence,
|
||||||
|
orderId: eventData.orderNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmResult.success) {
|
||||||
|
throw new Error(`确认扣款失败: ${confirmResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Deduction confirmed for expired order ${eventData.orderNo}`);
|
||||||
|
|
||||||
|
// 步骤2:发送 planting.order.expired 事件
|
||||||
|
const maxRetries = 3;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
await this.kafkaService.publish({
|
||||||
|
topic: 'planting.order.expired',
|
||||||
|
key: eventData.accountSequence,
|
||||||
|
value: {
|
||||||
|
eventName: 'planting.order.expired',
|
||||||
|
data: {
|
||||||
|
orderId: eventData.orderNo,
|
||||||
|
userId: eventData.userId,
|
||||||
|
accountSequence: eventData.accountSequence,
|
||||||
|
treeCount: eventData.treeCount,
|
||||||
|
provinceCode: eventData.provinceCode,
|
||||||
|
cityCode: eventData.cityCode,
|
||||||
|
expiredAt: eventData.expiredAt || new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Published planting.order.expired for order ${eventData.orderNo}, ` +
|
||||||
|
`accountSequence ${eventData.accountSequence}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to publish planting.order.expired (attempt ${attempt}/${maxRetries}): ${lastError.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
await this.sleep(1000 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Failed to publish planting.order.expired after ${maxRetries} attempts: ${lastError?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './user-registered.handler';
|
export * from './user-registered.handler';
|
||||||
export * from './planting-created.handler';
|
export * from './planting-created.handler';
|
||||||
|
export * from './contract-signing.handler';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { KafkaService, PrismaService } from '../../infrastructure';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { KafkaService, PrismaService, WalletServiceClient } from '../../infrastructure';
|
||||||
import { EventAckPublisher } from '../../infrastructure/kafka/event-ack.publisher';
|
import { EventAckPublisher } from '../../infrastructure/kafka/event-ack.publisher';
|
||||||
import { TeamStatisticsService } from '../services';
|
import { TeamStatisticsService } from '../services';
|
||||||
import { UpdateTeamStatisticsCommand } from '../commands';
|
import { UpdateTeamStatisticsCommand } from '../commands';
|
||||||
|
|
@ -29,20 +30,31 @@ interface PlantingCreatedEvent {
|
||||||
*
|
*
|
||||||
* 处理流程:
|
* 处理流程:
|
||||||
* 1. 更新团队统计数据
|
* 1. 更新团队统计数据
|
||||||
* 2. 发送 planting.order.paid 事件给 reward-service
|
* 2. 根据 CONTRACT_SIGNING_ENABLED 配置决定后续流程:
|
||||||
|
* - true(默认):等待合同签署结果,由 ContractSigningHandler 转发
|
||||||
|
* - false:立即发送 planting.order.paid,走原有奖励分配流程
|
||||||
*
|
*
|
||||||
* 这样确保统计数据已更新后再计算奖励分配,避免竞态条件。
|
* 合同签署流程(CONTRACT_SIGNING_ENABLED=true 时):
|
||||||
|
* - contract.signed → 发送 planting.order.paid → reward-service 正常奖励分配
|
||||||
|
* - contract.expired → 发送 planting.order.expired → reward-service 系统账户分配
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlantingCreatedHandler implements OnModuleInit {
|
export class PlantingCreatedHandler implements OnModuleInit {
|
||||||
private readonly logger = new Logger(PlantingCreatedHandler.name);
|
private readonly logger = new Logger(PlantingCreatedHandler.name);
|
||||||
|
private readonly contractSigningEnabled: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly kafkaService: KafkaService,
|
private readonly kafkaService: KafkaService,
|
||||||
private readonly teamStatisticsService: TeamStatisticsService,
|
private readonly teamStatisticsService: TeamStatisticsService,
|
||||||
private readonly eventAckPublisher: EventAckPublisher,
|
private readonly eventAckPublisher: EventAckPublisher,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
) {}
|
private readonly configService: ConfigService,
|
||||||
|
private readonly walletService: WalletServiceClient,
|
||||||
|
) {
|
||||||
|
// 默认启用合同签署功能
|
||||||
|
this.contractSigningEnabled = this.configService.get<string>('CONTRACT_SIGNING_ENABLED', 'true') === 'true';
|
||||||
|
this.logger.log(`Contract signing feature: ${this.contractSigningEnabled ? 'ENABLED' : 'DISABLED'}`);
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.kafkaService.subscribe(
|
await this.kafkaService.subscribe(
|
||||||
|
|
@ -96,9 +108,23 @@ export class PlantingCreatedHandler implements OnModuleInit {
|
||||||
`Updated team statistics for accountSequence ${event.data.accountSequence}, count: ${event.data.treeCount}`,
|
`Updated team statistics for accountSequence ${event.data.accountSequence}, count: ${event.data.treeCount}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 步骤2:发送 planting.order.paid 事件给 reward-service
|
// 步骤2:根据配置决定是否立即发送 planting.order.paid
|
||||||
// 统计更新完成后再触发奖励计算,确保数据一致性
|
if (this.contractSigningEnabled) {
|
||||||
await this.publishOrderPaidEvent(event);
|
// 合同签署功能已启用:等待合同签署结果后再发送
|
||||||
|
// - contract.signed → 发送 planting.order.paid → 正常奖励分配
|
||||||
|
// - contract.expired → 发送 planting.order.expired → 系统账户分配
|
||||||
|
this.logger.log(
|
||||||
|
`[CONTRACT_SIGNING=ON] Team statistics updated for order ${event.data.orderId}. ` +
|
||||||
|
`Waiting for contract signing result (contract.signed or contract.expired) to trigger reward distribution.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 合同签署功能未启用:立即发送 planting.order.paid(原流程)
|
||||||
|
await this.publishOrderPaidEvent(event);
|
||||||
|
this.logger.log(
|
||||||
|
`[CONTRACT_SIGNING=OFF] Published planting.order.paid for order ${event.data.orderId}. ` +
|
||||||
|
`Using original reward distribution flow.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 记录已处理的事件
|
// 记录已处理的事件
|
||||||
if (eventId !== 'unknown') {
|
if (eventId !== 'unknown') {
|
||||||
|
|
@ -129,10 +155,30 @@ export class PlantingCreatedHandler implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 planting.order.paid 事件给 reward-service
|
* 确认扣款并发送 planting.order.paid 事件给 reward-service
|
||||||
* 这个事件会触发奖励计算和分配
|
*
|
||||||
|
* 仅在 CONTRACT_SIGNING_ENABLED=false 时由此方法直接调用
|
||||||
|
* CONTRACT_SIGNING_ENABLED=true 时由 ContractSigningHandler 处理
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 调用 wallet-service 确认扣款(将冻结资金正式扣除)
|
||||||
|
* 2. 发送 planting.order.paid 事件
|
||||||
*/
|
*/
|
||||||
private async publishOrderPaidEvent(event: PlantingCreatedEvent): Promise<void> {
|
private async publishOrderPaidEvent(event: PlantingCreatedEvent): Promise<void> {
|
||||||
|
// 步骤1:确认扣款
|
||||||
|
const confirmResult = await this.walletService.confirmPlantingDeduction({
|
||||||
|
userId: event.data.userId,
|
||||||
|
accountSequence: event.data.accountSequence,
|
||||||
|
orderId: event.data.orderId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmResult.success) {
|
||||||
|
throw new Error(`确认扣款失败: ${confirmResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Deduction confirmed for order ${event.data.orderId}`);
|
||||||
|
|
||||||
|
// 步骤2:发送 planting.order.paid 事件
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
|
@ -140,13 +186,13 @@ export class PlantingCreatedHandler implements OnModuleInit {
|
||||||
try {
|
try {
|
||||||
await this.kafkaService.publish({
|
await this.kafkaService.publish({
|
||||||
topic: 'planting.order.paid',
|
topic: 'planting.order.paid',
|
||||||
key: event.data.accountSequence || event.data.userId,
|
key: event.data.accountSequence,
|
||||||
value: {
|
value: {
|
||||||
eventName: 'planting.order.paid',
|
eventName: 'planting.order.paid',
|
||||||
data: {
|
data: {
|
||||||
orderId: event.data.orderId,
|
orderId: event.data.orderId,
|
||||||
userId: event.data.userId,
|
userId: event.data.userId,
|
||||||
accountSequence: event.data.accountSequence, // 跨服务关联标识
|
accountSequence: event.data.accountSequence,
|
||||||
treeCount: event.data.treeCount,
|
treeCount: event.data.treeCount,
|
||||||
provinceCode: event.data.provinceCode,
|
provinceCode: event.data.provinceCode,
|
||||||
cityCode: event.data.cityCode,
|
cityCode: event.data.cityCode,
|
||||||
|
|
@ -156,9 +202,9 @@ export class PlantingCreatedHandler implements OnModuleInit {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Published planting.order.paid event for order ${event.data.orderId}, user ${event.data.userId}`,
|
`Published planting.order.paid event for order ${event.data.orderId}, accountSequence ${event.data.accountSequence}`,
|
||||||
);
|
);
|
||||||
return; // 成功后退出
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
|
|
@ -166,13 +212,11 @@ export class PlantingCreatedHandler implements OnModuleInit {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
// 等待后重试,使用指数退避
|
|
||||||
await this.sleep(1000 * attempt);
|
await this.sleep(1000 * attempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有重试都失败了,抛出错误
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to publish planting.order.paid event after ${maxRetries} attempts: ${lastError?.message}`,
|
`Failed to publish planting.order.paid event after ${maxRetries} attempts: ${lastError?.message}`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './authorization-service.client';
|
export * from './authorization-service.client';
|
||||||
|
export * from './wallet-service.client';
|
||||||
|
|
|
||||||
102
backend/services/referral-service/src/infrastructure/external/wallet-service.client.ts
vendored
Normal file
102
backend/services/referral-service/src/infrastructure/external/wallet-service.client.ts
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
export interface ConfirmPlantingDeductionRequest {
|
||||||
|
userId: string;
|
||||||
|
accountSequence?: string;
|
||||||
|
orderId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmPlantingDeductionResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WalletServiceClient {
|
||||||
|
private readonly logger = new Logger(WalletServiceClient.name);
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.baseUrl =
|
||||||
|
this.configService.get<string>('WALLET_SERVICE_URL') ||
|
||||||
|
'http://localhost:3002';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认认种扣款
|
||||||
|
* 将冻结的资金正式扣除,资金进入"待分配"状态
|
||||||
|
*
|
||||||
|
* 由 referral-service 在发送 planting.order.paid 事件前调用
|
||||||
|
*/
|
||||||
|
async confirmPlantingDeduction(
|
||||||
|
request: ConfirmPlantingDeductionRequest,
|
||||||
|
): Promise<ConfirmPlantingDeductionResult> {
|
||||||
|
const maxRetries = 3;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
this.logger.log(
|
||||||
|
`Confirming planting deduction for order ${request.orderId} (attempt ${attempt}/${maxRetries})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.baseUrl}/api/v1/wallets/confirm-planting-deduction`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to confirm deduction for order ${request.orderId}:`,
|
||||||
|
errorData,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
errorData.message ||
|
||||||
|
`Confirm deduction failed with status ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully confirmed deduction for order ${request.orderId}`,
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to confirm planting deduction (attempt ${attempt}/${maxRetries}): ${lastError.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
await this.sleep(1000 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在开发环境模拟成功
|
||||||
|
if (this.configService.get('NODE_ENV') === 'development') {
|
||||||
|
this.logger.warn(
|
||||||
|
'Development mode: simulating successful deduction confirmation',
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to confirm deduction after ${maxRetries} attempts: ${lastError?.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
TeamStatisticsService,
|
TeamStatisticsService,
|
||||||
UserRegisteredHandler,
|
UserRegisteredHandler,
|
||||||
PlantingCreatedHandler,
|
PlantingCreatedHandler,
|
||||||
|
ContractSigningHandler,
|
||||||
} from '../application';
|
} from '../application';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -15,6 +16,7 @@ import {
|
||||||
TeamStatisticsService,
|
TeamStatisticsService,
|
||||||
UserRegisteredHandler,
|
UserRegisteredHandler,
|
||||||
PlantingCreatedHandler,
|
PlantingCreatedHandler,
|
||||||
|
ContractSigningHandler,
|
||||||
],
|
],
|
||||||
exports: [ReferralService, TeamStatisticsService],
|
exports: [ReferralService, TeamStatisticsService],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
RedisService,
|
RedisService,
|
||||||
EventAckPublisher,
|
EventAckPublisher,
|
||||||
AuthorizationServiceClient,
|
AuthorizationServiceClient,
|
||||||
|
WalletServiceClient,
|
||||||
} from '../infrastructure';
|
} from '../infrastructure';
|
||||||
import {
|
import {
|
||||||
REFERRAL_RELATIONSHIP_REPOSITORY,
|
REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
|
|
@ -26,6 +27,7 @@ import {
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
EventAckPublisher,
|
EventAckPublisher,
|
||||||
AuthorizationServiceClient,
|
AuthorizationServiceClient,
|
||||||
|
WalletServiceClient,
|
||||||
{
|
{
|
||||||
provide: REFERRAL_RELATIONSHIP_REPOSITORY,
|
provide: REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
useClass: ReferralRelationshipRepository,
|
useClass: ReferralRelationshipRepository,
|
||||||
|
|
@ -42,6 +44,7 @@ import {
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
EventAckPublisher,
|
EventAckPublisher,
|
||||||
AuthorizationServiceClient,
|
AuthorizationServiceClient,
|
||||||
|
WalletServiceClient,
|
||||||
REFERRAL_RELATIONSHIP_REPOSITORY,
|
REFERRAL_RELATIONSHIP_REPOSITORY,
|
||||||
TEAM_STATISTICS_REPOSITORY,
|
TEAM_STATISTICS_REPOSITORY,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,16 @@ export class RewardApplicationService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分配奖励 (响应认种订单支付成功事件)
|
* 分配奖励 (响应 planting.order.paid 事件)
|
||||||
|
*
|
||||||
|
* 事件来源:referral-service 在确认扣款后发送
|
||||||
*
|
*
|
||||||
* 核心职责:
|
* 核心职责:
|
||||||
* 1. 计算考核后的奖励分配方案(调用 authorization-service)
|
* 1. 计算考核后的奖励分配方案(调用 authorization-service)
|
||||||
* 2. 调用 wallet-service 执行真正的资金分配
|
* 2. 调用 wallet-service 执行真正的资金分配
|
||||||
* 3. 保存奖励流水记录
|
* 3. 保存奖励流水记录
|
||||||
|
*
|
||||||
|
* 注意:扣款确认由 referral-service 在发送此事件前完成
|
||||||
*/
|
*/
|
||||||
async distributeRewards(params: {
|
async distributeRewards(params: {
|
||||||
sourceOrderNo: string; // 订单号是字符串格式如 PLT1765391584505Q0Q6QD
|
sourceOrderNo: string; // 订单号是字符串格式如 PLT1765391584505Q0Q6QD
|
||||||
|
|
@ -120,6 +124,94 @@ export class RewardApplicationService {
|
||||||
this.logger.log(`Distributed ${rewards.length} rewards for order ${params.sourceOrderNo}`);
|
this.logger.log(`Distributed ${rewards.length} rewards for order ${params.sourceOrderNo}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分配奖励到系统账户 (响应 planting.order.expired 事件)
|
||||||
|
*
|
||||||
|
* 事件来源:referral-service 在确认扣款后发送
|
||||||
|
*
|
||||||
|
* 与正常分配的区别:
|
||||||
|
* - 系统费用(成本费、运营费、底池、总部基础费):正常分配
|
||||||
|
* - 用户权益(分享、省团队、市团队、社区、省区域、市区域):全部进入系统默认账户
|
||||||
|
*
|
||||||
|
* 系统默认账户规则:
|
||||||
|
* - 分享权益 → S0000000005(分享权益池)
|
||||||
|
* - 省团队权益 → 7+省代码(省团队默认账户)
|
||||||
|
* - 市团队权益 → 6+市代码(市团队默认账户)
|
||||||
|
* - 社区权益 → S0000000001(总部社区)
|
||||||
|
* - 省区域权益 → 9+省代码(省区域账户)
|
||||||
|
* - 市区域权益 → 8+市代码(市区域账户)
|
||||||
|
*
|
||||||
|
* 注意:扣款确认由 referral-service 在发送此事件前完成
|
||||||
|
*/
|
||||||
|
async distributeRewardsForExpiredContract(params: {
|
||||||
|
sourceOrderNo: string;
|
||||||
|
sourceUserId: bigint;
|
||||||
|
sourceAccountSequence?: string;
|
||||||
|
treeCount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
this.logger.log(`Distributing rewards for EXPIRED contract, order ${params.sourceOrderNo}`);
|
||||||
|
|
||||||
|
// 1. 计算奖励,但使用系统账户作为用户权益的接收方
|
||||||
|
const rewards = await this.rewardCalculationService.calculateRewardsForExpiredContract(params);
|
||||||
|
|
||||||
|
// 2. 调用 wallet-service 执行资金分配
|
||||||
|
const allocations: import('../../infrastructure/external/wallet-service/wallet-service.client').FundAllocationItem[] = rewards.map(reward => ({
|
||||||
|
targetType: (reward.accountSequence.startsWith('S') || /^\d+$/.test(reward.accountSequence) ? 'SYSTEM' : 'USER') as 'USER' | 'SYSTEM',
|
||||||
|
targetId: reward.accountSequence,
|
||||||
|
allocationType: reward.rewardSource.rightType,
|
||||||
|
amount: reward.usdtAmount.amount,
|
||||||
|
hashpowerPercent: reward.hashpowerAmount.value > 0 ? reward.hashpowerAmount.value : undefined,
|
||||||
|
metadata: {
|
||||||
|
rightType: reward.rewardSource.rightType,
|
||||||
|
sourceOrderNo: params.sourceOrderNo,
|
||||||
|
sourceUserId: params.sourceUserId.toString(),
|
||||||
|
memo: reward.memo,
|
||||||
|
reason: 'CONTRACT_EXPIRED', // 标记为合同超时
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const allocateResult = await this.walletService.allocateFunds({
|
||||||
|
orderId: params.sourceOrderNo,
|
||||||
|
allocations,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allocateResult.success) {
|
||||||
|
this.logger.error(`Failed to allocate funds for expired contract order ${params.sourceOrderNo}: ${allocateResult.error}`);
|
||||||
|
throw new Error(`资金分配失败: ${allocateResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Wallet allocation completed for expired contract order ${params.sourceOrderNo}`);
|
||||||
|
|
||||||
|
// 3. 保存奖励流水
|
||||||
|
await this.rewardLedgerEntryRepository.saveAll(rewards);
|
||||||
|
|
||||||
|
// 4. 更新各系统账户的汇总数据
|
||||||
|
const userIds = [...new Set(rewards.map(r => r.userId))];
|
||||||
|
for (const userId of userIds) {
|
||||||
|
const userRewards = rewards.filter(r => r.userId === userId);
|
||||||
|
const accountSequence = userRewards[0].accountSequence;
|
||||||
|
const summary = await this.rewardSummaryRepository.getOrCreate(userId, accountSequence);
|
||||||
|
|
||||||
|
for (const reward of userRewards) {
|
||||||
|
if (reward.isSettleable) {
|
||||||
|
summary.addSettleable(reward.usdtAmount, reward.hashpowerAmount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.rewardSummaryRepository.save(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 发布领域事件
|
||||||
|
for (const reward of rewards) {
|
||||||
|
await this.eventPublisher.publishAll(reward.domainEvents);
|
||||||
|
reward.clearDomainEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Distributed ${rewards.length} rewards to system accounts for expired contract order ${params.sourceOrderNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户认种后,将该用户的待领取奖励转为可结算
|
* 用户认种后,将该用户的待领取奖励转为可结算
|
||||||
* @deprecated 使用 claimPendingRewardsForAccountSequence 代替
|
* @deprecated 使用 claimPendingRewardsForAccountSequence 代替
|
||||||
|
|
|
||||||
|
|
@ -678,4 +678,315 @@ export class RewardCalculationService {
|
||||||
|
|
||||||
return rewards;
|
return rewards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 合同超时场景的奖励计算
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算合同超时未签署时的奖励分配
|
||||||
|
*
|
||||||
|
* 与正常流程的区别:
|
||||||
|
* - 系统费用(成本费、运营费、底池、总部基础费):正常分配到系统账户
|
||||||
|
* - 用户权益:全部分配到系统默认账户,不分给真正的推荐人/团队
|
||||||
|
*
|
||||||
|
* 系统默认账户规则:
|
||||||
|
* - 分享权益 3600 → S0000000005(分享权益池)
|
||||||
|
* - 省团队权益 144 → 7+省代码(省团队默认账户)
|
||||||
|
* - 市团队权益 288 → 6+市代码(市团队默认账户)
|
||||||
|
* - 社区权益 576 → S0000000001(总部社区)
|
||||||
|
* - 省区域权益 108 → 9+省代码(省区域账户)
|
||||||
|
* - 市区域权益 252 → 8+市代码(市区域账户)
|
||||||
|
*/
|
||||||
|
async calculateRewardsForExpiredContract(params: {
|
||||||
|
sourceOrderNo: string;
|
||||||
|
sourceUserId: bigint;
|
||||||
|
sourceAccountSequence?: string;
|
||||||
|
treeCount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
}): Promise<RewardLedgerEntry[]> {
|
||||||
|
this.logger.log(
|
||||||
|
`[calculateRewardsForExpiredContract] START orderNo=${params.sourceOrderNo}, userId=${params.sourceUserId}, ` +
|
||||||
|
`treeCount=${params.treeCount}, province=${params.provinceCode}, city=${params.cityCode}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rewards: RewardLedgerEntry[] = [];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 系统费用类 (10863 USDT) - 正常分配
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 1. 成本费 (2800 USDT) → S0000000002
|
||||||
|
rewards.push(this.calculateCostFee(
|
||||||
|
params.sourceOrderNo,
|
||||||
|
params.sourceUserId,
|
||||||
|
params.treeCount,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 2. 运营费 (2100 USDT) → S0000000003
|
||||||
|
rewards.push(this.calculateOperationFee(
|
||||||
|
params.sourceOrderNo,
|
||||||
|
params.sourceUserId,
|
||||||
|
params.treeCount,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 3. 总部社区基础费 (203 USDT) → S0000000001
|
||||||
|
rewards.push(this.calculateHeadquartersBaseFee(
|
||||||
|
params.sourceOrderNo,
|
||||||
|
params.sourceUserId,
|
||||||
|
params.treeCount,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 4. RWAD底池注入 (5760 USDT) → S0000000004
|
||||||
|
rewards.push(this.calculateRwadPoolInjection(
|
||||||
|
params.sourceOrderNo,
|
||||||
|
params.sourceUserId,
|
||||||
|
params.treeCount,
|
||||||
|
));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 用户权益类 (4968 USDT) - 全部进入系统默认账户
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 5. 分享权益 (3600 USDT) → S0000000005(分享权益池)
|
||||||
|
rewards.push(this.calculateShareRightToSystemAccount(
|
||||||
|
params.sourceOrderNo,
|
||||||
|
params.sourceUserId,
|
||||||
|
params.treeCount,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 6. 省团队权益 (144 USDT) → 7+省代码
|
||||||
|
rewards.push(this.calculateProvinceTeamRightToSystemAccount(
|
||||||
|
params.sourceOrderNo,
|
||||||
|
params.sourceUserId,
|
||||||
|
params.provinceCode,
|
||||||
|
params.treeCount,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 7. 省区域权益 (108 USDT + 1%算力) → 9+省代码
|
||||||
|
rewards.push(this.calculateProvinceAreaRightToSystemAccount(
|
||||||
|
params.sourceOrderNo,
|
||||||
|
params.sourceUserId,
|
||||||
|
params.provinceCode,
|
||||||
|
params.treeCount,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 8. 市团队权益 (288 USDT) → 6+市代码
|
||||||
|
rewards.push(this.calculateCityTeamRightToSystemAccount(
|
||||||
|
params.sourceOrderNo,
|
||||||
|
params.sourceUserId,
|
||||||
|
params.cityCode,
|
||||||
|
params.treeCount,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 9. 市区域权益 (252 USDT + 2%算力) → 8+市代码
|
||||||
|
rewards.push(this.calculateCityAreaRightToSystemAccount(
|
||||||
|
params.sourceOrderNo,
|
||||||
|
params.sourceUserId,
|
||||||
|
params.cityCode,
|
||||||
|
params.treeCount,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 10. 社区权益 (576 USDT) → S0000000001(总部社区)
|
||||||
|
rewards.push(this.calculateCommunityRightToSystemAccount(
|
||||||
|
params.sourceOrderNo,
|
||||||
|
params.sourceUserId,
|
||||||
|
params.treeCount,
|
||||||
|
));
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[calculateRewardsForExpiredContract] DONE orderNo=${params.sourceOrderNo}, totalRewards=${rewards.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rewards;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 合同超时场景的用户权益计算(进入系统账户)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分享权益进入分享权益池
|
||||||
|
*/
|
||||||
|
private calculateShareRightToSystemAccount(
|
||||||
|
sourceOrderNo: string,
|
||||||
|
sourceUserId: bigint,
|
||||||
|
treeCount: number,
|
||||||
|
): RewardLedgerEntry {
|
||||||
|
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.SHARE_RIGHT];
|
||||||
|
const usdtAmount = Money.USDT(usdt * treeCount);
|
||||||
|
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||||||
|
|
||||||
|
const rewardSource = RewardSource.create(
|
||||||
|
RightType.SHARE_RIGHT,
|
||||||
|
sourceOrderNo,
|
||||||
|
sourceUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return RewardLedgerEntry.createSettleable({
|
||||||
|
userId: SHARE_RIGHT_POOL_USER_ID,
|
||||||
|
accountSequence: 'S0000000005',
|
||||||
|
rewardSource,
|
||||||
|
usdtAmount,
|
||||||
|
hashpowerAmount: hashpower,
|
||||||
|
memo: `分享权益:合同超时未签署,进分享权益池`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 省团队权益进入省团队默认账户 (7+省代码)
|
||||||
|
*/
|
||||||
|
private calculateProvinceTeamRightToSystemAccount(
|
||||||
|
sourceOrderNo: string,
|
||||||
|
sourceUserId: bigint,
|
||||||
|
provinceCode: string,
|
||||||
|
treeCount: number,
|
||||||
|
): RewardLedgerEntry {
|
||||||
|
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.PROVINCE_TEAM_RIGHT];
|
||||||
|
const usdtAmount = Money.USDT(usdt * treeCount);
|
||||||
|
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||||||
|
|
||||||
|
const rewardSource = RewardSource.create(
|
||||||
|
RightType.PROVINCE_TEAM_RIGHT,
|
||||||
|
sourceOrderNo,
|
||||||
|
sourceUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 省团队默认账户: 7 + 省代码
|
||||||
|
const accountSequence = `7${provinceCode}`;
|
||||||
|
|
||||||
|
return RewardLedgerEntry.createSettleable({
|
||||||
|
userId: BigInt(accountSequence),
|
||||||
|
accountSequence,
|
||||||
|
rewardSource,
|
||||||
|
usdtAmount,
|
||||||
|
hashpowerAmount: hashpower,
|
||||||
|
memo: `省团队权益(${provinceCode}):合同超时未签署,进省团队默认账户`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 省区域权益进入省区域账户 (9+省代码)
|
||||||
|
*/
|
||||||
|
private calculateProvinceAreaRightToSystemAccount(
|
||||||
|
sourceOrderNo: string,
|
||||||
|
sourceUserId: bigint,
|
||||||
|
provinceCode: string,
|
||||||
|
treeCount: number,
|
||||||
|
): RewardLedgerEntry {
|
||||||
|
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.PROVINCE_AREA_RIGHT];
|
||||||
|
const usdtAmount = Money.USDT(usdt * treeCount);
|
||||||
|
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||||||
|
|
||||||
|
const rewardSource = RewardSource.create(
|
||||||
|
RightType.PROVINCE_AREA_RIGHT,
|
||||||
|
sourceOrderNo,
|
||||||
|
sourceUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 省区域账户: 9 + 省代码
|
||||||
|
const accountSequence = `9${provinceCode}`;
|
||||||
|
|
||||||
|
return RewardLedgerEntry.createSettleable({
|
||||||
|
userId: BigInt(accountSequence),
|
||||||
|
accountSequence,
|
||||||
|
rewardSource,
|
||||||
|
usdtAmount,
|
||||||
|
hashpowerAmount: hashpower,
|
||||||
|
memo: `省区域权益(${provinceCode}):合同超时未签署,进省区域账户`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 市团队权益进入市团队默认账户 (6+市代码)
|
||||||
|
*/
|
||||||
|
private calculateCityTeamRightToSystemAccount(
|
||||||
|
sourceOrderNo: string,
|
||||||
|
sourceUserId: bigint,
|
||||||
|
cityCode: string,
|
||||||
|
treeCount: number,
|
||||||
|
): RewardLedgerEntry {
|
||||||
|
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.CITY_TEAM_RIGHT];
|
||||||
|
const usdtAmount = Money.USDT(usdt * treeCount);
|
||||||
|
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||||||
|
|
||||||
|
const rewardSource = RewardSource.create(
|
||||||
|
RightType.CITY_TEAM_RIGHT,
|
||||||
|
sourceOrderNo,
|
||||||
|
sourceUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 市团队默认账户: 6 + 市代码
|
||||||
|
const accountSequence = `6${cityCode}`;
|
||||||
|
|
||||||
|
return RewardLedgerEntry.createSettleable({
|
||||||
|
userId: BigInt(accountSequence),
|
||||||
|
accountSequence,
|
||||||
|
rewardSource,
|
||||||
|
usdtAmount,
|
||||||
|
hashpowerAmount: hashpower,
|
||||||
|
memo: `市团队权益(${cityCode}):合同超时未签署,进市团队默认账户`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 市区域权益进入市区域账户 (8+市代码)
|
||||||
|
*/
|
||||||
|
private calculateCityAreaRightToSystemAccount(
|
||||||
|
sourceOrderNo: string,
|
||||||
|
sourceUserId: bigint,
|
||||||
|
cityCode: string,
|
||||||
|
treeCount: number,
|
||||||
|
): RewardLedgerEntry {
|
||||||
|
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.CITY_AREA_RIGHT];
|
||||||
|
const usdtAmount = Money.USDT(usdt * treeCount);
|
||||||
|
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||||||
|
|
||||||
|
const rewardSource = RewardSource.create(
|
||||||
|
RightType.CITY_AREA_RIGHT,
|
||||||
|
sourceOrderNo,
|
||||||
|
sourceUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 市区域账户: 8 + 市代码
|
||||||
|
const accountSequence = `8${cityCode}`;
|
||||||
|
|
||||||
|
return RewardLedgerEntry.createSettleable({
|
||||||
|
userId: BigInt(accountSequence),
|
||||||
|
accountSequence,
|
||||||
|
rewardSource,
|
||||||
|
usdtAmount,
|
||||||
|
hashpowerAmount: hashpower,
|
||||||
|
memo: `市区域权益(${cityCode}):合同超时未签署,进市区域账户`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 社区权益进入总部社区 (S0000000001)
|
||||||
|
*/
|
||||||
|
private calculateCommunityRightToSystemAccount(
|
||||||
|
sourceOrderNo: string,
|
||||||
|
sourceUserId: bigint,
|
||||||
|
treeCount: number,
|
||||||
|
): RewardLedgerEntry {
|
||||||
|
const { usdt, hashpowerPercent } = RIGHT_AMOUNTS[RightType.COMMUNITY_RIGHT];
|
||||||
|
const usdtAmount = Money.USDT(usdt * treeCount);
|
||||||
|
const hashpower = Hashpower.fromTreeCount(treeCount, hashpowerPercent);
|
||||||
|
|
||||||
|
const rewardSource = RewardSource.create(
|
||||||
|
RightType.COMMUNITY_RIGHT,
|
||||||
|
sourceOrderNo,
|
||||||
|
sourceUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return RewardLedgerEntry.createSettleable({
|
||||||
|
userId: HEADQUARTERS_COMMUNITY_USER_ID,
|
||||||
|
accountSequence: 'S0000000001',
|
||||||
|
rewardSource,
|
||||||
|
usdtAmount,
|
||||||
|
hashpowerAmount: hashpower,
|
||||||
|
memo: `社区权益:合同超时未签署,进总部社区`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,47 @@ export class WalletServiceClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认认种扣款
|
||||||
|
* 将冻结的资金正式扣除,资金进入"待分配"状态
|
||||||
|
* 在合同签署结果确定后调用(contract.signed 或 contract.expired)
|
||||||
|
*/
|
||||||
|
async confirmPlantingDeduction(params: {
|
||||||
|
userId: string;
|
||||||
|
accountSequence?: string;
|
||||||
|
orderId: string;
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`Confirming planting deduction for order ${params.orderId}`);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/wallets/confirm-planting-deduction`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
this.logger.error(`Failed to confirm deduction for order ${params.orderId}:`, errorData);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorData.message || `Confirm deduction failed with status ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Successfully confirmed deduction for order ${params.orderId}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error confirming deduction for order ${params.orderId}:`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行资金分配
|
* 执行资金分配
|
||||||
* 将认种订单的资金分配到各个目标账户
|
* 将认种订单的资金分配到各个目标账户
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import { MessagePattern, Payload } from '@nestjs/microservices';
|
||||||
import { RewardApplicationService } from '../../application/services/reward-application.service';
|
import { RewardApplicationService } from '../../application/services/reward-application.service';
|
||||||
import { EventAckPublisher } from './event-ack.publisher';
|
import { EventAckPublisher } from './event-ack.publisher';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认种订单支付事件(正常奖励分配流程)
|
||||||
|
* 由 referral-service 在收到 contract.signed 后转发
|
||||||
|
*/
|
||||||
interface PlantingOrderPaidEvent {
|
interface PlantingOrderPaidEvent {
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
data?: {
|
data?: {
|
||||||
|
|
@ -30,6 +34,23 @@ interface PlantingOrderPaidEvent {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认种订单过期事件(合同超时未签署,系统账户分配)
|
||||||
|
* 由 referral-service 在收到 contract.expired 后转发
|
||||||
|
*/
|
||||||
|
interface PlantingOrderExpiredEvent {
|
||||||
|
eventName: string;
|
||||||
|
data: {
|
||||||
|
orderId: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
treeCount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
cityCode: string;
|
||||||
|
expiredAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class EventConsumerController {
|
export class EventConsumerController {
|
||||||
private readonly logger = new Logger(EventConsumerController.name);
|
private readonly logger = new Logger(EventConsumerController.name);
|
||||||
|
|
@ -41,6 +62,9 @@ export class EventConsumerController {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监听认种订单支付成功事件
|
* 监听认种订单支付成功事件
|
||||||
|
*
|
||||||
|
* 由 referral-service 在收到 contract.signed 后转发
|
||||||
|
* 触发正常奖励分配(与原流程完全一致)
|
||||||
*/
|
*/
|
||||||
@MessagePattern('planting.order.paid')
|
@MessagePattern('planting.order.paid')
|
||||||
async handlePlantingOrderPaid(@Payload() message: PlantingOrderPaidEvent) {
|
async handlePlantingOrderPaid(@Payload() message: PlantingOrderPaidEvent) {
|
||||||
|
|
@ -57,32 +81,27 @@ export class EventConsumerController {
|
||||||
paidAt: message.paidAt!,
|
paidAt: message.paidAt!,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 优先使用 accountSequence,如果未提供则使用 userId
|
|
||||||
const userIdentifier = eventData.accountSequence || eventData.userId;
|
|
||||||
this.logger.log(`Processing event with userIdentifier: ${userIdentifier} (accountSequence: ${eventData.accountSequence}, userId: ${eventData.userId})`);
|
|
||||||
|
|
||||||
// B方案:提取 outbox 信息用于发送确认
|
// B方案:提取 outbox 信息用于发送确认
|
||||||
const outboxInfo = message._outbox;
|
const outboxInfo = message._outbox;
|
||||||
const eventId = outboxInfo?.aggregateId || eventData.orderId;
|
const eventId = outboxInfo?.aggregateId || eventData.orderId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 计算并分配奖励
|
// 1. 计算并分配奖励(正常流程,100% 不变)
|
||||||
await this.rewardService.distributeRewards({
|
await this.rewardService.distributeRewards({
|
||||||
sourceOrderNo: eventData.orderId, // orderId 实际是 orderNo 字符串格式
|
sourceOrderNo: eventData.orderId,
|
||||||
sourceUserId: BigInt(eventData.userId),
|
sourceUserId: BigInt(eventData.userId),
|
||||||
sourceAccountSequence: userIdentifier, // 优先使用 accountSequence
|
sourceAccountSequence: eventData.accountSequence,
|
||||||
treeCount: eventData.treeCount,
|
treeCount: eventData.treeCount,
|
||||||
provinceCode: eventData.provinceCode,
|
provinceCode: eventData.provinceCode,
|
||||||
cityCode: eventData.cityCode,
|
cityCode: eventData.cityCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 检查该用户是否有待领取奖励需要转为可结算
|
// 2. 检查该用户是否有待领取奖励需要转为可结算
|
||||||
// 使用 accountSequence 查找,因为奖励是按 accountSequence 存储的
|
|
||||||
if (eventData.accountSequence) {
|
if (eventData.accountSequence) {
|
||||||
await this.rewardService.claimPendingRewardsForAccountSequence(eventData.accountSequence);
|
await this.rewardService.claimPendingRewardsForAccountSequence(eventData.accountSequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Successfully processed planting.order.paid for order ${eventData.orderId}`);
|
this.logger.log(`Successfully distributed rewards for order ${eventData.orderId}`);
|
||||||
|
|
||||||
// B方案:发送处理成功确认
|
// B方案:发送处理成功确认
|
||||||
if (outboxInfo) {
|
if (outboxInfo) {
|
||||||
|
|
@ -100,4 +119,42 @@ export class EventConsumerController {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听认种订单过期事件(合同超时未签署)
|
||||||
|
*
|
||||||
|
* 由 referral-service 在收到 contract.expired 后转发
|
||||||
|
* 触发系统账户奖励分配:
|
||||||
|
* - 系统费用(成本费、运营费、底池、总部基础费):正常分配
|
||||||
|
* - 用户权益(分享、省团队、市团队、社区、省区域、市区域):全部进入系统默认账户
|
||||||
|
*/
|
||||||
|
@MessagePattern('planting.order.expired')
|
||||||
|
async handlePlantingOrderExpired(@Payload() message: PlantingOrderExpiredEvent) {
|
||||||
|
this.logger.log(`Received planting.order.expired event: ${JSON.stringify(message)}`);
|
||||||
|
|
||||||
|
const eventData = message.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.warn(
|
||||||
|
`Order ${eventData.orderId} expired (contract not signed within 24h), ` +
|
||||||
|
`accountSequence: ${eventData.accountSequence}, expiredAt: ${eventData.expiredAt}. ` +
|
||||||
|
`Starting system account distribution (user rights go to default system accounts)...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 分配奖励到系统账户(用户权益进入系统默认账户)
|
||||||
|
await this.rewardService.distributeRewardsForExpiredContract({
|
||||||
|
sourceOrderNo: eventData.orderId,
|
||||||
|
sourceUserId: BigInt(eventData.userId),
|
||||||
|
sourceAccountSequence: eventData.accountSequence,
|
||||||
|
treeCount: eventData.treeCount,
|
||||||
|
provinceCode: eventData.provinceCode,
|
||||||
|
cityCode: eventData.cityCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Successfully distributed rewards to system accounts for expired order ${eventData.orderId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error processing planting.order.expired for order ${eventData.orderId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue