Compare commits

..

No commits in common. "main" and "v2.0.0-cdc-sync-fix" have entirely different histories.

767 changed files with 5281 additions and 103117 deletions

View File

@ -767,65 +767,7 @@
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-app\\): update splash page theme and fix token refresh\n\n- Update splash_page.dart to orange theme \\(#FF6B00\\) matching other pages\n- Change app name from \"榴莲挖矿\" to \"榴莲生态\"\n- Fix refreshTokenIfNeeded to properly throw on failure instead of\n silently calling logout \\(which caused Riverpod ref errors\\)\n- Clear local storage directly on refresh failure without remote API call\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(python3 -c \" import sys content = sys.stdin.read\\(\\) old = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' new = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' print\\(content.replace\\(old, new\\)\\) \")",
"Bash(git rm:*)",
"Bash(echo \"请在服务器运行以下命令检查 outbox 事件:\n\ndocker exec -it rwa-postgres psql -U rwa_user -d rwa_contribution -c \"\"\nSELECT id, event_type, aggregate_id, \n payload->>''sourceType'' as source_type,\n payload->>''accountSequence'' as account_seq,\n payload->>''sourceAccountSequence'' as source_account_seq,\n payload->>''bonusTier'' as bonus_tier\nFROM outbox_events \nWHERE payload->>''accountSequence'' = ''D25122900007''\nORDER BY id;\n\"\"\")",
"Bash(ssh -o ConnectTimeout=10 ceshi@14.215.128.96 'find /home/ceshi/rwadurian/frontend/mining-admin-web -name \"\"*.tsx\"\" -o -name \"\"*.ts\"\" | xargs grep -l \"\"用户管理\\\\|users\"\" 2>/dev/null | head -10')",
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\")",
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\")",
"Bash(ssh -J ceshi@103.39.231.231 ceshi@192.168.1.111 \"curl -s http://localhost:3021/api/v2/admin/status\")",
"Bash(del \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\domain\\\\usecases\\\\trading\\\\buy_shares.dart\")",
"Bash(del \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\domain\\\\usecases\\\\trading\\\\sell_shares.dart\")",
"Bash(ls -la \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\presentation\\\\pages\"\" 2>/dev/null || dir /b \"c:UsersdongDesktoprwadurianfrontendmining-applibpresentationpages \")",
"Bash(cd:*)",
"Bash(ssh -o StrictHostKeyChecking=no -J ceshi@103.39.231.231 ceshi@192.168.1.111 \"curl -s http://localhost:3020/api/v1/ | head -100\")",
"Bash(ssh -o StrictHostKeyChecking=no -J ceshi@103.39.231.231 ceshi@192.168.1.111:*)",
"Bash(bc:*)",
"Bash(DATABASE_URL=\"postgresql://postgres:password@localhost:5432/mining_db?schema=public\" npx prisma migrate diff:*)",
"Bash(git status:*)",
"Bash(xargs cat:*)",
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"docker ps | grep mining\")",
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\trading-service\\\\src\\\\application\\\\services\")",
"Bash(DATABASE_URL=\"postgresql://postgres:password@localhost:5432/trading_db?schema=public\" npx prisma migrate dev:*)",
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\mining-admin-service\\\\src\")",
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/service && ls -la\")",
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"ls -la /home/ceshi/rwadurian/backend/\")",
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"ls -la /home/ceshi/rwadurian/backend/services/\")",
"Bash(where:*)",
"Bash(npx md-to-pdf:*)",
"Bash(ssh -J ceshi@103.39.231.231 ceshi@192.168.1.111 \"curl -s ''http://localhost:3000/api/price/klines?period=1h&limit=5'' | head -500\")",
"Bash(dir /b /ad \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\")",
"Bash(timeout 30 cat:*)",
"Bash(npm run lint)",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"cat /home/ceshi/rwadurian/backend/services/mining-service/src/application/services/batch-mining.service.ts | head -250\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"docker logs rwa-mining-admin-service --tail 50 2>&1 | grep ''第一条数据\\\\|最后一条数据''\")",
"Bash(npx xlsx-cli 挖矿.xlsx)",
"Bash(DATABASE_URL=\"postgresql://postgres:password@localhost:5432/mining_db?schema=public\" npx prisma migrate dev:*)",
"Bash(md-to-pdf:*)",
"Bash(dir \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\docs\\\\deployment\\\\*.pdf\")",
"Bash(./gradlew compileDebugKotlin:*)",
"Bash(cmd.exe /c \"cd /d c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-android && gradlew.bat :app:compileDebugKotlin --no-daemon\")",
"Bash(powershell -Command \"Set-Location 'c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-android'; .\\\\gradlew.bat :app:compileDebugKotlin --no-daemon 2>&1\":*)",
"Bash(powershell -Command \"Set-Location ''c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-android''; .\\\\gradlew.bat :app:compileDebugKotlin --no-daemon 2>&1 | Select-Object -Last 20\")",
"Bash(cmd.exe /c \"gradlew.bat installDebug && adb logcat -c && adb logcat | findstr /C:\"\"EXPORT\"\" /C:\"\"IMPORT\"\" /C:\"\"STATE\"\"\")",
"Bash(./gradlew:*)",
"Bash(adb shell \"run-as com.durian.tssparty sqlite3 /data/data/com.durian.tssparty/databases/tss_party.db ''SELECT id, tx_hash, from_address, to_address, amount, token_type, status, direction, created_at FROM transaction_records ORDER BY id DESC LIMIT 5;''\")",
"WebFetch(domain:docs.kava.io)",
"WebFetch(domain:kavascan.com)",
"Bash(.gradlew.bat compileDebugKotlin:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:oneuptime.com)",
"Bash(gradlew.bat assembleDebug:*)",
"Bash(cmd /c \"gradlew.bat assembleDebug --no-daemon\")",
"Bash(./build-install-debug.bat)",
"Bash(dir /s /b \"backend\\\\mpc-system\\\\services\\\\service-party-android\\\\*.kt\")",
"Bash(set DATABASE_URL=postgresql://postgres:password@localhost:5432/trading_db?schema=public)",
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"curl -s ''http://localhost:3000/api/v2/trading/asset/account/D25122700015'' | jq .\")",
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"curl -s ''http://localhost:3000/api/v2/trading/trading/orders?accountSequence=D25122700015'' | jq .\")",
"Bash(docker stop:*)",
"Bash(ssh-add:*)",
"Bash(ls -la \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\auth-service\\\\src\"\" 2>/dev/null || echo \"Source directory structure: \")",
"Bash($env:DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/rwa_auth\")",
"Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/rwa_auth\" npx prisma migrate dev:*)",
"Bash(ssh -J ceshi@103.39.231.231 ceshi@192.168.1.111:*)"
"Bash(echo \"请在服务器运行以下命令检查 outbox 事件:\n\ndocker exec -it rwa-postgres psql -U rwa_user -d rwa_contribution -c \"\"\nSELECT id, event_type, aggregate_id, \n payload->>''sourceType'' as source_type,\n payload->>''accountSequence'' as account_seq,\n payload->>''sourceAccountSequence'' as source_account_seq,\n payload->>''bonusTier'' as bonus_tier\nFROM outbox_events \nWHERE payload->>''accountSequence'' = ''D25122900007''\nORDER BY id;\n\"\"\")"
],
"deny": [],
"ask": []

View File

@ -120,45 +120,6 @@ cmd_up() {
fi
}
# 启动服务 (2.0 standalone 模式)
# 使用 docker-compose.standalone.yml override:
# - Kong 加 extra_hosts: host.docker.internal (访问同机 2.0 服务)
# - kong-config 加载 kong-standalone.yml (2.0 → localhost, 1.0 → 192.168.1.111)
cmd_up2() {
log_info "启动 Kong API Gateway (standalone 模式)..."
check_backend
local STANDALONE="$COMPOSE_CMD -f docker-compose.yml -f docker-compose.standalone.yml"
$STANDALONE up -d
log_info "等待 Kong 启动..."
sleep 10
if docker ps | grep -q rwa-kong; then
log_success "Kong API Gateway (standalone) 启动成功!"
echo ""
echo "模式: standalone (2.0 → host.docker.internal, 1.0 → 192.168.1.111)"
echo "服务地址:"
echo " Proxy: http://localhost:8000"
echo " Admin API: http://localhost:8001"
echo " Admin GUI: http://localhost:8002"
echo ""
else
log_error "Kong 启动失败,查看日志: $STANDALONE logs"
exit 1
fi
}
# 重新同步 standalone 配置
cmd_sync2() {
log_info "同步 kong-standalone.yml 到 Kong..."
local STANDALONE="$COMPOSE_CMD -f docker-compose.yml -f docker-compose.standalone.yml"
$STANDALONE run --rm kong-config
log_success "standalone 配置同步完成"
echo ""
echo "查看路由: ./deploy.sh routes"
}
# 停止服务
cmd_down() {
log_info "停止 Kong API Gateway..."
@ -307,127 +268,6 @@ cmd_metrics() {
fi
}
# 安装 Nginx + SSL 证书 (新域名)
cmd_nginx_install() {
local domain="${1:-mapi.szaiai.com}"
local email="${2:-admin@szaiai.com}"
local conf_file="$SCRIPT_DIR/nginx/${domain}.conf"
log_info "为域名 $domain 安装 Nginx + SSL..."
# 检查 conf 文件是否存在
if [ ! -f "$conf_file" ]; then
log_error "Nginx 配置文件不存在: $conf_file"
log_error "请先在 nginx/ 目录下创建 ${domain}.conf"
exit 1
fi
# 检查 root 权限
if [ "$EUID" -ne 0 ]; then
log_error "需要 root 权限: sudo ./deploy.sh nginx install $domain"
exit 1
fi
# 1. 安装依赖
log_info "[1/4] 检查并安装依赖..."
if ! command -v nginx &> /dev/null; then
apt update && apt install -y nginx
systemctl enable nginx
systemctl start nginx
fi
log_success "Nginx 已就绪"
if ! command -v certbot &> /dev/null; then
apt install -y certbot python3-certbot-nginx
fi
log_success "Certbot 已就绪"
# 2. 部署 HTTP 临时配置
log_info "[2/4] 部署 HTTP 临时配置..."
mkdir -p /var/www/certbot
cat > /etc/nginx/sites-available/$domain << HTTPEOF
server {
listen 80;
listen [::]:80;
server_name $domain;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
HTTPEOF
ln -sf /etc/nginx/sites-available/$domain /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
log_success "HTTP 配置完成"
# 3. 申请 SSL 证书
log_info "[3/4] 申请 SSL 证书..."
if [ -d "/etc/letsencrypt/live/$domain" ]; then
log_warn "证书已存在,跳过申请"
else
echo ""
log_warn "请确保 DNS A 记录 $domain 已指向本服务器 IP"
read -p "继续申请证书? (y/n): " confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
log_info "已跳过,当前为 HTTP 模式。稍后运行: sudo ./deploy.sh nginx ssl $domain"
return 0
fi
certbot certonly --webroot --webroot-path=/var/www/certbot \
--email $email --agree-tos --no-eff-email -d $domain
fi
log_success "SSL 证书就绪"
# 4. 部署 HTTPS 完整配置
log_info "[4/4] 部署 HTTPS 配置..."
cp "$conf_file" /etc/nginx/sites-available/$domain
nginx -t && systemctl reload nginx
log_success "$domain 配置完成!"
echo ""
echo -e " 访问地址: ${BLUE}https://$domain${NC}"
echo -e " 查看日志: tail -f /var/log/nginx/${domain}.access.log"
echo ""
}
# 仅申请/续期 SSL 证书
cmd_nginx_ssl() {
local domain="${1:-mapi.szaiai.com}"
local email="${2:-admin@szaiai.com}"
local conf_file="$SCRIPT_DIR/nginx/${domain}.conf"
if [ "$EUID" -ne 0 ]; then
log_error "需要 root 权限: sudo ./deploy.sh nginx ssl $domain"
exit 1
fi
if [ -d "/etc/letsencrypt/live/$domain" ]; then
log_info "证书已存在,尝试续期..."
certbot renew --cert-name $domain
else
log_info "$domain 申请 SSL 证书..."
certbot certonly --webroot --webroot-path=/var/www/certbot \
--email $email --agree-tos --no-eff-email -d $domain
fi
# 部署 HTTPS 配置
if [ -f "$conf_file" ]; then
cp "$conf_file" /etc/nginx/sites-available/$domain
nginx -t && systemctl reload nginx
log_success "HTTPS 配置已部署"
fi
}
# 显示帮助
show_help() {
echo ""
@ -449,14 +289,6 @@ show_help() {
echo " test 测试 API 路由"
echo " clean 清理容器和数据"
echo ""
echo "Standalone 模式 (2.0 服务与 Kong 同机):"
echo " up2 启动 Kong (standalone, 2.0 → host.docker.internal)"
echo " sync2 重新同步 kong-standalone.yml 配置"
echo ""
echo "Nginx 命令:"
echo " nginx install [domain] 安装 Nginx + SSL 证书 (默认: mapi.szaiai.com)"
echo " nginx ssl [domain] 申请/续期 SSL 证书"
echo ""
echo "监控命令:"
echo " monitoring install [domain] 一键安装监控 (Nginx+SSL+服务)"
echo " monitoring up 启动监控栈"
@ -511,27 +343,6 @@ main() {
clean)
cmd_clean
;;
up2)
cmd_up2
;;
sync2)
cmd_sync2
;;
nginx)
case "${2:-install}" in
install)
cmd_nginx_install "$3" "$4"
;;
ssl)
cmd_nginx_ssl "$3" "$4"
;;
*)
log_error "未知 nginx 命令: $2"
echo "用法: ./deploy.sh nginx [install|ssl] [domain]"
exit 1
;;
esac
;;
monitoring)
case "${2:-up}" in
install)

View File

@ -1,15 +0,0 @@
# =============================================================================
# Kong Standalone Override - 2.0 服务与 Kong 同机部署
# =============================================================================
# 用法: ./deploy.sh up2
# 等价于: docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d
# =============================================================================
services:
kong:
extra_hosts:
- "host.docker.internal:host-gateway"
kong-config:
volumes:
- ./kong-standalone.yml:/etc/kong/kong.yml:ro

View File

@ -1,390 +0,0 @@
# =============================================================================
# Kong API Gateway - 2.0 Standalone 声明式配置
# =============================================================================
# 部署说明:
# - Kong + 2.0 服务: 同一台物理机 (192.168.1.10)
# - 1.0 后端服务器: 192.168.1.111
# - 2.0 服务通过 host.docker.internal 访问宿主机端口 (无需走局域网)
#
# 使用方法:
# ./deploy.sh up2 # 启动 Kong 并加载此配置
# ./deploy.sh sync2 # 仅重新同步此配置
# =============================================================================
_format_version: "3.0"
_transform: true
# =============================================================================
# Services
# =============================================================================
services:
# ===========================================================================
# 1.0 Services → 192.168.1.111 (通过局域网)
# ===========================================================================
- name: identity-service
url: http://192.168.1.111:3000
routes:
- name: identity-auth
paths:
- /api/v1/auth
strip_path: false
- name: identity-me
paths:
- /api/v1/me
strip_path: false
- name: identity-user
paths:
- /api/v1/user
strip_path: false
- name: identity-users
paths:
- /api/v1/users
strip_path: false
- name: identity-health
paths:
- /api/v1/identity/health
strip_path: true
- name: identity-admin-pending-actions
paths:
- /api/v1/admin/pending-actions
strip_path: false
- name: wallet-service
url: http://192.168.1.111:3001
routes:
- name: wallet-api
paths:
- /api/v1/wallets
strip_path: false
- name: wallet-main
paths:
- /api/v1/wallet
strip_path: false
- name: wallet-health
paths:
- /api/v1/wallet-service/health
strip_path: true
- name: backup-service
url: http://192.168.1.111:3002
routes:
- name: backup-api
paths:
- /api/v1/backups
strip_path: false
- name: backup-share-api
paths:
- /api/v1/backup-share
strip_path: false
- name: planting-service
url: http://192.168.1.111:3003
routes:
- name: planting-api
paths:
- /api/v1/planting
strip_path: false
- name: referral-service
url: http://192.168.1.111:3004
routes:
- name: referral-api
paths:
- /api/v1/referral
strip_path: false
- name: referral-referrals
paths:
- /api/v1/referrals
strip_path: false
- name: referral-team-statistics
paths:
- /api/v1/team-statistics
strip_path: false
- name: reward-service
url: http://192.168.1.111:3005
routes:
- name: reward-api
paths:
- /api/v1/rewards
strip_path: false
- name: mpc-service
url: http://192.168.1.111:3006
routes:
- name: mpc-api
paths:
- /api/v1/mpc
strip_path: false
- name: mpc-party-api
paths:
- /api/v1/mpc-party
strip_path: false
- name: leaderboard-service
url: http://192.168.1.111:3007
routes:
- name: leaderboard-api
paths:
- /api/v1/leaderboard
strip_path: false
- name: leaderboard-virtual-accounts
paths:
- /api/v1/virtual-accounts
strip_path: false
- name: reporting-service
url: http://192.168.1.111:3008
routes:
- name: reporting-dashboard
paths:
- /api/v1/dashboard
strip_path: false
- name: reporting-api
paths:
- /api/v1/reports
strip_path: false
- name: reporting-export
paths:
- /api/v1/export
strip_path: false
- name: reporting-system-accounts
paths:
- /api/v1/system-account-reports
strip_path: false
- name: authorization-service
url: http://192.168.1.111:3009
routes:
- name: authorization-api
paths:
- /api/v1/authorizations
strip_path: false
- name: authorization-admin
paths:
- /api/v1/admin/authorizations
strip_path: false
- name: admin-service
url: http://192.168.1.111:3010
routes:
- name: admin-versions
paths:
- /api/v1/versions
strip_path: false
- name: admin-api
paths:
- /api/v1/admin
strip_path: false
- name: admin-mobile-version
paths:
- /api/app/version
strip_path: false
- name: admin-downloads
paths:
- /downloads
strip_path: false
- name: admin-mobile-notifications
paths:
- /api/v1/mobile/notifications
strip_path: false
- name: admin-mobile-system
paths:
- /api/v1/mobile/system
strip_path: false
- name: presence-service
url: http://192.168.1.111:3011
routes:
- name: presence-api
paths:
- /api/v1/presence
strip_path: false
- name: presence-analytics
paths:
- /api/v1/analytics
strip_path: false
- name: blockchain-service
url: http://192.168.1.111:3012
routes:
- name: blockchain-deposit
paths:
- /api/v1/deposit
strip_path: false
- name: blockchain-balance
paths:
- /api/v1/balance
strip_path: false
- name: mpc-account-service
url: http://192.168.1.111:4000
routes:
- name: mpc-co-managed
paths:
- /api/v1/co-managed
strip_path: false
# ===========================================================================
# 2.0 Services → host.docker.internal (同一台物理机,通过宿主机端口)
# ===========================================================================
- name: contribution-service-v2
url: http://host.docker.internal:3020
routes:
- name: contribution-v2-api
paths:
- /api/v2/contribution
strip_path: false
- name: contribution-v2-health
paths:
- /api/v2/contribution/health
strip_path: false
- name: mining-service-v2
url: http://host.docker.internal:3021
routes:
- name: mining-v2-api
paths:
- /api/v2/mining
strip_path: false
- name: mining-v2-health
paths:
- /api/v2/mining/health
strip_path: false
- name: trading-service-v2
url: http://host.docker.internal:3022/api/v2
routes:
- name: trading-v2-api
paths:
- /api/v2/trading
strip_path: true
- name: trading-v2-health
paths:
- /api/v2/trading/health
strip_path: true
- name: trading-ws-service
url: http://host.docker.internal:3022
routes:
- name: trading-ws-price
paths:
- /ws/price
strip_path: true
protocols:
- http
- https
- name: mining-admin-service
url: http://host.docker.internal:3023/api/v2
routes:
- name: mining-admin-api
paths:
- /api/v2/mining-admin
strip_path: true
- name: mining-admin-health
paths:
- /api/v2/mining-admin/health
strip_path: true
- name: mining-admin-upgrade-service
url: http://host.docker.internal:3023
routes:
- name: mining-admin-upgrade
paths:
- /mining-admin
strip_path: true
- name: auth-service-v2
url: http://host.docker.internal:3024
routes:
- name: auth-v2-api
paths:
- /api/v2/auth
strip_path: false
- name: auth-v2-health
paths:
- /api/v2/auth/health
strip_path: false
- name: mining-wallet-service
url: http://host.docker.internal:3025/api/v2
routes:
- name: mining-wallet-api
paths:
- /api/v2/mining-wallet
strip_path: true
- name: mining-wallet-health
paths:
- /api/v2/mining-wallet/health
strip_path: true
- name: mining-blockchain-service
url: http://host.docker.internal:3026
routes:
- name: mining-blockchain-api
paths:
- /api/v1/mining-blockchain
strip_path: false
# =============================================================================
# Plugins
# =============================================================================
plugins:
- name: cors
config:
origins:
- "https://rwaadmin.szaiai.com"
- "https://madmin.szaiai.com"
- "https://mapi.szaiai.com"
- "https://update.szaiai.com"
- "https://app.rwadurian.com"
- "http://localhost:3000"
- "http://localhost:3020"
- "http://localhost:3100"
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
headers:
- Accept
- Accept-Version
- Content-Length
- Content-MD5
- Content-Type
- Date
- Authorization
- X-Auth-Token
exposed_headers:
- X-Auth-Token
credentials: true
max_age: 3600
- name: rate-limiting
config:
minute: 10000
hour: 500000
policy: local
- name: file-log
config:
path: /tmp/kong-access.log
reopen: true
- name: request-size-limiting
config:
allowed_payload_size: 500
size_unit: megabytes
- name: prometheus
config:
per_consumer: true
status_code_metrics: true
latency_metrics: true
bandwidth_metrics: true
upstream_health_metrics: true

View File

@ -228,18 +228,6 @@ services:
paths:
- /api/v1/mobile/system
strip_path: false
- name: admin-app-assets-public
paths:
- /api/v1/app-assets
strip_path: false
- name: admin-system-config-public
paths:
- /api/v1/system-config
strip_path: false
- name: admin-customer-service-contacts-public
paths:
- /api/v1/customer-service-contacts
strip_path: false
# ---------------------------------------------------------------------------
# Presence Service - 在线状态服务
@ -321,42 +309,24 @@ services:
# ---------------------------------------------------------------------------
# Trading Service 2.0 - 交易服务
# 前端路径: /api/v2/trading/... -> 后端路径: /api/v2/...
# ---------------------------------------------------------------------------
- name: trading-service-v2
url: http://192.168.1.111:3022/api/v2
url: http://192.168.1.111:3022
routes:
- name: trading-v2-api
paths:
- /api/v2/trading
strip_path: true
strip_path: false
- name: trading-v2-health
paths:
- /api/v2/trading/health
strip_path: true
# ---------------------------------------------------------------------------
# Trading Service WebSocket - 价格实时推送
# WebSocket 连接: wss://api.xxx.com/ws/price -> ws://192.168.1.111:3022/price
# Kong 会自动处理 HTTP -> WebSocket 升级,所以 protocols 只需要 http/https
# ---------------------------------------------------------------------------
- name: trading-ws-service
url: http://192.168.1.111:3022
routes:
- name: trading-ws-price
paths:
- /ws/price
strip_path: true
protocols:
- http
- https
strip_path: false
# ---------------------------------------------------------------------------
# Mining Admin Service 2.0 - 挖矿管理后台服务
# 前端路径: /api/v2/mining-admin/... -> 后端路径: /api/v2/...
# ---------------------------------------------------------------------------
- name: mining-admin-service
url: http://192.168.1.111:3023/api/v2
url: http://192.168.1.111:3023/api/v1
routes:
- name: mining-admin-api
paths:
@ -367,19 +337,6 @@ services:
- /api/v2/mining-admin/health
strip_path: true
# ---------------------------------------------------------------------------
# Mining Admin Service 2.0 - 版本管理(供 mobile-upgrade 前端使用)
# 前端路径: /mining-admin/api/v2/... -> 后端路径: /api/v2/...
# 注意: 不带 /api/v2 service path因为前端 URL 已包含 /api/v2
# ---------------------------------------------------------------------------
- name: mining-admin-upgrade-service
url: http://192.168.1.111:3023
routes:
- name: mining-admin-upgrade
paths:
- /mining-admin
strip_path: true
# ---------------------------------------------------------------------------
# Auth Service 2.0 - 用户认证服务
# 前端路径: /api/v2/auth/...
@ -399,19 +356,18 @@ services:
# ---------------------------------------------------------------------------
# Mining Wallet Service 2.0 - 挖矿钱包服务
# 前端路径: /api/v2/mining-wallet/... -> 后端路径: /api/v2/...
# ---------------------------------------------------------------------------
- name: mining-wallet-service
url: http://192.168.1.111:3025/api/v2
url: http://192.168.1.111:3025
routes:
- name: mining-wallet-api
paths:
- /api/v2/mining-wallet
strip_path: true
strip_path: false
- name: mining-wallet-health
paths:
- /api/v2/mining-wallet/health
strip_path: true
strip_path: false
# =============================================================================
# Plugins - 全局插件配置

View File

@ -1,88 +0,0 @@
# RWADurian Mining Admin Web - Nginx 配置
# 域名: madmin.szaiai.com
# 后端: mining-admin-web (Next.js, 端口 3100)
# API: 由 Next.js SSR rewrite 转发到 mapi.szaiai.com (Kong)
# HTTP 重定向到 HTTPS
server {
listen 80;
listen [::]:80;
server_name madmin.szaiai.com;
# Let's Encrypt 验证目录
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# 重定向到 HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS 配置
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name madmin.szaiai.com;
# SSL 证书 (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/madmin.szaiai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/madmin.szaiai.com/privkey.pem;
# SSL 配置优化
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# 日志
access_log /var/log/nginx/madmin.szaiai.com.access.log;
error_log /var/log/nginx/madmin.szaiai.com.error.log;
# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Next.js 静态资源 (长缓存)
location /_next/static/ {
proxy_pass http://127.0.0.1:3100;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 反向代理到 mining-admin-web (Next.js)
location / {
proxy_pass http://127.0.0.1:3100;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# 健康检查
location = /health {
access_log off;
return 200 '{"status":"ok","service":"madmin-nginx"}';
add_header Content-Type application/json;
}
}

View File

@ -1,99 +0,0 @@
# RWADurian Mining Admin API Gateway Nginx 配置
# 域名: mapi.szaiai.com
# 后端: Kong API Gateway (端口 8000)
# 放置路径: /etc/nginx/sites-available/mapi.szaiai.com
# 启用: ln -s /etc/nginx/sites-available/mapi.szaiai.com /etc/nginx/sites-enabled/
# HTTP 重定向到 HTTPS
server {
listen 80;
listen [::]:80;
server_name mapi.szaiai.com;
# Let's Encrypt 验证目录
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# 重定向到 HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS 配置
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name mapi.szaiai.com;
# SSL 证书 (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/mapi.szaiai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mapi.szaiai.com/privkey.pem;
# SSL 配置优化
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# 现代加密套件
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# 日志
access_log /var/log/nginx/mapi.szaiai.com.access.log;
error_log /var/log/nginx/mapi.szaiai.com.error.log;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 客户端请求大小限制 (500MB 用于 APK/IPA 上传)
client_max_body_size 500M;
# 反向代理到 Kong API Gateway
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_cache_bypass $http_upgrade;
# 超时设置 (适配大文件上传)
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# 缓冲设置
proxy_buffering on;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
# 健康检查端点 (直接返回)
location = /health {
access_log off;
return 200 '{"status":"ok","service":"mapi-nginx"}';
add_header Content-Type application/json;
}
}

View File

@ -46,7 +46,6 @@ services:
redis:
image: redis:7-alpine
container_name: rwa-redis
command: redis-server --databases 20
ports:
- "6379:6379"
healthcheck:

View File

@ -680,11 +680,8 @@ type SessionEvent struct {
ExpiresAt int64 `protobuf:"varint,10,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // Unix timestamp milliseconds
// For sign sessions with delegate party: user's share for delegate to use
DelegateUserShare *DelegateUserShare `protobuf:"bytes,11,opt,name=delegate_user_share,json=delegateUserShare,proto3" json:"delegate_user_share,omitempty"`
// For session_started event: complete list of participants with their indices
// CRITICAL: Use this for TSS protocol instead of JoinSession response
Participants []*PartyInfo `protobuf:"bytes,12,rep,name=participants,proto3" json:"participants,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SessionEvent) Reset() {
@ -794,13 +791,6 @@ func (x *SessionEvent) GetDelegateUserShare() *DelegateUserShare {
return nil
}
func (x *SessionEvent) GetParticipants() []*PartyInfo {
if x != nil {
return x.Participants
}
return nil
}
// DelegateUserShare contains user's share for delegate party to use in signing
type DelegateUserShare struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -2489,7 +2479,7 @@ const file_api_proto_message_router_proto_rawDesc = "" +
"\x1dSubscribeSessionEventsRequest\x12\x19\n" +
"\bparty_id\x18\x01 \x01(\tR\apartyId\x12\x1f\n" +
"\vevent_types\x18\x02 \x03(\tR\n" +
"eventTypes\"\xd2\x04\n" +
"eventTypes\"\x94\x04\n" +
"\fSessionEvent\x12\x19\n" +
"\bevent_id\x18\x01 \x01(\tR\aeventId\x12\x1d\n" +
"\n" +
@ -2509,8 +2499,7 @@ const file_api_proto_message_router_proto_rawDesc = "" +
"\n" +
"expires_at\x18\n" +
" \x01(\x03R\texpiresAt\x12P\n" +
"\x13delegate_user_share\x18\v \x01(\v2 .mpc.router.v1.DelegateUserShareR\x11delegateUserShare\x12<\n" +
"\fparticipants\x18\f \x03(\v2\x18.mpc.router.v1.PartyInfoR\fparticipants\x1a=\n" +
"\x13delegate_user_share\x18\v \x01(\v2 .mpc.router.v1.DelegateUserShareR\x11delegateUserShare\x1a=\n" +
"\x0fJoinTokensEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x89\x01\n" +
@ -2734,51 +2723,50 @@ var file_api_proto_message_router_proto_depIdxs = []int32{
6, // 1: mpc.router.v1.RegisterPartyRequest.notification:type_name -> mpc.router.v1.NotificationChannel
37, // 2: mpc.router.v1.SessionEvent.join_tokens:type_name -> mpc.router.v1.SessionEvent.JoinTokensEntry
11, // 3: mpc.router.v1.SessionEvent.delegate_user_share:type_name -> mpc.router.v1.DelegateUserShare
25, // 4: mpc.router.v1.SessionEvent.participants:type_name -> mpc.router.v1.PartyInfo
10, // 5: mpc.router.v1.PublishSessionEventRequest.event:type_name -> mpc.router.v1.SessionEvent
6, // 6: mpc.router.v1.RegisteredParty.notification:type_name -> mpc.router.v1.NotificationChannel
15, // 7: mpc.router.v1.GetRegisteredPartiesResponse.parties:type_name -> mpc.router.v1.RegisteredParty
20, // 8: mpc.router.v1.GetMessageStatusResponse.deliveries:type_name -> mpc.router.v1.MessageDeliveryStatus
24, // 9: mpc.router.v1.PartyInfo.device_info:type_name -> mpc.router.v1.DeviceInfo
24, // 10: mpc.router.v1.JoinSessionRequest.device_info:type_name -> mpc.router.v1.DeviceInfo
26, // 11: mpc.router.v1.JoinSessionResponse.session_info:type_name -> mpc.router.v1.SessionInfo
25, // 12: mpc.router.v1.JoinSessionResponse.other_parties:type_name -> mpc.router.v1.PartyInfo
25, // 13: mpc.router.v1.GetSessionStatusResponse.participants:type_name -> mpc.router.v1.PartyInfo
0, // 14: mpc.router.v1.MessageRouter.RouteMessage:input_type -> mpc.router.v1.RouteMessageRequest
2, // 15: mpc.router.v1.MessageRouter.SubscribeMessages:input_type -> mpc.router.v1.SubscribeMessagesRequest
4, // 16: mpc.router.v1.MessageRouter.GetPendingMessages:input_type -> mpc.router.v1.GetPendingMessagesRequest
17, // 17: mpc.router.v1.MessageRouter.AcknowledgeMessage:input_type -> mpc.router.v1.AcknowledgeMessageRequest
19, // 18: mpc.router.v1.MessageRouter.GetMessageStatus:input_type -> mpc.router.v1.GetMessageStatusRequest
7, // 19: mpc.router.v1.MessageRouter.RegisterParty:input_type -> mpc.router.v1.RegisterPartyRequest
22, // 20: mpc.router.v1.MessageRouter.Heartbeat:input_type -> mpc.router.v1.HeartbeatRequest
9, // 21: mpc.router.v1.MessageRouter.SubscribeSessionEvents:input_type -> mpc.router.v1.SubscribeSessionEventsRequest
12, // 22: mpc.router.v1.MessageRouter.PublishSessionEvent:input_type -> mpc.router.v1.PublishSessionEventRequest
14, // 23: mpc.router.v1.MessageRouter.GetRegisteredParties:input_type -> mpc.router.v1.GetRegisteredPartiesRequest
27, // 24: mpc.router.v1.MessageRouter.JoinSession:input_type -> mpc.router.v1.JoinSessionRequest
29, // 25: mpc.router.v1.MessageRouter.MarkPartyReady:input_type -> mpc.router.v1.MarkPartyReadyRequest
31, // 26: mpc.router.v1.MessageRouter.ReportCompletion:input_type -> mpc.router.v1.ReportCompletionRequest
33, // 27: mpc.router.v1.MessageRouter.GetSessionStatus:input_type -> mpc.router.v1.GetSessionStatusRequest
35, // 28: mpc.router.v1.MessageRouter.SubmitDelegateShare:input_type -> mpc.router.v1.SubmitDelegateShareRequest
1, // 29: mpc.router.v1.MessageRouter.RouteMessage:output_type -> mpc.router.v1.RouteMessageResponse
3, // 30: mpc.router.v1.MessageRouter.SubscribeMessages:output_type -> mpc.router.v1.MPCMessage
5, // 31: mpc.router.v1.MessageRouter.GetPendingMessages:output_type -> mpc.router.v1.GetPendingMessagesResponse
18, // 32: mpc.router.v1.MessageRouter.AcknowledgeMessage:output_type -> mpc.router.v1.AcknowledgeMessageResponse
21, // 33: mpc.router.v1.MessageRouter.GetMessageStatus:output_type -> mpc.router.v1.GetMessageStatusResponse
8, // 34: mpc.router.v1.MessageRouter.RegisterParty:output_type -> mpc.router.v1.RegisterPartyResponse
23, // 35: mpc.router.v1.MessageRouter.Heartbeat:output_type -> mpc.router.v1.HeartbeatResponse
10, // 36: mpc.router.v1.MessageRouter.SubscribeSessionEvents:output_type -> mpc.router.v1.SessionEvent
13, // 37: mpc.router.v1.MessageRouter.PublishSessionEvent:output_type -> mpc.router.v1.PublishSessionEventResponse
16, // 38: mpc.router.v1.MessageRouter.GetRegisteredParties:output_type -> mpc.router.v1.GetRegisteredPartiesResponse
28, // 39: mpc.router.v1.MessageRouter.JoinSession:output_type -> mpc.router.v1.JoinSessionResponse
30, // 40: mpc.router.v1.MessageRouter.MarkPartyReady:output_type -> mpc.router.v1.MarkPartyReadyResponse
32, // 41: mpc.router.v1.MessageRouter.ReportCompletion:output_type -> mpc.router.v1.ReportCompletionResponse
34, // 42: mpc.router.v1.MessageRouter.GetSessionStatus:output_type -> mpc.router.v1.GetSessionStatusResponse
36, // 43: mpc.router.v1.MessageRouter.SubmitDelegateShare:output_type -> mpc.router.v1.SubmitDelegateShareResponse
29, // [29:44] is the sub-list for method output_type
14, // [14:29] is the sub-list for method input_type
14, // [14:14] is the sub-list for extension type_name
14, // [14:14] is the sub-list for extension extendee
0, // [0:14] is the sub-list for field type_name
10, // 4: mpc.router.v1.PublishSessionEventRequest.event:type_name -> mpc.router.v1.SessionEvent
6, // 5: mpc.router.v1.RegisteredParty.notification:type_name -> mpc.router.v1.NotificationChannel
15, // 6: mpc.router.v1.GetRegisteredPartiesResponse.parties:type_name -> mpc.router.v1.RegisteredParty
20, // 7: mpc.router.v1.GetMessageStatusResponse.deliveries:type_name -> mpc.router.v1.MessageDeliveryStatus
24, // 8: mpc.router.v1.PartyInfo.device_info:type_name -> mpc.router.v1.DeviceInfo
24, // 9: mpc.router.v1.JoinSessionRequest.device_info:type_name -> mpc.router.v1.DeviceInfo
26, // 10: mpc.router.v1.JoinSessionResponse.session_info:type_name -> mpc.router.v1.SessionInfo
25, // 11: mpc.router.v1.JoinSessionResponse.other_parties:type_name -> mpc.router.v1.PartyInfo
25, // 12: mpc.router.v1.GetSessionStatusResponse.participants:type_name -> mpc.router.v1.PartyInfo
0, // 13: mpc.router.v1.MessageRouter.RouteMessage:input_type -> mpc.router.v1.RouteMessageRequest
2, // 14: mpc.router.v1.MessageRouter.SubscribeMessages:input_type -> mpc.router.v1.SubscribeMessagesRequest
4, // 15: mpc.router.v1.MessageRouter.GetPendingMessages:input_type -> mpc.router.v1.GetPendingMessagesRequest
17, // 16: mpc.router.v1.MessageRouter.AcknowledgeMessage:input_type -> mpc.router.v1.AcknowledgeMessageRequest
19, // 17: mpc.router.v1.MessageRouter.GetMessageStatus:input_type -> mpc.router.v1.GetMessageStatusRequest
7, // 18: mpc.router.v1.MessageRouter.RegisterParty:input_type -> mpc.router.v1.RegisterPartyRequest
22, // 19: mpc.router.v1.MessageRouter.Heartbeat:input_type -> mpc.router.v1.HeartbeatRequest
9, // 20: mpc.router.v1.MessageRouter.SubscribeSessionEvents:input_type -> mpc.router.v1.SubscribeSessionEventsRequest
12, // 21: mpc.router.v1.MessageRouter.PublishSessionEvent:input_type -> mpc.router.v1.PublishSessionEventRequest
14, // 22: mpc.router.v1.MessageRouter.GetRegisteredParties:input_type -> mpc.router.v1.GetRegisteredPartiesRequest
27, // 23: mpc.router.v1.MessageRouter.JoinSession:input_type -> mpc.router.v1.JoinSessionRequest
29, // 24: mpc.router.v1.MessageRouter.MarkPartyReady:input_type -> mpc.router.v1.MarkPartyReadyRequest
31, // 25: mpc.router.v1.MessageRouter.ReportCompletion:input_type -> mpc.router.v1.ReportCompletionRequest
33, // 26: mpc.router.v1.MessageRouter.GetSessionStatus:input_type -> mpc.router.v1.GetSessionStatusRequest
35, // 27: mpc.router.v1.MessageRouter.SubmitDelegateShare:input_type -> mpc.router.v1.SubmitDelegateShareRequest
1, // 28: mpc.router.v1.MessageRouter.RouteMessage:output_type -> mpc.router.v1.RouteMessageResponse
3, // 29: mpc.router.v1.MessageRouter.SubscribeMessages:output_type -> mpc.router.v1.MPCMessage
5, // 30: mpc.router.v1.MessageRouter.GetPendingMessages:output_type -> mpc.router.v1.GetPendingMessagesResponse
18, // 31: mpc.router.v1.MessageRouter.AcknowledgeMessage:output_type -> mpc.router.v1.AcknowledgeMessageResponse
21, // 32: mpc.router.v1.MessageRouter.GetMessageStatus:output_type -> mpc.router.v1.GetMessageStatusResponse
8, // 33: mpc.router.v1.MessageRouter.RegisterParty:output_type -> mpc.router.v1.RegisterPartyResponse
23, // 34: mpc.router.v1.MessageRouter.Heartbeat:output_type -> mpc.router.v1.HeartbeatResponse
10, // 35: mpc.router.v1.MessageRouter.SubscribeSessionEvents:output_type -> mpc.router.v1.SessionEvent
13, // 36: mpc.router.v1.MessageRouter.PublishSessionEvent:output_type -> mpc.router.v1.PublishSessionEventResponse
16, // 37: mpc.router.v1.MessageRouter.GetRegisteredParties:output_type -> mpc.router.v1.GetRegisteredPartiesResponse
28, // 38: mpc.router.v1.MessageRouter.JoinSession:output_type -> mpc.router.v1.JoinSessionResponse
30, // 39: mpc.router.v1.MessageRouter.MarkPartyReady:output_type -> mpc.router.v1.MarkPartyReadyResponse
32, // 40: mpc.router.v1.MessageRouter.ReportCompletion:output_type -> mpc.router.v1.ReportCompletionResponse
34, // 41: mpc.router.v1.MessageRouter.GetSessionStatus:output_type -> mpc.router.v1.GetSessionStatusResponse
36, // 42: mpc.router.v1.MessageRouter.SubmitDelegateShare:output_type -> mpc.router.v1.SubmitDelegateShareResponse
28, // [28:43] is the sub-list for method output_type
13, // [13:28] is the sub-list for method input_type
13, // [13:13] is the sub-list for extension type_name
13, // [13:13] is the sub-list for extension extendee
0, // [0:13] is the sub-list for field type_name
}
func init() { file_api_proto_message_router_proto_init() }

View File

@ -166,9 +166,6 @@ message SessionEvent {
int64 expires_at = 10; // Unix timestamp milliseconds
// For sign sessions with delegate party: user's share for delegate to use
DelegateUserShare delegate_user_share = 11;
// For session_started event: complete list of participants with their indices
// CRITICAL: Use this for TSS protocol instead of JoinSession response
repeated PartyInfo participants = 12;
}
// DelegateUserShare contains user's share for delegate party to use in signing

View File

@ -32,11 +32,9 @@ type PendingSession struct {
SessionID uuid.UUID
JoinToken string
MessageHash []byte
KeygenSessionID uuid.UUID // For sign sessions: the keygen session that created the keys
ThresholdN int
ThresholdT int
SelectedParties []string
Participants []use_cases.ParticipantInfo // CRITICAL: Correct PartyIndex from database (via JoinSession)
CreatedAt time.Time
}
@ -151,14 +149,6 @@ func main() {
cryptoService,
)
// Initialize signing use case (for co-managed sign sessions)
participateSigningUC := use_cases.NewParticipateSigningUseCase(
keyShareRepo,
messageRouter,
messageRouter,
cryptoService,
)
// Create shutdown context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@ -196,15 +186,14 @@ func main() {
defer heartbeatCancel()
logger.Info("Heartbeat started", zap.String("party_id", partyID), zap.Duration("interval", 30*time.Second))
// Subscribe to session events with two-phase handling for co_managed_keygen and co_managed_sign
logger.Info("Subscribing to session events (co_managed_keygen and co_managed_sign)", zap.String("party_id", partyID))
// Subscribe to session events with two-phase handling for co_managed_keygen
logger.Info("Subscribing to session events (co_managed_keygen only)", zap.String("party_id", partyID))
eventHandler := createCoManagedSessionEventHandler(
ctx,
partyID,
messageRouter,
participateKeygenUC,
participateSigningUC,
)
if err := messageRouter.SubscribeSessionEvents(ctx, partyID, eventHandler); err != nil {
@ -317,17 +306,15 @@ func startHTTPServer(cfg *config.Config) error {
return r.Run(fmt.Sprintf(":%d", cfg.Server.HTTPPort))
}
// createCoManagedSessionEventHandler creates a handler for co_managed_keygen and co_managed_sign sessions
// createCoManagedSessionEventHandler creates a handler specifically for co_managed_keygen sessions
// Two-phase event handling:
// Phase 1 (session_created): JoinSession immediately + store session info
// Phase 2 (session_started): Execute TSS protocol (same timing as user clients receiving all_joined)
// Supports both keygen (no message_hash) and sign (with message_hash) sessions
func createCoManagedSessionEventHandler(
ctx context.Context,
partyID string,
messageRouter *grpcclient.MessageRouterClient,
participateKeygenUC *use_cases.ParticipateKeygenUseCase,
participateSigningUC *use_cases.ParticipateSigningUseCase,
) func(*router.SessionEvent) {
return func(event *router.SessionEvent) {
// Check if this party is selected for the session
@ -361,26 +348,11 @@ func createCoManagedSessionEventHandler(
// Handle different event types
switch event.EventType {
case "session_created":
// Handle both keygen (no message_hash) and sign (with message_hash) sessions
// For sign sessions: only support 2-of-3 configuration
// Only handle keygen sessions (no message_hash)
if len(event.MessageHash) > 0 {
// This is a sign session
// Security check: only support 2-of-3 configuration
if event.ThresholdT != 2 || event.ThresholdN != 3 {
logger.Warn("Ignoring sign session: only 2-of-3 configuration is supported",
zap.String("session_id", event.SessionId),
zap.Int32("threshold_t", event.ThresholdT),
zap.Int32("threshold_n", event.ThresholdN))
return
}
logger.Info("Sign session detected (2-of-3), proceeding with participation",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
} else {
// This is a keygen session
logger.Info("Keygen session detected, proceeding with participation",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
logger.Debug("Ignoring sign session (co-managed only handles keygen)",
zap.String("session_id", event.SessionId))
return
}
// Phase 1: Get join token
@ -394,7 +366,7 @@ func createCoManagedSessionEventHandler(
// Immediately call JoinSession (this is required to trigger session_started)
joinCtx, joinCancel := context.WithTimeout(ctx, 30*time.Second)
sessionInfo, err := messageRouter.JoinSession(joinCtx, sessionID, partyID, joinToken)
_, err := messageRouter.JoinSession(joinCtx, sessionID, partyID, joinToken)
joinCancel()
if err != nil {
logger.Error("Failed to join session",
@ -406,19 +378,16 @@ func createCoManagedSessionEventHandler(
logger.Info("Successfully joined session, waiting for session_started",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID),
zap.String("keygen_session_id", sessionInfo.KeygenSessionID.String()))
zap.String("party_id", partyID))
// Store pending session for later use when session_started arrives
pendingSessionCache.Store(event.SessionId, &PendingSession{
SessionID: sessionID,
JoinToken: joinToken,
MessageHash: event.MessageHash,
KeygenSessionID: sessionInfo.KeygenSessionID, // CRITICAL: Save the correct keygen session ID from JoinSession
ThresholdN: int(event.ThresholdN),
ThresholdT: int(event.ThresholdT),
SelectedParties: event.SelectedParties,
Participants: sessionInfo.Participants, // CRITICAL: Save participants with correct PartyIndex from database
CreatedAt: time.Now(),
})
@ -432,114 +401,57 @@ func createCoManagedSessionEventHandler(
return
}
// CRITICAL FIX: Use participants from session_started event, NOT from JoinSession cache
// The JoinSession response only contains parties that had joined at that moment,
// but session_started event contains the COMPLETE list of all participants
var participants []use_cases.ParticipantInfo
if len(event.Participants) > 0 {
// Use participants from event (preferred - complete list)
participants = make([]use_cases.ParticipantInfo, len(event.Participants))
for i, p := range event.Participants {
participants[i] = use_cases.ParticipantInfo{
PartyID: p.PartyId,
PartyIndex: int(p.PartyIndex),
}
}
logger.Info("Using participants from session_started event",
zap.String("session_id", event.SessionId),
zap.Int("participant_count", len(participants)))
} else {
// Fallback to cached participants (for backward compatibility)
participants = pendingSession.Participants
logger.Warn("No participants in session_started event, using cached participants",
zap.String("session_id", event.SessionId),
zap.Int("participant_count", len(participants)))
}
logger.Info("Session started event received, beginning TSS keygen protocol",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
// Determine session type based on message_hash
isSignSession := len(pendingSession.MessageHash) > 0
if isSignSession {
logger.Info("Session started event received, beginning TSS signing protocol",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID),
zap.Int("participant_count", len(participants)))
} else {
logger.Info("Session started event received, beginning TSS keygen protocol",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID),
zap.Int("participant_count", len(participants)))
}
// Execute TSS protocol in goroutine
// Execute TSS keygen protocol in goroutine
// Timeout starts NOW (when session_started is received), not at session_created
go func() {
// 10 minute timeout for TSS protocol execution
participateCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
if isSignSession {
// Execute signing protocol
logger.Info("Auto-participating in co_managed_sign session",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID),
zap.String("keygen_session_id", pendingSession.KeygenSessionID.String()))
logger.Info("Auto-participating in co_managed_keygen session",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
sessionInfo := &use_cases.SessionInfo{
SessionID: pendingSession.SessionID,
SessionType: "co_managed_sign",
ThresholdN: int(event.ThresholdN),
ThresholdT: int(event.ThresholdT),
MessageHash: pendingSession.MessageHash,
KeygenSessionID: pendingSession.KeygenSessionID, // CRITICAL: Use the correct keygen session ID from JoinSession
Participants: participants,
// Build SessionInfo from session_started event (NOT from pendingSession cache)
// session_started event contains ALL participants who have joined,
// including external parties that joined dynamically after session_created
// Note: We already called JoinSession in session_created phase,
// so we use ExecuteWithSessionInfo to skip the duplicate JoinSession call
participants := make([]use_cases.ParticipantInfo, len(event.SelectedParties))
for i, p := range event.SelectedParties {
participants[i] = use_cases.ParticipantInfo{
PartyID: p,
PartyIndex: i,
}
}
result, err := participateSigningUC.ExecuteWithSessionInfo(
participateCtx,
pendingSession.SessionID,
partyID,
sessionInfo,
)
if err != nil {
logger.Error("Co-managed signing participation failed",
zap.Error(err),
zap.String("session_id", event.SessionId))
} else {
logger.Info("Co-managed signing participation completed",
zap.String("session_id", event.SessionId),
zap.String("signature", hex.EncodeToString(result.Signature)))
}
sessionInfo := &use_cases.SessionInfo{
SessionID: pendingSession.SessionID,
SessionType: "co_managed_keygen",
ThresholdN: int(event.ThresholdN),
ThresholdT: int(event.ThresholdT),
MessageHash: pendingSession.MessageHash,
Participants: participants,
}
result, err := participateKeygenUC.ExecuteWithSessionInfo(
participateCtx,
pendingSession.SessionID,
partyID,
sessionInfo,
)
if err != nil {
logger.Error("Co-managed keygen participation failed",
zap.Error(err),
zap.String("session_id", event.SessionId))
} else {
// Execute keygen protocol
logger.Info("Auto-participating in co_managed_keygen session",
logger.Info("Co-managed keygen participation completed",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
sessionInfo := &use_cases.SessionInfo{
SessionID: pendingSession.SessionID,
SessionType: "co_managed_keygen",
ThresholdN: int(event.ThresholdN),
ThresholdT: int(event.ThresholdT),
MessageHash: pendingSession.MessageHash,
Participants: participants,
}
result, err := participateKeygenUC.ExecuteWithSessionInfo(
participateCtx,
pendingSession.SessionID,
partyID,
sessionInfo,
)
if err != nil {
logger.Error("Co-managed keygen participation failed",
zap.Error(err),
zap.String("session_id", event.SessionId))
} else {
logger.Info("Co-managed keygen participation completed",
zap.String("session_id", event.SessionId),
zap.String("public_key", hex.EncodeToString(result.PublicKey)))
}
zap.String("public_key", hex.EncodeToString(result.PublicKey)))
}
}()

View File

@ -63,30 +63,6 @@ func NewParticipateSigningUseCase(
}
}
// ExecuteWithSessionInfo participates in a signing session with pre-obtained SessionInfo
// This is used by server-party-co-managed which has already called JoinSession in session_created phase
// and receives session_started event when all participants have joined
func (uc *ParticipateSigningUseCase) ExecuteWithSessionInfo(
ctx context.Context,
sessionID uuid.UUID,
partyID string,
sessionInfo *SessionInfo,
) (*ParticipateSigningOutput, error) {
// Validate session type
if sessionInfo.SessionType != "sign" && sessionInfo.SessionType != "co_managed_sign" {
return nil, ErrInvalidSignSession
}
logger.Info("ExecuteWithSessionInfo: starting signing with pre-obtained session info",
zap.String("session_id", sessionID.String()),
zap.String("party_id", partyID),
zap.String("session_type", sessionInfo.SessionType),
zap.Int("participants", len(sessionInfo.Participants)))
// Delegate to the common execution logic (skipping JoinSession)
return uc.executeWithSessionInfo(ctx, sessionID, partyID, sessionInfo)
}
// Execute participates in a signing session using real TSS protocol
func (uc *ParticipateSigningUseCase) Execute(
ctx context.Context,
@ -235,123 +211,6 @@ func (uc *ParticipateSigningUseCase) Execute(
}, nil
}
// executeWithSessionInfo is the internal logic for ExecuteWithSessionInfo (persistent party only)
func (uc *ParticipateSigningUseCase) executeWithSessionInfo(
ctx context.Context,
sessionID uuid.UUID,
partyID string,
sessionInfo *SessionInfo,
) (*ParticipateSigningOutput, error) {
// Get share data from database (persistent party only - used by server-party-co-managed)
var shareData []byte
var keyShareForUpdate *entities.PartyKeyShare
var originalThresholdN int
var err error
// Load from database using KeygenSessionID
if sessionInfo.KeygenSessionID != uuid.Nil {
keyShareForUpdate, err = uc.keyShareRepo.FindBySessionAndParty(ctx, sessionInfo.KeygenSessionID, partyID)
if err != nil {
logger.Error("Failed to find keyshare for keygen session",
zap.String("party_id", partyID),
zap.String("keygen_session_id", sessionInfo.KeygenSessionID.String()),
zap.Error(err))
return nil, ErrKeyShareNotFound
}
logger.Info("Using specific keyshare by keygen_session_id",
zap.String("party_id", partyID),
zap.String("keygen_session_id", sessionInfo.KeygenSessionID.String()))
} else {
// Fallback: use the most recent key share
keyShares, err := uc.keyShareRepo.ListByParty(ctx, partyID)
if err != nil || len(keyShares) == 0 {
return nil, ErrKeyShareNotFound
}
keyShareForUpdate = keyShares[len(keyShares)-1]
logger.Warn("Using most recent keyshare (keygen_session_id not provided)",
zap.String("party_id", partyID),
zap.String("fallback_session_id", keyShareForUpdate.SessionID.String()))
}
originalThresholdN = keyShareForUpdate.ThresholdN
shareData, err = uc.cryptoService.DecryptShare(keyShareForUpdate.ShareData, partyID)
if err != nil {
return nil, err
}
logger.Info("Using database share (persistent party)",
zap.String("party_id", partyID),
zap.String("session_id", sessionID.String()),
zap.String("keygen_session_id", keyShareForUpdate.SessionID.String()),
zap.Int("original_threshold_n", originalThresholdN),
zap.Int("threshold_t", keyShareForUpdate.ThresholdT))
// Find self in participants and build party index map
var selfIndex int
partyIndexMap := make(map[string]int)
for _, p := range sessionInfo.Participants {
partyIndexMap[p.PartyID] = p.PartyIndex
if p.PartyID == partyID {
selfIndex = p.PartyIndex
}
}
// Subscribe to messages
msgChan, err := uc.messageRouter.SubscribeMessages(ctx, sessionID, partyID)
if err != nil {
return nil, err
}
// Wait for all parties to subscribe
expectedParties := len(sessionInfo.Participants)
logger.Info("Waiting for all parties to subscribe",
zap.String("session_id", sessionID.String()),
zap.String("party_id", partyID),
zap.Int("expected_parties", expectedParties))
time.Sleep(500 * time.Millisecond)
messageHash := sessionInfo.MessageHash
// Run TSS Signing protocol
signature, r, s, err := uc.runSigningProtocol(
ctx,
sessionID,
partyID,
selfIndex,
sessionInfo.Participants,
sessionInfo.ThresholdT,
originalThresholdN,
shareData,
messageHash,
msgChan,
partyIndexMap,
)
if err != nil {
return nil, err
}
// Update key share last used
if keyShareForUpdate != nil {
keyShareForUpdate.MarkUsed()
if err := uc.keyShareRepo.Update(ctx, keyShareForUpdate); err != nil {
logger.Warn("failed to update key share last used", zap.Error(err))
}
}
// Report completion to coordinator
if err := uc.sessionClient.ReportCompletion(ctx, sessionID, partyID, signature); err != nil {
logger.Error("failed to report signing completion", zap.Error(err))
}
return &ParticipateSigningOutput{
Success: true,
Signature: signature,
R: r,
S: s,
}, nil
}
// runSigningProtocol runs the TSS signing protocol using tss-lib
func (uc *ParticipateSigningUseCase) runSigningProtocol(
ctx context.Context,

View File

@ -1,249 +0,0 @@
# 2-of-3 服务器参与选项 - 纯新增实施方案
## 目标
允许 2-of-3 MPC 用户勾选"包含服务器备份"参与签名,以便在丢失一个设备时转出资产。
## 核心设计
### 安全限制
- **仅** 2-of-3 配置显示此选项
- 其他配置3-of-5, 4-of-7等不显示
### 实施范围
- ✅ 只修改 Android 客户端
- ❌ **不需要**修改后端account-service, message-router
- ✅ 纯新增代码,现有逻辑保持不变
## 修改文件清单
### 1. TssRepository.kt2处新增
#### 1.1 新增辅助方法private
```kotlin
// 位置3712行之前类内部末尾
/**
* 构建参与方列表(新增辅助方法)
* @param participants 所有参与方
* @param includeServerParties 是否包含服务器方(默认 false保持现有行为
*/
private fun buildSigningParticipantList(
participants: List<ParticipantStatusInfo>,
includeServerParties: Boolean = false
): List<Pair<String, Int>> {
val filtered = if (includeServerParties) {
// 包含所有参与方(含服务器)
participants
} else {
// 过滤掉服务器方(现有行为)
participants.filter { !it.partyId.startsWith("co-managed-party-") }
}
return filtered.map { Pair(it.partyId, it.partyIndex) }
}
```
#### 1.2 新增签名会话创建方法
```kotlin
// 位置buildSigningParticipantList 之后
/**
* 创建签名会话(支持选择是否包含服务器)
* @param includeServerBackup 是否包含服务器备份参与方(仅 2-of-3 时使用)
* 新增方法,不影响现有 createSignSession
*/
suspend fun createSignSessionWithOptions(
shareId: Long,
messageHash: String,
password: String,
initiatorName: String,
includeServerBackup: Boolean = false // 新增参数
): Result<SignSessionResult> {
return withContext(Dispatchers.IO) {
try {
val shareEntity = shareRecordDao.getShareById(shareId)
?: return@withContext Result.failure(Exception("Share not found"))
val signingPartyIdForEvents = shareEntity.partyId
android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Creating sign session with includeServerBackup=$includeServerBackup")
ensureSessionEventSubscriptionActive(signingPartyIdForEvents)
val keygenStatusResult = getSessionStatus(shareEntity.sessionId)
if (keygenStatusResult.isFailure) {
return@withContext Result.failure(Exception("无法获取 keygen 会话的参与者信息: ${keygenStatusResult.exceptionOrNull()?.message}"))
}
val keygenStatus = keygenStatusResult.getOrThrow()
// 使用新的辅助方法构建参与方列表
val signingParties = buildSigningParticipantList(
keygenStatus.participants,
includeServerBackup
)
android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Signing parties: ${signingParties.size} of ${keygenStatus.participants.size} (includeServer=$includeServerBackup)")
signingParties.forEach { (id, index) ->
android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] party_id=${id.take(16)}, party_index=$index")
}
if (signingParties.size < shareEntity.thresholdT) {
return@withContext Result.failure(Exception(
"签名参与方不足: 需要 ${shareEntity.thresholdT} 个,但只有 ${signingParties.size} 个参与方"
))
}
// 后续逻辑与 createSignSession 相同
// ... 构建请求、创建session、加入gRPC等
// (复用现有 createSignSession 的代码)
// 调用现有方法的内部逻辑(需要提取)
createSignSessionInternal(
shareEntity,
signingParties,
messageHash,
password,
initiatorName
)
} catch (e: Exception) {
Result.failure(e)
}
}
}
```
### 2. MainViewModel.kt1处新增
```kotlin
// 位置initiateSignSession 方法之后
/**
* 创建签名会话(支持选择服务器参与)
* 新增方法,不影响现有 initiateSignSession
*/
fun initiateSignSessionWithOptions(
shareId: Long,
password: String,
initiatorName: String = "发起者",
includeServerBackup: Boolean = false // 新增参数
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
val tx = _preparedTx.value
if (tx == null) {
_uiState.update { it.copy(isLoading = false, error = "交易未准备") }
return@launch
}
android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Initiating sign session with includeServerBackup=$includeServerBackup")
val result = repository.createSignSessionWithOptions(
shareId = shareId,
messageHash = tx.signHash,
password = password,
initiatorName = initiatorName,
includeServerBackup = includeServerBackup // 传递参数
)
result.fold(
onSuccess = { sessionResult ->
_signSessionId.value = sessionResult.sessionId
_signInviteCode.value = sessionResult.inviteCode
_signParticipants.value = listOf(initiatorName)
_uiState.update { it.copy(isLoading = false) }
pendingSignInitiatorInfo = PendingSignInitiatorInfo(
sessionId = sessionResult.sessionId,
shareId = shareId,
password = password
)
if (sessionResult.sessionAlreadyInProgress) {
startSigningProcess(sessionResult.sessionId, shareId, password)
}
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
```
### 3. TransferScreen.ktUI 新增)
```kotlin
// 在交易确认界面新增复选框Step 2
// 位置:密码输入框之后
// 仅在 2-of-3 时显示
if (wallet.thresholdT == 2 && wallet.thresholdN == 3) {
Spacer(modifier = Modifier.height(16.dp))
var includeServerBackup by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = includeServerBackup,
onCheckedChange = { includeServerBackup = it }
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = "包含服务器备份参与签名",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "如果您丢失了一个设备,勾选此项以使用服务器备份完成签名",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
```
### 4. MainActivity.kt传递参数
```kotlin
// 修改 TransferScreen 的 onConfirmTransaction 回调
onConfirmTransaction = { includeServer ->
viewModel.initiateSignSessionWithOptions(
shareId = shareId,
password = "",
includeServerBackup = includeServer
)
}
```
## 测试场景
### 场景12-of-3 正常使用(不勾选)
- 设备A + 设备B 签名 ✅
- 服务器被过滤(现有行为)
### 场景22-of-3 设备丢失(勾选)
- 设备A + 服务器 签名 ✅
- 用户明确勾选"包含服务器备份"
### 场景33-of-5 配置
- 不显示复选框 ✅
- 保持现有行为
## 优势
1. ✅ **零后端修改**:后端只接收 parties 数组
2. ✅ **完全向后兼容**:默认行为不变
3. ✅ **安全限制**:仅 2-of-3 可用
4. ✅ **纯新增**:不修改现有方法
5. ✅ **用户明确选择**:需要主动勾选
## 实施顺序
1. TssRepository新增辅助方法
2. TssRepository新增 createSignSessionWithOptions
3. MainViewModel新增 initiateSignSessionWithOptions
4. TransferScreen新增 UI 复选框
5. MainActivity传递参数
6. 测试编译和功能

View File

@ -39,9 +39,8 @@ android {
}
// NDK configuration for TSS native library
// Only include ARM ABIs for real devices (x86_64 is for emulators only)
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
}
}

View File

@ -13,7 +13,6 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
@ -77,7 +76,6 @@ fun TssPartyApp(
val currentSessionId by viewModel.currentSessionId.collectAsState()
val sessionParticipants by viewModel.sessionParticipants.collectAsState()
val currentRound by viewModel.currentRound.collectAsState()
val totalRounds by viewModel.totalRounds.collectAsState()
val publicKey by viewModel.publicKey.collectAsState()
val hasEnteredSession by viewModel.hasEnteredSession.collectAsState()
@ -111,111 +109,69 @@ fun TssPartyApp(
val exportResult by viewModel.exportResult.collectAsState()
val importResult by viewModel.importResult.collectAsState()
// Transaction history state
val transactionRecords by viewModel.transactionRecords.collectAsState()
val isSyncingHistory by viewModel.isSyncingHistory.collectAsState()
val syncResultMessage by viewModel.syncResultMessage.collectAsState()
// Current transfer wallet
var transferWalletId by remember { mutableStateOf<Long?>(null) }
// Export/Import file handling
val context = LocalContext.current
// Use rememberSaveable to persist across configuration changes (e.g., file picker activity)
var pendingExportJson by rememberSaveable { mutableStateOf<String?>(null) }
var pendingExportAddress by rememberSaveable { mutableStateOf<String?>(null) }
var pendingExportJson by remember { mutableStateOf<String?>(null) }
var pendingExportAddress by remember { mutableStateOf<String?>(null) }
// File picker for saving backup
val createDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(ShareBackup.MIME_TYPE)
) { uri: Uri? ->
android.util.Log.d("MainActivity", "[EXPORT-FILE] ========== createDocumentLauncher callback ==========")
android.util.Log.d("MainActivity", "[EXPORT-FILE] uri: $uri")
android.util.Log.d("MainActivity", "[EXPORT-FILE] pendingExportJson isNull: ${pendingExportJson == null}")
android.util.Log.d("MainActivity", "[EXPORT-FILE] pendingExportJson length: ${pendingExportJson?.length ?: 0}")
uri?.let { targetUri ->
pendingExportJson?.let { json ->
try {
android.util.Log.d("MainActivity", "[EXPORT-FILE] Opening output stream to: $targetUri")
context.contentResolver.openOutputStream(targetUri)?.use { outputStream ->
android.util.Log.d("MainActivity", "[EXPORT-FILE] Writing ${json.length} bytes...")
outputStream.write(json.toByteArray(Charsets.UTF_8))
android.util.Log.d("MainActivity", "[EXPORT-FILE] Write completed")
}
android.util.Log.d("MainActivity", "[EXPORT-FILE] File saved successfully!")
Toast.makeText(context, "备份文件已保存", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
android.util.Log.e("MainActivity", "[EXPORT-FILE] Failed to save file: ${e.message}", e)
Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
}
android.util.Log.d("MainActivity", "[EXPORT-FILE] Clearing pendingExportJson and pendingExportAddress")
pendingExportJson = null
pendingExportAddress = null
} ?: run {
android.util.Log.w("MainActivity", "[EXPORT-FILE] pendingExportJson is null, nothing to write!")
}
} ?: run {
android.util.Log.w("MainActivity", "[EXPORT-FILE] User cancelled file picker (uri is null)")
}
android.util.Log.d("MainActivity", "[EXPORT-FILE] ========== callback finished ==========")
}
// File picker for importing backup
val openDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
android.util.Log.d("MainActivity", "[IMPORT-FILE] ========== openDocumentLauncher callback ==========")
android.util.Log.d("MainActivity", "[IMPORT-FILE] uri: $uri")
uri?.let { sourceUri ->
try {
android.util.Log.d("MainActivity", "[IMPORT-FILE] Opening input stream from: $sourceUri")
context.contentResolver.openInputStream(sourceUri)?.use { inputStream ->
val json = inputStream.bufferedReader().readText()
android.util.Log.d("MainActivity", "[IMPORT-FILE] Read ${json.length} bytes")
android.util.Log.d("MainActivity", "[IMPORT-FILE] JSON preview: ${json.take(100)}...")
android.util.Log.d("MainActivity", "[IMPORT-FILE] Calling viewModel.importShareBackup...")
viewModel.importShareBackup(json)
android.util.Log.d("MainActivity", "[IMPORT-FILE] viewModel.importShareBackup called")
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "[IMPORT-FILE] Failed to read file: ${e.message}", e)
Toast.makeText(context, "读取文件失败: ${e.message}", Toast.LENGTH_LONG).show()
}
} ?: run {
android.util.Log.w("MainActivity", "[IMPORT-FILE] User cancelled file picker (uri is null)")
}
android.util.Log.d("MainActivity", "[IMPORT-FILE] ========== callback finished ==========")
}
// Handle export result - trigger file save dialog
LaunchedEffect(pendingExportJson) {
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] LaunchedEffect(pendingExportJson) triggered")
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] pendingExportJson isNull: ${pendingExportJson == null}")
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] pendingExportJson length: ${pendingExportJson?.length ?: 0}")
pendingExportJson?.let { json ->
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val addressSuffix = pendingExportAddress?.take(8) ?: "wallet"
val fileName = "tss_backup_${addressSuffix}_$timestamp.${ShareBackup.FILE_EXTENSION}"
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] Launching file picker with filename: $fileName")
createDocumentLauncher.launch(fileName)
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] File picker launched")
}
}
// Handle import result - show toast
LaunchedEffect(importResult) {
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] LaunchedEffect(importResult) triggered")
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] importResult: $importResult")
importResult?.let { result ->
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] isSuccess: ${result.isSuccess}, error: ${result.error}, message: ${result.message}")
when {
result.isSuccess -> {
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] Showing success toast")
Toast.makeText(context, result.message ?: "导入成功", Toast.LENGTH_SHORT).show()
viewModel.clearExportImportResult()
}
result.error != null -> {
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] Showing error toast: ${result.error}")
Toast.makeText(context, result.error, Toast.LENGTH_LONG).show()
viewModel.clearExportImportResult()
}
@ -224,9 +180,7 @@ fun TssPartyApp(
}
// Track if startup is complete
// Use rememberSaveable to persist across configuration changes (e.g., file picker activity)
var startupComplete by rememberSaveable { mutableStateOf(false) }
android.util.Log.d("MainActivity", "[STATE] TssPartyApp composing, startupComplete: $startupComplete")
var startupComplete by remember { mutableStateOf(false) }
// Handle success messages
LaunchedEffect(uiState.successMessage) {
@ -302,34 +256,18 @@ fun TssPartyApp(
transferWalletId = shareId
navController.navigate("transfer/$shareId")
},
onHistory = { shareId, address ->
navController.navigate("history/$shareId/$address")
},
onExportBackup = { shareId, _ ->
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] ========== onExportBackup called ==========")
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] shareId: $shareId")
// Get address for filename
val share = shares.find { it.id == shareId }
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] share found: ${share != null}, address: ${share?.address}")
pendingExportAddress = share?.address
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] pendingExportAddress set to: $pendingExportAddress")
// Export and save to file
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] Calling viewModel.exportShareBackup...")
viewModel.exportShareBackup(shareId) { json ->
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] exportShareBackup callback received")
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] json length: ${json.length}")
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] Setting pendingExportJson...")
pendingExportJson = json
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] pendingExportJson set, length: ${pendingExportJson?.length}")
}
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] viewModel.exportShareBackup called (async)")
},
onImportBackup = {
android.util.Log.d("MainActivity", "[IMPORT-TRIGGER] ========== onImportBackup called ==========")
android.util.Log.d("MainActivity", "[IMPORT-TRIGGER] Launching file picker...")
// Open file picker to select backup file
openDocumentLauncher.launch(arrayOf("*/*"))
android.util.Log.d("MainActivity", "[IMPORT-TRIGGER] File picker launched")
},
onCreateWallet = {
navController.navigate(BottomNavItem.Create.route)
@ -350,7 +288,7 @@ fun TssPartyApp(
sessionStatus = sessionStatus,
participants = signParticipants,
currentRound = signCurrentRound,
totalRounds = if (totalRounds > 0) totalRounds else 9, // Default to sign rounds
totalRounds = 9,
preparedTx = preparedTx,
signSessionId = signSessionId,
inviteCode = signInviteCode,
@ -363,19 +301,8 @@ fun TssPartyApp(
onPrepareTransaction = { toAddress, amount, tokenType ->
viewModel.prepareTransfer(shareId, toAddress, amount, tokenType)
},
onConfirmTransaction = { includeServerBackup ->
// 【新增】根据用户选择调用相应的签名方法
// includeServerBackup = true: 使用新方法,包含服务器备份参与方
// includeServerBackup = false: 使用现有方法,排除服务器方(默认行为)
if (includeServerBackup) {
viewModel.initiateSignSessionWithOptions(
shareId = shareId,
password = "",
includeServerBackup = true
)
} else {
viewModel.initiateSignSession(shareId, "")
}
onConfirmTransaction = {
viewModel.initiateSignSession(shareId, "")
},
onCopyInviteCode = {
signInviteCode?.let { onCopyToClipboard(it) }
@ -398,33 +325,6 @@ fun TssPartyApp(
}
}
// Transaction History Screen
composable("history/{shareId}/{address}") { backStackEntry ->
val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull() ?: 0L
val address = backStackEntry.arguments?.getString("address") ?: ""
// Load records and sync when entering screen
LaunchedEffect(shareId, address) {
viewModel.loadTransactionRecords(shareId)
// Auto-sync from blockchain on first entry
if (address.isNotEmpty()) {
viewModel.syncTransactionHistory(shareId, address)
}
}
TransactionHistoryScreen(
shareId = shareId,
walletAddress = address,
transactions = transactionRecords,
networkType = settings.networkType,
isSyncing = isSyncingHistory,
syncResultMessage = syncResultMessage,
onBack = { navController.popBackStack() },
onRefresh = { viewModel.syncTransactionHistory(shareId, address) },
onClearSyncMessage = { viewModel.clearSyncResultMessage() }
)
}
// Tab 2: Create Wallet (创建钱包)
composable(BottomNavItem.Create.route) {
CreateWalletScreen(
@ -436,7 +336,7 @@ fun TssPartyApp(
hasEnteredSession = hasEnteredSession,
participants = sessionParticipants,
currentRound = currentRound,
totalRounds = if (totalRounds > 0) totalRounds else 4, // Default to keygen rounds
totalRounds = 9,
publicKey = publicKey,
countdownSeconds = uiState.countdownSeconds,
onCreateSession = { name, t, n, participantName ->
@ -487,7 +387,7 @@ fun TssPartyApp(
sessionInfo = screenSessionInfo,
participants = joinKeygenParticipants,
currentRound = joinKeygenRound,
totalRounds = if (totalRounds > 0) totalRounds else 4, // Default to keygen rounds
totalRounds = 9,
publicKey = joinKeygenPublicKey,
countdownSeconds = uiState.countdownSeconds,
onValidateInviteCode = { inviteCode ->
@ -543,7 +443,7 @@ fun TssPartyApp(
signSessionInfo = screenSignSessionInfo,
participants = coSignParticipants,
currentRound = coSignRound,
totalRounds = if (totalRounds > 0) totalRounds else 9, // Default to sign rounds
totalRounds = 9,
signature = coSignSignature,
countdownSeconds = uiState.countdownSeconds,
onValidateInviteCode = { inviteCode ->

View File

@ -1,87 +1,7 @@
package com.durian.tssparty
import android.app.Application
import android.util.Log
import dagger.hilt.android.HiltAndroidApp
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@HiltAndroidApp
class TssPartyApplication : Application() {
companion object {
private const val TAG = "TssPartyApplication"
}
private var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Application onCreate")
// Set up global exception handler
setupCrashHandler()
}
private fun setupCrashHandler() {
defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Log.e(TAG, "=== UNCAUGHT EXCEPTION ===")
Log.e(TAG, "Thread: ${thread.name}")
Log.e(TAG, "Exception: ${throwable.javaClass.simpleName}")
Log.e(TAG, "Message: ${throwable.message}")
// Get full stack trace
val sw = StringWriter()
throwable.printStackTrace(PrintWriter(sw))
val stackTrace = sw.toString()
Log.e(TAG, "Stack trace:\n$stackTrace")
// Try to save crash log to file
try {
saveCrashLog(thread, throwable, stackTrace)
} catch (e: Exception) {
Log.e(TAG, "Failed to save crash log: ${e.message}")
}
// Call the default handler
defaultExceptionHandler?.uncaughtException(thread, throwable)
}
Log.d(TAG, "Crash handler installed")
}
private fun saveCrashLog(thread: Thread, throwable: Throwable, stackTrace: String) {
val crashDir = File(filesDir, "crash_logs")
if (!crashDir.exists()) {
crashDir.mkdirs()
}
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault())
val timestamp = dateFormat.format(Date())
val crashFile = File(crashDir, "crash_$timestamp.txt")
crashFile.writeText(buildString {
appendLine("=== TSS Party Crash Report ===")
appendLine("Time: $timestamp")
appendLine("Thread: ${thread.name}")
appendLine("Exception: ${throwable.javaClass.name}")
appendLine("Message: ${throwable.message}")
appendLine()
appendLine("=== Stack Trace ===")
appendLine(stackTrace)
appendLine()
appendLine("=== Device Info ===")
appendLine("Android Version: ${android.os.Build.VERSION.RELEASE}")
appendLine("SDK: ${android.os.Build.VERSION.SDK_INT}")
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
})
Log.d(TAG, "Crash log saved to: ${crashFile.absolutePath}")
}
}
class TssPartyApplication : Application()

View File

@ -29,9 +29,6 @@ data class ShareRecordEntity(
@ColumnInfo(name = "party_index")
val partyIndex: Int,
@ColumnInfo(name = "party_id")
val partyId: String, // The original partyId used during keygen - required for signing
@ColumnInfo(name = "address")
val address: String,
@ -93,159 +90,15 @@ interface AppSettingDao {
suspend fun setValue(setting: AppSettingEntity)
}
/**
* 转账记录数据库实体
* Entity for storing transaction history records
*/
@Entity(
tableName = "transaction_records",
foreignKeys = [
ForeignKey(
entity = ShareRecordEntity::class,
parentColumns = ["id"],
childColumns = ["share_id"],
onDelete = ForeignKey.CASCADE // 删除钱包时自动删除关联的转账记录
)
],
indices = [
Index(value = ["share_id"]),
Index(value = ["tx_hash"], unique = true),
Index(value = ["from_address"]),
Index(value = ["to_address"]),
Index(value = ["created_at"])
]
)
data class TransactionRecordEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "share_id")
val shareId: Long, // 关联的钱包ID
@ColumnInfo(name = "from_address")
val fromAddress: String, // 发送方地址
@ColumnInfo(name = "to_address")
val toAddress: String, // 接收方地址
@ColumnInfo(name = "amount")
val amount: String, // 转账金额(人类可读格式)
@ColumnInfo(name = "token_type")
val tokenType: String, // 代币类型KAVA, GREEN_POINTS, ENERGY_POINTS, FUTURE_POINTS
@ColumnInfo(name = "tx_hash")
val txHash: String, // 交易哈希
@ColumnInfo(name = "gas_price")
val gasPrice: String, // Gas 价格Wei
@ColumnInfo(name = "gas_used")
val gasUsed: String = "", // 实际消耗的 Gas
@ColumnInfo(name = "tx_fee")
val txFee: String = "", // 交易手续费
@ColumnInfo(name = "status")
val status: String, // 交易状态PENDING, CONFIRMED, FAILED
@ColumnInfo(name = "direction")
val direction: String, // 交易方向SENT, RECEIVED
@ColumnInfo(name = "note")
val note: String = "", // 备注
@ColumnInfo(name = "created_at")
val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "confirmed_at")
val confirmedAt: Long? = null, // 确认时间
@ColumnInfo(name = "block_number")
val blockNumber: Long? = null // 区块高度
)
/**
* 转账记录 DAO
* Data Access Object for transaction records
*/
@Dao
interface TransactionRecordDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecord(record: TransactionRecordEntity): Long
@Query("SELECT * FROM transaction_records WHERE id = :id")
suspend fun getRecordById(id: Long): TransactionRecordEntity?
@Query("SELECT * FROM transaction_records WHERE tx_hash = :txHash")
suspend fun getRecordByTxHash(txHash: String): TransactionRecordEntity?
@Query("SELECT * FROM transaction_records WHERE share_id = :shareId ORDER BY created_at DESC")
fun getRecordsForShare(shareId: Long): Flow<List<TransactionRecordEntity>>
@Query("SELECT * FROM transaction_records WHERE share_id = :shareId ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
suspend fun getRecordsForSharePaged(shareId: Long, limit: Int, offset: Int): List<TransactionRecordEntity>
@Query("SELECT * FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType ORDER BY created_at DESC")
fun getRecordsForShareByToken(shareId: Long, tokenType: String): Flow<List<TransactionRecordEntity>>
@Query("SELECT * FROM transaction_records WHERE status = 'PENDING' ORDER BY created_at ASC")
suspend fun getPendingRecords(): List<TransactionRecordEntity>
@Query("UPDATE transaction_records SET status = :status, confirmed_at = :confirmedAt, block_number = :blockNumber, gas_used = :gasUsed, tx_fee = :txFee WHERE id = :id")
suspend fun updateStatus(id: Long, status: String, confirmedAt: Long?, blockNumber: Long?, gasUsed: String, txFee: String)
@Query("""
SELECT
COUNT(*) as total_count,
SUM(CASE WHEN direction = 'SENT' THEN 1 ELSE 0 END) as sent_count,
SUM(CASE WHEN direction = 'RECEIVED' THEN 1 ELSE 0 END) as received_count
FROM transaction_records
WHERE share_id = :shareId AND token_type = :tokenType
""")
suspend fun getTransactionStats(shareId: Long, tokenType: String): TransactionStats
@Query("SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType AND direction = 'SENT' AND status = 'CONFIRMED'")
suspend fun getTotalSentAmount(shareId: Long, tokenType: String): Double
@Query("SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType AND direction = 'RECEIVED' AND status = 'CONFIRMED'")
suspend fun getTotalReceivedAmount(shareId: Long, tokenType: String): Double
@Query("SELECT COALESCE(SUM(CAST(tx_fee AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND direction = 'SENT' AND status = 'CONFIRMED'")
suspend fun getTotalTxFee(shareId: Long): Double
@Query("DELETE FROM transaction_records WHERE id = :id")
suspend fun deleteRecordById(id: Long)
@Query("DELETE FROM transaction_records WHERE share_id = :shareId")
suspend fun deleteRecordsForShare(shareId: Long)
@Query("SELECT COUNT(*) FROM transaction_records WHERE share_id = :shareId")
suspend fun getRecordCount(shareId: Long): Int
}
/**
* 转账统计数据类
*/
data class TransactionStats(
@ColumnInfo(name = "total_count")
val totalCount: Int,
@ColumnInfo(name = "sent_count")
val sentCount: Int,
@ColumnInfo(name = "received_count")
val receivedCount: Int
)
/**
* Room database
*/
@Database(
entities = [ShareRecordEntity::class, AppSettingEntity::class, TransactionRecordEntity::class],
version = 4, // Version 4: added transaction_records table for transfer history
entities = [ShareRecordEntity::class, AppSettingEntity::class],
version = 2,
exportSchema = false
)
abstract class TssDatabase : RoomDatabase() {
abstract fun shareRecordDao(): ShareRecordDao
abstract fun appSettingDao(): AppSettingDao
abstract fun transactionRecordDao(): TransactionRecordDao
}

View File

@ -97,11 +97,6 @@ class GrpcClient @Inject constructor() {
private var registeredPartyId: String? = null
private var registeredPartyRole: String? = null
// Additional signing party registration (for imported/restored shares)
// When signing with a restored wallet, the signing partyId differs from the device partyId
// and must also be registered with the message-router
private var registeredSigningPartyId: String? = null
// Heartbeat state
private var heartbeatJob: Job? = null
private val heartbeatFailCount = AtomicInteger(0)
@ -128,26 +123,17 @@ class GrpcClient @Inject constructor() {
* Connect to the Message Router server
*/
fun connect(host: String, port: Int) {
Log.d(TAG, "=== connect() called ===")
Log.d(TAG, " host: $host, port: $port")
Log.d(TAG, " isReconnecting before reset: ${isReconnecting.get()}")
// Save connection params for reconnection
currentHost = host
currentPort = port
shouldReconnect.set(true)
reconnectAttempts.set(0)
// 重要:初次连接时确保 isReconnecting 为 false
// 这样 waitForConnection() 能正确区分初次连接和重连
isReconnecting.set(false)
Log.d(TAG, " isReconnecting after reset: ${isReconnecting.get()} (should be false for first connect)")
doConnect(host, port)
}
private fun doConnect(host: String, port: Int) {
Log.d(TAG, "doConnect: $host:$port, isReconnecting=${isReconnecting.get()}")
Log.d(TAG, "Connecting to $host:$port")
_connectionState.value = GrpcConnectionState.Connecting
try {
@ -197,39 +183,24 @@ class GrpcClient @Inject constructor() {
when (state) {
ConnectivityState.READY -> {
// 关键修复:先读取 isReconnecting 再重置,用于区分初次连接和重连
// - 初次连接isReconnecting = false由 connect() 触发)
// - 重连isReconnecting = true由 triggerReconnect() 触发,包括后台唤醒)
val wasReconnecting = isReconnecting.getAndSet(false)
Log.d(TAG, "=== Channel READY ===")
Log.d(TAG, " wasReconnecting: $wasReconnecting")
Log.d(TAG, " registeredPartyId: $registeredPartyId")
Log.d(TAG, " eventStreamSubscribed: ${eventStreamSubscribed.get()}")
Log.d(TAG, " eventStreamPartyId: $eventStreamPartyId")
Log.d(TAG, "Connected successfully")
_connectionState.value = GrpcConnectionState.Connected
reconnectAttempts.set(0)
heartbeatFailCount.set(0)
isReconnecting.set(false)
// Start channel state monitoring
startChannelStateMonitor()
// 只有重连时才需要恢复注册和订阅
// 初次连接时registerParty() 和 subscribeSessionEvents() 会在外部显式调用
if (wasReconnecting) {
Log.d(TAG, ">>> RECONNECT: Restoring registration and streams")
// Re-register if we were registered before
reRegisterIfNeeded()
// Re-subscribe to streams
reSubscribeStreams()
} else {
Log.d(TAG, ">>> FIRST CONNECT: Skipping restore (will be done by caller)")
}
// Re-register if we were registered before
reRegisterIfNeeded()
// Restart heartbeat (both first connect and reconnect need this)
// Restart heartbeat
startHeartbeat()
// Re-subscribe to streams
reSubscribeStreams()
return@withTimeout
}
ConnectivityState.TRANSIENT_FAILURE, ConnectivityState.SHUTDOWN -> {
@ -337,23 +308,18 @@ class GrpcClient @Inject constructor() {
* Trigger reconnection with exponential backoff
*/
private fun triggerReconnect(reason: String) {
Log.d(TAG, "[IDLE_CRASH_DEBUG] triggerReconnect called: $reason")
Log.d(TAG, "[IDLE_CRASH_DEBUG] shouldReconnect=${shouldReconnect.get()}, isReconnecting=${isReconnecting.get()}")
if (!shouldReconnect.get() || isReconnecting.getAndSet(true)) {
Log.d(TAG, "[IDLE_CRASH_DEBUG] triggerReconnect skipped (already reconnecting or disabled)")
return
}
val host = currentHost
val port = currentPort
if (host == null || port == null) {
Log.d(TAG, "[IDLE_CRASH_DEBUG] triggerReconnect skipped (no host/port)")
isReconnecting.set(false)
return
}
Log.d(TAG, "[IDLE_CRASH_DEBUG] Triggering reconnect to $host:$port")
Log.d(TAG, "Triggering reconnect: $reason")
// Emit disconnected event
_connectionEvents.tryEmit(GrpcConnectionEvent.Disconnected(reason))
@ -381,10 +347,7 @@ class GrpcClient @Inject constructor() {
Log.d(TAG, "Reconnecting in ${delay}ms (attempt $attempt/${reconnectConfig.maxRetries})")
delay(delay)
// 注意:不要在这里重置 isReconnecting
// isReconnecting 会在 waitForConnection() 的 READY 分支中被重置
// 这样 waitForConnection() 才能知道这是重连而非初次连接
Log.d(TAG, ">>> Starting reconnect, isReconnecting=$isReconnecting (should be true)")
isReconnecting.set(false)
doConnect(host, port)
}
}
@ -433,18 +396,15 @@ class GrpcClient @Inject constructor() {
private fun handleHeartbeatFailure(reason: String) {
val fails = heartbeatFailCount.incrementAndGet()
Log.w(TAG, "[IDLE_CRASH_DEBUG] Heartbeat failed ($fails/$MAX_HEARTBEAT_FAILS): $reason")
Log.w(TAG, "[IDLE_CRASH_DEBUG] Connection state: ${_connectionState.value}")
Log.w(TAG, "[IDLE_CRASH_DEBUG] Channel state: ${channel?.getState(false)}")
Log.w(TAG, "Heartbeat failed ($fails/$MAX_HEARTBEAT_FAILS): $reason")
if (fails >= MAX_HEARTBEAT_FAILS) {
Log.e(TAG, "[IDLE_CRASH_DEBUG] Too many heartbeat failures, triggering reconnect")
Log.e(TAG, "Too many heartbeat failures, triggering reconnect")
triggerReconnect("Heartbeat failed")
}
}
private fun stopHeartbeat() {
Log.d(TAG, "[IDLE_CRASH_DEBUG] stopHeartbeat called")
heartbeatJob?.cancel()
heartbeatJob = null
heartbeatFailCount.set(0)
@ -465,17 +425,6 @@ class GrpcClient @Inject constructor() {
Log.e(TAG, "Re-registration failed: ${e.message}")
}
}
// Also re-register the signing partyId if active (for imported/restored shares)
val signingId = registeredSigningPartyId
if (signingId != null && signingId != partyId) {
Log.d(TAG, "Re-registering signing party: $signingId")
try {
registerPartyInternal(signingId, "temporary", "1.0.0")
} catch (e: Exception) {
Log.e(TAG, "Re-registration of signing party failed: ${e.message}")
}
}
}
/**
@ -499,28 +448,23 @@ class GrpcClient @Inject constructor() {
* Notifies the repository layer to re-establish message/event subscriptions
*/
private fun reSubscribeStreams() {
Log.d(TAG, "[IDLE_CRASH_DEBUG] reSubscribeStreams called")
val needsResubscribe = eventStreamSubscribed.get() || activeMessageSubscription != null
if (needsResubscribe) {
Log.d(TAG, "[IDLE_CRASH_DEBUG] Triggering stream re-subscription callback")
Log.d(TAG, "[IDLE_CRASH_DEBUG] - Event stream: ${eventStreamSubscribed.get()}, partyId: $eventStreamPartyId")
Log.d(TAG, "[IDLE_CRASH_DEBUG] - Message stream: ${activeMessageSubscription?.sessionId}")
Log.d(TAG, "Triggering stream re-subscription callback")
Log.d(TAG, " - Event stream: ${eventStreamSubscribed.get()}, partyId: $eventStreamPartyId")
Log.d(TAG, " - Message stream: ${activeMessageSubscription?.sessionId}")
// Notify repository to re-establish streams
scope.launch {
Log.d(TAG, "[IDLE_CRASH_DEBUG] Waiting for channel to be ready...")
// Wait for channel to be fully ready instead of fixed delay
if (waitForChannelReady()) {
Log.d(TAG, "[IDLE_CRASH_DEBUG] Channel ready, invoking reconnect callback")
try {
onReconnectedCallback?.invoke()
Log.d(TAG, "[IDLE_CRASH_DEBUG] Reconnect callback completed")
// Emit reconnected event
_connectionEvents.tryEmit(GrpcConnectionEvent.Reconnected)
} catch (e: Exception) {
Log.e(TAG, "[IDLE_CRASH_DEBUG] Reconnect callback failed: ${e.message}")
Log.e(TAG, "[IDLE_CRASH_DEBUG] Stack trace: ${e.stackTraceToString()}")
Log.e(TAG, "Reconnect callback failed: ${e.message}")
// Don't let callback failure affect the connection state
}
} else {
@ -622,15 +566,6 @@ class GrpcClient @Inject constructor() {
partyRole: String = "temporary",
version: String = "1.0.0"
): Result<Boolean> = withContext(Dispatchers.IO) {
// 必须等待 channel READY 后才能注册
Log.d(TAG, "registerParty: Waiting for channel READY...")
val isReady = waitForChannelReady(CONNECTION_TIMEOUT_SECONDS * 1000)
if (!isReady) {
Log.e(TAG, "registerParty: Channel not ready after timeout")
return@withContext Result.failure(Exception("Channel not ready"))
}
Log.d(TAG, "registerParty: Channel is READY, proceeding with registration")
// Save for re-registration
registeredPartyId = partyId
registeredPartyRole = partyRole
@ -667,29 +602,6 @@ class GrpcClient @Inject constructor() {
}
}
/**
* Register an additional signing partyId with the message-router.
* Used when signing with imported/restored shares where the signing partyId
* differs from the device's own partyId. Does not overwrite the device registration.
*/
suspend fun registerSigningParty(signingPartyId: String): Result<Boolean> = withContext(Dispatchers.IO) {
if (signingPartyId == registeredPartyId) {
// Same as device partyId, already registered
return@withContext Result.success(true)
}
Log.d(TAG, "Registering signing partyId: $signingPartyId (device partyId: $registeredPartyId)")
registeredSigningPartyId = signingPartyId
registerPartyInternal(signingPartyId, "temporary", "1.0.0")
}
/**
* Clear the additional signing party registration.
* Called when signing completes or fails.
*/
fun clearSigningPartyRegistration() {
registeredSigningPartyId = null
}
/**
* Join a session
*/
@ -829,16 +741,15 @@ class GrpcClient @Inject constructor() {
override fun onError(t: Throwable) {
Log.e(TAG, "Message stream error: ${t.message}")
// Ignore events from stale streams - close without exception to avoid crash
// Ignore events from stale streams
if (messageStreamVersion.get() != streamVersion) {
Log.d(TAG, "Ignoring error from stale message stream")
close()
close(t)
return
}
// Don't trigger reconnect for CANCELLED or channel shutdown errors
val errorMessage = t.message.orEmpty()
if (!errorMessage.contains("CANCELLED") && !errorMessage.contains("shutdownNow")) {
// Don't trigger reconnect for CANCELLED errors
if (!t.message.orEmpty().contains("CANCELLED")) {
triggerReconnect("Message stream error: ${t.message}")
}
close(t)
@ -910,16 +821,15 @@ class GrpcClient @Inject constructor() {
override fun onError(t: Throwable) {
Log.e(TAG, "Session event stream error: ${t.message}")
// Ignore events from stale streams - close without exception to avoid crash
// Ignore events from stale streams
if (eventStreamVersion.get() != streamVersion) {
Log.d(TAG, "Ignoring error from stale event stream")
close()
close(t)
return
}
// Don't trigger reconnect for CANCELLED or channel shutdown errors
val errorMessage = t.message.orEmpty()
if (!errorMessage.contains("CANCELLED") && !errorMessage.contains("shutdownNow")) {
// Don't trigger reconnect for CANCELLED errors
if (!t.message.orEmpty().contains("CANCELLED")) {
triggerReconnect("Event stream error: ${t.message}")
}
close(t)

View File

@ -6,7 +6,6 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.durian.tssparty.data.local.AppSettingDao
import com.durian.tssparty.data.local.ShareRecordDao
import com.durian.tssparty.data.local.TransactionRecordDao
import com.durian.tssparty.data.local.TssDatabase
import com.durian.tssparty.data.local.TssNativeBridge
import com.durian.tssparty.data.remote.GrpcClient
@ -35,53 +34,6 @@ object AppModule {
}
}
// Migration from version 2 to 3: add party_id column to share_records
// This is critical for backup/restore - the partyId must be preserved for signing to work
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
// Add party_id column with empty default (existing records will need to be re-exported)
database.execSQL(
"ALTER TABLE `share_records` ADD COLUMN `party_id` TEXT NOT NULL DEFAULT ''"
)
}
}
// Migration from version 3 to 4: add transaction_records table for transfer history
// 添加转账记录表,用于存储交易历史和分类账
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
// 创建转账记录表
database.execSQL("""
CREATE TABLE IF NOT EXISTS `transaction_records` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`share_id` INTEGER NOT NULL,
`from_address` TEXT NOT NULL,
`to_address` TEXT NOT NULL,
`amount` TEXT NOT NULL,
`token_type` TEXT NOT NULL,
`tx_hash` TEXT NOT NULL,
`gas_price` TEXT NOT NULL,
`gas_used` TEXT NOT NULL DEFAULT '',
`tx_fee` TEXT NOT NULL DEFAULT '',
`status` TEXT NOT NULL,
`direction` TEXT NOT NULL,
`note` TEXT NOT NULL DEFAULT '',
`created_at` INTEGER NOT NULL,
`confirmed_at` INTEGER,
`block_number` INTEGER,
FOREIGN KEY(`share_id`) REFERENCES `share_records`(`id`) ON DELETE CASCADE
)
""".trimIndent())
// 创建索引以优化查询性能
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_share_id` ON `transaction_records` (`share_id`)")
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_transaction_records_tx_hash` ON `transaction_records` (`tx_hash`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_from_address` ON `transaction_records` (`from_address`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_to_address` ON `transaction_records` (`to_address`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_created_at` ON `transaction_records` (`created_at`)")
}
}
@Provides
@Singleton
fun provideGson(): Gson {
@ -96,7 +48,7 @@ object AppModule {
TssDatabase::class.java,
"tss_party.db"
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.addMigrations(MIGRATION_1_2)
.build()
}
@ -112,12 +64,6 @@ object AppModule {
return database.appSettingDao()
}
@Provides
@Singleton
fun provideTransactionRecordDao(database: TssDatabase): TransactionRecordDao {
return database.transactionRecordDao()
}
@Provides
@Singleton
fun provideGrpcClient(): GrpcClient {
@ -136,9 +82,8 @@ object AppModule {
grpcClient: GrpcClient,
tssNativeBridge: TssNativeBridge,
shareRecordDao: ShareRecordDao,
appSettingDao: AppSettingDao,
transactionRecordDao: TransactionRecordDao
appSettingDao: AppSettingDao
): TssRepository {
return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao, transactionRecordDao)
return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao)
}
}

View File

@ -86,7 +86,6 @@ data class ShareRecord(
val thresholdT: Int,
val thresholdN: Int,
val partyIndex: Int,
val partyId: String, // The original partyId used during keygen - required for signing
val address: String,
val createdAt: Long = System.currentTimeMillis()
)
@ -130,21 +129,7 @@ enum class NetworkType {
*/
enum class TokenType {
KAVA, // Native KAVA token
GREEN_POINTS, // 绿积分 (dUSDT) ERC-20 token
ENERGY_POINTS, // 积分股 (eUSDT) ERC-20 token
FUTURE_POINTS // 积分值 (fUSDT) ERC-20 token
}
/**
* ERC-20 通用函数签名keccak256 哈希的前4字节
* Common ERC-20 function selectors
*/
object ERC20Selectors {
const val BALANCE_OF = "0x70a08231" // balanceOf(address)
const val TRANSFER = "0xa9059cbb" // transfer(address,uint256)
const val APPROVE = "0x095ea7b3" // approve(address,uint256)
const val ALLOWANCE = "0xdd62ed3e" // allowance(address,address)
const val TOTAL_SUPPLY = "0x18160ddd" // totalSupply()
GREEN_POINTS // 绿积分 (dUSDT) ERC-20 token
}
/**
@ -157,122 +142,22 @@ object GreenPointsToken {
const val SYMBOL = "dUSDT"
const val DECIMALS = 6
// ERC-20 function signatures (kept for backward compatibility)
const val BALANCE_OF_SELECTOR = ERC20Selectors.BALANCE_OF
const val TRANSFER_SELECTOR = ERC20Selectors.TRANSFER
const val APPROVE_SELECTOR = ERC20Selectors.APPROVE
const val ALLOWANCE_SELECTOR = ERC20Selectors.ALLOWANCE
const val TOTAL_SUPPLY_SELECTOR = ERC20Selectors.TOTAL_SUPPLY
// ERC-20 function signatures (first 4 bytes of keccak256 hash)
const val BALANCE_OF_SELECTOR = "0x70a08231" // balanceOf(address)
const val TRANSFER_SELECTOR = "0xa9059cbb" // transfer(address,uint256)
const val APPROVE_SELECTOR = "0x095ea7b3" // approve(address,uint256)
const val ALLOWANCE_SELECTOR = "0xdd62ed3e" // allowance(address,address)
const val TOTAL_SUPPLY_SELECTOR = "0x18160ddd" // totalSupply()
}
/**
* Energy Points (积分股) Token Contract Configuration
* eUSDT - ERC-20 token on Kava EVM
* 总供应量100.02亿 (10,002,000,000)
*/
object EnergyPointsToken {
const val CONTRACT_ADDRESS = "0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931"
const val NAME = "积分股"
const val SYMBOL = "eUSDT"
const val DECIMALS = 6 // 与 dUSDT 相同的精度
}
/**
* Future Points (积分值) Token Contract Configuration
* fUSDT - ERC-20 token on Kava EVM
* 总供应量1万亿 (1,000,000,000,000)
*/
object FuturePointsToken {
const val CONTRACT_ADDRESS = "0x14dc4f7d3E4197438d058C3D156dd9826A161134"
const val NAME = "积分值"
const val SYMBOL = "fUSDT"
const val DECIMALS = 6 // 与 dUSDT 相同的精度
}
/**
* 代币配置工具类
* Token configuration utility
*/
object TokenConfig {
/**
* 获取代币合约地址
*/
fun getContractAddress(tokenType: TokenType): String? {
return when (tokenType) {
TokenType.KAVA -> null // 原生代币无合约地址
TokenType.GREEN_POINTS -> GreenPointsToken.CONTRACT_ADDRESS
TokenType.ENERGY_POINTS -> EnergyPointsToken.CONTRACT_ADDRESS
TokenType.FUTURE_POINTS -> FuturePointsToken.CONTRACT_ADDRESS
}
}
/**
* 获取代币精度
*/
fun getDecimals(tokenType: TokenType): Int {
return when (tokenType) {
TokenType.KAVA -> 18 // KAVA 原生代币精度
TokenType.GREEN_POINTS -> GreenPointsToken.DECIMALS
TokenType.ENERGY_POINTS -> EnergyPointsToken.DECIMALS
TokenType.FUTURE_POINTS -> FuturePointsToken.DECIMALS
}
}
/**
* 获取代币名称
*/
fun getName(tokenType: TokenType): String {
return when (tokenType) {
TokenType.KAVA -> "KAVA"
TokenType.GREEN_POINTS -> GreenPointsToken.NAME
TokenType.ENERGY_POINTS -> EnergyPointsToken.NAME
TokenType.FUTURE_POINTS -> FuturePointsToken.NAME
}
}
/**
* 获取代币符号
*/
fun getSymbol(tokenType: TokenType): String {
return when (tokenType) {
TokenType.KAVA -> "KAVA"
TokenType.GREEN_POINTS -> GreenPointsToken.SYMBOL
TokenType.ENERGY_POINTS -> EnergyPointsToken.SYMBOL
TokenType.FUTURE_POINTS -> FuturePointsToken.SYMBOL
}
}
/**
* 判断是否为 ERC-20 代币
*/
fun isERC20(tokenType: TokenType): Boolean {
return tokenType != TokenType.KAVA
}
}
/**
* Wallet balance containing native and all token balances
* 钱包余额包含原生代币和所有 ERC-20 代币余额
* Wallet balance containing both native and token balances
*/
data class WalletBalance(
val address: String,
val kavaBalance: String = "0", // Native KAVA balance
val greenPointsBalance: String = "0", // 绿积分 (dUSDT) balance
val energyPointsBalance: String = "0", // 积分股 (eUSDT) balance
val futurePointsBalance: String = "0" // 积分值 (fUSDT) balance
) {
/**
* 根据代币类型获取余额
*/
fun getBalance(tokenType: TokenType): String {
return when (tokenType) {
TokenType.KAVA -> kavaBalance
TokenType.GREEN_POINTS -> greenPointsBalance
TokenType.ENERGY_POINTS -> energyPointsBalance
TokenType.FUTURE_POINTS -> futurePointsBalance
}
}
}
val kavaBalance: String = "0", // Native KAVA balance
val greenPointsBalance: String = "0" // 绿积分 (dUSDT) balance
)
/**
* Share backup data for export/import
@ -280,7 +165,7 @@ data class WalletBalance(
*/
data class ShareBackup(
@SerializedName("version")
val version: Int = 2, // Version 2: added partyId field for proper backup/restore
val version: Int = 1, // Backup format version for future compatibility
@SerializedName("sessionId")
val sessionId: String,
@ -300,9 +185,6 @@ data class ShareBackup(
@SerializedName("partyIndex")
val partyIndex: Int,
@SerializedName("partyId")
val partyId: String, // The original partyId used during keygen - CRITICAL for signing after restore
@SerializedName("address")
val address: String,
@ -327,7 +209,6 @@ data class ShareBackup(
thresholdT = share.thresholdT,
thresholdN = share.thresholdN,
partyIndex = share.partyIndex,
partyId = share.partyId,
address = share.address,
createdAt = share.createdAt
)
@ -346,7 +227,6 @@ data class ShareBackup(
thresholdT = thresholdT,
thresholdN = thresholdN,
partyIndex = partyIndex,
partyId = partyId,
address = address,
createdAt = createdAt
)

View File

@ -1,398 +0,0 @@
package com.durian.tssparty.presentation.screens
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.durian.tssparty.data.local.TransactionRecordEntity
import com.durian.tssparty.domain.model.EnergyPointsToken
import com.durian.tssparty.domain.model.FuturePointsToken
import com.durian.tssparty.domain.model.GreenPointsToken
import com.durian.tssparty.domain.model.NetworkType
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionHistoryScreen(
shareId: Long,
walletAddress: String,
transactions: List<TransactionRecordEntity>,
networkType: NetworkType,
isSyncing: Boolean,
syncResultMessage: String? = null,
onBack: () -> Unit,
onRefresh: () -> Unit,
onClearSyncMessage: () -> Unit = {}
) {
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
// Show snackbar when sync result message changes
LaunchedEffect(syncResultMessage) {
syncResultMessage?.let { message ->
snackbarHostState.showSnackbar(message)
onClearSyncMessage()
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("交易记录") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}
},
actions = {
if (isSyncing) {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp)
.padding(end = 8.dp),
strokeWidth = 2.dp
)
} else {
IconButton(onClick = onRefresh) {
Icon(Icons.Default.Refresh, contentDescription = "刷新")
}
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
) {
// Wallet address header
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = walletAddress,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Transaction count
Text(
text = "${transactions.size} 条记录",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
if (transactions.isEmpty()) {
// Empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.Receipt,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "暂无交易记录",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (isSyncing) "正在同步中..." else "发起转账后将在此显示",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
// Transaction list
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(
items = transactions.sortedByDescending { it.createdAt },
key = { it.id }
) { tx ->
TransactionItemCard(
transaction = tx,
walletAddress = walletAddress,
networkType = networkType,
onClick = {
// Open transaction in block explorer
val explorerUrl = getExplorerUrl(networkType, tx.txHash)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(explorerUrl))
context.startActivity(intent)
}
)
}
}
}
}
}
}
@Composable
private fun TransactionItemCard(
transaction: TransactionRecordEntity,
walletAddress: String,
networkType: NetworkType,
onClick: () -> Unit
) {
val isSent = transaction.direction == "SENT" ||
transaction.fromAddress.equals(walletAddress, ignoreCase = true)
val statusColor = when (transaction.status) {
"CONFIRMED" -> Color(0xFF4CAF50) // Green
"FAILED" -> MaterialTheme.colorScheme.error
else -> Color(0xFFFF9800) // Orange for PENDING
}
val tokenColor = when (transaction.tokenType) {
"GREEN_POINTS" -> Color(0xFF4CAF50)
"ENERGY_POINTS" -> Color(0xFF2196F3)
"FUTURE_POINTS" -> Color(0xFF9C27B0)
else -> MaterialTheme.colorScheme.primary // KAVA
}
val tokenName = when (transaction.tokenType) {
"GREEN_POINTS" -> GreenPointsToken.NAME
"ENERGY_POINTS" -> EnergyPointsToken.NAME
"FUTURE_POINTS" -> FuturePointsToken.NAME
else -> "KAVA"
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
) {
Column(
modifier = Modifier.padding(12.dp)
) {
// Row 1: Direction icon + Amount + Status
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Direction icon
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (isSent)
MaterialTheme.colorScheme.errorContainer
else
Color(0xFFE8F5E9)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isSent) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward,
contentDescription = if (isSent) "发送" else "接收",
tint = if (isSent)
MaterialTheme.colorScheme.error
else
Color(0xFF4CAF50),
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
// Amount and token
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${if (isSent) "-" else "+"}${transaction.amount}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (isSent)
MaterialTheme.colorScheme.error
else
Color(0xFF4CAF50)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = tokenName,
style = MaterialTheme.typography.bodySmall,
color = tokenColor
)
}
Text(
text = if (isSent) "发送" else "接收",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Status badge
Surface(
color = statusColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = when (transaction.status) {
"CONFIRMED" -> "已确认"
"FAILED" -> "失败"
else -> "待确认"
},
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = statusColor
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Divider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(8.dp))
// Row 2: Address (to/from)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = if (isSent) "发送至" else "来自",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = if (isSent) shortenAddress(transaction.toAddress) else shortenAddress(transaction.fromAddress),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = "时间",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = formatTimestamp(transaction.createdAt),
style = MaterialTheme.typography.bodySmall
)
}
}
// Row 3: Tx Hash (abbreviated)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "交易哈希: ${shortenTxHash(transaction.txHash)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.OpenInNew,
contentDescription = "查看详情",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.outline
)
}
// Row 4: Fee (if confirmed)
if (transaction.status == "CONFIRMED" && transaction.txFee.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "手续费: ${transaction.txFee} KAVA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
private fun shortenAddress(address: String): String {
return if (address.length > 16) {
"${address.take(10)}...${address.takeLast(6)}"
} else {
address
}
}
private fun shortenTxHash(txHash: String): String {
return if (txHash.length > 20) {
"${txHash.take(10)}...${txHash.takeLast(8)}"
} else {
txHash
}
}
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}
private fun getExplorerUrl(networkType: NetworkType, txHash: String): String {
return when (networkType) {
NetworkType.MAINNET -> "https://kavascan.com/tx/$txHash"
NetworkType.TESTNET -> "https://testnet.kavascan.com/tx/$txHash"
}
}

View File

@ -27,13 +27,10 @@ import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.ui.graphics.asImageBitmap
import com.durian.tssparty.domain.model.EnergyPointsToken
import com.durian.tssparty.domain.model.FuturePointsToken
import com.durian.tssparty.domain.model.GreenPointsToken
import com.durian.tssparty.domain.model.NetworkType
import com.durian.tssparty.domain.model.SessionStatus
import com.durian.tssparty.domain.model.ShareRecord
import com.durian.tssparty.domain.model.TokenConfig
import com.durian.tssparty.domain.model.TokenType
import com.durian.tssparty.domain.model.WalletBalance
import com.durian.tssparty.util.TransactionUtils
@ -78,7 +75,7 @@ fun TransferScreen(
networkType: NetworkType = NetworkType.MAINNET,
rpcUrl: String = "https://evm.kava.io",
onPrepareTransaction: (toAddress: String, amount: String, tokenType: TokenType) -> Unit,
onConfirmTransaction: (includeServerBackup: Boolean) -> Unit, // 新增参数:是否包含服务器备份参与签名
onConfirmTransaction: () -> Unit,
onCopyInviteCode: () -> Unit,
onBroadcastTransaction: () -> Unit,
onCancel: () -> Unit,
@ -159,8 +156,10 @@ fun TransferScreen(
rpcUrl = rpcUrl,
onSubmit = {
// Get current balance for the selected token type
val currentBalance = walletBalance?.getBalance(selectedTokenType)
?: if (selectedTokenType == TokenType.KAVA) balance else null
val currentBalance = when (selectedTokenType) {
TokenType.KAVA -> walletBalance?.kavaBalance ?: balance
TokenType.GREEN_POINTS -> walletBalance?.greenPointsBalance
}
when {
toAddress.isBlank() -> validationError = "请输入收款地址"
!toAddress.startsWith("0x") || toAddress.length != 42 -> validationError = "地址格式不正确"
@ -196,9 +195,9 @@ fun TransferScreen(
toAddress = toAddress,
amount = amount,
error = error,
onConfirm = { includeServerBackup ->
onConfirm = {
validationError = null
onConfirmTransaction(includeServerBackup) // 传递服务器备份选项
onConfirmTransaction()
},
onBack = onCancel
)
@ -258,9 +257,14 @@ private fun TransferInputScreen(
var isCalculatingMax by remember { mutableStateOf(false) }
// Get current balance for the selected token type
val currentBalance = walletBalance?.getBalance(selectedTokenType)
?: if (selectedTokenType == TokenType.KAVA) balance else null
val tokenSymbol = TokenConfig.getName(selectedTokenType)
val currentBalance = when (selectedTokenType) {
TokenType.KAVA -> walletBalance?.kavaBalance ?: balance
TokenType.GREEN_POINTS -> walletBalance?.greenPointsBalance
}
val tokenSymbol = when (selectedTokenType) {
TokenType.KAVA -> "KAVA"
TokenType.GREEN_POINTS -> GreenPointsToken.NAME
}
Column(
modifier = Modifier
@ -289,74 +293,38 @@ private fun TransferInputScreen(
)
Spacer(modifier = Modifier.height(8.dp))
// Show all token balances in a 2x2 grid
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// KAVA balance
Column {
Text(
text = "KAVA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = walletBalance?.kavaBalance ?: balance ?: "加载中...",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
// Green Points balance (绿积分)
Column(horizontalAlignment = Alignment.End) {
Text(
text = GreenPointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = walletBalance?.greenPointsBalance ?: "加载中...",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = Color(0xFF4CAF50)
)
}
// Show both balances
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// KAVA balance
Column {
Text(
text = "KAVA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = walletBalance?.kavaBalance ?: balance ?: "加载中...",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// Energy Points balance (积分股)
Column {
Text(
text = EnergyPointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = walletBalance?.energyPointsBalance ?: "加载中...",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = Color(0xFF2196F3) // Blue
)
}
// Future Points balance (积分值)
Column(horizontalAlignment = Alignment.End) {
Text(
text = FuturePointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = walletBalance?.futurePointsBalance ?: "加载中...",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = Color(0xFF9C27B0) // Purple
)
}
// Green Points balance
Column(horizontalAlignment = Alignment.End) {
Text(
text = GreenPointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = walletBalance?.greenPointsBalance ?: "加载中...",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = Color(0xFF4CAF50)
)
}
}
}
@ -371,7 +339,6 @@ private fun TransferInputScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// First row: KAVA and Green Points
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
@ -392,7 +359,7 @@ private fun TransferInputScreen(
},
modifier = Modifier.weight(1f)
)
// Green Points option (绿积分)
// Green Points option
FilterChip(
selected = selectedTokenType == TokenType.GREEN_POINTS,
onClick = { onTokenTypeChange(TokenType.GREEN_POINTS) },
@ -413,53 +380,6 @@ private fun TransferInputScreen(
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(8.dp))
// Second row: Energy Points and Future Points
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Energy Points option (积分股)
FilterChip(
selected = selectedTokenType == TokenType.ENERGY_POINTS,
onClick = { onTokenTypeChange(TokenType.ENERGY_POINTS) },
label = { Text(EnergyPointsToken.NAME) },
leadingIcon = {
if (selectedTokenType == TokenType.ENERGY_POINTS) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFF2196F3).copy(alpha = 0.2f),
selectedLabelColor = Color(0xFF2196F3)
),
modifier = Modifier.weight(1f)
)
// Future Points option (积分值)
FilterChip(
selected = selectedTokenType == TokenType.FUTURE_POINTS,
onClick = { onTokenTypeChange(TokenType.FUTURE_POINTS) },
label = { Text(FuturePointsToken.NAME) },
leadingIcon = {
if (selectedTokenType == TokenType.FUTURE_POINTS) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFF9C27B0).copy(alpha = 0.2f),
selectedLabelColor = Color(0xFF9C27B0)
),
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(16.dp))
@ -498,14 +418,9 @@ private fun TransferInputScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
leadingIcon = {
Icon(
if (selectedTokenType == TokenType.KAVA) Icons.Default.AttachMoney else Icons.Default.Stars,
if (selectedTokenType == TokenType.GREEN_POINTS) Icons.Default.Stars else Icons.Default.AttachMoney,
contentDescription = null,
tint = when (selectedTokenType) {
TokenType.KAVA -> MaterialTheme.colorScheme.onSurfaceVariant
TokenType.GREEN_POINTS -> Color(0xFF4CAF50)
TokenType.ENERGY_POINTS -> Color(0xFF2196F3)
TokenType.FUTURE_POINTS -> Color(0xFF9C27B0)
}
tint = if (selectedTokenType == TokenType.GREEN_POINTS) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingIcon = {
@ -524,7 +439,7 @@ private fun TransferInputScreen(
onAmountChange(currentBalance)
}
} else {
// For ERC-20 tokens (dUSDT, eUSDT, fUSDT), use the full balance
// For tokens, use the full balance
onAmountChange(currentBalance)
}
isCalculatingMax = false
@ -651,15 +566,12 @@ private fun TransferConfirmScreen(
toAddress: String,
amount: String,
error: String?,
onConfirm: (includeServerBackup: Boolean) -> Unit, // 新增参数:是否包含服务器备份参与签名
onConfirm: () -> Unit,
onBack: () -> Unit
) {
val gasFee = TransactionUtils.weiToKava(preparedTx.gasPrice.multiply(preparedTx.gasLimit))
val gasGwei = TransactionUtils.weiToGwei(preparedTx.gasPrice)
// 【新增】服务器备份选项状态(仅 2-of-3 时使用)
var includeServerBackup by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
@ -736,48 +648,6 @@ private fun TransferConfirmScreen(
}
}
// 【新增功能】2-of-3 服务器备份选项
// 仅在 2-of-3 配置时显示此选项
// 目的:允许用户在丢失一个设备时,使用服务器备份 + 剩余设备完成签名
// 安全限制:仅 2-of-3 配置可用其他配置3-of-5, 4-of-7 等)不显示
// 回滚方法:删除此代码块即可恢复原有行为
if (wallet.thresholdT == 2 && wallet.thresholdN == 3) {
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = includeServerBackup,
onCheckedChange = { includeServerBackup = it }
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = "包含服务器备份参与签名",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "如果您丢失了一个设备,勾选此项以使用服务器备份完成签名",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
}
// Error display
error?.let {
Spacer(modifier = Modifier.height(16.dp))
@ -819,7 +689,7 @@ private fun TransferConfirmScreen(
Text("返回")
}
Button(
onClick = { onConfirm(includeServerBackup) }, // 传递服务器备份选项
onClick = onConfirm,
modifier = Modifier.weight(1f)
) {
Icon(

View File

@ -35,8 +35,6 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import android.content.Intent
import android.net.Uri
import com.durian.tssparty.domain.model.EnergyPointsToken
import com.durian.tssparty.domain.model.FuturePointsToken
import com.durian.tssparty.domain.model.GreenPointsToken
import com.durian.tssparty.domain.model.NetworkType
import com.durian.tssparty.domain.model.ShareRecord
@ -57,7 +55,6 @@ fun WalletsScreen(
onDeleteShare: (Long) -> Unit,
onRefreshBalance: ((String) -> Unit)? = null,
onTransfer: ((shareId: Long) -> Unit)? = null,
onHistory: ((shareId: Long, address: String) -> Unit)? = null,
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
onImportBackup: (() -> Unit)? = null,
onCreateWallet: (() -> Unit)? = null
@ -158,9 +155,6 @@ fun WalletsScreen(
onTransfer = {
onTransfer?.invoke(share.id)
},
onHistory = {
onHistory?.invoke(share.id, share.address)
},
onDelete = { onDeleteShare(share.id) }
)
}
@ -229,7 +223,6 @@ private fun WalletItemCard(
walletBalance: WalletBalance? = null,
onViewDetails: () -> Unit,
onTransfer: () -> Unit,
onHistory: () -> Unit,
onDelete: () -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
@ -288,123 +281,62 @@ private fun WalletItemCard(
Spacer(modifier = Modifier.height(12.dp))
// Balance display - shows all token balances in a 2x2 grid
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// KAVA balance
Column {
Text(
text = "KAVA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
// Balance display - now shows both KAVA and Green Points
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// KAVA balance
Column {
Text(
text = "KAVA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.AccountBalance,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.AccountBalance,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = walletBalance?.kavaBalance ?: balance ?: "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = if (walletBalance != null || balance != null)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.Medium
)
}
}
// Green Points (绿积分) balance
Column(horizontalAlignment = Alignment.End) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = GreenPointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
text = walletBalance?.kavaBalance ?: balance ?: "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = if (walletBalance != null || balance != null)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.Medium
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF4CAF50)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = walletBalance?.greenPointsBalance ?: "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = if (walletBalance != null)
Color(0xFF4CAF50)
else
MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.Medium
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// Energy Points (积分股) balance
Column {
Text(
text = EnergyPointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF2196F3) // Blue
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = walletBalance?.energyPointsBalance ?: "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = if (walletBalance != null)
Color(0xFF2196F3)
else
MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.Medium
)
}
}
// Future Points (积分值) balance
Column(horizontalAlignment = Alignment.End) {
Text(
text = FuturePointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
// Green Points (绿积分) balance
Column(horizontalAlignment = Alignment.End) {
Text(
text = GreenPointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF4CAF50) // Green color for Green Points
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = walletBalance?.greenPointsBalance ?: "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = if (walletBalance != null)
Color(0xFF4CAF50)
else
MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.Medium
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF9C27B0) // Purple
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = walletBalance?.futurePointsBalance ?: "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = if (walletBalance != null)
Color(0xFF9C27B0)
else
MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.Medium
)
}
}
}
}
@ -440,16 +372,6 @@ private fun WalletItemCard(
Text("转账")
}
TextButton(onClick = onHistory) {
Icon(
Icons.Default.Receipt,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("记录")
}
TextButton(
onClick = { showDeleteDialog = true },
colors = ButtonDefaults.textButtonColors(

View File

@ -2,8 +2,8 @@ package com.durian.tssparty.presentation.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.durian.tssparty.data.repository.JoinKeygenViaGrpcResult
import com.durian.tssparty.data.repository.TssRepository
import com.durian.tssparty.data.repository.TssRepository.JoinKeygenViaGrpcResult
import com.durian.tssparty.domain.model.*
import com.durian.tssparty.util.AddressUtils
import com.durian.tssparty.util.TransactionUtils
@ -45,11 +45,6 @@ class MainViewModel @Inject constructor(
private val _hasEnteredSession = MutableStateFlow(false)
val hasEnteredSession: StateFlow<Boolean> = _hasEnteredSession.asStateFlow()
// Synchronous flag to prevent participant_joined from adding duplicates after session_started
// This is set immediately (synchronously) when session_started is processed, ensuring
// any subsequent participant_joined events in the same callback queue will see the flag
private var sessionStartedForSession: String? = null
init {
// Start initialization on app launch
checkAllServices()
@ -223,9 +218,6 @@ class MainViewModel @Inject constructor(
private val _currentRound = MutableStateFlow(0)
val currentRound: StateFlow<Int> = _currentRound.asStateFlow()
private val _totalRounds = MutableStateFlow(0)
val totalRounds: StateFlow<Int> = _totalRounds.asStateFlow()
private val _publicKey = MutableStateFlow<String?>(null)
val publicKey: StateFlow<String?> = _publicKey.asStateFlow()
@ -296,30 +288,19 @@ class MainViewModel @Inject constructor(
// Setup keygen timeout callback (matching Electron's 5-minute timeout in checkAndTriggerKeygen)
repository.setKeygenTimeoutCallback { errorMessage ->
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Keygen timeout callback invoked: $errorMessage")
try {
_uiState.update { it.copy(isLoading = false, error = errorMessage, countdownSeconds = -1L) }
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] Keygen timeout callback completed")
} catch (e: Exception) {
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Exception in keygen timeout callback: ${e.message}")
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Stack: ${e.stackTraceToString()}")
}
android.util.Log.e("MainViewModel", "Keygen timeout: $errorMessage")
_uiState.update { it.copy(isLoading = false, error = errorMessage, countdownSeconds = -1L) }
}
// Setup countdown tick callback for UI countdown display
repository.setCountdownTickCallback { remainingSeconds ->
try {
_uiState.update { it.copy(countdownSeconds = remainingSeconds) }
} catch (e: Exception) {
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Exception in countdown tick callback: ${e.message}")
}
android.util.Log.d("MainViewModel", "Countdown tick: $remainingSeconds seconds remaining")
_uiState.update { it.copy(countdownSeconds = remainingSeconds) }
}
// Setup progress callback for real-time round updates from native TSS bridge
repository.setProgressCallback { round, totalRoundsFromGo ->
android.util.Log.d("MainViewModel", "Progress update: $round / $totalRoundsFromGo")
// Update totalRounds from Go library (keygen=4, sign=9)
_totalRounds.value = totalRoundsFromGo
repository.setProgressCallback { round, totalRounds ->
android.util.Log.d("MainViewModel", "Progress update: $round / $totalRounds")
// Update the appropriate round state based on which session type is active
when {
// Initiator keygen (CreateWallet)
@ -342,32 +323,21 @@ class MainViewModel @Inject constructor(
}
repository.setSessionEventCallback { event ->
try {
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] === MainViewModel received session event ===")
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] eventType: ${event.eventType}")
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] sessionId: ${event.sessionId}")
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] _currentSessionId: ${_currentSessionId.value}")
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] pendingJoinKeygenInfo?.sessionId: ${pendingJoinKeygenInfo?.sessionId}")
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] pendingJoinSignInfo?.sessionId: ${pendingJoinSignInfo?.sessionId}")
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] _signSessionId: ${_signSessionId.value}")
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] pendingSignInitiatorInfo?.sessionId: ${pendingSignInitiatorInfo?.sessionId}")
android.util.Log.d("MainViewModel", "=== MainViewModel received session event ===")
android.util.Log.d("MainViewModel", " eventType: ${event.eventType}")
android.util.Log.d("MainViewModel", " sessionId: ${event.sessionId}")
android.util.Log.d("MainViewModel", " _currentSessionId: ${_currentSessionId.value}")
android.util.Log.d("MainViewModel", " pendingJoinKeygenInfo?.sessionId: ${pendingJoinKeygenInfo?.sessionId}")
android.util.Log.d("MainViewModel", " pendingJoinSignInfo?.sessionId: ${pendingJoinSignInfo?.sessionId}")
android.util.Log.d("MainViewModel", " _signSessionId: ${_signSessionId.value}")
android.util.Log.d("MainViewModel", " pendingSignInitiatorInfo?.sessionId: ${pendingSignInitiatorInfo?.sessionId}")
when (event.eventType) {
"session_started" -> {
// CRITICAL: Set flag immediately (synchronously) to prevent subsequent
// participant_joined events from adding duplicates. This must be the
// first line before any async operations.
sessionStartedForSession = event.sessionId
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] Session started flag set for: ${event.sessionId}")
// Check if this is for keygen initiator (CreateWallet)
val currentSessionId = _currentSessionId.value
if (currentSessionId != null && event.sessionId == currentSessionId) {
android.util.Log.d("MainViewModel", "Session started event for keygen initiator, triggering keygen")
// Ensure participant list has exactly N parties (fill if incomplete, don't add more)
if (_sessionParticipants.value.size < event.thresholdN) {
_sessionParticipants.value = (1..event.thresholdN).map { "参与方 $it" }
}
viewModelScope.launch {
startKeygenAsInitiator(
sessionId = currentSessionId,
@ -382,10 +352,6 @@ class MainViewModel @Inject constructor(
val joinKeygenInfo = pendingJoinKeygenInfo
if (joinKeygenInfo != null && event.sessionId == joinKeygenInfo.sessionId) {
android.util.Log.d("MainViewModel", "Session started event for keygen joiner, triggering keygen")
// Ensure participant list has exactly N parties
if (_joinKeygenParticipants.value.size < event.thresholdN) {
_joinKeygenParticipants.value = (1..event.thresholdN).map { "参与方 $it" }
}
startKeygenAsJoiner()
}
@ -393,10 +359,6 @@ class MainViewModel @Inject constructor(
val joinSignInfo = pendingJoinSignInfo
if (joinSignInfo != null && event.sessionId == joinSignInfo.sessionId) {
android.util.Log.d("MainViewModel", "Session started event for sign joiner, triggering sign")
// Ensure participant list has exactly T parties
if (_coSignParticipants.value.size < event.thresholdT) {
_coSignParticipants.value = (1..event.thresholdT).map { "参与方 $it" }
}
startSignAsJoiner()
}
@ -405,10 +367,6 @@ class MainViewModel @Inject constructor(
android.util.Log.d("MainViewModel", "Checking for sign initiator: signSessionId=$signSessionId, eventSessionId=${event.sessionId}")
if (signSessionId != null && event.sessionId == signSessionId) {
android.util.Log.d("MainViewModel", "Session started event for sign initiator, triggering sign")
// Ensure participant list has exactly T parties
if (_signParticipants.value.size < event.thresholdT) {
_signParticipants.value = (1..event.thresholdT).map { "参与方 $it" }
}
startSignAsInitiator(event.selectedParties)
} else {
android.util.Log.d("MainViewModel", "NOT triggering sign initiator: signSessionId=$signSessionId, pendingSignInitiatorInfo=${pendingSignInitiatorInfo?.sessionId}")
@ -417,15 +375,6 @@ class MainViewModel @Inject constructor(
"party_joined", "participant_joined" -> {
android.util.Log.d("MainViewModel", "Processing participant_joined event...")
// CRITICAL: Check synchronous flag first - if session_started was already
// processed for this session, don't add more participants
// This is 100% reliable because the flag is set synchronously in session_started
// handler before any async operations, and callbacks are processed sequentially
if (sessionStartedForSession == event.sessionId) {
android.util.Log.d("MainViewModel", " Session already started for ${event.sessionId}, ignoring participant_joined")
return@setSessionEventCallback
}
// Update participant count for initiator's CreateWallet screen
val currentSessionId = _currentSessionId.value
android.util.Log.d("MainViewModel", " Checking for initiator: currentSessionId=$currentSessionId, eventSessionId=${event.sessionId}")
@ -506,12 +455,6 @@ class MainViewModel @Inject constructor(
}
}
}
} catch (e: Exception) {
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Exception in session event callback!")
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Event: ${event.eventType}, sessionId: ${event.sessionId}")
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Exception: ${e.javaClass.simpleName}: ${e.message}")
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Stack: ${e.stackTraceToString()}")
}
}
}
@ -572,12 +515,9 @@ class MainViewModel @Inject constructor(
_currentSessionId.value = null
_sessionParticipants.value = emptyList()
_currentRound.value = 0
_totalRounds.value = 0
_publicKey.value = null
_createdInviteCode.value = null
_hasEnteredSession.value = false
// Reset synchronous flag for fresh session
sessionStartedForSession = null
// Reset session status to WAITING for fresh start
repository.resetSessionStatus()
}
@ -719,11 +659,7 @@ class MainViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
// Initialize participant list with all N parties (keygen requires all parties)
// This ensures UI shows correct participant count even if we missed some participant_joined events
_joinKeygenParticipants.value = (1..joinInfo.thresholdN).map { "参与方 $it" }
android.util.Log.d("MainViewModel", "Starting keygen as joiner: sessionId=${joinInfo.sessionId}, partyIndex=${joinInfo.partyIndex}, thresholdN=${joinInfo.thresholdN}")
android.util.Log.d("MainViewModel", "Starting keygen as joiner: sessionId=${joinInfo.sessionId}, partyIndex=${joinInfo.partyIndex}")
val result = repository.executeKeygenAsJoiner(
sessionId = joinInfo.sessionId,
@ -770,8 +706,6 @@ class MainViewModel @Inject constructor(
pendingJoinToken = ""
pendingPassword = ""
pendingJoinKeygenInfo = null
// Reset synchronous flag for fresh session
sessionStartedForSession = null
// Reset session status to WAITING for fresh start
repository.resetSessionStatus()
}
@ -957,8 +891,6 @@ class MainViewModel @Inject constructor(
pendingCoSignInviteCode = ""
pendingCoSignJoinToken = ""
pendingJoinSignInfo = null
// Reset synchronous flag for fresh session
sessionStartedForSession = null
// Reset session status to WAITING for fresh start
repository.resetSessionStatus()
}
@ -985,79 +917,6 @@ class MainViewModel @Inject constructor(
}
}
// ========== Transaction Records ==========
private val _transactionRecords = MutableStateFlow<List<com.durian.tssparty.data.local.TransactionRecordEntity>>(emptyList())
val transactionRecords: StateFlow<List<com.durian.tssparty.data.local.TransactionRecordEntity>> = _transactionRecords.asStateFlow()
private val _isSyncingHistory = MutableStateFlow(false)
val isSyncingHistory: StateFlow<Boolean> = _isSyncingHistory.asStateFlow()
private val _syncResultMessage = MutableStateFlow<String?>(null)
val syncResultMessage: StateFlow<String?> = _syncResultMessage.asStateFlow()
fun clearSyncResultMessage() {
_syncResultMessage.value = null
}
/**
* 加载钱包的交易记录
*/
fun loadTransactionRecords(shareId: Long) {
viewModelScope.launch {
repository.getTransactionRecords(shareId).collect { records ->
_transactionRecords.value = records
}
}
}
/**
* 同步钱包的所有历史交易
* 首次导入钱包时调用
*/
fun syncTransactionHistory(shareId: Long, address: String) {
viewModelScope.launch {
_isSyncingHistory.value = true
android.util.Log.d("MainViewModel", "[SYNC] Starting transaction history sync for $address")
val rpcUrl = _settings.value.kavaRpcUrl
val networkType = _settings.value.networkType
val result = repository.syncAllTransactionHistory(shareId, address, rpcUrl, networkType)
result.fold(
onSuccess = { count ->
android.util.Log.d("MainViewModel", "[SYNC] Synced $count transactions")
_syncResultMessage.value = if (count > 0) {
"同步完成,新增 $count 条记录"
} else {
"同步完成,无新记录"
}
},
onFailure = { e ->
android.util.Log.e("MainViewModel", "[SYNC] Error syncing: ${e.message}")
_syncResultMessage.value = "同步失败: ${e.message}"
}
)
_isSyncingHistory.value = false
}
}
/**
* 确认所有待处理的交易
* 应用启动时调用
*/
fun confirmPendingTransactions() {
viewModelScope.launch {
val rpcUrl = _settings.value.kavaRpcUrl
val pendingRecords = repository.getPendingTransactions()
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Found ${pendingRecords.size} pending transactions")
for (record in pendingRecords) {
repository.confirmTransaction(record.txHash, rpcUrl)
}
}
}
// ========== Share Export/Import ==========
private val _exportResult = MutableStateFlow<ExportImportResult?>(null)
@ -1072,30 +931,19 @@ class MainViewModel @Inject constructor(
* @return The backup JSON string on success
*/
fun exportShareBackup(shareId: Long, onSuccess: (String) -> Unit) {
android.util.Log.d("MainViewModel", "[EXPORT] ========== exportShareBackup called ==========")
android.util.Log.d("MainViewModel", "[EXPORT] shareId: $shareId")
viewModelScope.launch {
android.util.Log.d("MainViewModel", "[EXPORT] Setting loading state...")
_exportResult.value = ExportImportResult(isLoading = true)
android.util.Log.d("MainViewModel", "[EXPORT] Calling repository.exportShareBackup...")
val result = repository.exportShareBackup(shareId)
android.util.Log.d("MainViewModel", "[EXPORT] Repository returned, isSuccess: ${result.isSuccess}")
result.fold(
onSuccess = { json ->
android.util.Log.d("MainViewModel", "[EXPORT] Export succeeded, json length: ${json.length}")
android.util.Log.d("MainViewModel", "[EXPORT] Setting success state and calling onSuccess callback...")
_exportResult.value = ExportImportResult(isSuccess = true)
android.util.Log.d("MainViewModel", "[EXPORT] Calling onSuccess callback with json...")
onSuccess(json)
android.util.Log.d("MainViewModel", "[EXPORT] onSuccess callback completed")
},
onFailure = { e ->
android.util.Log.e("MainViewModel", "[EXPORT] Export failed: ${e.message}", e)
_exportResult.value = ExportImportResult(error = e.message ?: "导出失败")
}
)
android.util.Log.d("MainViewModel", "[EXPORT] ========== exportShareBackup finished ==========")
}
}
@ -1104,46 +952,27 @@ class MainViewModel @Inject constructor(
* @param backupJson The backup JSON string to import
*/
fun importShareBackup(backupJson: String) {
android.util.Log.d("MainViewModel", "[IMPORT] ========== importShareBackup called ==========")
android.util.Log.d("MainViewModel", "[IMPORT] JSON length: ${backupJson.length}")
android.util.Log.d("MainViewModel", "[IMPORT] JSON preview: ${backupJson.take(100)}...")
viewModelScope.launch {
android.util.Log.d("MainViewModel", "[IMPORT] Setting loading state...")
_importResult.value = ExportImportResult(isLoading = true)
android.util.Log.d("MainViewModel", "[IMPORT] Calling repository.importShareBackup...")
val result = repository.importShareBackup(backupJson)
android.util.Log.d("MainViewModel", "[IMPORT] Repository returned, isSuccess: ${result.isSuccess}")
result.fold(
onSuccess = { share ->
android.util.Log.d("MainViewModel", "[IMPORT] Import succeeded:")
android.util.Log.d("MainViewModel", "[IMPORT] - id: ${share.id}")
android.util.Log.d("MainViewModel", "[IMPORT] - address: ${share.address}")
android.util.Log.d("MainViewModel", "[IMPORT] - partyId: ${share.partyId}")
_importResult.value = ExportImportResult(
isSuccess = true,
message = "已成功导入钱包 (${share.address.take(10)}...)"
)
// Update wallet count
android.util.Log.d("MainViewModel", "[IMPORT] Updating wallet count...")
_appState.update { state ->
state.copy(walletCount = state.walletCount + 1)
}
// Fetch balance for the imported wallet
android.util.Log.d("MainViewModel", "[IMPORT] Fetching balance...")
fetchBalanceForShare(share)
// Sync transaction history from blockchain (first-time import)
android.util.Log.d("MainViewModel", "[IMPORT] Starting transaction history sync...")
syncTransactionHistory(share.id, share.address)
android.util.Log.d("MainViewModel", "[IMPORT] Import complete!")
},
onFailure = { e ->
android.util.Log.e("MainViewModel", "[IMPORT] Import failed: ${e.message}", e)
_importResult.value = ExportImportResult(error = e.message ?: "导入失败")
}
)
android.util.Log.d("MainViewModel", "[IMPORT] ========== importShareBackup finished ==========")
}
}
@ -1453,95 +1282,9 @@ class MainViewModel @Inject constructor(
}
}
// ========== 2-of-3 服务器参与选项(新增功能)==========
// 新增日期2026-01-27
// 新增原因:允许 2-of-3 用户在丢失一个设备时,通过服务器参与签名转出资产
// 影响范围:纯新增,不影响现有 initiateSignSession
// 回滚方法:删除此方法及相关 UI 代码即可恢复
/**
* 创建签名会话支持选择服务器参与
*
* 新增方法不修改现有 initiateSignSession
* 仅在 UI 层判断为 2-of-3 且用户主动勾选时调用此方法
*
* @param shareId 钱包 ID
* @param password 钱包密码
* @param initiatorName 发起者名称
* @param includeServerBackup 是否包含服务器备份参与方新增参数
*
* 使用场景
* - 2-of-3 用户丢失一个设备
* - 用户勾选"包含服务器备份"选项
* - 使用剩余设备 + 服务器完成签名
*
* 安全保障
* - UI 层限制仅 2-of-3 显示此选项
* - 用户主动明确选择
* - 服务器只有 1 key < t=2
*/
fun initiateSignSessionWithOptions(
shareId: Long,
password: String,
initiatorName: String = "发起者",
includeServerBackup: Boolean = false // 新增参数
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
val tx = _preparedTx.value
if (tx == null) {
_uiState.update { it.copy(isLoading = false, error = "交易未准备") }
return@launch
}
android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Initiating sign session with includeServerBackup=$includeServerBackup")
// 调用新的 repository 方法
val result = repository.createSignSessionWithOptions(
shareId = shareId,
messageHash = tx.signHash,
password = password,
initiatorName = initiatorName,
includeServerBackup = includeServerBackup // 传递新参数
)
result.fold(
onSuccess = { sessionResult ->
_signSessionId.value = sessionResult.sessionId
_signInviteCode.value = sessionResult.inviteCode
_signParticipants.value = listOf(initiatorName)
_uiState.update { it.copy(isLoading = false) }
pendingSignInitiatorInfo = PendingSignInitiatorInfo(
sessionId = sessionResult.sessionId,
shareId = shareId,
password = password
)
android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Sign session created with server=${includeServerBackup}, sessionId=${sessionResult.sessionId}")
if (sessionResult.sessionAlreadyInProgress) {
android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Session already in_progress, triggering sign immediately")
startSigningProcess(sessionResult.sessionId, shareId, password)
}
},
onFailure = { e ->
android.util.Log.e("MainViewModel", "[SIGN-OPTIONS] Failed to create sign session: ${e.message}")
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
// ========== 2-of-3 服务器参与选项结束 ==========
/**
* Start sign as initiator (called when session_started event is received)
* Matches Electron's handleCoSignStart for initiator
*
* CRITICAL: This method includes防重入检查 to prevent double execution
* Race condition fix: TssRepository may have already triggered signing via
* its session_started handler. This callback serves as a fallback.
*/
private fun startSignAsInitiator(selectedParties: List<String>) {
val info = pendingSignInitiatorInfo
@ -1550,13 +1293,6 @@ class MainViewModel @Inject constructor(
return
}
// CRITICAL: Prevent double execution if TssRepository already started signing
// TssRepository sets signingTriggered=true when it auto-triggers from session_started
if (repository.isSigningTriggered()) {
android.util.Log.d("MainViewModel", "[RACE-FIX] Signing already triggered by TssRepository, skipping duplicate from MainViewModel")
return
}
android.util.Log.d("MainViewModel", "Starting sign as initiator: sessionId=${info.sessionId}, selectedParties=$selectedParties")
startSigningProcess(info.sessionId, info.shareId, info.password)
}
@ -1628,30 +1364,7 @@ class MainViewModel @Inject constructor(
onSuccess = { hash ->
android.util.Log.d("MainViewModel", "[BROADCAST] SUCCESS! txHash=$hash")
_txHash.value = hash
// 保存交易记录到本地数据库
val state = _transferState.value
android.util.Log.d("MainViewModel", "[BROADCAST] Saving transaction record: shareId=${state.shareId}, tokenType=${state.tokenType}")
try {
repository.saveTransactionRecord(
shareId = state.shareId,
fromAddress = tx.from,
toAddress = tx.to,
amount = state.amount,
tokenType = state.tokenType,
txHash = hash,
gasPrice = tx.gasPrice.toString()
)
android.util.Log.d("MainViewModel", "[BROADCAST] Transaction record saved successfully")
// 启动后台确认交易状态
confirmTransactionInBackground(hash, rpcUrl)
_uiState.update { it.copy(isLoading = false, successMessage = "交易已广播!") }
} catch (e: Exception) {
android.util.Log.e("MainViewModel", "[BROADCAST] Failed to save transaction record: ${e.message}", e)
_uiState.update { it.copy(isLoading = false, error = "交易已广播但保存记录失败: ${e.message}") }
}
_uiState.update { it.copy(isLoading = false, successMessage = "交易已广播!") }
},
onFailure = { e ->
android.util.Log.e("MainViewModel", "[BROADCAST] FAILED: ${e.message}", e)
@ -1661,37 +1374,6 @@ class MainViewModel @Inject constructor(
}
}
/**
* 后台确认交易状态
* 3 秒轮询一次最多尝试 60 3 分钟
*/
private fun confirmTransactionInBackground(txHash: String, rpcUrl: String) {
viewModelScope.launch {
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Starting background confirmation for $txHash")
var attempts = 0
val maxAttempts = 60
while (attempts < maxAttempts) {
kotlinx.coroutines.delay(3000) // 等待 3 秒
attempts++
val result = repository.confirmTransaction(txHash, rpcUrl)
result.fold(
onSuccess = { confirmed ->
if (confirmed) {
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Transaction confirmed after $attempts attempts")
return@launch
}
},
onFailure = { e ->
android.util.Log.w("MainViewModel", "[TX-CONFIRM] Error checking confirmation: ${e.message}")
}
)
}
android.util.Log.w("MainViewModel", "[TX-CONFIRM] Max attempts reached, transaction may still be pending")
}
}
/**
* Reset transfer state
*/
@ -1705,8 +1387,6 @@ class MainViewModel @Inject constructor(
_signature.value = null
_txHash.value = null
pendingSignInitiatorInfo = null
// Reset synchronous flag for fresh session
sessionStartedForSession = null
// Reset session status to WAITING for fresh start
repository.resetSessionStatus()
}

View File

@ -1,10 +1,6 @@
package com.durian.tssparty.util
import com.durian.tssparty.domain.model.ERC20Selectors
import com.durian.tssparty.domain.model.EnergyPointsToken
import com.durian.tssparty.domain.model.FuturePointsToken
import com.durian.tssparty.domain.model.GreenPointsToken
import com.durian.tssparty.domain.model.TokenConfig
import com.durian.tssparty.domain.model.TokenType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -23,50 +19,13 @@ import java.util.concurrent.TimeUnit
*/
object TransactionUtils {
/**
* HTTP client for blockchain RPC calls
*
* 架构安全修复 - 配置连接池防止资源泄漏
*
* 配置连接池参数限制资源占用:
* - maxIdleConnections: 5 (最多保留 5 个空闲连接)
* - keepAliveDuration: 5 分钟 (空闲连接保活时间)
*
* 注意: TransactionUtils object 单例,生命周期与应用一致
* 如果应用需要完全清理资源,可调用 cleanup() 方法
*/
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.connectionPool(okhttp3.ConnectionPool(
maxIdleConnections = 5,
keepAliveDuration = 5,
timeUnit = TimeUnit.MINUTES
))
.build()
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
/**
* Cleanup OkHttpClient resources
*
* 架构安全修复 - 提供资源清理方法
*
* 虽然 TransactionUtils object 单例,但提供此方法允许:
* 1. 测试环境清理资源
* 2. 应用完全退出时释放资源
* 3. 内存紧张时主动清理
*/
fun cleanup() {
try {
client.connectionPool.evictAll()
client.dispatcher.executorService.shutdown()
client.cache?.close()
} catch (e: Exception) {
// 静默失败,因为这是清理操作
}
}
// Chain IDs
const val KAVA_TESTNET_CHAIN_ID = 2221
const val KAVA_MAINNET_CHAIN_ID = 2222
@ -102,7 +61,7 @@ object TransactionUtils {
/**
* Prepare a transaction for signing
* Gets nonce, gas price, estimates gas, and calculates sign hash
* Supports both native KAVA transfers and ERC-20 token transfers (绿积分/积分股/积分值)
* Supports both native KAVA transfers and ERC-20 token transfers (绿积分)
*/
suspend fun prepareTransaction(params: TransactionParams): Result<PreparedTransaction> = withContext(Dispatchers.IO) {
try {
@ -118,16 +77,13 @@ object TransactionUtils {
// Native KAVA transfer
Triple(params.to, kavaToWei(params.amount), ByteArray(0))
}
TokenType.GREEN_POINTS, TokenType.ENERGY_POINTS, TokenType.FUTURE_POINTS -> {
// ERC-20 token transfer
TokenType.GREEN_POINTS -> {
// ERC-20 token transfer (绿积分)
// To address is the contract, value is 0
// Data is transfer(recipient, amount) encoded
val contractAddress = TokenConfig.getContractAddress(params.tokenType)
?: return@withContext Result.failure(Exception("Invalid token type"))
val decimals = TokenConfig.getDecimals(params.tokenType)
val tokenAmount = tokenToRaw(params.amount, decimals)
val tokenAmount = greenPointsToRaw(params.amount)
val transferData = encodeErc20Transfer(params.to, tokenAmount)
Triple(contractAddress, BigInteger.ZERO, transferData)
Triple(GreenPointsToken.CONTRACT_ADDRESS, BigInteger.ZERO, transferData)
}
}
@ -142,7 +98,7 @@ object TransactionUtils {
// Default gas limits
when (params.tokenType) {
TokenType.KAVA -> BigInteger.valueOf(21000)
else -> BigInteger.valueOf(65000) // ERC-20 transfers need more gas
TokenType.GREEN_POINTS -> BigInteger.valueOf(65000) // ERC-20 transfers need more gas
}
}
@ -183,7 +139,7 @@ object TransactionUtils {
*/
private fun encodeErc20Transfer(to: String, amount: BigInteger): ByteArray {
// Function selector: transfer(address,uint256) = 0xa9059cbb
val selector = ERC20Selectors.TRANSFER.removePrefix("0x").hexToByteArray()
val selector = GreenPointsToken.TRANSFER_SELECTOR.removePrefix("0x").hexToByteArray()
// Encode recipient address (padded to 32 bytes)
val paddedAddress = to.removePrefix("0x").lowercase().padStart(64, '0').hexToByteArray()
@ -196,43 +152,21 @@ object TransactionUtils {
}
/**
* Convert token amount to raw units based on decimals
* @param amount Human-readable amount (e.g., "100.5")
* @param decimals Token decimals (e.g., 6 for USDT-like tokens, 18 for native)
* Convert Green Points amount to raw units (6 decimals)
*/
fun tokenToRaw(amount: String, decimals: Int): BigInteger {
fun greenPointsToRaw(amount: String): BigInteger {
val decimal = BigDecimal(amount)
val multiplier = BigDecimal.TEN.pow(decimals)
val rawDecimal = decimal.multiply(multiplier)
val rawDecimal = decimal.multiply(BigDecimal("1000000")) // 10^6
return rawDecimal.toBigInteger()
}
/**
* Convert raw units to human-readable token amount
* @param raw Raw amount in smallest units
* @param decimals Token decimals (e.g., 6 for USDT-like tokens, 18 for native)
*/
fun rawToToken(raw: BigInteger, decimals: Int): String {
val rawDecimal = BigDecimal(raw)
val divisor = BigDecimal.TEN.pow(decimals)
val displayDecimal = rawDecimal.divide(divisor, decimals, java.math.RoundingMode.DOWN)
return displayDecimal.toPlainString()
}
/**
* Convert Green Points amount to raw units (6 decimals)
* @deprecated Use tokenToRaw(amount, 6) instead
*/
fun greenPointsToRaw(amount: String): BigInteger {
return tokenToRaw(amount, GreenPointsToken.DECIMALS)
}
/**
* Convert raw units to Green Points display amount
* @deprecated Use rawToToken(raw, 6) instead
*/
fun rawToGreenPoints(raw: BigInteger): String {
return rawToToken(raw, GreenPointsToken.DECIMALS)
val rawDecimal = BigDecimal(raw)
val displayDecimal = rawDecimal.divide(BigDecimal("1000000"), 6, java.math.RoundingMode.DOWN)
return displayDecimal.toPlainString()
}
/**

View File

@ -75,20 +75,6 @@ echo [INFO] Using SDK from local.properties
type local.properties
echo.
:: Parse rebuild argument early - must happen BEFORE checking tsslib.aar
set REBUILD_REQUESTED=0
if "%1"=="rebuild" (
set REBUILD_REQUESTED=1
echo [INFO] Rebuild requested - deleting tsslib.aar to recompile Go code...
if exist "app\libs\tsslib.aar" (
del /f "app\libs\tsslib.aar"
echo [INFO] tsslib.aar deleted, will be rebuilt
) else (
echo [INFO] tsslib.aar not found, will be built fresh
)
echo.
)
:: Check and build tsslib.aar if needed
if not exist "app\libs\tsslib.aar" (
echo [INFO] tsslib.aar not found, attempting to build TSS library...
@ -197,14 +183,8 @@ set BUILD_TYPE=all
if "%1"=="debug" set BUILD_TYPE=debug
if "%1"=="release" set BUILD_TYPE=release
if "%1"=="clean" set BUILD_TYPE=clean
if "%1"=="rebuild" set BUILD_TYPE=rebuild
if "%1"=="help" goto :show_help
:: Handle rebuild - aar deletion already done above, just set build type
if "%BUILD_TYPE%"=="rebuild" (
set BUILD_TYPE=all
)
:: Show build type
echo Build type: %BUILD_TYPE%
echo.
@ -295,16 +275,14 @@ echo Options:
echo debug - Build debug APK only
echo release - Build release APK only
echo all - Build both debug and release APKs (default)
echo clean - Clean Gradle build files
echo rebuild - Delete tsslib.aar and rebuild everything (use after Go code changes)
echo clean - Clean build files
echo help - Show this help message
echo.
echo Examples:
echo build-apk.bat - Build both APKs
echo build-apk.bat debug - Build debug APK only
echo build-apk.bat release - Build release APK only
echo build-apk.bat clean - Clean Gradle project
echo build-apk.bat rebuild - Recompile Go code and build APKs
echo build-apk.bat clean - Clean project
echo.
:end

View File

@ -1,147 +0,0 @@
@echo off
chcp 65001 >nul 2>&1
setlocal enabledelayedexpansion
echo ========================================
echo Build - Install - Launch - Debug
echo ========================================
echo.
:: Check for rebuild flag
if "%1"=="rebuild" (
echo [0/5] Rebuild requested - deleting tsslib.aar to recompile Go code...
if exist "app\libs\tsslib.aar" (
del /f "app\libs\tsslib.aar"
echo [INFO] tsslib.aar deleted, will be rebuilt
) else (
echo [INFO] tsslib.aar not found, will be built fresh
)
echo.
:: Build tsslib.aar
echo [0/5] Building tsslib.aar...
:: Get GOPATH for bin directory
for /f "tokens=*" %%G in ('go env GOPATH') do set "GOPATH_DIR=%%G"
if not defined GOPATH_DIR set "GOPATH_DIR=%USERPROFILE%\go"
set "GOBIN_DIR=!GOPATH_DIR!\bin"
:: Add GOPATH/bin to PATH if not already there
echo !PATH! | findstr /i /c:"!GOBIN_DIR!" >nul 2>nul
if !errorlevel! neq 0 (
set "PATH=!PATH!;!GOBIN_DIR!"
)
pushd tsslib
"!GOBIN_DIR!\gomobile.exe" bind -target=android -androidapi 21 -o "..\app\libs\tsslib.aar" .
if !errorlevel! neq 0 (
echo [ERROR] gomobile bind failed!
popd
pause
exit /b 1
)
popd
echo [SUCCESS] tsslib.aar rebuilt!
for %%F in ("app\libs\tsslib.aar") do echo Size: %%~zF bytes
echo.
)
:: Show help
if "%1"=="help" (
echo Usage: build-install-debug.bat [option]
echo.
echo Options:
echo rebuild - Delete and rebuild tsslib.aar before building APK
echo help - Show this help message
echo.
echo Examples:
echo build-install-debug.bat - Build and install debug APK
echo build-install-debug.bat rebuild - Rebuild Go code, then build and install
echo.
pause
exit /b 0
)
:: Step 1: Build Debug APK
echo [1/5] Building Debug APK...
call gradlew.bat assembleDebug --no-daemon
if %errorlevel% neq 0 (
echo [ERROR] Build failed!
pause
exit /b 1
)
echo [SUCCESS] Build completed!
echo.
:: Step 2: Check device connection
echo [2/5] Checking device connection...
adb devices
adb devices | find "device" | find /v "List" >nul
if %errorlevel% neq 0 (
echo [ERROR] No device detected! Please connect your phone and enable USB debugging.
pause
exit /b 1
)
echo [SUCCESS] Device connected!
echo.
:: Step 3: Uninstall old version (to avoid signature conflicts)
echo [3/5] Uninstalling old version (if exists)...
adb uninstall com.durian.tssparty 2>nul
echo Done!
echo.
:: Step 4: Install APK
echo [4/5] Installing APK...
adb install app\build\outputs\apk\debug\app-debug.apk
if %errorlevel% neq 0 (
echo [ERROR] Installation failed!
pause
exit /b 1
)
echo [SUCCESS] Installation completed!
echo.
:: Step 5: Launch app
echo [5/5] Launching app...
adb shell am start -n com.durian.tssparty/.MainActivity
if %errorlevel% neq 0 (
echo [ERROR] Launch failed!
pause
exit /b 1
)
echo [SUCCESS] App launched!
echo.
:: Clear old logs
echo Clearing old logs...
adb logcat -c
echo.
:: Show instructions
echo ========================================
echo App successfully launched!
echo ========================================
echo.
echo Starting log monitoring...
echo.
echo Key log tags:
echo - MainViewModel (ViewModel layer)
echo - TssRepository (Repository layer)
echo - GrpcClient (Network communication)
echo - TssNativeBridge (TSS native library)
echo - AndroidRuntime (Crash logs)
echo.
echo Press Ctrl+C to stop log monitoring
echo.
timeout /t 2 /nobreak >nul
:: Start monitoring logs
adb logcat -v time MainViewModel:D TssRepository:D GrpcClient:D TssNativeBridge:D AndroidRuntime:E *:S
:: If user stops log monitoring
echo.
echo Log monitoring stopped.
echo.
pause

View File

@ -393,17 +393,6 @@ func SendIncomingMessage(fromPartyIndex int, isBroadcast bool, payloadBase64 str
return fmt.Errorf("failed to parse message: %w", err)
}
// Extract round from incoming message and update progress
// This ensures progress updates on both sending and receiving messages
totalRounds := 4 // GG20 keygen has 4 rounds
if !session.isKeygen {
totalRounds = 9 // GG20 signing has 9 rounds
}
currentRound := extractRoundFromMessageType(parsedMsg.Type())
if currentRound > 0 {
session.callback.OnProgress(currentRound, totalRounds)
}
go func() {
_, err := session.localParty.Update(parsedMsg)
if err != nil {

View File

@ -821,21 +821,6 @@ async function handleCoSignStart(event: {
// 标记签名开始
signInProgressSessionId = event.sessionId;
// CRITICAL: Get the original partyId from keygen (stored in share) for signing
// This is essential for backup/restore - the partyId must match what was used during keygen
const share = database?.getShare(activeCoSignSession.shareId, activeCoSignSession.sharePassword);
if (!share) {
debugLog.error('main', 'Failed to get share data');
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
type: 'failed',
error: 'Failed to get share data',
});
signInProgressSessionId = null;
return;
}
const signingPartyId = share.party_id || grpcClient?.getPartyId() || '';
debugLog.info('main', `Using signingPartyId=${signingPartyId} (currentDevicePartyId=${grpcClient?.getPartyId()})`);
// 打印当前 activeCoSignSession.participants 状态
console.log('[CO-SIGN] Current activeCoSignSession.participants before update:',
activeCoSignSession.participants.map(p => ({
@ -847,9 +832,8 @@ async function handleCoSignStart(event: {
// 从 event.selectedParties 更新参与者列表
// 优先使用 activeCoSignSession.participants 中的 partyIndex来自 signingParties 或 other_parties
// CRITICAL: Use signingPartyId (original from keygen) for identification
if (event.selectedParties && event.selectedParties.length > 0) {
const myPartyId = signingPartyId;
const myPartyId = grpcClient?.getPartyId();
const updatedParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = [];
event.selectedParties.forEach((partyId) => {
@ -885,11 +869,21 @@ async function handleCoSignStart(event: {
})));
}
// Note: share already fetched above for getting signingPartyId
// 获取 share 数据
const share = database?.getShare(activeCoSignSession.shareId, activeCoSignSession.sharePassword);
if (!share) {
debugLog.error('main', 'Failed to get share data');
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
type: 'failed',
error: 'Failed to get share data',
});
signInProgressSessionId = null;
return;
}
console.log('[CO-SIGN] Calling tssHandler.participateSign with:', {
sessionId: activeCoSignSession.sessionId,
partyId: signingPartyId, // CRITICAL: Use signingPartyId (original from keygen)
partyId: grpcClient?.getPartyId(),
partyIndex: activeCoSignSession.partyIndex,
participants: activeCoSignSession.participants.map(p => ({ partyId: p.partyId.substring(0, 8), partyIndex: p.partyIndex })),
threshold: activeCoSignSession.threshold,
@ -898,10 +892,9 @@ async function handleCoSignStart(event: {
debugLog.info('tss', `Starting sign for session ${event.sessionId}...`);
try {
// CRITICAL: Use signingPartyId (original partyId from keygen) for signing
const result = await (tssHandler as TSSHandler).participateSign(
activeCoSignSession.sessionId,
signingPartyId, // CRITICAL: Use original partyId from keygen for backup/restore to work
grpcClient?.getPartyId() || '',
activeCoSignSession.partyIndex,
activeCoSignSession.participants,
activeCoSignSession.threshold,
@ -1620,9 +1613,9 @@ function setupIpcHandlers() {
initiatorName?: string;
}) => {
try {
// 获取当前 party ID (用于检查连接状态)
const currentDevicePartyId = grpcClient?.getPartyId();
if (!currentDevicePartyId) {
// 获取当前 party ID
const partyId = grpcClient?.getPartyId();
if (!partyId) {
return { success: false, error: '请先连接到消息路由器' };
}
@ -1632,11 +1625,6 @@ function setupIpcHandlers() {
return { success: false, error: 'Share 不存在或密码错误' };
}
// CRITICAL: Use the original partyId from keygen (stored in share) for signing
// This is essential for backup/restore - the partyId must match what was used during keygen
const partyId = share.party_id || currentDevicePartyId;
debugLog.info('main', `Initiator using partyId=${partyId} (currentDevicePartyId=${currentDevicePartyId})`);
// 从后端获取 keygen 会话的参与者信息(包含正确的 party_index
const keygenStatus = await accountClient?.getSessionStatus(share.session_id);
if (!keygenStatus?.participants || keygenStatus.participants.length === 0) {
@ -1822,8 +1810,8 @@ function setupIpcHandlers() {
parties?: Array<{ party_id: string; party_index: number }>;
}) => {
try {
const currentDevicePartyId = grpcClient?.getPartyId();
if (!currentDevicePartyId) {
const partyId = grpcClient?.getPartyId();
if (!partyId) {
return { success: false, error: '请先连接到消息路由器' };
}
@ -1833,12 +1821,9 @@ function setupIpcHandlers() {
return { success: false, error: 'Share 不存在或密码错误' };
}
// CRITICAL: Use the original partyId from keygen (stored in share) for signing
// This is essential for backup/restore - the partyId must match what was used during keygen
const signingPartyId = share.party_id || currentDevicePartyId;
debugLog.info('grpc', `Joining co-sign session: sessionId=${params.sessionId}, signingPartyId=${signingPartyId} (currentDevicePartyId=${currentDevicePartyId})`);
debugLog.info('grpc', `Joining co-sign session: sessionId=${params.sessionId}, partyId=${partyId}`);
const result = await grpcClient?.joinSession(params.sessionId, signingPartyId, params.joinToken);
const result = await grpcClient?.joinSession(params.sessionId, partyId, params.joinToken);
if (result?.success) {
// 设置活跃的 Co-Sign 会话
// 优先使用 params.parties来自 validateInviteCode包含所有预期参与者
@ -1847,11 +1832,10 @@ function setupIpcHandlers() {
if (params.parties && params.parties.length > 0) {
// 使用完整的 parties 列表
// CRITICAL: Use signingPartyId (original from keygen) for identification
participants = params.parties.map(p => ({
partyId: p.party_id,
partyIndex: p.party_index,
name: p.party_id === signingPartyId ? '我' : `参与方 ${p.party_index + 1}`,
name: p.party_id === partyId ? '我' : `参与方 ${p.party_index + 1}`,
}));
console.log('[CO-SIGN] Participant using params.parties (complete list):', participants.map(p => ({
partyId: p.partyId.substring(0, 8),
@ -1866,9 +1850,9 @@ function setupIpcHandlers() {
name: `参与方 ${idx + 1}`,
})) || [];
// 添加自己 - CRITICAL: Use signingPartyId (original from keygen)
// 添加自己
participants.push({
partyId: signingPartyId,
partyId: partyId,
partyIndex: result.party_index,
name: '我',
});
@ -1902,11 +1886,11 @@ function setupIpcHandlers() {
messageHash: params.messageHash,
});
// 预订阅消息流 - CRITICAL: Use signingPartyId (original from keygen)
// 预订阅消息流
if (tssHandler && 'prepareForSign' in tssHandler) {
try {
debugLog.info('tss', `Preparing for sign: subscribing to messages for session ${params.sessionId}, signingPartyId=${signingPartyId}`);
(tssHandler as TSSHandler).prepareForSign(params.sessionId, signingPartyId);
debugLog.info('tss', `Preparing for sign: subscribing to messages for session ${params.sessionId}`);
(tssHandler as TSSHandler).prepareForSign(params.sessionId, partyId);
} catch (prepareErr) {
debugLog.error('tss', `Failed to prepare for sign: ${(prepareErr as Error).message}`);
return { success: false, error: `消息订阅失败: ${(prepareErr as Error).message}` };

View File

@ -11,12 +11,7 @@ import {
getCurrentRpcUrl,
getGasPrice,
fetchGreenPointsBalance,
fetchEnergyPointsBalance,
fetchFuturePointsBalance,
GREEN_POINTS_TOKEN,
ENERGY_POINTS_TOKEN,
FUTURE_POINTS_TOKEN,
TOKEN_CONFIG,
type PreparedTransaction,
type TokenType,
} from '../utils/transaction';
@ -37,8 +32,6 @@ interface ShareWithAddress extends ShareItem {
evmAddress?: string;
kavaBalance?: string;
greenPointsBalance?: string;
energyPointsBalance?: string;
futurePointsBalance?: string;
balanceLoading?: boolean;
}
@ -96,30 +89,15 @@ export default function Home() {
const [isCalculatingMax, setIsCalculatingMax] = useState(false);
const [copySuccess, setCopySuccess] = useState(false);
// 获取当前选择代币的余额
const getTokenBalance = (share: ShareWithAddress | null, tokenType: TokenType): string => {
if (!share) return '0';
switch (tokenType) {
case 'KAVA':
return share.kavaBalance || '0';
case 'GREEN_POINTS':
return share.greenPointsBalance || '0';
case 'ENERGY_POINTS':
return share.energyPointsBalance || '0';
case 'FUTURE_POINTS':
return share.futurePointsBalance || '0';
}
};
// 计算扣除 Gas 费后的最大可转账金额
const calculateMaxAmount = async () => {
if (!transferShare?.evmAddress) return;
setIsCalculatingMax(true);
try {
if (TOKEN_CONFIG.isERC20(transferTokenType)) {
// For ERC-20 token transfers, use the full token balance (gas is paid in KAVA)
const balance = getTokenBalance(transferShare, transferTokenType);
if (transferTokenType === 'GREEN_POINTS') {
// For token transfers, use the full token balance (gas is paid in KAVA)
const balance = transferShare.greenPointsBalance || '0';
setTransferAmount(balance);
setTransferError(null);
} else {
@ -153,8 +131,8 @@ export default function Home() {
}
} catch (error) {
console.error('Failed to calculate max amount:', error);
if (TOKEN_CONFIG.isERC20(transferTokenType)) {
setTransferAmount(getTokenBalance(transferShare, transferTokenType));
if (transferTokenType === 'GREEN_POINTS') {
setTransferAmount(transferShare.greenPointsBalance || '0');
} else {
// 如果获取 Gas 失败,使用默认估算 (1 gwei * 21000)
const defaultGasFee = 0.000021; // ~21000 * 1 gwei
@ -187,14 +165,12 @@ export default function Home() {
const updatedShares = await Promise.all(
sharesWithAddrs.map(async (share) => {
if (share.evmAddress) {
// Fetch all balances in parallel
const [kavaBalance, greenPointsBalance, energyPointsBalance, futurePointsBalance] = await Promise.all([
// Fetch both balances in parallel
const [kavaBalance, greenPointsBalance] = await Promise.all([
fetchKavaBalance(share.evmAddress),
fetchGreenPointsBalance(share.evmAddress),
fetchEnergyPointsBalance(share.evmAddress),
fetchFuturePointsBalance(share.evmAddress),
]);
return { ...share, kavaBalance, greenPointsBalance, energyPointsBalance, futurePointsBalance, balanceLoading: false };
return { ...share, kavaBalance, greenPointsBalance, balanceLoading: false };
}
return { ...share, balanceLoading: false };
})
@ -339,7 +315,11 @@ export default function Home() {
return '转账金额无效';
}
const amount = parseFloat(transferAmount);
const balance = parseFloat(getTokenBalance(transferShare, transferTokenType));
const balance = parseFloat(
transferTokenType === 'GREEN_POINTS'
? (transferShare?.greenPointsBalance || '0')
: (transferShare?.kavaBalance || '0')
);
if (amount > balance) {
return '余额不足';
}
@ -506,7 +486,7 @@ export default function Home() {
</div>
)}
{/* 余额显示 - 所有代币 */}
{/* 余额显示 - KAVA 和 绿积分 */}
{share.evmAddress && (
<div className={styles.balanceSection}>
<div className={styles.balanceRow}>
@ -529,26 +509,6 @@ export default function Home() {
)}
</span>
</div>
<div className={styles.balanceRow}>
<span className={styles.balanceLabel} style={{ color: '#2196F3' }}>{ENERGY_POINTS_TOKEN.name}</span>
<span className={styles.balanceValue} style={{ color: '#2196F3' }}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.energyPointsBalance || '0'}</>
)}
</span>
</div>
<div className={styles.balanceRow}>
<span className={styles.balanceLabel} style={{ color: '#9C27B0' }}>{FUTURE_POINTS_TOKEN.name}</span>
<span className={styles.balanceValue} style={{ color: '#9C27B0' }}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.futurePointsBalance || '0'}</>
)}
</span>
</div>
</div>
)}
@ -618,10 +578,7 @@ export default function Home() {
<div className={styles.transferWalletInfo}>
<div className={styles.transferWalletName}>{transferShare.walletName}</div>
<div className={styles.transferWalletBalance}>
KAVA: {transferShare.kavaBalance || '0'} | <span style={{color: '#4CAF50'}}>{GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'}</span>
</div>
<div className={styles.transferWalletBalance}>
<span style={{color: '#2196F3'}}>{ENERGY_POINTS_TOKEN.name}: {transferShare.energyPointsBalance || '0'}</span> | <span style={{color: '#9C27B0'}}>{FUTURE_POINTS_TOKEN.name}: {transferShare.futurePointsBalance || '0'}</span>
KAVA: {transferShare.kavaBalance || '0'} | {GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'}
</div>
<div className={styles.transferNetwork}>
网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'}
@ -648,22 +605,6 @@ export default function Home() {
{GREEN_POINTS_TOKEN.name}
</button>
</div>
<div className={styles.tokenTypeSelector} style={{ marginTop: '8px' }}>
<button
className={`${styles.tokenTypeButton} ${transferTokenType === 'ENERGY_POINTS' ? styles.tokenTypeActive : ''}`}
onClick={() => { setTransferTokenType('ENERGY_POINTS'); setTransferAmount(''); }}
style={transferTokenType === 'ENERGY_POINTS' ? { backgroundColor: '#2196F3', borderColor: '#2196F3' } : {}}
>
{ENERGY_POINTS_TOKEN.name}
</button>
<button
className={`${styles.tokenTypeButton} ${transferTokenType === 'FUTURE_POINTS' ? styles.tokenTypeActive : ''}`}
onClick={() => { setTransferTokenType('FUTURE_POINTS'); setTransferAmount(''); }}
style={transferTokenType === 'FUTURE_POINTS' ? { backgroundColor: '#9C27B0', borderColor: '#9C27B0' } : {}}
>
{FUTURE_POINTS_TOKEN.name}
</button>
</div>
</div>
{/* 收款地址 */}
@ -681,7 +622,7 @@ export default function Home() {
{/* 转账金额 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}>
({TOKEN_CONFIG.getName(transferTokenType)})
({transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'})
</label>
<div className={styles.transferAmountWrapper}>
<input
@ -748,8 +689,8 @@ export default function Home() {
<div className={styles.confirmDetails}>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue} style={TOKEN_CONFIG.isERC20(transferTokenType) ? { color: transferTokenType === 'GREEN_POINTS' ? '#4CAF50' : transferTokenType === 'ENERGY_POINTS' ? '#2196F3' : '#9C27B0' } : {}}>
{TOKEN_CONFIG.getName(transferTokenType)}
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}>
{transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}
</span>
</div>
<div className={styles.confirmRow}>
@ -758,8 +699,8 @@ export default function Home() {
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue} style={TOKEN_CONFIG.isERC20(transferTokenType) ? { color: transferTokenType === 'GREEN_POINTS' ? '#4CAF50' : transferTokenType === 'ENERGY_POINTS' ? '#2196F3' : '#9C27B0' } : {}}>
{transferAmount} {TOKEN_CONFIG.getName(transferTokenType)}
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}>
{transferAmount} {transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}
</span>
</div>
<div className={styles.confirmRow}>

View File

@ -17,97 +17,17 @@ export const KAVA_RPC_URL = {
};
// Token types
export type TokenType = 'KAVA' | 'GREEN_POINTS' | 'ENERGY_POINTS' | 'FUTURE_POINTS';
export type TokenType = 'KAVA' | 'GREEN_POINTS';
// ERC-20 通用函数选择器
export const ERC20_SELECTORS = {
balanceOf: '0x70a08231', // balanceOf(address)
transfer: '0xa9059cbb', // transfer(address,uint256)
approve: '0x095ea7b3', // approve(address,uint256)
allowance: '0xdd62ed3e', // allowance(address,address)
totalSupply: '0x18160ddd', // totalSupply()
};
// Green Points (绿积分) Token Configuration - dUSDT
// Green Points (绿积分) Token Configuration
export const GREEN_POINTS_TOKEN = {
contractAddress: '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3',
name: '绿积分',
symbol: 'dUSDT',
decimals: 6,
// ERC-20 function selectors (kept for backward compatibility)
balanceOfSelector: ERC20_SELECTORS.balanceOf,
transferSelector: ERC20_SELECTORS.transfer,
};
// Energy Points (积分股) Token Configuration - eUSDT
export const ENERGY_POINTS_TOKEN = {
contractAddress: '0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931',
name: '积分股',
symbol: 'eUSDT',
decimals: 6,
};
// Future Points (积分值) Token Configuration - fUSDT
export const FUTURE_POINTS_TOKEN = {
contractAddress: '0x14dc4f7d3E4197438d058C3D156dd9826A161134',
name: '积分值',
symbol: 'fUSDT',
decimals: 6,
};
// Token configuration utility
export const TOKEN_CONFIG = {
getContractAddress: (tokenType: TokenType): string | null => {
switch (tokenType) {
case 'KAVA':
return null; // Native token has no contract
case 'GREEN_POINTS':
return GREEN_POINTS_TOKEN.contractAddress;
case 'ENERGY_POINTS':
return ENERGY_POINTS_TOKEN.contractAddress;
case 'FUTURE_POINTS':
return FUTURE_POINTS_TOKEN.contractAddress;
}
},
getDecimals: (tokenType: TokenType): number => {
switch (tokenType) {
case 'KAVA':
return 18;
case 'GREEN_POINTS':
return GREEN_POINTS_TOKEN.decimals;
case 'ENERGY_POINTS':
return ENERGY_POINTS_TOKEN.decimals;
case 'FUTURE_POINTS':
return FUTURE_POINTS_TOKEN.decimals;
}
},
getName: (tokenType: TokenType): string => {
switch (tokenType) {
case 'KAVA':
return 'KAVA';
case 'GREEN_POINTS':
return GREEN_POINTS_TOKEN.name;
case 'ENERGY_POINTS':
return ENERGY_POINTS_TOKEN.name;
case 'FUTURE_POINTS':
return FUTURE_POINTS_TOKEN.name;
}
},
getSymbol: (tokenType: TokenType): string => {
switch (tokenType) {
case 'KAVA':
return 'KAVA';
case 'GREEN_POINTS':
return GREEN_POINTS_TOKEN.symbol;
case 'ENERGY_POINTS':
return ENERGY_POINTS_TOKEN.symbol;
case 'FUTURE_POINTS':
return FUTURE_POINTS_TOKEN.symbol;
}
},
isERC20: (tokenType: TokenType): boolean => {
return tokenType !== 'KAVA';
},
// ERC-20 function selectors
balanceOfSelector: '0x70a08231',
transferSelector: '0xa9059cbb',
};
// 当前网络配置 (从 localStorage 读取或使用默认值)
@ -407,69 +327,44 @@ export function weiToKava(wei: bigint): string {
}
/**
*
* @param amount Human-readable amount
* @param decimals Token decimals (default 6 for USDT-like tokens)
* 绿 (6 decimals)
*/
export function tokenToRaw(amount: string, decimals: number = 6): bigint {
export function greenPointsToRaw(amount: string): bigint {
const parts = amount.split('.');
const whole = BigInt(parts[0] || '0');
let fraction = parts[1] || '';
// 补齐或截断到指定位数
if (fraction.length > decimals) {
fraction = fraction.substring(0, decimals);
// 补齐或截断到 6 位
if (fraction.length > 6) {
fraction = fraction.substring(0, 6);
} else {
fraction = fraction.padEnd(decimals, '0');
fraction = fraction.padEnd(6, '0');
}
return whole * BigInt(10 ** decimals) + BigInt(fraction);
}
/**
*
* @param raw Raw amount in smallest units
* @param decimals Token decimals (default 6 for USDT-like tokens)
*/
export function rawToToken(raw: bigint, decimals: number = 6): string {
const rawStr = raw.toString().padStart(decimals + 1, '0');
const whole = rawStr.slice(0, -decimals) || '0';
const fraction = rawStr.slice(-decimals).replace(/0+$/, '');
return fraction ? `${whole}.${fraction}` : whole;
}
/**
* 绿 (6 decimals)
* @deprecated Use tokenToRaw(amount, 6) instead
*/
export function greenPointsToRaw(amount: string): bigint {
return tokenToRaw(amount, GREEN_POINTS_TOKEN.decimals);
return whole * BigInt(10 ** 6) + BigInt(fraction);
}
/**
* 绿
* @deprecated Use rawToToken(raw, 6) instead
*/
export function rawToGreenPoints(raw: bigint): string {
return rawToToken(raw, GREEN_POINTS_TOKEN.decimals);
const rawStr = raw.toString().padStart(7, '0');
const whole = rawStr.slice(0, -6) || '0';
const fraction = rawStr.slice(-6).replace(/0+$/, '');
return fraction ? `${whole}.${fraction}` : whole;
}
/**
* ERC-20
* @param address Wallet address
* @param contractAddress Token contract address
* @param decimals Token decimals
* 绿 (ERC-20)
*/
export async function fetchERC20Balance(
address: string,
contractAddress: string,
decimals: number = 6
): Promise<string> {
export async function fetchGreenPointsBalance(address: string): Promise<string> {
try {
const rpcUrl = getCurrentRpcUrl();
// Encode balanceOf(address) call data
// Function selector: 0x70a08231
// Address parameter: padded to 32 bytes
const paddedAddress = address.toLowerCase().replace('0x', '').padStart(64, '0');
const callData = ERC20_SELECTORS.balanceOf + paddedAddress;
const callData = GREEN_POINTS_TOKEN.balanceOfSelector + paddedAddress;
const response = await fetch(rpcUrl, {
method: 'POST',
@ -479,7 +374,7 @@ export async function fetchERC20Balance(
method: 'eth_call',
params: [
{
to: contractAddress,
to: GREEN_POINTS_TOKEN.contractAddress,
data: callData,
},
'latest',
@ -491,65 +386,21 @@ export async function fetchERC20Balance(
const data = await response.json();
if (data.result && data.result !== '0x') {
const balanceRaw = BigInt(data.result);
return rawToToken(balanceRaw, decimals);
return rawToGreenPoints(balanceRaw);
}
return '0';
} catch (error) {
console.error('Failed to fetch ERC20 balance:', error);
console.error('Failed to fetch Green Points balance:', error);
return '0';
}
}
/**
* 绿 (ERC-20)
*/
export async function fetchGreenPointsBalance(address: string): Promise<string> {
return fetchERC20Balance(address, GREEN_POINTS_TOKEN.contractAddress, GREEN_POINTS_TOKEN.decimals);
}
/**
* (eUSDT)
*/
export async function fetchEnergyPointsBalance(address: string): Promise<string> {
return fetchERC20Balance(address, ENERGY_POINTS_TOKEN.contractAddress, ENERGY_POINTS_TOKEN.decimals);
}
/**
* (fUSDT)
*/
export async function fetchFuturePointsBalance(address: string): Promise<string> {
return fetchERC20Balance(address, FUTURE_POINTS_TOKEN.contractAddress, FUTURE_POINTS_TOKEN.decimals);
}
/**
*
*/
export async function fetchAllTokenBalances(address: string): Promise<{
kava: string;
greenPoints: string;
energyPoints: string;
futurePoints: string;
}> {
const [greenPoints, energyPoints, futurePoints] = await Promise.all([
fetchGreenPointsBalance(address),
fetchEnergyPointsBalance(address),
fetchFuturePointsBalance(address),
]);
// Note: KAVA balance is fetched separately via eth_getBalance
return {
kava: '0', // Caller should fetch KAVA balance separately
greenPoints,
energyPoints,
futurePoints,
};
}
/**
* Encode ERC-20 transfer function call
*/
function encodeErc20Transfer(to: string, amount: bigint): string {
// Function selector: transfer(address,uint256) = 0xa9059cbb
const selector = ERC20_SELECTORS.transfer;
const selector = GREEN_POINTS_TOKEN.transferSelector;
// Encode recipient address (padded to 32 bytes)
const paddedAddress = to.toLowerCase().replace('0x', '').padStart(64, '0');
// Encode amount (padded to 32 bytes)
@ -625,15 +476,13 @@ export async function estimateGas(params: { from: string; to: string; value: str
// For token transfers, we need different params
let txParams: { from: string; to: string; value: string; data?: string };
if (TOKEN_CONFIG.isERC20(tokenType)) {
if (tokenType === 'GREEN_POINTS') {
// ERC-20 transfer: to is contract, value is 0, data is transfer call
const contractAddress = TOKEN_CONFIG.getContractAddress(tokenType);
const decimals = TOKEN_CONFIG.getDecimals(tokenType);
const tokenAmount = tokenToRaw(params.value, decimals);
const tokenAmount = greenPointsToRaw(params.value);
const transferData = encodeErc20Transfer(params.to, tokenAmount);
txParams = {
from: params.from,
to: contractAddress!,
to: GREEN_POINTS_TOKEN.contractAddress,
value: '0x0',
data: transferData,
};
@ -662,7 +511,7 @@ export async function estimateGas(params: { from: string; to: string; value: str
if (data.error) {
// 如果估算失败,使用默认值
console.warn('Gas 估算失败,使用默认值:', data.error);
return TOKEN_CONFIG.isERC20(tokenType) ? BigInt(65000) : BigInt(21000);
return tokenType === 'GREEN_POINTS' ? BigInt(65000) : BigInt(21000);
}
return BigInt(data.result);
}
@ -694,14 +543,12 @@ export async function prepareTransaction(params: TransactionParams): Promise<Pre
let value: bigint;
let data: string;
if (TOKEN_CONFIG.isERC20(tokenType)) {
if (tokenType === 'GREEN_POINTS') {
// ERC-20 token transfer
// To address is the contract, value is 0
// Data is transfer(recipient, amount) encoded
const contractAddress = TOKEN_CONFIG.getContractAddress(tokenType);
const decimals = TOKEN_CONFIG.getDecimals(tokenType);
const tokenAmount = tokenToRaw(params.value, decimals);
toAddress = contractAddress!.toLowerCase();
const tokenAmount = greenPointsToRaw(params.value);
toAddress = GREEN_POINTS_TOKEN.contractAddress.toLowerCase();
value = BigInt(0);
data = encodeErc20Transfer(params.to, tokenAmount);
} else {

View File

@ -149,8 +149,6 @@ func (c *MessageRouterClient) PublishSessionCreated(
}
// PublishSessionStarted publishes a session_started event when all parties have joined
// CRITICAL: participants contains the complete list of all parties with their indices
// Receivers should use this list for TSS protocol instead of JoinSession response
func (c *MessageRouterClient) PublishSessionStarted(
ctx context.Context,
sessionID string,
@ -159,17 +157,7 @@ func (c *MessageRouterClient) PublishSessionStarted(
selectedParties []string,
joinTokens map[string]string,
startedAt int64,
participants []use_cases.SessionParticipantInfo,
) error {
// Convert participants to proto format
protoParticipants := make([]*router.PartyInfo, len(participants))
for i, p := range participants {
protoParticipants[i] = &router.PartyInfo{
PartyId: p.PartyID,
PartyIndex: p.PartyIndex,
}
}
event := &router.SessionEvent{
EventId: uuid.New().String(),
EventType: "session_started",
@ -179,13 +167,8 @@ func (c *MessageRouterClient) PublishSessionStarted(
SelectedParties: selectedParties,
JoinTokens: joinTokens,
CreatedAt: startedAt,
Participants: protoParticipants,
}
logger.Info("Publishing session_started event with participants",
zap.String("session_id", sessionID),
zap.Int("participant_count", len(participants)))
return c.PublishSessionEvent(ctx, event)
}

View File

@ -21,16 +21,8 @@ import (
// Maximum retries for optimistic lock conflicts during join session
const joinSessionMaxRetries = 3
// SessionParticipantInfo contains party ID and index for session_started event
type SessionParticipantInfo struct {
PartyID string
PartyIndex int32
}
// JoinSessionMessageRouterClient defines the interface for publishing session events via gRPC
type JoinSessionMessageRouterClient interface {
// PublishSessionStarted publishes session_started event with complete participants list
// CRITICAL: participants contains all parties with their indices for TSS protocol
PublishSessionStarted(
ctx context.Context,
sessionID string,
@ -39,7 +31,6 @@ type JoinSessionMessageRouterClient interface {
selectedParties []string,
joinTokens map[string]string,
startedAt int64,
participants []SessionParticipantInfo,
) error
// PublishParticipantJoined broadcasts a participant_joined event to all parties in the session
@ -257,16 +248,6 @@ func (uc *JoinSessionUseCase) executeWithRetry(
// Build join tokens map (empty for session_started, parties already have tokens)
joinTokens := make(map[string]string)
// CRITICAL: Build complete participants list with party indices
// This ensures all parties have the same participant list for TSS protocol
participants := make([]SessionParticipantInfo, len(session.Participants))
for i, p := range session.Participants {
participants[i] = SessionParticipantInfo{
PartyID: p.PartyID.String(),
PartyIndex: int32(p.PartyIndex),
}
}
if err := uc.messageRouterClient.PublishSessionStarted(
ctx,
session.ID.String(),
@ -275,7 +256,6 @@ func (uc *JoinSessionUseCase) executeWithRetry(
selectedParties,
joinTokens,
startedAt,
participants,
); err != nil {
logger.Error("failed to publish session started event to message router",
zap.String("session_id", session.ID.String()),
@ -283,8 +263,7 @@ func (uc *JoinSessionUseCase) executeWithRetry(
} else {
logger.Info("published session started event to message router",
zap.String("session_id", session.ID.String()),
zap.Int("party_count", len(selectedParties)),
zap.Int("participant_count", len(participants)))
zap.Int("party_count", len(selectedParties)))
}
}
}

View File

@ -21,8 +21,6 @@
"@prisma/client": "^5.7.0",
"@types/multer": "^2.0.0",
"adbkit-apkreader": "^3.2.0",
"archiver": "^6.0.1",
"axios": "^1.6.2",
"bplist-parser": "^0.3.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@ -38,7 +36,6 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/archiver": "^6.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
@ -2287,16 +2284,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/archiver": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz",
"integrity": "sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/readdir-glob": "*"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -2575,16 +2562,6 @@
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"license": "MIT"
},
"node_modules/@types/readdir-glob": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
"integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
@ -3307,73 +3284,6 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/archiver": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-6.0.2.tgz",
"integrity": "sha512-UQ/2nW7NMl1G+1UnrLypQw1VdT9XZg/ECcKPq7l+STzStrSivFIXIp34D8M5zeNGW5NoOupdYCHv6VySCPNNlw==",
"license": "MIT",
"dependencies": {
"archiver-utils": "^4.0.1",
"async": "^3.2.4",
"buffer-crc32": "^0.2.1",
"readable-stream": "^3.6.0",
"readdir-glob": "^1.1.2",
"tar-stream": "^3.0.0",
"zip-stream": "^5.0.1"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/archiver-utils": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-4.0.1.tgz",
"integrity": "sha512-Q4Q99idbvzmgCTEAAhi32BkOyq8iVI5EwdO0PmBDSGIzzjYNdcFn7Q7k3OzbLy4kLUPXfJtG6fO2RjftXbobBg==",
"license": "MIT",
"dependencies": {
"glob": "^8.0.0",
"graceful-fs": "^4.2.0",
"lazystream": "^1.0.0",
"lodash": "^4.17.15",
"normalize-path": "^3.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/archiver-utils/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/archiver-utils/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@ -3417,43 +3327,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/b4a": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
"integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@ -3584,22 +3464,9 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/bare-events": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -3726,6 +3593,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@ -4211,6 +4079,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@ -4256,21 +4125,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/compress-commons": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz",
"integrity": "sha512-/UIcLWvwAQyVibgpQDPtfNM3SvqN7G9elAPAV7GM0L53EbNWwWiCsWtK8Fwed/APEbptPHXs5PuW+y8Bq8lFTA==",
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
"crc32-stream": "^5.0.0",
"normalize-path": "^3.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -4395,31 +4249,6 @@
}
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/crc32-stream": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-5.0.1.tgz",
"integrity": "sha512-lO1dFui+CEUh/ztYIpgpKItKW9Bb4NWakCRJrnqAbFIYD+OZAwb2VfD5T5eXMw2FNcsDHkQcNl/Wh3iVXYwU6g==",
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
"readable-stream": "^3.4.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/create-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@ -4558,6 +4387,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@ -4855,6 +4685,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -5186,15 +5017,6 @@
"node": ">=0.8.x"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@ -5349,12 +5171,6 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -5569,26 +5385,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -5663,6 +5459,7 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@ -5735,6 +5532,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
@ -6040,6 +5838,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@ -6190,6 +5989,7 @@
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
@ -7390,48 +7190,6 @@
"node": ">=6"
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"license": "MIT",
"dependencies": {
"readable-stream": "^2.0.5"
},
"engines": {
"node": ">= 0.6.3"
}
},
"node_modules/lazystream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -7946,6 +7704,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -8001,6 +7760,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@ -8520,12 +8280,6 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -8644,27 +8398,6 @@
"node": ">= 6"
}
},
"node_modules/readdir-glob": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.1.0"
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -9350,17 +9083,6 @@
"node": ">=10.0.0"
}
},
"node_modules/streamx": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
"integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -9613,17 +9335,6 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tar-stream": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/terser": {
"version": "5.44.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
@ -9797,15 +9508,6 @@
"node": "*"
}
},
"node_modules/text-decoder": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
"integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -10591,6 +10293,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/write-file-atomic": {
@ -10701,20 +10404,6 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zip-stream": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz",
"integrity": "sha512-LfOdrUvPB8ZoXtvOBz6DlNClfvi//b5d56mSWyJi7XbH/HfhOHfUhOqxhT/rUiR7yiktlunqRo+jY6y/cWC/5g==",
"license": "MIT",
"dependencies": {
"archiver-utils": "^4.0.1",
"compress-commons": "^5.0.1",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">= 12.0.0"
}
}
}
}

View File

@ -41,8 +41,6 @@
"@prisma/client": "^5.7.0",
"@types/multer": "^2.0.0",
"adbkit-apkreader": "^3.2.0",
"archiver": "^6.0.1",
"axios": "^1.6.2",
"bplist-parser": "^0.3.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
@ -58,7 +56,6 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/archiver": "^6.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",

View File

@ -1,34 +0,0 @@
-- =============================================================================
-- App Assets Migration
-- 应用资源管理 (开屏图/引导图)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. 应用资源类型枚举
-- -----------------------------------------------------------------------------
CREATE TYPE "AppAssetType" AS ENUM ('SPLASH', 'GUIDE');
-- -----------------------------------------------------------------------------
-- 2. 应用资源表
-- -----------------------------------------------------------------------------
CREATE TABLE "app_assets" (
"id" TEXT NOT NULL,
"type" "AppAssetType" NOT NULL,
"sort_order" INTEGER NOT NULL,
"image_url" TEXT NOT NULL,
"title" VARCHAR(100),
"subtitle" VARCHAR(200),
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "app_assets_pkey" PRIMARY KEY ("id")
);
-- Unique constraint: 同类型同排序位置只能有一条记录
CREATE UNIQUE INDEX "app_assets_type_sort_order_key" ON "app_assets"("type", "sort_order");
-- 按类型和启用状态查询索引
CREATE INDEX "app_assets_type_is_enabled_idx" ON "app_assets"("type", "is_enabled");

View File

@ -1,43 +0,0 @@
-- =============================================================================
-- Customer Service Contacts Migration
-- 客服联系方式管理
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. 客服联系方式类型枚举
-- -----------------------------------------------------------------------------
CREATE TYPE "ContactType" AS ENUM ('WECHAT', 'QQ');
-- -----------------------------------------------------------------------------
-- 2. 客服联系方式表
-- -----------------------------------------------------------------------------
CREATE TABLE "customer_service_contacts" (
"id" TEXT NOT NULL,
"type" "ContactType" NOT NULL,
"label" VARCHAR(100) NOT NULL,
"value" VARCHAR(200) NOT NULL,
"sort_order" INTEGER NOT NULL,
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "customer_service_contacts_pkey" PRIMARY KEY ("id")
);
-- 按类型和启用状态查询索引
CREATE INDEX "customer_service_contacts_type_is_enabled_idx" ON "customer_service_contacts"("type", "is_enabled");
-- 按排序查询索引
CREATE INDEX "customer_service_contacts_sort_order_idx" ON "customer_service_contacts"("sort_order");
-- -----------------------------------------------------------------------------
-- 3. 初始数据 (保留现有硬编码的联系方式)
-- -----------------------------------------------------------------------------
INSERT INTO "customer_service_contacts" ("id", "type", "label", "value", "sort_order", "is_enabled", "created_at", "updated_at") VALUES
(gen_random_uuid(), 'WECHAT', '客服微信1', 'liulianhuanghou1', 1, true, NOW(), NOW()),
(gen_random_uuid(), 'WECHAT', '客服微信2', 'liulianhuanghou2', 2, true, NOW(), NOW()),
(gen_random_uuid(), 'QQ', '客服QQ1', '1502109619', 3, true, NOW(), NOW()),
(gen_random_uuid(), 'QQ', '客服QQ2', '2171447109', 4, true, NOW(), NOW());

View File

@ -1,40 +0,0 @@
-- 合同批量下载任务表
-- [2026-02-05] 新增:用于记录和追踪合同批量下载任务
-- CreateEnum
CREATE TYPE "BatchDownloadStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED');
-- CreateTable
CREATE TABLE "contract_batch_download_tasks" (
"id" BIGSERIAL NOT NULL,
"task_no" VARCHAR(50) NOT NULL,
"status" "BatchDownloadStatus" NOT NULL DEFAULT 'PENDING',
"total_contracts" INTEGER NOT NULL DEFAULT 0,
"downloaded_count" INTEGER NOT NULL DEFAULT 0,
"failed_count" INTEGER NOT NULL DEFAULT 0,
"progress" INTEGER NOT NULL DEFAULT 0,
"last_processed_order_no" VARCHAR(50),
"result_file_url" VARCHAR(500),
"result_file_size" BIGINT,
"errors" JSONB,
"operator_id" VARCHAR(50) NOT NULL,
"filters" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"expires_at" TIMESTAMP(3),
CONSTRAINT "contract_batch_download_tasks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "contract_batch_download_tasks_task_no_key" ON "contract_batch_download_tasks"("task_no");
-- CreateIndex
CREATE INDEX "contract_batch_download_tasks_status_idx" ON "contract_batch_download_tasks"("status");
-- CreateIndex
CREATE INDEX "contract_batch_download_tasks_operator_id_idx" ON "contract_batch_download_tasks"("operator_id");
-- CreateIndex
CREATE INDEX "contract_batch_download_tasks_created_at_idx" ON "contract_batch_download_tasks"("created_at");

View File

@ -1150,111 +1150,3 @@ model CoManagedWallet {
@@index([createdAt])
@@map("co_managed_wallets")
}
// =============================================================================
// App Assets (应用资源 - 开屏图/引导图)
// =============================================================================
/// 应用资源类型
enum AppAssetType {
SPLASH // 开屏图
GUIDE // 引导图
}
/// 应用资源 - 管理开屏图和引导图
model AppAsset {
id String @id @default(uuid())
type AppAssetType
sortOrder Int @map("sort_order")
imageUrl String @map("image_url") @db.Text
title String? @db.VarChar(100)
subtitle String? @db.VarChar(200)
isEnabled Boolean @default(true) @map("is_enabled")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([type, sortOrder])
@@index([type, isEnabled])
@@map("app_assets")
}
// =============================================================================
// Contract Batch Download Tasks (合同批量下载任务)
// [2026-02-05] 新增:支持管理后台合同批量下载
// 回滚方式:删除此 model 并运行 prisma migrate
// =============================================================================
/// 批量下载任务状态
enum BatchDownloadStatus {
PENDING // 待处理
PROCESSING // 处理中
COMPLETED // 已完成
FAILED // 失败
CANCELLED // 已取消
}
/// 合同批量下载任务 - 记录批量下载请求的执行状态
model ContractBatchDownloadTask {
id BigInt @id @default(autoincrement())
taskNo String @unique @map("task_no") @db.VarChar(50)
status BatchDownloadStatus @default(PENDING)
// 下载统计
totalContracts Int @default(0) @map("total_contracts")
downloadedCount Int @default(0) @map("downloaded_count")
failedCount Int @default(0) @map("failed_count")
progress Int @default(0) // 0-100
// 断点续传支持
lastProcessedOrderNo String? @map("last_processed_order_no") @db.VarChar(50)
// 结果文件
resultFileUrl String? @map("result_file_url") @db.VarChar(500)
resultFileSize BigInt? @map("result_file_size")
// 错误信息
errors Json? // 失败的合同列表
// 操作者
operatorId String @map("operator_id") @db.VarChar(50)
// 筛选条件
filters Json? // { signedAfter, signedBefore, provinceCode, cityCode }
// 时间戳
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
expiresAt DateTime? @map("expires_at") // 结果文件过期时间
@@index([status])
@@index([operatorId])
@@index([createdAt])
@@map("contract_batch_download_tasks")
}
// =============================================================================
// Customer Service Contacts (客服联系方式)
// =============================================================================
/// 客服联系方式类型
enum ContactType {
WECHAT // 微信
QQ // QQ
}
/// 客服联系方式 - 管理员配置的客服联系信息
model CustomerServiceContact {
id String @id @default(uuid())
type ContactType
label String @db.VarChar(100)
value String @db.VarChar(200)
sortOrder Int @map("sort_order")
isEnabled Boolean @default(true) @map("is_enabled")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([type, isEnabled])
@@index([sortOrder])
@@map("customer_service_contacts")
}

View File

@ -1,262 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UploadedFile,
HttpCode,
HttpStatus,
UseInterceptors,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common'
import { FileInterceptor } from '@nestjs/platform-express'
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiBody, ApiQuery } from '@nestjs/swagger'
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'
import { FileStorageService } from '../../infrastructure/storage/file-storage.service'
import { AppAssetType } from '@prisma/client'
// 图片最大 10MB
const MAX_IMAGE_SIZE = 10 * 1024 * 1024
// 每种类型的数量上限
const ASSET_LIMITS: Record<AppAssetType, number> = {
SPLASH: 10,
GUIDE: 10,
}
// ===== Response DTO =====
interface AppAssetResponseDto {
id: string
type: AppAssetType
sortOrder: number
imageUrl: string
title: string | null
subtitle: string | null
isEnabled: boolean
createdAt: Date
updatedAt: Date
}
// =============================================================================
// Admin Controller (需要认证)
// =============================================================================
@ApiTags('App Asset Management')
@Controller('admin/app-assets')
export class AdminAppAssetController {
private readonly logger = new Logger(AdminAppAssetController.name)
constructor(
private readonly prisma: PrismaService,
private readonly fileStorage: FileStorageService,
) {}
@Get()
@ApiBearerAuth()
@ApiOperation({ summary: '查询应用资源列表' })
@ApiQuery({ name: 'type', required: false, enum: AppAssetType })
async list(@Query('type') type?: AppAssetType): Promise<AppAssetResponseDto[]> {
const where = type ? { type } : {}
const assets = await this.prisma.appAsset.findMany({
where,
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }],
})
return assets.map(this.toDto)
}
@Post('upload')
@ApiBearerAuth()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: '上传图片并创建/替换资源' })
@ApiBody({
schema: {
type: 'object',
required: ['file', 'type', 'sortOrder'],
properties: {
file: { type: 'string', format: 'binary' },
type: { type: 'string', enum: ['SPLASH', 'GUIDE'] },
sortOrder: { type: 'integer', minimum: 1 },
title: { type: 'string' },
subtitle: { type: 'string' },
},
},
})
async upload(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: MAX_IMAGE_SIZE }),
new FileTypeValidator({ fileType: /^image\/(jpeg|jpg|png|webp)$/ }),
],
fileIsRequired: true,
}),
)
file: Express.Multer.File,
@Body('type') type: string,
@Body('sortOrder') sortOrderStr: string,
@Body('title') title?: string,
@Body('subtitle') subtitle?: string,
): Promise<AppAssetResponseDto> {
// 校验 type
const assetType = type as AppAssetType
if (!Object.values(AppAssetType).includes(assetType)) {
throw new BadRequestException(`Invalid type: ${type}. Must be SPLASH or GUIDE`)
}
// 校验 sortOrder
const sortOrder = parseInt(sortOrderStr, 10)
if (isNaN(sortOrder) || sortOrder < 1) {
throw new BadRequestException('sortOrder must be a positive integer')
}
const limit = ASSET_LIMITS[assetType]
if (sortOrder > limit) {
throw new BadRequestException(`sortOrder for ${assetType} must be between 1 and ${limit}`)
}
// 保存文件
const uploadResult = await this.fileStorage.saveFile(
file.buffer,
file.originalname,
'app-assets',
`${assetType.toLowerCase()}-${sortOrder}`,
)
this.logger.log(
`Uploaded app asset: type=${assetType}, sortOrder=${sortOrder}, url=${uploadResult.url}`,
)
// Upsert: 同 type+sortOrder 自动替换
const asset = await this.prisma.appAsset.upsert({
where: {
type_sortOrder: { type: assetType, sortOrder },
},
create: {
type: assetType,
sortOrder,
imageUrl: uploadResult.url,
title: title || null,
subtitle: subtitle || null,
isEnabled: true,
},
update: {
imageUrl: uploadResult.url,
title: title !== undefined ? (title || null) : undefined,
subtitle: subtitle !== undefined ? (subtitle || null) : undefined,
},
})
return this.toDto(asset)
}
@Put(':id')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新资源元数据 (标题/副标题/启停)' })
async update(
@Param('id') id: string,
@Body() body: { title?: string; subtitle?: string; isEnabled?: boolean },
): Promise<AppAssetResponseDto> {
const existing = await this.prisma.appAsset.findUnique({ where: { id } })
if (!existing) {
throw new NotFoundException('App asset not found')
}
const data: Record<string, unknown> = {}
if (body.title !== undefined) data.title = body.title || null
if (body.subtitle !== undefined) data.subtitle = body.subtitle || null
if (body.isEnabled !== undefined) data.isEnabled = body.isEnabled
const updated = await this.prisma.appAsset.update({
where: { id },
data,
})
return this.toDto(updated)
}
@Delete(':id')
@ApiBearerAuth()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: '删除资源' })
async delete(@Param('id') id: string): Promise<void> {
const existing = await this.prisma.appAsset.findUnique({ where: { id } })
if (!existing) {
throw new NotFoundException('App asset not found')
}
await this.prisma.appAsset.delete({ where: { id } })
this.logger.log(`Deleted app asset: id=${id}, type=${existing.type}, sortOrder=${existing.sortOrder}`)
}
private toDto(asset: {
id: string
type: AppAssetType
sortOrder: number
imageUrl: string
title: string | null
subtitle: string | null
isEnabled: boolean
createdAt: Date
updatedAt: Date
}): AppAssetResponseDto {
return {
id: asset.id,
type: asset.type,
sortOrder: asset.sortOrder,
imageUrl: asset.imageUrl,
title: asset.title,
subtitle: asset.subtitle,
isEnabled: asset.isEnabled,
createdAt: asset.createdAt,
updatedAt: asset.updatedAt,
}
}
}
// =============================================================================
// Public Controller (移动端调用,无需认证)
// =============================================================================
@ApiTags('App Assets (Public)')
@Controller('app-assets')
export class PublicAppAssetController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@ApiOperation({ summary: '获取已启用的应用资源 (移动端)' })
@ApiQuery({ name: 'type', required: false, enum: AppAssetType })
async list(@Query('type') type?: AppAssetType): Promise<AppAssetResponseDto[]> {
const where: { isEnabled: boolean; type?: AppAssetType } = { isEnabled: true }
if (type && Object.values(AppAssetType).includes(type)) {
where.type = type
}
const assets = await this.prisma.appAsset.findMany({
where,
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }],
})
return assets.map((asset) => ({
id: asset.id,
type: asset.type,
sortOrder: asset.sortOrder,
imageUrl: asset.imageUrl,
title: asset.title,
subtitle: asset.subtitle,
isEnabled: asset.isEnabled,
createdAt: asset.createdAt,
updatedAt: asset.updatedAt,
}))
}
}

View File

@ -1,309 +0,0 @@
/**
*
* [2026-02-05]
* app.module.ts
*/
import {
Controller,
Get,
Post,
Param,
Query,
Body,
Res,
Req,
NotFoundException,
BadRequestException,
Logger,
StreamableFile,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { ContractService, ContractQueryParams } from '../../application/services/contract.service';
/**
* DTO
*/
interface BatchDownloadRequestDto {
filters?: {
signedAfter?: string;
signedBefore?: string;
provinceCode?: string;
cityCode?: string;
};
orderNos?: string[];
}
/**
*
*
*/
@ApiTags('Admin - Contracts')
@Controller('admin/contracts')
export class ContractController {
private readonly logger = new Logger(ContractController.name);
constructor(
private readonly prisma: PrismaService,
private readonly contractService: ContractService,
) {}
/**
*
*/
@Get()
@ApiOperation({ summary: '获取合同列表' })
@ApiQuery({ name: 'signedAfter', required: false, description: '签署时间起始ISO格式' })
@ApiQuery({ name: 'signedBefore', required: false, description: '签署时间结束ISO格式' })
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
@ApiQuery({ name: 'status', required: false, description: '合同状态' })
@ApiQuery({ name: 'page', required: false, description: '页码默认1' })
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数默认50' })
@ApiQuery({ name: 'orderBy', required: false, description: '排序字段signedAt/createdAt' })
@ApiQuery({ name: 'orderDir', required: false, description: '排序方向asc/desc' })
@ApiResponse({ status: 200, description: '合同列表' })
async getContracts(
@Query('signedAfter') signedAfter?: string,
@Query('signedBefore') signedBefore?: string,
@Query('provinceCode') provinceCode?: string,
@Query('cityCode') cityCode?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('orderBy') orderBy?: string,
@Query('orderDir') orderDir?: string,
) {
this.logger.log(`========== GET /v1/admin/contracts 请求 ==========`);
const params: ContractQueryParams = {
signedAfter,
signedBefore,
provinceCode,
cityCode,
status,
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
orderBy: orderBy as 'signedAt' | 'createdAt',
orderDir: orderDir as 'asc' | 'desc',
};
return this.contractService.getContracts(params);
}
/**
*
*/
@Get('statistics')
@ApiOperation({ summary: '获取合同统计信息' })
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
@ApiResponse({ status: 200, description: '合同统计' })
async getStatistics(
@Query('provinceCode') provinceCode?: string,
@Query('cityCode') cityCode?: string,
) {
this.logger.log(`========== GET /v1/admin/contracts/statistics 请求 ==========`);
return this.contractService.getStatistics({ provinceCode, cityCode });
}
/**
*
*/
@Post('batch-download')
@ApiOperation({ summary: '创建合同批量下载任务' })
@ApiBody({ description: '筛选条件' })
@ApiResponse({ status: 201, description: '任务创建成功' })
async createBatchDownload(
@Body() body: BatchDownloadRequestDto,
@Req() req: Request,
) {
this.logger.log(`========== POST /v1/admin/contracts/batch-download 请求 ==========`);
this.logger.log(`筛选条件: ${JSON.stringify(body.filters)}`);
// 生成任务号
const taskNo = `BD${Date.now()}`;
// 获取操作者 ID从请求头或默认值
const operatorId = (req.headers['x-operator-id'] as string) || 'system';
// 创建任务记录
const task = await this.prisma.contractBatchDownloadTask.create({
data: {
taskNo,
operatorId,
filters: body.filters ? JSON.parse(JSON.stringify(body.filters)) : null,
status: 'PENDING',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
},
});
// TODO: 触发异步任务处理(后续实现)
// 可以使用 Bull Queue 或 Kafka 消息
this.logger.log(`批量下载任务创建成功: ${taskNo}`);
return {
success: true,
taskId: task.id.toString(),
taskNo: task.taskNo,
status: task.status,
createdAt: task.createdAt.toISOString(),
};
}
/**
*
*/
@Get('batch-download/:taskNo')
@ApiOperation({ summary: '查询批量下载任务状态' })
@ApiParam({ name: 'taskNo', description: '任务号' })
@ApiResponse({ status: 200, description: '任务状态' })
@ApiResponse({ status: 404, description: '任务不存在' })
async getBatchDownloadStatus(@Param('taskNo') taskNo: string) {
this.logger.log(`========== GET /v1/admin/contracts/batch-download/${taskNo} 请求 ==========`);
const task = await this.prisma.contractBatchDownloadTask.findUnique({
where: { taskNo },
});
if (!task) {
throw new NotFoundException(`任务不存在: ${taskNo}`);
}
return {
taskId: task.id.toString(),
taskNo: task.taskNo,
status: task.status,
totalContracts: task.totalContracts,
downloadedCount: task.downloadedCount,
failedCount: task.failedCount,
progress: task.progress,
resultFileUrl: task.resultFileUrl,
resultFileSize: task.resultFileSize?.toString(),
errors: task.errors,
createdAt: task.createdAt.toISOString(),
startedAt: task.startedAt?.toISOString(),
completedAt: task.completedAt?.toISOString(),
expiresAt: task.expiresAt?.toISOString(),
};
}
/**
*
*/
@Get('users/:accountSequence')
@ApiOperation({ summary: '获取用户的合同列表' })
@ApiParam({ name: 'accountSequence', description: '用户账户序列号' })
@ApiQuery({ name: 'page', required: false, description: '页码' })
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数' })
@ApiResponse({ status: 200, description: '合同列表' })
async getUserContracts(
@Param('accountSequence') accountSequence: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
this.logger.log(`========== GET /v1/admin/contracts/users/${accountSequence} 请求 ==========`);
return this.contractService.getUserContracts(accountSequence, {
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
});
}
/**
*
*/
@Get(':orderNo')
@ApiOperation({ summary: '获取合同详情' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: '合同详情' })
@ApiResponse({ status: 404, description: '合同不存在' })
async getContract(@Param('orderNo') orderNo: string) {
this.logger.log(`========== GET /v1/admin/contracts/${orderNo} 请求 ==========`);
const contract = await this.contractService.getContract(orderNo);
if (!contract) {
throw new NotFoundException(`合同不存在: ${orderNo}`);
}
return contract;
}
/**
* PDF
*/
@Get(':orderNo/download')
@ApiOperation({ summary: '下载合同 PDF支持断点续传' })
@ApiParam({ name: 'orderNo', description: '订单号' })
@ApiResponse({ status: 200, description: 'PDF 文件' })
@ApiResponse({ status: 206, description: '部分内容(断点续传)' })
@ApiResponse({ status: 404, description: '合同不存在' })
async downloadContract(
@Param('orderNo') orderNo: string,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
this.logger.log(`========== GET /v1/admin/contracts/${orderNo}/download 请求 ==========`);
// 获取合同详情
const contract = await this.contractService.getContract(orderNo);
if (!contract) {
throw new NotFoundException(`合同不存在: ${orderNo}`);
}
if (!contract.signedPdfUrl) {
throw new NotFoundException(`合同PDF不存在: ${orderNo},状态: ${contract.status}`);
}
// 下载 PDF
const pdfBuffer = await this.contractService.downloadContractPdf(orderNo);
const fileSize = pdfBuffer.length;
// 生成文件名
const safeRealName = contract.userRealName?.replace(/[\/\\:*?"<>|]/g, '_') || '未知';
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵_${contract.provinceName}${contract.cityName}.pdf`;
const encodedFileName = encodeURIComponent(fileName);
// 检查 Range 请求头
const range = req.headers.range;
// 设置通用响应头
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
res.setHeader('Cache-Control', 'public, max-age=86400');
if (range) {
// 断点续传
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
if (start >= fileSize || end >= fileSize || start > end) {
res.status(416);
res.setHeader('Content-Range', `bytes */${fileSize}`);
res.end();
return;
}
const chunkSize = end - start + 1;
const chunk = pdfBuffer.slice(start, end + 1);
this.logger.log(`Range 请求: ${fileName}, bytes ${start}-${end}/${fileSize}`);
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
res.setHeader('Content-Length', chunkSize);
res.end(chunk);
} else {
// 完整文件
this.logger.log(`完整下载: ${fileName}, size=${fileSize}`);
res.setHeader('Content-Length', fileSize);
res.end(pdfBuffer);
}
}
}

View File

@ -1,196 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
HttpCode,
HttpStatus,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common'
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'
import { ContactType } from '@prisma/client'
// ===== DTOs =====
interface CreateContactDto {
type: string
label: string
value: string
sortOrder: number
isEnabled?: boolean
}
interface UpdateContactDto {
type?: string
label?: string
value?: string
sortOrder?: number
isEnabled?: boolean
}
interface ContactResponseDto {
id: string
type: ContactType
label: string
value: string
sortOrder: number
isEnabled: boolean
createdAt: Date
updatedAt: Date
}
// =============================================================================
// Admin Controller (需要认证)
// =============================================================================
@ApiTags('Customer Service Contact Management')
@Controller('admin/customer-service-contacts')
export class AdminCustomerServiceContactController {
private readonly logger = new Logger(AdminCustomerServiceContactController.name)
constructor(private readonly prisma: PrismaService) {}
@Get()
@ApiBearerAuth()
@ApiOperation({ summary: '查询客服联系方式列表 (全部)' })
async list(): Promise<ContactResponseDto[]> {
const contacts = await this.prisma.customerServiceContact.findMany({
orderBy: [{ sortOrder: 'asc' }],
})
return contacts.map(this.toDto)
}
@Post()
@ApiBearerAuth()
@ApiOperation({ summary: '新增客服联系方式' })
async create(@Body() body: CreateContactDto): Promise<ContactResponseDto> {
const contactType = body.type as ContactType
if (!Object.values(ContactType).includes(contactType)) {
throw new BadRequestException(`Invalid type: ${body.type}. Must be WECHAT or QQ`)
}
if (!body.label || !body.value) {
throw new BadRequestException('label and value are required')
}
if (body.sortOrder === undefined || body.sortOrder < 0) {
throw new BadRequestException('sortOrder must be a non-negative integer')
}
const contact = await this.prisma.customerServiceContact.create({
data: {
type: contactType,
label: body.label,
value: body.value,
sortOrder: body.sortOrder,
isEnabled: body.isEnabled ?? true,
},
})
this.logger.log(`Created customer service contact: id=${contact.id}, type=${contact.type}, label=${contact.label}`)
return this.toDto(contact)
}
@Put(':id')
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新客服联系方式' })
async update(
@Param('id') id: string,
@Body() body: UpdateContactDto,
): Promise<ContactResponseDto> {
const existing = await this.prisma.customerServiceContact.findUnique({ where: { id } })
if (!existing) {
throw new NotFoundException('Contact not found')
}
const data: Record<string, unknown> = {}
if (body.type !== undefined) {
const contactType = body.type as ContactType
if (!Object.values(ContactType).includes(contactType)) {
throw new BadRequestException(`Invalid type: ${body.type}`)
}
data.type = contactType
}
if (body.label !== undefined) data.label = body.label
if (body.value !== undefined) data.value = body.value
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder
if (body.isEnabled !== undefined) data.isEnabled = body.isEnabled
const updated = await this.prisma.customerServiceContact.update({
where: { id },
data,
})
return this.toDto(updated)
}
@Delete(':id')
@ApiBearerAuth()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: '删除客服联系方式' })
async delete(@Param('id') id: string): Promise<void> {
const existing = await this.prisma.customerServiceContact.findUnique({ where: { id } })
if (!existing) {
throw new NotFoundException('Contact not found')
}
await this.prisma.customerServiceContact.delete({ where: { id } })
this.logger.log(`Deleted customer service contact: id=${id}, type=${existing.type}`)
}
private toDto(contact: {
id: string
type: ContactType
label: string
value: string
sortOrder: number
isEnabled: boolean
createdAt: Date
updatedAt: Date
}): ContactResponseDto {
return {
id: contact.id,
type: contact.type,
label: contact.label,
value: contact.value,
sortOrder: contact.sortOrder,
isEnabled: contact.isEnabled,
createdAt: contact.createdAt,
updatedAt: contact.updatedAt,
}
}
}
// =============================================================================
// Public Controller (移动端调用,无需认证)
// =============================================================================
@ApiTags('Customer Service Contacts (Public)')
@Controller('customer-service-contacts')
export class PublicCustomerServiceContactController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@ApiOperation({ summary: '获取已启用的客服联系方式 (移动端)' })
async list(): Promise<ContactResponseDto[]> {
const contacts = await this.prisma.customerServiceContact.findMany({
where: { isEnabled: true },
orderBy: [{ sortOrder: 'asc' }],
})
return contacts.map((c) => ({
id: c.id,
type: c.type,
label: c.label,
value: c.value,
sortOrder: c.sortOrder,
isEnabled: c.isEnabled,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
}))
}
}

View File

@ -173,7 +173,7 @@ export class AdminSystemConfigController {
* /
* mobile-app
*/
@Controller('system-config')
@Controller('api/v1/system-config')
export class PublicSystemConfigController {
constructor(
@Inject(SYSTEM_CONFIG_REPOSITORY)

View File

@ -61,7 +61,6 @@ import { UserTagController } from './api/controllers/user-tag.controller';
import { ClassificationRuleController } from './api/controllers/classification-rule.controller';
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
import { ContractBatchDownloadJob } from './infrastructure/jobs/contract-batch-download.job';
// Co-Managed Wallet imports
import { CoManagedWalletController } from './api/controllers/co-managed-wallet.controller';
import { CoManagedWalletService } from './application/services/co-managed-wallet.service';
@ -77,13 +76,6 @@ import { SYSTEM_MAINTENANCE_REPOSITORY } from './domain/repositories/system-main
import { SystemMaintenanceRepositoryImpl } from './infrastructure/persistence/repositories/system-maintenance.repository.impl';
import { AdminMaintenanceController, MobileMaintenanceController } from './api/controllers/system-maintenance.controller';
import { MaintenanceInterceptor } from './api/interceptors/maintenance.interceptor';
// App Asset imports
import { AdminAppAssetController, PublicAppAssetController } from './api/controllers/app-asset.controller'
// Customer Service Contact imports
import { AdminCustomerServiceContactController, PublicCustomerServiceContactController } from './api/controllers/customer-service-contact.controller';
// [2026-02-05] 新增:合同管理模块
import { ContractController } from './api/controllers/contract.controller';
import { ContractService } from './application/services/contract.service';
@Module({
imports: [
@ -119,14 +111,6 @@ import { ContractService } from './application/services/contract.service';
// System Maintenance Controllers
AdminMaintenanceController,
MobileMaintenanceController,
// App Asset Controllers
AdminAppAssetController,
PublicAppAssetController,
// Customer Service Contact Controllers
AdminCustomerServiceContactController,
PublicCustomerServiceContactController,
// [2026-02-05] 新增:合同管理控制器
ContractController,
],
providers: [
PrismaService,
@ -192,7 +176,6 @@ import { ContractService } from './application/services/contract.service';
AudienceSegmentService,
// Scheduled Jobs
AutoTagSyncJob,
ContractBatchDownloadJob,
// Co-Managed Wallet
CoManagedWalletMapper,
CoManagedWalletService,
@ -214,8 +197,6 @@ import { ContractService } from './application/services/contract.service';
provide: APP_INTERCEPTOR,
useClass: MaintenanceInterceptor,
},
// [2026-02-05] 新增:合同管理服务
ContractService,
],
})
export class AppModule {}

View File

@ -1,203 +0,0 @@
/**
*
* [2026-02-05] planting-service API
* app.module.ts
*/
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
/**
* DTO
*/
export interface ContractDto {
orderNo: string;
contractNo: string;
userId: string;
accountSequence: string;
userRealName: string | null;
userPhoneNumber: string | null;
treeCount: number;
totalAmount: number;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
status: string;
signedAt: string | null;
signedPdfUrl: string | null;
createdAt: string;
}
/**
*
*/
export interface ContractsListResponse {
items: ContractDto[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
*
*/
export interface ContractStatisticsResponse {
totalContracts: number;
signedContracts: number;
pendingContracts: number;
expiredContracts: number;
}
/**
*
*/
export interface ContractQueryParams {
accountSequences?: string[];
signedAfter?: string;
signedBefore?: string;
provinceCode?: string;
cityCode?: string;
status?: string;
page?: number;
pageSize?: number;
orderBy?: 'signedAt' | 'createdAt';
orderDir?: 'asc' | 'desc';
}
@Injectable()
export class ContractService {
private readonly logger = new Logger(ContractService.name);
private readonly httpClient: AxiosInstance;
private readonly plantingServiceUrl: string;
constructor(private readonly configService: ConfigService) {
this.plantingServiceUrl = this.configService.get<string>(
'PLANTING_SERVICE_URL',
'http://rwa-planting-service:3003',
);
this.httpClient = axios.create({
baseURL: this.plantingServiceUrl,
timeout: 30000,
});
this.logger.log(`ContractService initialized, planting-service URL: ${this.plantingServiceUrl}`);
}
/**
*
*/
async getContracts(params: ContractQueryParams): Promise<ContractsListResponse> {
try {
const queryParams = new URLSearchParams();
if (params.accountSequences?.length) {
queryParams.append('accountSequences', params.accountSequences.join(','));
}
if (params.signedAfter) queryParams.append('signedAfter', params.signedAfter);
if (params.signedBefore) queryParams.append('signedBefore', params.signedBefore);
if (params.provinceCode) queryParams.append('provinceCode', params.provinceCode);
if (params.cityCode) queryParams.append('cityCode', params.cityCode);
if (params.status) queryParams.append('status', params.status);
if (params.page) queryParams.append('page', params.page.toString());
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
if (params.orderBy) queryParams.append('orderBy', params.orderBy);
if (params.orderDir) queryParams.append('orderDir', params.orderDir);
const url = `/api/v1/planting/internal/contracts?${queryParams.toString()}`;
this.logger.debug(`[getContracts] 请求: ${url}`);
const response = await this.httpClient.get<ContractsListResponse>(url);
return response.data;
} catch (error) {
this.logger.error(`[getContracts] 失败: ${error.message}`);
return {
items: [],
total: 0,
page: params.page ?? 1,
pageSize: params.pageSize ?? 50,
totalPages: 0,
};
}
}
/**
*
*/
async getUserContracts(accountSequence: string, params?: {
page?: number;
pageSize?: number;
}): Promise<ContractsListResponse> {
return this.getContracts({
accountSequences: [accountSequence],
page: params?.page,
pageSize: params?.pageSize,
status: undefined, // 查询所有状态
});
}
/**
*
*/
async getContract(orderNo: string): Promise<ContractDto | null> {
try {
const url = `/api/v1/planting/internal/contracts/${orderNo}`;
this.logger.debug(`[getContract] 请求: ${url}`);
const response = await this.httpClient.get<ContractDto>(url);
return response.data;
} catch (error) {
if (error.response?.status === 404) {
return null;
}
this.logger.error(`[getContract] 失败: ${error.message}`);
throw error;
}
}
/**
* PDF
* @returns PDF Buffer
*/
async downloadContractPdf(orderNo: string): Promise<Buffer> {
const url = `/api/v1/planting/internal/contracts/${orderNo}/pdf`;
this.logger.debug(`[downloadContractPdf] 请求: ${url}`);
const response = await this.httpClient.get(url, {
responseType: 'arraybuffer',
});
return Buffer.from(response.data);
}
/**
*
*/
async getStatistics(params?: {
provinceCode?: string;
cityCode?: string;
}): Promise<ContractStatisticsResponse> {
try {
const queryParams = new URLSearchParams();
if (params?.provinceCode) queryParams.append('provinceCode', params.provinceCode);
if (params?.cityCode) queryParams.append('cityCode', params.cityCode);
const url = `/api/v1/planting/internal/contracts/statistics?${queryParams.toString()}`;
this.logger.debug(`[getStatistics] 请求: ${url}`);
const response = await this.httpClient.get<ContractStatisticsResponse>(url);
return response.data;
} catch (error) {
this.logger.error(`[getStatistics] 失败: ${error.message}`);
return {
totalContracts: 0,
signedContracts: 0,
pendingContracts: 0,
expiredContracts: 0,
};
}
}
}

View File

@ -1,343 +0,0 @@
/**
*
* [2026-02-05]
* app.module.ts
*/
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as archiver from 'archiver';
import { createWriteStream, existsSync, mkdirSync } from 'fs';
import { PrismaService } from '../persistence/prisma/prisma.service';
import { ContractService, ContractDto } from '../../application/services/contract.service';
/**
*
*/
interface BatchDownloadFilters {
signedAfter?: string;
signedBefore?: string;
provinceCode?: string;
cityCode?: string;
}
/**
* Job
*
*/
@Injectable()
export class ContractBatchDownloadJob implements OnModuleInit {
private readonly logger = new Logger(ContractBatchDownloadJob.name);
private isRunning = false;
private readonly downloadDir: string;
private readonly baseUrl: string;
constructor(
private readonly prisma: PrismaService,
private readonly contractService: ContractService,
private readonly configService: ConfigService,
) {
this.downloadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads';
this.baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3005';
}
onModuleInit() {
this.logger.log('ContractBatchDownloadJob initialized');
// 确保下载目录存在
const contractsDir = path.join(this.downloadDir, 'contracts');
if (!existsSync(contractsDir)) {
mkdirSync(contractsDir, { recursive: true });
this.logger.log(`Created contracts download directory: ${contractsDir}`);
}
}
/**
*
*/
@Cron('0 * * * * *') // 每分钟的第0秒
async processPendingTasks(): Promise<void> {
if (this.isRunning) {
this.logger.debug('Batch download job is already running, skipping...');
return;
}
this.isRunning = true;
try {
// 查找待处理的任务
const pendingTask = await this.prisma.contractBatchDownloadTask.findFirst({
where: { status: 'PENDING' },
orderBy: { createdAt: 'asc' },
});
if (!pendingTask) {
return;
}
this.logger.log(`开始处理批量下载任务: ${pendingTask.taskNo}`);
// 更新状态为处理中
await this.prisma.contractBatchDownloadTask.update({
where: { id: pendingTask.id },
data: {
status: 'PROCESSING',
startedAt: new Date(),
},
});
try {
await this.processTask(pendingTask.id, pendingTask.taskNo, pendingTask.filters as BatchDownloadFilters);
} catch (error) {
this.logger.error(`任务处理失败: ${pendingTask.taskNo}`, error);
await this.prisma.contractBatchDownloadTask.update({
where: { id: pendingTask.id },
data: {
status: 'FAILED',
errors: { message: error.message, stack: error.stack },
completedAt: new Date(),
},
});
}
} catch (error) {
this.logger.error('批量下载任务检查失败', error);
} finally {
this.isRunning = false;
}
}
/**
*
*/
private async processTask(
taskId: bigint,
taskNo: string,
filters: BatchDownloadFilters | null,
): Promise<void> {
const errors: Array<{ orderNo: string; error: string }> = [];
let downloadedCount = 0;
let failedCount = 0;
// 1. 获取符合条件的合同列表(只获取已签署的)
this.logger.log(`获取合同列表, 筛选条件: ${JSON.stringify(filters)}`);
const contractsResult = await this.contractService.getContracts({
signedAfter: filters?.signedAfter,
signedBefore: filters?.signedBefore,
provinceCode: filters?.provinceCode,
cityCode: filters?.cityCode,
status: 'SIGNED',
pageSize: 10000, // 最大获取1万份
orderBy: 'signedAt',
orderDir: 'asc',
});
const contracts = contractsResult.items;
const totalContracts = contracts.length;
this.logger.log(`共找到 ${totalContracts} 份已签署合同`);
if (totalContracts === 0) {
// 没有合同需要下载
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: {
status: 'COMPLETED',
totalContracts: 0,
downloadedCount: 0,
failedCount: 0,
progress: 100,
completedAt: new Date(),
},
});
return;
}
// 更新总数
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: { totalContracts },
});
// 2. 创建临时目录
const tempDir = path.join(this.downloadDir, 'temp', taskNo);
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true });
}
// 3. 逐个下载合同 PDF
for (let i = 0; i < contracts.length; i++) {
const contract = contracts[i];
try {
// 下载 PDF
const pdfBuffer = await this.contractService.downloadContractPdf(contract.orderNo);
// 生成文件路径(按省市分组)
const safeProvince = this.sanitizeFileName(contract.provinceName || '未知省份');
const safeCity = this.sanitizeFileName(contract.cityName || '未知城市');
const subDir = path.join(tempDir, safeProvince, safeCity);
if (!existsSync(subDir)) {
mkdirSync(subDir, { recursive: true });
}
// 生成文件名
const safeRealName = this.sanitizeFileName(contract.userRealName || '未知');
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵.pdf`;
const filePath = path.join(subDir, fileName);
// 保存文件
await fs.writeFile(filePath, pdfBuffer);
downloadedCount++;
this.logger.debug(`下载成功: ${contract.orderNo} -> ${fileName}`);
} catch (error) {
failedCount++;
errors.push({ orderNo: contract.orderNo, error: error.message });
this.logger.warn(`下载失败: ${contract.orderNo} - ${error.message}`);
}
// 更新进度
const progress = Math.floor(((i + 1) / totalContracts) * 100);
if (progress % 10 === 0 || i === totalContracts - 1) {
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: {
downloadedCount,
failedCount,
progress,
lastProcessedOrderNo: contract.orderNo,
errors: errors.length > 0 ? errors : undefined,
},
});
this.logger.log(`进度: ${progress}% (${downloadedCount}/${totalContracts})`);
}
}
// 4. 打包成 ZIP
this.logger.log('开始打包 ZIP...');
const zipFileName = this.generateZipFileName(filters, downloadedCount);
const zipDir = path.join(this.downloadDir, 'contracts');
const zipPath = path.join(zipDir, zipFileName);
await this.createZipArchive(tempDir, zipPath);
// 获取 ZIP 文件大小
const zipStats = await fs.stat(zipPath);
const resultFileUrl = `${this.baseUrl}/uploads/contracts/${zipFileName}`;
this.logger.log(`ZIP 打包完成: ${zipFileName}, 大小: ${zipStats.size} bytes`);
// 5. 清理临时文件
await this.cleanupTempDir(tempDir);
// 6. 更新任务状态为完成
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: {
status: 'COMPLETED',
downloadedCount,
failedCount,
progress: 100,
resultFileUrl,
resultFileSize: BigInt(zipStats.size),
errors: errors.length > 0 ? errors : undefined,
completedAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
},
});
this.logger.log(`任务完成: ${taskNo}, 成功: ${downloadedCount}, 失败: ${failedCount}`);
}
/**
* ZIP
*/
private generateZipFileName(filters: BatchDownloadFilters | null, count: number): string {
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
let rangeStr = '';
if (filters?.signedAfter || filters?.signedBefore) {
const start = filters.signedAfter
? new Date(filters.signedAfter).toISOString().slice(0, 10).replace(/-/g, '')
: 'all';
const end = filters.signedBefore
? new Date(filters.signedBefore).toISOString().slice(0, 10).replace(/-/g, '')
: 'now';
rangeStr = `_${start}-${end}`;
}
return `contracts_${dateStr}${rangeStr}_${count}份.zip`;
}
/**
* ZIP
*/
private async createZipArchive(sourceDir: string, zipPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver('zip', {
zlib: { level: 6 }, // 压缩级别
});
output.on('close', () => {
this.logger.log(`ZIP 文件大小: ${archive.pointer()} bytes`);
resolve();
});
archive.on('error', (err: Error) => {
reject(err);
});
archive.pipe(output);
// 添加目录下所有文件
archive.directory(sourceDir, false);
archive.finalize();
});
}
/**
*
*/
private async cleanupTempDir(tempDir: string): Promise<void> {
try {
await fs.rm(tempDir, { recursive: true, force: true });
this.logger.debug(`清理临时目录: ${tempDir}`);
} catch (error) {
this.logger.warn(`清理临时目录失败: ${tempDir}`, error);
}
}
/**
*
*/
private sanitizeFileName(name: string): string {
return name.replace(/[\/\\:*?"<>|]/g, '_').trim() || '未知';
}
/**
* API
*/
async triggerProcessing(): Promise<{ processed: boolean; taskNo?: string }> {
if (this.isRunning) {
return { processed: false };
}
await this.processPendingTasks();
return { processed: true };
}
/**
*
*/
getProcessingStatus(): { isRunning: boolean } {
return { isRunning: this.isRunning };
}
}

View File

@ -24,32 +24,6 @@ export class UserQueryRepositoryImpl implements IUserQueryRepository {
const where = this.buildWhereClause(filters);
const orderBy = this.buildOrderBy(sort);
// 认种筛选UserQueryView.personalAdoptionCount 可能未同步,
// 改为实时查询 PlantingOrderQueryView与 getBatchUserStats 数据源一致)
if (filters.minAdoptions !== undefined || filters.maxAdoptions !== undefined) {
const adoptedAccounts = await this.prisma.plantingOrderQueryView.groupBy({
by: ['accountSequence'],
where: { status: 'MINING_ENABLED' },
_count: { id: true },
});
const adoptedSeqs = new Set(adoptedAccounts.map(a => a.accountSequence));
if (filters.minAdoptions !== undefined && filters.minAdoptions > 0) {
// 已认种accountSequence 必须在有 MINING_ENABLED 订单的集合中
where.accountSequence = {
...(typeof where.accountSequence === 'object' ? where.accountSequence as any : {}),
in: [...adoptedSeqs],
};
}
if (filters.maxAdoptions !== undefined && filters.maxAdoptions === 0) {
// 未认种accountSequence 不在有 MINING_ENABLED 订单的集合中
where.accountSequence = {
...(typeof where.accountSequence === 'object' ? where.accountSequence as any : {}),
notIn: [...adoptedSeqs],
};
}
}
const [items, total] = await Promise.all([
this.prisma.userQueryView.findMany({
where,
@ -290,8 +264,16 @@ export class UserQueryRepositoryImpl implements IUserQueryRepository {
where.inviterSequence = filters.hasInviter ? { not: null } : null;
}
// 认种数范围:不再使用 personalAdoptionCount预计算字段可能未同步
// 改为在 findMany 中实时查询 PlantingOrderQueryView 处理
// 认种数范围
if (filters.minAdoptions !== undefined || filters.maxAdoptions !== undefined) {
where.personalAdoptionCount = {};
if (filters.minAdoptions !== undefined) {
where.personalAdoptionCount.gte = filters.minAdoptions;
}
if (filters.maxAdoptions !== undefined) {
where.personalAdoptionCount.lte = filters.maxAdoptions;
}
}
// 注册时间范围
if (filters.registeredAfter || filters.registeredBefore) {

View File

@ -1,6 +1,7 @@
-- ============================================================================
-- auth-service 初始化 migration
-- 合并自: 0001_init, 0002_add_transactional_idempotency
-- 合并自: 20260111000000_init, 20260111083500_allow_nullable_phone_password,
-- 20260112110000_add_nickname_to_synced_legacy_users
-- ============================================================================
-- CreateEnum
@ -240,26 +241,3 @@ ALTER TABLE "sms_logs" ADD CONSTRAINT "sms_logs_user_id_fkey" FOREIGN KEY ("user
-- AddForeignKey
ALTER TABLE "login_logs" ADD CONSTRAINT "login_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- ============================================================================
-- 事务性幂等消费支持 (从 0002_add_transactional_idempotency 合并)
-- 用于 1.0 -> 2.0 CDC 同步的 100% exactly-once 语义
-- ============================================================================
-- CreateTable
CREATE TABLE "processed_cdc_events" (
"id" BIGSERIAL NOT NULL,
"source_topic" TEXT NOT NULL,
"offset" BIGINT NOT NULL,
"table_name" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "processed_cdc_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex (复合唯一索引保证幂等性)
CREATE UNIQUE INDEX "processed_cdc_events_source_topic_offset_key" ON "processed_cdc_events"("source_topic", "offset");
-- CreateIndex (时间索引用于清理旧数据)
CREATE INDEX "processed_cdc_events_processed_at_idx" ON "processed_cdc_events"("processed_at");

View File

@ -1,27 +0,0 @@
-- CreateTable
CREATE TABLE "synced_wallet_addresses" (
"id" BIGSERIAL NOT NULL,
"legacy_address_id" BIGINT NOT NULL,
"legacy_user_id" BIGINT NOT NULL,
"chain_type" TEXT NOT NULL,
"address" TEXT NOT NULL,
"public_key" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"legacy_bound_at" TIMESTAMP(3) NOT NULL,
"source_sequence_num" BIGINT NOT NULL,
"synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "synced_wallet_addresses_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "synced_wallet_addresses_legacy_address_id_key" ON "synced_wallet_addresses"("legacy_address_id");
-- CreateIndex
CREATE UNIQUE INDEX "synced_wallet_addresses_legacy_user_id_chain_type_key" ON "synced_wallet_addresses"("legacy_user_id", "chain_type");
-- CreateIndex
CREATE INDEX "synced_wallet_addresses_legacy_user_id_idx" ON "synced_wallet_addresses"("legacy_user_id");
-- CreateIndex
CREATE INDEX "synced_wallet_addresses_chain_type_address_idx" ON "synced_wallet_addresses"("chain_type", "address");

View File

@ -0,0 +1,25 @@
-- ============================================================================
-- 添加事务性幂等消费支持
-- 用于 1.0 -> 2.0 CDC 同步的 100% exactly-once 语义
-- ============================================================================
-- 创建 processed_cdc_events 表(用于 CDC 事件幂等)
-- 唯一键: (source_topic, offset) - Kafka topic 名称 + 消息偏移量
-- 用于保证每个 CDC 事件只处理一次exactly-once 语义)
CREATE TABLE IF NOT EXISTS "processed_cdc_events" (
"id" BIGSERIAL NOT NULL,
"source_topic" VARCHAR(200) NOT NULL, -- Kafka topic 名称(如 cdc.identity.public.user_accounts
"offset" BIGINT NOT NULL, -- Kafka 消息偏移量(在 partition 内唯一)
"table_name" VARCHAR(100) NOT NULL, -- 源表名
"operation" VARCHAR(10) NOT NULL, -- CDC 操作类型: c(create), u(update), d(delete), r(snapshot read)
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "processed_cdc_events_pkey" PRIMARY KEY ("id")
);
-- 复合唯一索引:(source_topic, offset) 保证幂等性
-- 注意:这不是数据库自增 ID而是 Kafka 消息的唯一标识
CREATE UNIQUE INDEX "processed_cdc_events_source_topic_offset_key" ON "processed_cdc_events"("source_topic", "offset");
-- 时间索引用于清理旧数据
CREATE INDEX "processed_cdc_events_processed_at_idx" ON "processed_cdc_events"("processed_at");

View File

@ -1,8 +0,0 @@
-- AlterTable
-- 添加支付密码字段
-- 支付密码独立于登录密码,用于交易时的二次验证
-- 存储的是bcrypt哈希值不是明文密码
ALTER TABLE "users" ADD COLUMN "trade_password_hash" TEXT;
-- 添加注释说明该字段用途
COMMENT ON COLUMN "users"."trade_password_hash" IS '支付密码哈希值 - 用于交易时的二次安全验证,独立于登录密码';

View File

@ -20,7 +20,6 @@ model User {
// 基本信息
phone String @unique
passwordHash String @map("password_hash")
tradePasswordHash String? @map("trade_password_hash") // 支付密码(独立于登录密码)
// 统一关联键 (跨所有服务)
// V1: 12位 (D + 6位日期 + 5位序号), 如 D2512110008
@ -105,33 +104,6 @@ model SyncedLegacyUser {
@@map("synced_legacy_users")
}
// ============================================================================
// CDC 同步的 1.0 钱包地址(只读)
// ============================================================================
model SyncedWalletAddress {
id BigInt @id @default(autoincrement())
// 1.0 钱包地址数据
legacyAddressId BigInt @unique @map("legacy_address_id") // 1.0 的 wallet_addresses.address_id
legacyUserId BigInt @map("legacy_user_id") // 1.0 的 wallet_addresses.user_id
chainType String @map("chain_type") // KAVA, BSC 等
address String // 钱包地址
publicKey String @map("public_key") // MPC 公钥
status String @default("ACTIVE") // ACTIVE, DELETED
legacyBoundAt DateTime @map("legacy_bound_at") // 1.0 绑定时间
// CDC 元数据
sourceSequenceNum BigInt @map("source_sequence_num")
syncedAt DateTime @default(now()) @map("synced_at")
@@unique([legacyUserId, chainType])
@@index([legacyUserId])
@@index([chainType, address])
@@map("synced_wallet_addresses")
}
// ============================================================================
// 刷新令牌
// ============================================================================

View File

@ -5,12 +5,10 @@ import {
AuthController,
SmsController,
PasswordController,
TradePasswordController,
KycController,
UserController,
HealthController,
AdminController,
InternalController,
} from './controllers';
import { ApplicationModule } from '@/application';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
@ -33,12 +31,10 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
AuthController,
SmsController,
PasswordController,
TradePasswordController,
KycController,
UserController,
HealthController,
AdminController,
InternalController,
],
providers: [JwtAuthGuard],
})

View File

@ -1,9 +1,7 @@
export * from './auth.controller';
export * from './sms.controller';
export * from './password.controller';
export * from './trade-password.controller';
export * from './kyc.controller';
export * from './user.controller';
export * from './health.controller';
export * from './admin.controller';
export * from './internal.controller';

View File

@ -1,50 +0,0 @@
import { Controller, Get, Param, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
/**
* API - 2.0 JWT
*/
@Controller('internal')
export class InternalController {
private readonly logger = new Logger(InternalController.name);
constructor(private readonly prisma: PrismaService) {}
/**
* accountSequence Kava
* trading-service
*/
@Get('users/:accountSequence/kava-address')
async getUserKavaAddress(
@Param('accountSequence') accountSequence: string,
): Promise<{ kavaAddress: string }> {
// 1. 通过 SyncedLegacyUser 查找 legacyId
const legacyUser = await this.prisma.syncedLegacyUser.findUnique({
where: { accountSequence },
select: { legacyId: true },
});
if (!legacyUser) {
this.logger.warn(`[Internal] Legacy user not found: ${accountSequence}`);
throw new NotFoundException(`用户未找到: ${accountSequence}`);
}
// 2. 通过 legacyUserId + chainType 查找 KAVA 钱包地址
const walletAddress = await this.prisma.syncedWalletAddress.findUnique({
where: {
legacyUserId_chainType: {
legacyUserId: legacyUser.legacyId,
chainType: 'KAVA',
},
},
select: { address: true, status: true },
});
if (!walletAddress || walletAddress.status !== 'ACTIVE') {
this.logger.warn(`[Internal] Kava address not found for: ${accountSequence}`);
throw new NotFoundException(`未找到 Kava 钱包地址: ${accountSequence}`);
}
return { kavaAddress: walletAddress.address };
}
}

View File

@ -22,7 +22,7 @@ class ChangePasswordDto {
newPassword: string;
}
@Controller('auth/password')
@Controller('password')
@UseGuards(ThrottlerGuard)
export class PasswordController {
constructor(private readonly passwordService: PasswordService) {}

View File

@ -21,7 +21,7 @@ class VerifySmsDto {
type: 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
}
@Controller('auth/sms')
@Controller('sms')
@UseGuards(ThrottlerGuard)
export class SmsController {
constructor(private readonly smsService: SmsService) {}

View File

@ -1,104 +0,0 @@
import {
Controller,
Post,
Get,
Body,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { TradePasswordService } from '@/application/services/trade-password.service';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
class SetTradePasswordDto {
loginPassword: string;
tradePassword: string;
}
class ChangeTradePasswordDto {
oldTradePassword: string;
newTradePassword: string;
}
class VerifyTradePasswordDto {
tradePassword: string;
}
@Controller('auth/trade-password')
@UseGuards(ThrottlerGuard)
export class TradePasswordController {
constructor(private readonly tradePasswordService: TradePasswordService) {}
/**
*
* GET /trade-password/status
*/
@Get('status')
@UseGuards(JwtAuthGuard)
async getStatus(
@CurrentUser() user: { accountSequence: string },
): Promise<{ hasTradePassword: boolean }> {
return this.tradePasswordService.getStatus(user.accountSequence);
}
/**
*
* POST /trade-password/set
*/
@Post('set')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
async setTradePassword(
@CurrentUser() user: { accountSequence: string },
@Body() dto: SetTradePasswordDto,
): Promise<{ success: boolean }> {
await this.tradePasswordService.setTradePassword({
accountSequence: user.accountSequence,
loginPassword: dto.loginPassword,
tradePassword: dto.tradePassword,
});
return { success: true };
}
/**
*
* POST /trade-password/change
*/
@Post('change')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
async changeTradePassword(
@CurrentUser() user: { accountSequence: string },
@Body() dto: ChangeTradePasswordDto,
): Promise<{ success: boolean }> {
await this.tradePasswordService.changeTradePassword({
accountSequence: user.accountSequence,
oldTradePassword: dto.oldTradePassword,
newTradePassword: dto.newTradePassword,
});
return { success: true };
}
/**
*
* POST /trade-password/verify
*/
@Post('verify')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
async verifyTradePassword(
@CurrentUser() user: { accountSequence: string },
@Body() dto: VerifyTradePasswordDto,
): Promise<{ valid: boolean }> {
const valid = await this.tradePasswordService.verifyTradePassword({
accountSequence: user.accountSequence,
tradePassword: dto.tradePassword,
});
return { valid };
}
}

View File

@ -1,15 +1,13 @@
import {
Controller,
Get,
Query,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { UserService, UserProfileResult } from '@/application/services';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
@Controller('auth/user')
@Controller('user')
@UseGuards(JwtAuthGuard)
export class UserController {
constructor(private readonly userService: UserService) {}
@ -25,21 +23,4 @@ export class UserController {
const result = await this.userService.getProfile(user.accountSequence);
return { success: true, data: result };
}
/**
* P2P转账验证
* GET /user/lookup?phone=13800138000
*/
@Get('lookup')
async lookupByPhone(
@Query('phone') phone: string,
@CurrentUser() currentUser: { accountSequence: string },
): Promise<{ success: boolean; data: { exists: boolean; nickname?: string; accountSequence?: string } }> {
if (!phone || phone.length !== 11) {
throw new BadRequestException('请输入有效的11位手机号');
}
const result = await this.userService.lookupByPhone(phone);
return { success: true, data: result };
}
}

View File

@ -9,7 +9,7 @@ import { InfrastructureModule } from './infrastructure/infrastructure.module';
// 配置模块
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env', '../.env'],
envFilePath: ['.env.local', '.env'],
}),
// 限流模块

View File

@ -5,7 +5,6 @@ import { ScheduleModule } from '@nestjs/schedule';
import {
AuthService,
PasswordService,
TradePasswordService,
SmsService,
KycService,
UserService,
@ -33,7 +32,6 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
providers: [
AuthService,
PasswordService,
TradePasswordService,
SmsService,
KycService,
UserService,
@ -44,7 +42,6 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
exports: [
AuthService,
PasswordService,
TradePasswordService,
SmsService,
KycService,
UserService,

View File

@ -347,7 +347,7 @@ export class AuthService {
expiresIn,
user: {
accountSequence: user.accountSequence.value,
phone: user.phone.value,
phone: user.phone.masked,
source: user.source,
kycStatus: user.kycStatus,
},

View File

@ -1,6 +1,5 @@
export * from './auth.service';
export * from './password.service';
export * from './trade-password.service';
export * from './sms.service';
export * from './kyc.service';
export * from './user.service';

View File

@ -1,144 +0,0 @@
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
import {
USER_REPOSITORY,
UserRepository,
AccountSequence,
} from '@/domain';
export interface SetTradePasswordDto {
accountSequence: string;
loginPassword: string; // 需要验证登录密码
tradePassword: string;
}
export interface ChangeTradePasswordDto {
accountSequence: string;
oldTradePassword: string;
newTradePassword: string;
}
export interface VerifyTradePasswordDto {
accountSequence: string;
tradePassword: string;
}
export interface TradePasswordStatusDto {
hasTradePassword: boolean;
}
@Injectable()
export class TradePasswordService {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository,
) {}
/**
*
*/
async getStatus(accountSequence: string): Promise<TradePasswordStatusDto> {
const user = await this.userRepository.findByAccountSequence(
AccountSequence.create(accountSequence),
);
if (!user) {
throw new NotFoundException('用户不存在');
}
return {
hasTradePassword: user.hasTradePassword,
};
}
/**
*
*
*/
async setTradePassword(dto: SetTradePasswordDto): Promise<void> {
const user = await this.userRepository.findByAccountSequence(
AccountSequence.create(dto.accountSequence),
);
if (!user) {
throw new NotFoundException('用户不存在');
}
// 验证登录密码
const isLoginPasswordValid = await user.verifyPassword(dto.loginPassword);
if (!isLoginPasswordValid) {
throw new BadRequestException('登录密码错误');
}
// 支付密码不能与登录密码相同
const isSameAsLogin = await user.verifyPassword(dto.tradePassword);
if (isSameAsLogin) {
throw new BadRequestException('支付密码不能与登录密码相同');
}
// 验证密码格式6位数字
if (!/^\d{6}$/.test(dto.tradePassword)) {
throw new BadRequestException('支付密码必须是6位数字');
}
// 设置支付密码
await user.setTradePassword(dto.tradePassword);
await this.userRepository.save(user);
}
/**
*
*/
async changeTradePassword(dto: ChangeTradePasswordDto): Promise<void> {
const user = await this.userRepository.findByAccountSequence(
AccountSequence.create(dto.accountSequence),
);
if (!user) {
throw new NotFoundException('用户不存在');
}
if (!user.hasTradePassword) {
throw new BadRequestException('尚未设置支付密码');
}
// 验证旧支付密码
const isOldPasswordValid = await user.verifyTradePassword(dto.oldTradePassword);
if (!isOldPasswordValid) {
throw new BadRequestException('原支付密码错误');
}
// 新密码不能与旧密码相同
if (dto.oldTradePassword === dto.newTradePassword) {
throw new BadRequestException('新密码不能与原密码相同');
}
// 验证新密码格式6位数字
if (!/^\d{6}$/.test(dto.newTradePassword)) {
throw new BadRequestException('支付密码必须是6位数字');
}
// 设置新支付密码
await user.setTradePassword(dto.newTradePassword);
await this.userRepository.save(user);
}
/**
*
*/
async verifyTradePassword(dto: VerifyTradePasswordDto): Promise<boolean> {
const user = await this.userRepository.findByAccountSequence(
AccountSequence.create(dto.accountSequence),
);
if (!user) {
throw new NotFoundException('用户不存在');
}
if (!user.hasTradePassword) {
// 未设置支付密码,视为验证通过(允许交易)
return true;
}
return user.verifyTradePassword(dto.tradePassword);
}
}

View File

@ -4,8 +4,6 @@ import {
Phone,
USER_REPOSITORY,
UserRepository,
SYNCED_LEGACY_USER_REPOSITORY,
SyncedLegacyUserRepository,
} from '@/domain';
export interface UserProfileResult {
@ -24,8 +22,6 @@ export class UserService {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: UserRepository,
@Inject(SYNCED_LEGACY_USER_REPOSITORY)
private readonly syncedLegacyUserRepository: SyncedLegacyUserRepository,
) {}
/**
@ -42,7 +38,7 @@ export class UserService {
return {
accountSequence: user.accountSequence.value,
phone: user.phone.value,
phone: user.phone.masked,
source: user.source,
status: user.status,
kycStatus: user.kycStatus,
@ -52,36 +48,6 @@ export class UserService {
};
}
/**
* P2P转账验证
* V2 users synced_legacy_users
*/
async lookupByPhone(phone: string): Promise<{ exists: boolean; accountSequence?: string; nickname?: string }> {
const phoneVO = Phone.create(phone);
// 1. 先查 V2 用户表
const user = await this.userRepository.findByPhone(phoneVO);
if (user && user.status === 'ACTIVE') {
return {
exists: true,
accountSequence: user.accountSequence.value,
nickname: user.isKycVerified ? this.maskName(user.realName!) : user.phone.masked,
};
}
// 2. 查 1.0 同步用户表(未迁移的老用户)
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phoneVO);
if (legacyUser && legacyUser.status === 'ACTIVE' && !legacyUser.migratedToV2) {
return {
exists: true,
accountSequence: legacyUser.accountSequence.value,
nickname: legacyUser.nickname || legacyUser.phone.masked,
};
}
return { exists: false };
}
/**
*
*/

View File

@ -17,7 +17,6 @@ export interface UserProps {
id?: bigint;
phone: Phone;
passwordHash: string;
tradePasswordHash?: string; // 支付密码(独立于登录密码)
accountSequence: AccountSequence;
status: UserStatus;
kycStatus: KycStatus;
@ -43,7 +42,6 @@ export class UserAggregate {
private _id?: bigint;
private _phone: Phone;
private _passwordHash: string;
private _tradePasswordHash?: string; // 支付密码哈希
private _accountSequence: AccountSequence;
private _status: UserStatus;
private _kycStatus: KycStatus;
@ -65,7 +63,6 @@ export class UserAggregate {
this._id = props.id;
this._phone = props.phone;
this._passwordHash = props.passwordHash;
this._tradePasswordHash = props.tradePasswordHash;
this._accountSequence = props.accountSequence;
this._status = props.status;
this._kycStatus = props.kycStatus;
@ -123,17 +120,6 @@ export class UserAggregate {
return this._passwordHash;
}
get tradePasswordHash(): string | undefined {
return this._tradePasswordHash;
}
/**
*
*/
get hasTradePassword(): boolean {
return this._tradePasswordHash !== undefined && this._tradePasswordHash !== null;
}
get accountSequence(): AccountSequence {
return this._accountSequence;
}
@ -250,34 +236,6 @@ export class UserAggregate {
this._updatedAt = new Date();
}
/**
*
*/
async setTradePassword(newPlainPassword: string): Promise<void> {
const password = await Password.create(newPlainPassword);
this._tradePasswordHash = password.hash;
this._updatedAt = new Date();
}
/**
*
*/
async verifyTradePassword(plainPassword: string): Promise<boolean> {
if (!this._tradePasswordHash) {
return false;
}
const password = Password.fromHash(this._tradePasswordHash);
return password.verify(plainPassword);
}
/**
*
*/
clearTradePassword(): void {
this._tradePasswordHash = undefined;
this._updatedAt = new Date();
}
/**
*
*/
@ -440,7 +398,6 @@ export class UserAggregate {
id: this._id,
phone: this._phone,
passwordHash: this._passwordHash,
tradePasswordHash: this._tradePasswordHash,
accountSequence: this._accountSequence,
status: this._status,
kycStatus: this._kycStatus,

View File

@ -7,7 +7,7 @@ import {
PrismaRefreshTokenRepository,
PrismaSmsVerificationRepository,
} from './persistence/repositories';
import { LegacyUserCdcConsumer, WalletAddressCdcConsumer } from './messaging/cdc';
import { LegacyUserCdcConsumer } from './messaging/cdc';
import { KafkaModule, KafkaProducerService } from './kafka';
import { RedisService } from './redis';
import {
@ -24,7 +24,6 @@ import { ApplicationModule } from '@/application/application.module';
providers: [
// CDC
LegacyUserCdcConsumer,
WalletAddressCdcConsumer,
// Kafka Producer
KafkaProducerService,

View File

@ -1,2 +1 @@
export * from './legacy-user-cdc.consumer';
export * from './wallet-address-cdc.consumer';

View File

@ -1,243 +0,0 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
import { Prisma, PrismaClient } from '@prisma/client';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
/** Prisma 事务客户端类型 */
type TransactionClient = Omit<
PrismaClient,
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
>;
/**
* ExtractNewRecordState
* identity-service wallet_addresses + Debezium
*/
interface UnwrappedCdcWalletAddress {
// 1.0 identity-service wallet_addresses 表字段
address_id: number;
user_id: number;
chain_type: string;
address: string;
public_key: string;
address_digest: string;
mpc_signature_r: string;
mpc_signature_s: string;
mpc_signature_v: number;
status: string;
bound_at: number; // timestamp in milliseconds
// Debezium ExtractNewRecordState 添加的元数据字段
__op: 'c' | 'u' | 'd' | 'r';
__table: string;
__source_ts_ms: number;
__deleted?: string;
}
/**
* CDC Consumer - 1.0
* Debezium CDC synced_wallet_addresses
*
* Transactional Idempotent Consumer
* - CDC exactly-once
* - processed_cdc_events
* -
*/
@Injectable()
export class WalletAddressCdcConsumer implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WalletAddressCdcConsumer.name);
private kafka: Kafka;
private consumer: Consumer;
private isConnected = false;
private topic: string;
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
const brokers = this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(',');
this.kafka = new Kafka({
clientId: 'auth-service-cdc-wallet',
brokers,
});
this.consumer = this.kafka.consumer({
groupId: this.configService.get<string>('CDC_CONSUMER_GROUP', 'auth-service-cdc-group') + '-wallet',
});
this.topic = this.configService.get<string>(
'CDC_TOPIC_WALLET_ADDRESSES',
'cdc.identity.public.wallet_addresses',
);
}
async onModuleInit() {
if (this.configService.get('CDC_ENABLED', 'true') !== 'true') {
this.logger.log('Wallet Address CDC Consumer is disabled');
return;
}
try {
await this.consumer.connect();
this.isConnected = true;
await this.consumer.subscribe({ topic: this.topic, fromBeginning: true });
await this.consumer.run({
eachMessage: async (payload) => {
await this.handleMessage(payload);
},
});
this.logger.log(
`Wallet Address CDC Consumer started, listening to topic: ${this.topic}`,
);
} catch (error) {
this.logger.error('Failed to start Wallet Address CDC Consumer', error);
}
}
async onModuleDestroy() {
if (this.isConnected) {
await this.consumer.disconnect();
this.logger.log('Wallet Address CDC Consumer disconnected');
}
}
private async handleMessage(payload: EachMessagePayload) {
const { topic, partition, message } = payload;
if (!message.value) return;
const offset = BigInt(message.offset);
const idempotencyKey = `${topic}:${offset}`;
try {
const cdcEvent: UnwrappedCdcWalletAddress = JSON.parse(message.value.toString());
const op = cdcEvent.__op;
const tableName = cdcEvent.__table || 'wallet_addresses';
this.logger.log(`[CDC] Processing wallet address event: topic=${topic}, offset=${offset}, op=${op}`);
await this.processWithIdempotency(topic, offset, tableName, op, cdcEvent);
this.logger.log(`[CDC] Successfully processed wallet address event: ${idempotencyKey}`);
} catch (error: any) {
if (error.code === 'P2002') {
this.logger.debug(`[CDC] Skipping duplicate wallet address event: ${idempotencyKey}`);
return;
}
this.logger.error(
`[CDC] Failed to process wallet address message from ${topic}[${partition}], offset=${offset}`,
error,
);
}
}
/**
*
*/
private async processWithIdempotency(
topic: string,
offset: bigint,
tableName: string,
operation: string,
event: UnwrappedCdcWalletAddress,
): Promise<void> {
await this.prisma.$transaction(async (tx) => {
// 1. 尝试插入幂等记录
try {
await tx.processedCdcEvent.create({
data: {
sourceTopic: topic,
offset: offset,
tableName: tableName,
operation: operation,
},
});
} catch (error: any) {
if (error.code === 'P2002') {
this.logger.debug(`[CDC] Wallet address event already processed: ${topic}:${offset}`);
return;
}
throw error;
}
// 2. 执行业务逻辑
await this.processCdcEvent(event, offset, tx);
}, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
timeout: 30000,
});
}
private async processCdcEvent(
event: UnwrappedCdcWalletAddress,
sequenceNum: bigint,
tx: TransactionClient,
): Promise<void> {
const op = event.__op;
const isDeleted = event.__deleted === 'true';
if (isDeleted || op === 'd') {
await this.deleteWalletAddress(event.address_id, tx);
return;
}
switch (op) {
case 'c':
case 'r':
case 'u':
await this.upsertWalletAddress(event, sequenceNum, tx);
break;
}
}
private async upsertWalletAddress(
walletAddress: UnwrappedCdcWalletAddress,
sequenceNum: bigint,
tx: TransactionClient,
): Promise<void> {
await tx.syncedWalletAddress.upsert({
where: { legacyAddressId: BigInt(walletAddress.address_id) },
update: {
legacyUserId: BigInt(walletAddress.user_id),
chainType: walletAddress.chain_type,
address: walletAddress.address,
publicKey: walletAddress.public_key,
status: walletAddress.status,
sourceSequenceNum: sequenceNum,
syncedAt: new Date(),
},
create: {
legacyAddressId: BigInt(walletAddress.address_id),
legacyUserId: BigInt(walletAddress.user_id),
chainType: walletAddress.chain_type,
address: walletAddress.address,
publicKey: walletAddress.public_key,
status: walletAddress.status,
legacyBoundAt: new Date(walletAddress.bound_at),
sourceSequenceNum: sequenceNum,
},
});
this.logger.debug(
`[CDC] Synced wallet address: addressId=${walletAddress.address_id}, chain=${walletAddress.chain_type}`,
);
}
private async deleteWalletAddress(addressId: number, tx: TransactionClient): Promise<void> {
try {
await tx.syncedWalletAddress.update({
where: { legacyAddressId: BigInt(addressId) },
data: { status: 'DELETED' },
});
this.logger.debug(`[CDC] Marked wallet address as deleted: ${addressId}`);
} catch (error) {
this.logger.error(`[CDC] Failed to mark wallet address as deleted: ${addressId}`, error);
}
}
}

View File

@ -45,7 +45,6 @@ export class PrismaUserRepository implements UserRepository {
const data = {
phone: snapshot.phone.value,
passwordHash: snapshot.passwordHash,
tradePasswordHash: snapshot.tradePasswordHash,
accountSequence: snapshot.accountSequence.value,
status: snapshot.status,
kycStatus: snapshot.kycStatus,
@ -121,7 +120,6 @@ export class PrismaUserRepository implements UserRepository {
id: user.id,
phone: Phone.create(user.phone),
passwordHash: user.passwordHash,
tradePasswordHash: user.tradePasswordHash,
accountSequence: AccountSequence.create(user.accountSequence),
status: user.status as UserStatus,
kycStatus: user.kycStatus as KycStatus,

View File

@ -1,78 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
/**
* @title EnergyUSDT
* @dev Fixed supply ERC-20 token - NO MINTING CAPABILITY
* Total Supply: 10,002,000,000 (100.02 Billion) tokens with 6 decimals (matching USDT)
*
* IMPORTANT: This contract has NO mint function and NO way to increase supply.
* All tokens are minted to the deployer at construction time.
*/
contract EnergyUSDT {
string public constant name = "Energy USDT";
string public constant symbol = "eUSDT";
uint8 public constant decimals = 6;
// Fixed total supply: 100.02 billion tokens (10,002,000,000 * 10^6)
uint256 public constant totalSupply = 10_002_000_000 * 10**6;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
/**
* @dev Constructor - mints entire fixed supply to deployer
* No mint function exists - supply is permanently fixed
*/
constructor() {
_balances[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(_balances[msg.sender] >= amount, "Insufficient balance");
unchecked {
_balances[msg.sender] -= amount;
_balances[to] += amount;
}
emit Transfer(msg.sender, to, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "Approve to zero address");
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(from != address(0), "Transfer from zero address");
require(to != address(0), "Transfer to zero address");
require(_balances[from] >= amount, "Insufficient balance");
require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");
unchecked {
_balances[from] -= amount;
_balances[to] += amount;
_allowances[from][msg.sender] -= amount;
}
emit Transfer(from, to, amount);
return true;
}
}

View File

@ -1,81 +0,0 @@
# eUSDT (Energy USDT)
## 代币信息
| 属性 | 值 |
|------|-----|
| 名称 | Energy USDT |
| 符号 | eUSDT |
| 精度 | 6 decimals |
| 总供应量 | 10,002,000,000 (100.02亿) |
| 标准 | ERC-20 |
| 部署链 | KAVA Mainnet (Chain ID: 2222) |
## 合约特性
- **固定供应量**100.02亿代币,部署时全部铸造给部署者
- **不可增发**:合约中没有 mint 函数,供应量永久固定
- **不可销毁**:合约层面无销毁功能
- **不可升级**:合约逻辑永久固定
- **标准ERC-20**完全兼容所有主流钱包和DEX
## 部署步骤
### 1. 安装依赖
```bash
cd backend/services/blockchain-service/contracts/eUSDT
npm install
```
### 2. 编译合约
```bash
node compile.mjs
```
编译后会在 `build/` 目录生成:
- `EnergyUSDT.abi` - 合约ABI
- `EnergyUSDT.bin` - 合约字节码
### 3. 部署合约
确保部署账户有足够的 KAVA 支付 gas 费(约 0.02 KAVA
```bash
node deploy.mjs
```
## 合约函数
| 函数 | 说明 |
|------|------|
| `name()` | 返回 "Energy USDT" |
| `symbol()` | 返回 "eUSDT" |
| `decimals()` | 返回 6 |
| `totalSupply()` | 返回 10,002,000,000 * 10^6 |
| `balanceOf(address)` | 查询账户余额 |
| `transfer(address, uint256)` | 转账 |
| `approve(address, uint256)` | 授权额度 |
| `transferFrom(address, address, uint256)` | 代理转账 |
| `allowance(address, address)` | 查询授权额度 |
## 事件
| 事件 | 说明 |
|------|------|
| `Transfer(from, to, value)` | 转账事件 |
| `Approval(owner, spender, value)` | 授权事件 |
## 部署信息
| 网络 | 合约地址 | 区块浏览器 |
|------|---------|-----------|
| KAVA Mainnet | `0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931` | https://kavascan.com/address/0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931 |
**部署详情:**
- 部署者/代币拥有者:`0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E`
- 私钥:`0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
- 初始持有量10,002,000,000 eUSDT全部代币
- 交易哈希:`0x5bebaa4a35378438ba5c891972024a1766935d2e01397a33502aa99e956a6b19`
- 部署时间2026-01-19

View File

@ -1,51 +0,0 @@
import solc from 'solc';
import fs from 'fs';
const source = fs.readFileSync('EnergyUSDT.sol', 'utf8');
const input = {
language: 'Solidity',
sources: {
'EnergyUSDT.sol': {
content: source
}
},
settings: {
optimizer: {
enabled: true,
runs: 200
},
evmVersion: 'paris', // Use paris to avoid PUSH0
outputSelection: {
'*': {
'*': ['abi', 'evm.bytecode']
}
}
}
};
const output = JSON.parse(solc.compile(JSON.stringify(input)));
if (output.errors) {
output.errors.forEach(err => {
console.log(err.formattedMessage);
});
// Check for actual errors (not just warnings)
const hasErrors = output.errors.some(err => err.severity === 'error');
if (hasErrors) {
process.exit(1);
}
}
const contract = output.contracts['EnergyUSDT.sol']['EnergyUSDT'];
const bytecode = contract.evm.bytecode.object;
const abi = contract.abi;
fs.mkdirSync('build', { recursive: true });
fs.writeFileSync('build/EnergyUSDT.bin', bytecode);
fs.writeFileSync('build/EnergyUSDT.abi', JSON.stringify(abi, null, 2));
console.log('Compiled successfully!');
console.log('Bytecode length:', bytecode.length);
console.log('ABI functions:', abi.filter(x => x.type === 'function').map(x => x.name).join(', '));

View File

@ -1,86 +0,0 @@
import { ethers } from 'ethers';
import fs from 'fs';
// Same deployer account as dUSDT
const PRIVATE_KEY = '0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a';
const RPC_URL = 'https://evm.kava.io';
// Contract bytecode
const BYTECODE = '0x' + fs.readFileSync('build/EnergyUSDT.bin', 'utf8');
const ABI = JSON.parse(fs.readFileSync('build/EnergyUSDT.abi', 'utf8'));
async function deploy() {
// Connect to Kava mainnet
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
console.log('Deployer address:', wallet.address);
// Check balance
const balance = await provider.getBalance(wallet.address);
console.log('Balance:', ethers.formatEther(balance), 'KAVA');
if (parseFloat(ethers.formatEther(balance)) < 0.01) {
console.error('Insufficient KAVA balance for deployment!');
process.exit(1);
}
// Get network info
const network = await provider.getNetwork();
console.log('Chain ID:', network.chainId.toString());
// Create contract factory
const factory = new ethers.ContractFactory(ABI, BYTECODE, wallet);
console.log('Deploying EnergyUSDT (eUSDT) contract...');
// Deploy
const contract = await factory.deploy();
console.log('Transaction hash:', contract.deploymentTransaction().hash);
// Wait for deployment
console.log('Waiting for confirmation...');
await contract.waitForDeployment();
const contractAddress = await contract.getAddress();
console.log('Contract deployed at:', contractAddress);
// Verify deployment
console.log('\nVerifying deployment...');
const name = await contract.name();
const symbol = await contract.symbol();
const decimals = await contract.decimals();
const totalSupply = await contract.totalSupply();
const ownerBalance = await contract.balanceOf(wallet.address);
console.log('Token name:', name);
console.log('Token symbol:', symbol);
console.log('Decimals:', decimals.toString());
console.log('Total supply:', ethers.formatUnits(totalSupply, 6), 'eUSDT');
console.log('Owner balance:', ethers.formatUnits(ownerBalance, 6), 'eUSDT');
console.log('\n=== DEPLOYMENT COMPLETE ===');
console.log('Contract Address:', contractAddress);
console.log('Explorer:', `https://kavascan.com/address/${contractAddress}`);
// Save deployment info
const deploymentInfo = {
network: 'KAVA Mainnet',
chainId: 2222,
contractAddress,
deployer: wallet.address,
transactionHash: contract.deploymentTransaction().hash,
deployedAt: new Date().toISOString(),
token: {
name,
symbol,
decimals: decimals.toString(),
totalSupply: totalSupply.toString()
}
};
fs.writeFileSync('deployment.json', JSON.stringify(deploymentInfo, null, 2));
console.log('\nDeployment info saved to deployment.json');
}
deploy().catch(console.error);

View File

@ -1,14 +0,0 @@
{
"network": "KAVA Mainnet",
"chainId": 2222,
"contractAddress": "0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931",
"deployer": "0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E",
"transactionHash": "0x5bebaa4a35378438ba5c891972024a1766935d2e01397a33502aa99e956a6b19",
"deployedAt": "2026-01-19T13:25:28.071Z",
"token": {
"name": "Energy USDT",
"symbol": "eUSDT",
"decimals": "6",
"totalSupply": "10002000000000000"
}
}

View File

@ -1,222 +0,0 @@
{
"name": "eusdt-contract",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "eusdt-contract",
"version": "1.0.0",
"dependencies": {
"ethers": "^6.9.0",
"solc": "^0.8.19"
}
},
"node_modules/@adraffy/ens-normalize": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
"integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
"license": "MIT"
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/node": {
"version": "22.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/aes-js": {
"version": "4.0.0-beta.5",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
"license": "MIT"
},
"node_modules/command-exists": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
"integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==",
"license": "MIT"
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ethers": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
"integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/ethers-io/"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@adraffy/ens-normalize": "1.10.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.2",
"@types/node": "22.7.5",
"aes-js": "4.0.0-beta.5",
"tslib": "2.7.0",
"ws": "8.17.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"license": "MIT"
},
"node_modules/memorystream": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
"integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==",
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/solc": {
"version": "0.8.19",
"resolved": "https://registry.npmjs.org/solc/-/solc-0.8.19.tgz",
"integrity": "sha512-yqurS3wzC4LdEvmMobODXqprV4MYJcVtinuxgrp61ac8K2zz40vXA0eSAskSHPgv8dQo7Nux39i3QBsHx4pqyA==",
"license": "MIT",
"dependencies": {
"command-exists": "^1.2.8",
"commander": "^8.1.0",
"follow-redirects": "^1.12.1",
"js-sha3": "0.8.0",
"memorystream": "^0.3.1",
"semver": "^5.5.0",
"tmp": "0.0.33"
},
"bin": {
"solcjs": "solc.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"license": "MIT",
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"license": "0BSD"
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@ -1,14 +0,0 @@
{
"name": "eusdt-contract",
"version": "1.0.0",
"type": "module",
"description": "Energy USDT (eUSDT) ERC-20 Token Contract",
"scripts": {
"compile": "node compile.mjs",
"deploy": "node deploy.mjs"
},
"dependencies": {
"ethers": "^6.9.0",
"solc": "^0.8.19"
}
}

View File

@ -1,78 +0,0 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
/**
* @title FutureUSDT
* @dev Fixed supply ERC-20 token - NO MINTING CAPABILITY
* Total Supply: 1,000,000,000,000 (1 Trillion) tokens with 6 decimals (matching USDT)
*
* IMPORTANT: This contract has NO mint function and NO way to increase supply.
* All tokens are minted to the deployer at construction time.
*/
contract FutureUSDT {
string public constant name = "Future USDT";
string public constant symbol = "fUSDT";
uint8 public constant decimals = 6;
// Fixed total supply: 1 trillion tokens (1,000,000,000,000 * 10^6)
uint256 public constant totalSupply = 1_000_000_000_000 * 10**6;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
/**
* @dev Constructor - mints entire fixed supply to deployer
* No mint function exists - supply is permanently fixed
*/
constructor() {
_balances[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(_balances[msg.sender] >= amount, "Insufficient balance");
unchecked {
_balances[msg.sender] -= amount;
_balances[to] += amount;
}
emit Transfer(msg.sender, to, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "Approve to zero address");
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(from != address(0), "Transfer from zero address");
require(to != address(0), "Transfer to zero address");
require(_balances[from] >= amount, "Insufficient balance");
require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");
unchecked {
_balances[from] -= amount;
_balances[to] += amount;
_allowances[from][msg.sender] -= amount;
}
emit Transfer(from, to, amount);
return true;
}
}

View File

@ -1,81 +0,0 @@
# fUSDT (Future USDT)
## 代币信息
| 属性 | 值 |
|------|-----|
| 名称 | Future USDT |
| 符号 | fUSDT |
| 精度 | 6 decimals |
| 总供应量 | 1,000,000,000,000 (1万亿) |
| 标准 | ERC-20 |
| 部署链 | KAVA Mainnet (Chain ID: 2222) |
## 合约特性
- **固定供应量**1万亿代币部署时全部铸造给部署者
- **不可增发**:合约中没有 mint 函数,供应量永久固定
- **不可销毁**:合约层面无销毁功能
- **不可升级**:合约逻辑永久固定
- **标准ERC-20**完全兼容所有主流钱包和DEX
## 部署步骤
### 1. 安装依赖
```bash
cd backend/services/blockchain-service/contracts/fUSDT
npm install
```
### 2. 编译合约
```bash
node compile.mjs
```
编译后会在 `build/` 目录生成:
- `FutureUSDT.abi` - 合约ABI
- `FutureUSDT.bin` - 合约字节码
### 3. 部署合约
确保部署账户有足够的 KAVA 支付 gas 费(约 0.02 KAVA
```bash
node deploy.mjs
```
## 合约函数
| 函数 | 说明 |
|------|------|
| `name()` | 返回 "Future USDT" |
| `symbol()` | 返回 "fUSDT" |
| `decimals()` | 返回 6 |
| `totalSupply()` | 返回 1,000,000,000,000 * 10^6 |
| `balanceOf(address)` | 查询账户余额 |
| `transfer(address, uint256)` | 转账 |
| `approve(address, uint256)` | 授权额度 |
| `transferFrom(address, address, uint256)` | 代理转账 |
| `allowance(address, address)` | 查询授权额度 |
## 事件
| 事件 | 说明 |
|------|------|
| `Transfer(from, to, value)` | 转账事件 |
| `Approval(owner, spender, value)` | 授权事件 |
## 部署信息
| 网络 | 合约地址 | 区块浏览器 |
|------|---------|-----------|
| KAVA Mainnet | `0x14dc4f7d3E4197438d058C3D156dd9826A161134` | https://kavascan.com/address/0x14dc4f7d3E4197438d058C3D156dd9826A161134 |
**部署详情:**
- 部署者/代币拥有者:`0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E`
- 私钥:`0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
- 初始持有量1,000,000,000,000 fUSDT全部代币
- 交易哈希:`0x071f535971bc3a134dd26c182b6f05c53f0c3783e91fe6ef471d6c914e4cdb06`
- 部署时间2026-01-19

View File

@ -1,51 +0,0 @@
import solc from 'solc';
import fs from 'fs';
const source = fs.readFileSync('FutureUSDT.sol', 'utf8');
const input = {
language: 'Solidity',
sources: {
'FutureUSDT.sol': {
content: source
}
},
settings: {
optimizer: {
enabled: true,
runs: 200
},
evmVersion: 'paris', // Use paris to avoid PUSH0
outputSelection: {
'*': {
'*': ['abi', 'evm.bytecode']
}
}
}
};
const output = JSON.parse(solc.compile(JSON.stringify(input)));
if (output.errors) {
output.errors.forEach(err => {
console.log(err.formattedMessage);
});
// Check for actual errors (not just warnings)
const hasErrors = output.errors.some(err => err.severity === 'error');
if (hasErrors) {
process.exit(1);
}
}
const contract = output.contracts['FutureUSDT.sol']['FutureUSDT'];
const bytecode = contract.evm.bytecode.object;
const abi = contract.abi;
fs.mkdirSync('build', { recursive: true });
fs.writeFileSync('build/FutureUSDT.bin', bytecode);
fs.writeFileSync('build/FutureUSDT.abi', JSON.stringify(abi, null, 2));
console.log('Compiled successfully!');
console.log('Bytecode length:', bytecode.length);
console.log('ABI functions:', abi.filter(x => x.type === 'function').map(x => x.name).join(', '));

View File

@ -1,86 +0,0 @@
import { ethers } from 'ethers';
import fs from 'fs';
// Same deployer account as dUSDT
const PRIVATE_KEY = '0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a';
const RPC_URL = 'https://evm.kava.io';
// Contract bytecode
const BYTECODE = '0x' + fs.readFileSync('build/FutureUSDT.bin', 'utf8');
const ABI = JSON.parse(fs.readFileSync('build/FutureUSDT.abi', 'utf8'));
async function deploy() {
// Connect to Kava mainnet
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
console.log('Deployer address:', wallet.address);
// Check balance
const balance = await provider.getBalance(wallet.address);
console.log('Balance:', ethers.formatEther(balance), 'KAVA');
if (parseFloat(ethers.formatEther(balance)) < 0.01) {
console.error('Insufficient KAVA balance for deployment!');
process.exit(1);
}
// Get network info
const network = await provider.getNetwork();
console.log('Chain ID:', network.chainId.toString());
// Create contract factory
const factory = new ethers.ContractFactory(ABI, BYTECODE, wallet);
console.log('Deploying FutureUSDT (fUSDT) contract...');
// Deploy
const contract = await factory.deploy();
console.log('Transaction hash:', contract.deploymentTransaction().hash);
// Wait for deployment
console.log('Waiting for confirmation...');
await contract.waitForDeployment();
const contractAddress = await contract.getAddress();
console.log('Contract deployed at:', contractAddress);
// Verify deployment
console.log('\nVerifying deployment...');
const name = await contract.name();
const symbol = await contract.symbol();
const decimals = await contract.decimals();
const totalSupply = await contract.totalSupply();
const ownerBalance = await contract.balanceOf(wallet.address);
console.log('Token name:', name);
console.log('Token symbol:', symbol);
console.log('Decimals:', decimals.toString());
console.log('Total supply:', ethers.formatUnits(totalSupply, 6), 'fUSDT');
console.log('Owner balance:', ethers.formatUnits(ownerBalance, 6), 'fUSDT');
console.log('\n=== DEPLOYMENT COMPLETE ===');
console.log('Contract Address:', contractAddress);
console.log('Explorer:', `https://kavascan.com/address/${contractAddress}`);
// Save deployment info
const deploymentInfo = {
network: 'KAVA Mainnet',
chainId: 2222,
contractAddress,
deployer: wallet.address,
transactionHash: contract.deploymentTransaction().hash,
deployedAt: new Date().toISOString(),
token: {
name,
symbol,
decimals: decimals.toString(),
totalSupply: totalSupply.toString()
}
};
fs.writeFileSync('deployment.json', JSON.stringify(deploymentInfo, null, 2));
console.log('\nDeployment info saved to deployment.json');
}
deploy().catch(console.error);

View File

@ -1,14 +0,0 @@
{
"network": "KAVA Mainnet",
"chainId": 2222,
"contractAddress": "0x14dc4f7d3E4197438d058C3D156dd9826A161134",
"deployer": "0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E",
"transactionHash": "0x071f535971bc3a134dd26c182b6f05c53f0c3783e91fe6ef471d6c914e4cdb06",
"deployedAt": "2026-01-19T13:26:05.111Z",
"token": {
"name": "Future USDT",
"symbol": "fUSDT",
"decimals": "6",
"totalSupply": "1000000000000000000"
}
}

View File

@ -1,222 +0,0 @@
{
"name": "fusdt-contract",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fusdt-contract",
"version": "1.0.0",
"dependencies": {
"ethers": "^6.9.0",
"solc": "^0.8.19"
}
},
"node_modules/@adraffy/ens-normalize": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
"integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
"license": "MIT"
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/node": {
"version": "22.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/aes-js": {
"version": "4.0.0-beta.5",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
"license": "MIT"
},
"node_modules/command-exists": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
"integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==",
"license": "MIT"
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ethers": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
"integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/ethers-io/"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@adraffy/ens-normalize": "1.10.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.2",
"@types/node": "22.7.5",
"aes-js": "4.0.0-beta.5",
"tslib": "2.7.0",
"ws": "8.17.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"license": "MIT"
},
"node_modules/memorystream": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
"integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==",
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/solc": {
"version": "0.8.19",
"resolved": "https://registry.npmjs.org/solc/-/solc-0.8.19.tgz",
"integrity": "sha512-yqurS3wzC4LdEvmMobODXqprV4MYJcVtinuxgrp61ac8K2zz40vXA0eSAskSHPgv8dQo7Nux39i3QBsHx4pqyA==",
"license": "MIT",
"dependencies": {
"command-exists": "^1.2.8",
"commander": "^8.1.0",
"follow-redirects": "^1.12.1",
"js-sha3": "0.8.0",
"memorystream": "^0.3.1",
"semver": "^5.5.0",
"tmp": "0.0.33"
},
"bin": {
"solcjs": "solc.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"license": "MIT",
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"license": "0BSD"
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@ -1,14 +0,0 @@
{
"name": "fusdt-contract",
"version": "1.0.0",
"type": "module",
"description": "Future USDT (fUSDT) ERC-20 Token Contract",
"scripts": {
"compile": "node compile.mjs",
"deploy": "node deploy.mjs"
},
"dependencies": {
"ethers": "^6.9.0",
"solc": "^0.8.19"
}
}

View File

@ -30,8 +30,6 @@ export default registerAs('blockchain', () => {
? {
// KAVA Testnet
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.testnet.kava.io',
// 逗号分隔的多个 RPC URL用于故障转移可选不配置则仅使用 rpcUrl
rpcUrls: process.env.KAVA_RPC_URLS || '',
chainId: parseInt(process.env.KAVA_CHAIN_ID || '2221', 10),
// 测试网 USDT 合约 (自定义部署的 TestUSDT)
usdtContract: process.env.KAVA_USDT_CONTRACT || '0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF',
@ -40,8 +38,6 @@ export default registerAs('blockchain', () => {
: {
// KAVA Mainnet
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.kava.io',
// 逗号分隔的多个 RPC URL用于故障转移可选不配置则仅使用 rpcUrl
rpcUrls: process.env.KAVA_RPC_URLS || '',
chainId: parseInt(process.env.KAVA_CHAIN_ID || '2222', 10),
// dUSDT (绿积分) 合约地址 - Durian USDT, 精度6位
usdtContract: process.env.KAVA_USDT_CONTRACT || '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3',
@ -53,7 +49,6 @@ export default registerAs('blockchain', () => {
? {
// BSC Testnet (BNB Smart Chain Testnet)
rpcUrl: process.env.BSC_RPC_URL || 'https://data-seed-prebsc-1-s1.binance.org:8545',
rpcUrls: process.env.BSC_RPC_URLS || '',
chainId: parseInt(process.env.BSC_CHAIN_ID || '97', 10),
// BSC Testnet 官方测试 USDT 合约
usdtContract: process.env.BSC_USDT_CONTRACT || '0x337610d27c682E347C9cD60BD4b3b107C9d34dDd',
@ -62,7 +57,6 @@ export default registerAs('blockchain', () => {
: {
// BSC Mainnet
rpcUrl: process.env.BSC_RPC_URL || 'https://bsc-dataseed.binance.org',
rpcUrls: process.env.BSC_RPC_URLS || '',
chainId: parseInt(process.env.BSC_CHAIN_ID || '56', 10),
usdtContract: process.env.BSC_USDT_CONTRACT || '0x55d398326f99059fF775485246999027B3197955',
confirmations: parseInt(process.env.BSC_CONFIRMATIONS || '15', 10),

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { ConfirmationPolicyService, ChainConfigService, RpcProviderManager } from './services';
import { ConfirmationPolicyService, ChainConfigService } from './services';
import { Erc20TransferService } from './services/erc20-transfer.service';
@Module({
providers: [ConfirmationPolicyService, ChainConfigService, RpcProviderManager, Erc20TransferService],
exports: [ConfirmationPolicyService, ChainConfigService, RpcProviderManager, Erc20TransferService],
providers: [ConfirmationPolicyService, ChainConfigService, Erc20TransferService],
exports: [ConfirmationPolicyService, ChainConfigService, Erc20TransferService],
})
export class DomainModule {}

View File

@ -7,8 +7,6 @@ export interface ChainConfig {
chainType: ChainTypeEnum;
chainId: number;
rpcUrl: string;
/** RPC URL 列表(含主端点和备选端点),用于故障转移 */
rpcUrls: string[];
usdtContract: string;
nativeSymbol: string;
blockTime: number; // 平均出块时间(秒)
@ -44,13 +42,6 @@ export class ChainConfigService {
'blockchain.kava.rpcUrl',
this.isTestnet ? 'https://evm.testnet.kava.io' : 'https://evm.kava.io',
),
rpcUrls: this.parseRpcUrls(
'blockchain.kava.rpcUrls',
this.configService.get<string>(
'blockchain.kava.rpcUrl',
this.isTestnet ? 'https://evm.testnet.kava.io' : 'https://evm.kava.io',
),
),
// dUSDT (绿积分) 合约地址 - Durian USDT, 精度6位
usdtContract: this.configService.get<string>(
'blockchain.kava.usdtContract',
@ -70,13 +61,6 @@ export class ChainConfigService {
'blockchain.bsc.rpcUrl',
this.isTestnet ? 'https://data-seed-prebsc-1-s1.binance.org:8545' : 'https://bsc-dataseed.binance.org',
),
rpcUrls: this.parseRpcUrls(
'blockchain.bsc.rpcUrls',
this.configService.get<string>(
'blockchain.bsc.rpcUrl',
this.isTestnet ? 'https://data-seed-prebsc-1-s1.binance.org:8545' : 'https://bsc-dataseed.binance.org',
),
),
usdtContract: this.configService.get<string>(
'blockchain.bsc.usdtContract',
this.isTestnet ? '0x337610d27c682E347C9cD60BD4b3b107C9d34dDd' : '0x55d398326f99059fF775485246999027B3197955',
@ -130,24 +114,4 @@ export class ChainConfigService {
isSupported(chainType: ChainType): boolean {
return this.configs.has(chainType.value);
}
/**
* RPC URL
*
* URL KAVA_RPC_URLS使
* 退 rpcUrl
*/
private parseRpcUrls(configKey: string, fallbackUrl: string): string[] {
const urlsStr = this.configService.get<string>(configKey, '');
if (urlsStr) {
const urls = urlsStr
.split(',')
.map((u) => u.trim())
.filter((u) => u.length > 0);
if (urls.length > 0) {
return urls;
}
}
return [fallbackUrl];
}
}

View File

@ -10,7 +10,6 @@ import {
recoverAddress,
} from 'ethers';
import { ChainConfigService } from './chain-config.service';
import { RpcProviderManager } from './rpc-provider-manager.service';
import { ChainType } from '@/domain/value-objects';
import { ChainTypeEnum } from '@/domain/enums';
@ -48,16 +47,16 @@ export const MPC_SIGNING_CLIENT = Symbol('MPC_SIGNING_CLIENT');
@Injectable()
export class Erc20TransferService {
private readonly logger = new Logger(Erc20TransferService.name);
private readonly providers: Map<ChainTypeEnum, JsonRpcProvider> = new Map();
private readonly hotWalletAddress: string;
private mpcSigningClient: IMpcSigningClient | null = null;
constructor(
private readonly configService: ConfigService,
private readonly chainConfig: ChainConfigService,
private readonly rpcProviderManager: RpcProviderManager,
) {
this.hotWalletAddress = this.configService.get<string>('HOT_WALLET_ADDRESS', '');
this.initializeWalletConfig();
this.initializeProviders();
}
/**
@ -68,40 +67,19 @@ export class Erc20TransferService {
this.logger.log(`[INIT] MPC Signing Client injected`);
}
/**
* provider RpcProviderManager
*/
private getProvider(chainType: ChainTypeEnum): JsonRpcProvider {
return this.rpcProviderManager.getProvider(chainType);
}
private initializeProviders(): void {
// 为每条支持的链创建 Provider
for (const chainType of this.chainConfig.getSupportedChains()) {
try {
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
const provider = new JsonRpcProvider(config.rpcUrl, config.chainId);
this.providers.set(chainType, provider);
this.logger.log(`[INIT] Provider initialized for ${chainType}: ${config.rpcUrl}`);
} catch (error) {
this.logger.error(`[INIT] Failed to initialize provider for ${chainType}`, error);
}
}
/**
* RPC
* RPC 503revert
*/
private isRpcConnectionError(error: any): boolean {
const message = (error?.message || '').toLowerCase();
return (
message.includes('could not detect network') ||
message.includes('connection refused') ||
message.includes('timeout') ||
message.includes('econnrefused') ||
message.includes('enotfound') ||
message.includes('503') ||
message.includes('502') ||
message.includes('server error') ||
message.includes('missing response') ||
message.includes('request failed') ||
error?.code === 'NETWORK_ERROR' ||
error?.code === 'SERVER_ERROR' ||
error?.code === 'TIMEOUT'
);
}
/**
* provider RpcProviderManager
*/
private initializeWalletConfig(): void {
// 检查热钱包地址配置
if (this.hotWalletAddress) {
this.logger.log(`[INIT] Hot wallet address configured: ${this.hotWalletAddress}`);
@ -122,7 +100,10 @@ export class Erc20TransferService {
* USDT
*/
async getHotWalletBalance(chainType: ChainTypeEnum): Promise<string> {
const provider = this.getProvider(chainType);
const provider = this.providers.get(chainType);
if (!provider) {
throw new Error(`Provider not configured for chain: ${chainType}`);
}
if (!this.hotWalletAddress) {
throw new Error('Hot wallet address not configured');
@ -155,7 +136,12 @@ export class Erc20TransferService {
this.logger.log(`[TRANSFER] To: ${toAddress}`);
this.logger.log(`[TRANSFER] Amount: ${amount} USDT`);
const provider = this.getProvider(chainType);
const provider = this.providers.get(chainType);
if (!provider) {
const error = `Provider not configured for chain: ${chainType}`;
this.logger.error(`[TRANSFER] ${error}`);
return { success: false, error };
}
if (!this.mpcSigningClient || !this.mpcSigningClient.isConfigured()) {
const error = 'MPC signing client not configured';
@ -310,7 +296,6 @@ export class Erc20TransferService {
this.logger.log(`[TRANSFER] Block: ${receipt.blockNumber}`);
this.logger.log(`[TRANSFER] Gas used: ${receipt.gasUsed.toString()}`);
this.rpcProviderManager.reportSuccess(chainType);
return {
success: true,
txHash: txResponse.hash,
@ -323,9 +308,6 @@ export class Erc20TransferService {
return { success: false, txHash: txResponse.hash, error };
}
} catch (error: any) {
if (this.isRpcConnectionError(error)) {
this.rpcProviderManager.reportFailure(chainType, error);
}
this.logger.error(`[TRANSFER] Transfer failed:`, error);
return {
success: false,
@ -338,11 +320,8 @@ export class Erc20TransferService {
*
*/
isConfigured(chainType: ChainTypeEnum): boolean {
try {
this.rpcProviderManager.getProvider(chainType);
return !!this.hotWalletAddress && !!this.mpcSigningClient?.isConfigured();
} catch {
return false;
}
return this.providers.has(chainType) &&
!!this.hotWalletAddress &&
!!this.mpcSigningClient?.isConfigured();
}
}

View File

@ -1,3 +1,2 @@
export * from './confirmation-policy.service';
export * from './chain-config.service';
export * from './rpc-provider-manager.service';

View File

@ -1,212 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { JsonRpcProvider } from 'ethers';
import { ChainConfigService } from './chain-config.service';
import { ChainTypeEnum } from '@/domain/enums';
/**
* RPC
*/
interface RpcHealthState {
/** 当前活跃的 JsonRpcProvider 实例 */
provider: JsonRpcProvider;
/** 该链可用的所有 RPC URL 列表(第一个为默认主端点) */
urls: string[];
/** 当前使用的 URL 在 urls 数组中的索引 */
currentIndex: number;
/** 该链的 chainId用于创建新 provider */
chainId: number;
/** 首次连续失败的时间戳null 表示当前健康) */
firstFailureAt: number | null;
/** 连续失败次数(用于日志) */
consecutiveFailures: number;
}
/**
* RPC Provider
*
* JsonRpcProvider RPC
* FAILOVER_THRESHOLD_MS 3
*
*
* 使:
* - EvmProviderAdapter Erc20TransferService provider
* - RPC reportSuccess(chain)
* - RPC reportFailure(chain, error)
* - URL
*
* :
* - KAVA_RPC_URLS: 逗号分隔的多个 Kava RPC URL使 KAVA_RPC_URL
* - BSC_RPC_URLS: 逗号分隔的多个 BSC RPC URL使 BSC_RPC_URL
*/
@Injectable()
export class RpcProviderManager implements OnModuleInit {
private readonly logger = new Logger(RpcProviderManager.name);
private readonly healthStates: Map<ChainTypeEnum, RpcHealthState> = new Map();
/** 持续失败多久后触发端点切换(毫秒),默认 3 分钟 */
private readonly FAILOVER_THRESHOLD_MS = 3 * 60 * 1000;
constructor(private readonly chainConfig: ChainConfigService) {}
onModuleInit(): void {
this.initializeAllChains();
}
/**
* provider
* ChainConfig rpcUrls provider
*/
private initializeAllChains(): void {
for (const chainType of this.chainConfig.getSupportedChains()) {
const config = this.chainConfig.getConfig(
{ value: chainType, toString: () => chainType } as any,
);
const urls = config.rpcUrls;
const primaryUrl = urls[0];
const provider = new JsonRpcProvider(primaryUrl, config.chainId);
this.healthStates.set(chainType, {
provider,
urls,
currentIndex: 0,
chainId: config.chainId,
firstFailureAt: null,
consecutiveFailures: 0,
});
if (urls.length > 1) {
this.logger.log(
`[INIT] ${chainType} RPC 端点列表 (${urls.length} 个): ${urls.join(', ')}`,
);
} else {
this.logger.log(
`[INIT] ${chainType} RPC 端点: ${primaryUrl}(未配置备选端点)`,
);
}
}
}
/**
* provider
*
* @param chain
* @returns JsonRpcProvider
* @throws Error
*/
getProvider(chain: ChainTypeEnum): JsonRpcProvider {
const state = this.healthStates.get(chain);
if (!state) {
throw new Error(`RPC Provider 未初始化: ${chain}`);
}
return state.provider;
}
/**
* 使 RPC URL/
*/
getCurrentUrl(chain: ChainTypeEnum): string {
const state = this.healthStates.get(chain);
return state ? state.urls[state.currentIndex] : 'unknown';
}
/**
* RPC URL
*/
getUrlCount(chain: ChainTypeEnum): number {
const state = this.healthStates.get(chain);
return state ? state.urls.length : 0;
}
/**
* RPC
*
*/
reportSuccess(chain: ChainTypeEnum): void {
const state = this.healthStates.get(chain);
if (!state) return;
// 如果之前处于失败状态,记录恢复日志
if (state.firstFailureAt !== null) {
this.logger.log(
`[${chain}] RPC 恢复正常: ${state.urls[state.currentIndex]}` +
` (之前连续失败 ${state.consecutiveFailures} 次)`,
);
}
state.firstFailureAt = null;
state.consecutiveFailures = 0;
}
/**
* RPC
*
* FAILOVER_THRESHOLD_MS
*
*
* @param chain
* @param error
*/
reportFailure(chain: ChainTypeEnum, error?: Error): void {
const state = this.healthStates.get(chain);
if (!state) return;
const now = Date.now();
state.consecutiveFailures++;
// 首次失败:记录起始时间
if (state.firstFailureAt === null) {
state.firstFailureAt = now;
this.logger.warn(
`[${chain}] RPC 开始失败: ${state.urls[state.currentIndex]}` +
`${error?.message || 'unknown error'}`,
);
return;
}
// 检查是否超过故障转移阈值
const elapsedMs = now - state.firstFailureAt;
if (elapsedMs >= this.FAILOVER_THRESHOLD_MS) {
this.switchToNextUrl(chain, state);
} else {
// 每 30 秒输出一条持续失败日志,避免日志洪水
if (state.consecutiveFailures % 6 === 0) {
this.logger.warn(
`[${chain}] RPC 持续失败中 (${Math.round(elapsedMs / 1000)}s / ` +
`${this.FAILOVER_THRESHOLD_MS / 1000}s): ` +
`${state.urls[state.currentIndex]}`,
);
}
}
}
/**
* RPC URL
*
* urls JsonRpcProvider
* URL provider
*/
private switchToNextUrl(chain: ChainTypeEnum, state: RpcHealthState): void {
const oldUrl = state.urls[state.currentIndex];
// 轮转到下一个 URL
state.currentIndex = (state.currentIndex + 1) % state.urls.length;
const newUrl = state.urls[state.currentIndex];
if (state.urls.length === 1) {
this.logger.error(
`[${chain}] 仅有一个 RPC URL无法切换到备选端点将重新创建 provider: ${newUrl}`,
);
} else {
this.logger.warn(
`[${chain}] === RPC 端点切换 === ${oldUrl}${newUrl}`,
);
}
// 创建新的 provider 实例ethers.js v6 的 JsonRpcProvider 创建后 URL 不可变)
state.provider = new JsonRpcProvider(newUrl, state.chainId);
// 重置失败状态,给新端点一个全新的 3 分钟窗口
state.firstFailureAt = null;
state.consecutiveFailures = 0;
}
}

View File

@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { JsonRpcProvider, Contract } from 'ethers';
import { RpcProviderManager } from '@/domain/services/rpc-provider-manager.service';
import { ChainConfigService } from '@/domain/services/chain-config.service';
import { ChainType, BlockNumber, TokenAmount } from '@/domain/value-objects';
import { ChainTypeEnum } from '@/domain/enums';
@ -28,71 +28,53 @@ export interface TransferEvent {
/**
* EVM
*
* EVM RpcProviderManager provider
* RPC /
* EVM
*/
@Injectable()
export class EvmProviderAdapter {
private readonly logger = new Logger(EvmProviderAdapter.name);
private readonly providers: Map<ChainTypeEnum, JsonRpcProvider> = new Map();
constructor(private readonly rpcProviderManager: RpcProviderManager) {}
/**
* provider RpcProviderManager
*/
private getProvider(chainType: ChainType): JsonRpcProvider {
return this.rpcProviderManager.getProvider(chainType.value);
constructor(private readonly chainConfig: ChainConfigService) {
this.initializeProviders();
}
/**
* RPC /
*
* RPC
* - reportSuccess()
* - reportFailure() 3
* - re-throw
*/
private async executeWithFailover<T>(
chainType: ChainType,
operation: () => Promise<T>,
): Promise<T> {
try {
const result = await operation();
this.rpcProviderManager.reportSuccess(chainType.value);
return result;
} catch (error) {
this.rpcProviderManager.reportFailure(
chainType.value,
error instanceof Error ? error : new Error(String(error)),
);
throw error;
private initializeProviders(): void {
for (const chainType of this.chainConfig.getSupportedChains()) {
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
const provider = new JsonRpcProvider(config.rpcUrl, config.chainId);
this.providers.set(chainType, provider);
this.logger.log(`Initialized provider for ${chainType}: ${config.rpcUrl}`);
}
}
private getProvider(chainType: ChainType): JsonRpcProvider {
const provider = this.providers.get(chainType.value);
if (!provider) {
throw new Error(`No provider for chain: ${chainType.toString()}`);
}
return provider;
}
/**
*
*/
async getCurrentBlockNumber(chainType: ChainType): Promise<BlockNumber> {
return this.executeWithFailover(chainType, async () => {
const provider = this.getProvider(chainType);
const blockNumber = await provider.getBlockNumber();
return BlockNumber.create(blockNumber);
});
const provider = this.getProvider(chainType);
const blockNumber = await provider.getBlockNumber();
return BlockNumber.create(blockNumber);
}
/**
*
*/
async getBlockTimestamp(chainType: ChainType, blockNumber: BlockNumber): Promise<Date> {
return this.executeWithFailover(chainType, async () => {
const provider = this.getProvider(chainType);
const block = await provider.getBlock(blockNumber.asNumber);
if (!block) {
throw new Error(`Block not found: ${blockNumber.toString()}`);
}
return new Date(block.timestamp * 1000);
});
const provider = this.getProvider(chainType);
const block = await provider.getBlock(blockNumber.asNumber);
if (!block) {
throw new Error(`Block not found: ${blockNumber.toString()}`);
}
return new Date(block.timestamp * 1000);
}
/**
@ -104,40 +86,38 @@ export class EvmProviderAdapter {
toBlock: BlockNumber,
tokenContract: string,
): Promise<TransferEvent[]> {
return this.executeWithFailover(chainType, async () => {
const provider = this.getProvider(chainType);
const contract = new Contract(tokenContract, ERC20_TRANSFER_EVENT_ABI, provider);
const provider = this.getProvider(chainType);
const contract = new Contract(tokenContract, ERC20_TRANSFER_EVENT_ABI, provider);
const filter = contract.filters.Transfer();
const logs = await contract.queryFilter(filter, fromBlock.asNumber, toBlock.asNumber);
const filter = contract.filters.Transfer();
const logs = await contract.queryFilter(filter, fromBlock.asNumber, toBlock.asNumber);
const events: TransferEvent[] = [];
const events: TransferEvent[] = [];
for (const log of logs) {
const block = await provider.getBlock(log.blockNumber);
if (!block) continue;
for (const log of logs) {
const block = await provider.getBlock(log.blockNumber);
if (!block) continue;
const parsedLog = contract.interface.parseLog({
topics: log.topics as string[],
data: log.data,
const parsedLog = contract.interface.parseLog({
topics: log.topics as string[],
data: log.data,
});
if (parsedLog) {
events.push({
txHash: log.transactionHash,
logIndex: log.index,
blockNumber: BigInt(log.blockNumber),
blockTimestamp: new Date(block.timestamp * 1000),
from: parsedLog.args[0],
to: parsedLog.args[1],
value: parsedLog.args[2],
tokenContract,
});
if (parsedLog) {
events.push({
txHash: log.transactionHash,
logIndex: log.index,
blockNumber: BigInt(log.blockNumber),
blockTimestamp: new Date(block.timestamp * 1000),
from: parsedLog.args[0],
to: parsedLog.args[1],
value: parsedLog.args[2],
tokenContract,
});
}
}
}
return events;
});
return events;
}
/**
@ -148,50 +128,42 @@ export class EvmProviderAdapter {
tokenContract: string,
address: string,
): Promise<TokenAmount> {
return this.executeWithFailover(chainType, async () => {
const provider = this.getProvider(chainType);
const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider);
const [balance, decimals] = await Promise.all([
contract.balanceOf(address),
contract.decimals(),
]);
return TokenAmount.fromRaw(balance, Number(decimals));
});
const provider = this.getProvider(chainType);
const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider);
const [balance, decimals] = await Promise.all([
contract.balanceOf(address),
contract.decimals(),
]);
return TokenAmount.fromRaw(balance, Number(decimals));
}
/**
* ERC20 decimals
*/
async getTokenDecimals(chainType: ChainType, tokenContract: string): Promise<number> {
return this.executeWithFailover(chainType, async () => {
const provider = this.getProvider(chainType);
const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider);
const decimals = await contract.decimals();
return Number(decimals);
});
const provider = this.getProvider(chainType);
const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider);
const decimals = await contract.decimals();
return Number(decimals);
}
/**
*
*/
async getNativeBalance(chainType: ChainType, address: string): Promise<TokenAmount> {
return this.executeWithFailover(chainType, async () => {
const provider = this.getProvider(chainType);
const balance = await provider.getBalance(address);
return TokenAmount.fromRaw(balance, 18);
});
const provider = this.getProvider(chainType);
const balance = await provider.getBalance(address);
return TokenAmount.fromRaw(balance, 18);
}
/**
* 广
*/
async broadcastTransaction(chainType: ChainType, signedTx: string): Promise<string> {
return this.executeWithFailover(chainType, async () => {
const provider = this.getProvider(chainType);
const txResponse = await provider.broadcastTransaction(signedTx);
this.logger.log(`Transaction broadcasted: ${txResponse.hash}`);
return txResponse.hash;
});
const provider = this.getProvider(chainType);
const txResponse = await provider.broadcastTransaction(signedTx);
this.logger.log(`Transaction broadcasted: ${txResponse.hash}`);
return txResponse.hash;
}
/**
@ -202,11 +174,9 @@ export class EvmProviderAdapter {
txHash: string,
confirmations: number = 1,
): Promise<boolean> {
return this.executeWithFailover(chainType, async () => {
const provider = this.getProvider(chainType);
const receipt = await provider.waitForTransaction(txHash, confirmations);
return receipt !== null && receipt.status === 1;
});
const provider = this.getProvider(chainType);
const receipt = await provider.waitForTransaction(txHash, confirmations);
return receipt !== null && receipt.status === 1;
}
/**
@ -217,14 +187,12 @@ export class EvmProviderAdapter {
txHash: string,
requiredConfirmations: number,
): Promise<boolean> {
return this.executeWithFailover(chainType, async () => {
const provider = this.getProvider(chainType);
const receipt = await provider.getTransactionReceipt(txHash);
if (!receipt) return false;
const provider = this.getProvider(chainType);
const receipt = await provider.getTransactionReceipt(txHash);
if (!receipt) return false;
const currentBlock = await provider.getBlockNumber();
const confirmations = currentBlock - receipt.blockNumber;
return confirmations >= requiredConfirmations;
});
const currentBlock = await provider.getBlockNumber();
const confirmations = currentBlock - receipt.blockNumber;
return confirmations >= requiredConfirmations;
}
}

View File

@ -72,7 +72,6 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
private kafka: Kafka;
private consumer: Consumer;
private isConnected = false;
private isShuttingDown = false;
private keygenCompletedHandler?: MpcEventHandler<KeygenCompletedPayload>;
private signingCompletedHandler?: MpcEventHandler<SigningCompletedPayload>;
@ -112,71 +111,24 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
heartbeatInterval: 3000,
});
// 监听 consumer crash 事件,自动重连
// 当 Kafka topic-partition 不可用或其他运行时错误导致 consumer 崩溃时触发
this.consumer.on(this.consumer.events.CRASH, async (event) => {
if (this.isShuttingDown) return;
this.logger.error(`[CRASH] Kafka consumer crashed: ${event.payload.error?.message || 'unknown'}, restart: ${event.payload.restart}`);
// 如果 KafkaJS 内部不自动重启restart=false手动触发重连
if (!event.payload.restart) {
this.logger.warn(`[CRASH] KafkaJS will not auto-restart, triggering manual reconnect...`);
this.isConnected = false;
await this.connectWithRetry();
}
});
try {
this.logger.log(`[CONNECT] Connecting MPC Event consumer...`);
await this.consumer.connect();
this.isConnected = true;
this.logger.log(`[CONNECT] MPC Event Kafka consumer connected successfully`);
await this.connectWithRetry();
}
// Subscribe to MPC topics
await this.consumer.subscribe({ topics: Object.values(MPC_TOPICS), fromBeginning: false });
this.logger.log(`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`);
/**
* 退
*
* Kafka topic-partition subscribe()
* "This server does not host this topic-partition" catch
* consumer MPC signing timeout 300s
*
* 退2s4s8s...60s 10 5
*/
private async connectWithRetry(maxRetries = 10): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
if (this.isShuttingDown) return;
try {
if (!this.isConnected) {
this.logger.log(`[CONNECT] Connecting MPC Event consumer (attempt ${attempt}/${maxRetries})...`);
await this.consumer.connect();
this.isConnected = true;
this.logger.log(`[CONNECT] MPC Event Kafka consumer connected successfully`);
}
// Subscribe to MPC topics
await this.consumer.subscribe({ topics: Object.values(MPC_TOPICS), fromBeginning: false });
this.logger.log(`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`);
// Start consuming
await this.startConsuming();
return; // 成功,退出重试循环
} catch (error: any) {
this.logger.error(`[ERROR] Failed to connect/subscribe Kafka consumer (attempt ${attempt}/${maxRetries}): ${error.message}`);
if (attempt < maxRetries) {
// 指数退避2s, 4s, 8s, 16s, 32s, 60s, 60s, ...
const delay = Math.min(2000 * Math.pow(2, attempt - 1), 60000);
this.logger.log(`[RETRY] Will retry in ${delay / 1000}s...`);
await new Promise(resolve => setTimeout(resolve, delay));
// 断开连接以清理状态,下次循环重新建立
try { await this.consumer.disconnect(); } catch (_) {}
this.isConnected = false;
}
}
// Start consuming
await this.startConsuming();
} catch (error) {
this.logger.error(`[ERROR] Failed to connect MPC Event Kafka consumer`, error);
}
this.logger.error(`[FATAL] Failed to connect Kafka consumer after ${maxRetries} attempts. MPC events will NOT be received!`);
}
async onModuleDestroy() {
this.isShuttingDown = true;
if (this.isConnected) {
await this.consumer.disconnect();
this.logger.log('MPC Event Kafka consumer disconnected');

View File

@ -1,6 +1,7 @@
-- ============================================================================
-- contribution-service 初始化 migration
-- 合并自: 0001_init, 0002_add_transactional_idempotency, 20250120000001_add_region_to_system_accounts
-- 合并自: 20260111000000_init, 20260111100000_add_referral_user_ids,
-- 20260112020000_fix_status_varchar_length, 20260112200000_add_adoption_province_city
-- ============================================================================
-- ============================================
@ -227,9 +228,8 @@ CREATE INDEX "unallocated_contributions_status_idx" ON "unallocated_contribution
CREATE TABLE "system_accounts" (
"id" BIGSERIAL NOT NULL,
"account_type" TEXT NOT NULL,
"region_code" TEXT,
"name" TEXT NOT NULL,
"account_type" VARCHAR(20) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"contribution_balance" DECIMAL(30,10) NOT NULL DEFAULT 0,
"contribution_never_expires" BOOLEAN NOT NULL DEFAULT false,
"version" INTEGER NOT NULL DEFAULT 1,
@ -239,26 +239,18 @@ CREATE TABLE "system_accounts" (
CONSTRAINT "system_accounts_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "system_accounts_account_type_region_code_key" ON "system_accounts"("account_type", "region_code");
CREATE INDEX "system_accounts_account_type_idx" ON "system_accounts"("account_type");
CREATE INDEX "system_accounts_region_code_idx" ON "system_accounts"("region_code");
CREATE UNIQUE INDEX "system_accounts_account_type_key" ON "system_accounts"("account_type");
CREATE TABLE "system_contribution_records" (
"id" BIGSERIAL NOT NULL,
"system_account_id" BIGINT NOT NULL,
"source_adoption_id" BIGINT NOT NULL,
"source_account_sequence" VARCHAR(20) NOT NULL,
-- 来源类型: FIXED_RATE(固定比例) / LEVEL_OVERFLOW(层级溢出) / LEVEL_NO_ANCESTOR(无上线) / BONUS_TIER_1/2/3(团队奖励未解锁)
"source_type" VARCHAR(30) NOT NULL,
-- 层级深度1-15仅对 LEVEL_OVERFLOW 和 LEVEL_NO_ANCESTOR 类型有效
"level_depth" INTEGER,
"distribution_rate" DECIMAL(10,6) NOT NULL,
"amount" DECIMAL(30,10) NOT NULL,
"effective_date" DATE NOT NULL,
"expire_date" DATE,
"is_expired" BOOLEAN NOT NULL DEFAULT false,
-- 软删除时间戳
"deleted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "system_contribution_records_pkey" PRIMARY KEY ("id")
@ -266,8 +258,6 @@ CREATE TABLE "system_contribution_records" (
CREATE INDEX "system_contribution_records_system_account_id_idx" ON "system_contribution_records"("system_account_id");
CREATE INDEX "system_contribution_records_source_adoption_id_idx" ON "system_contribution_records"("source_adoption_id");
CREATE INDEX "system_contribution_records_source_type_idx" ON "system_contribution_records"("source_type");
CREATE INDEX "system_contribution_records_deleted_at_idx" ON "system_contribution_records"("deleted_at");
ALTER TABLE "system_contribution_records" ADD CONSTRAINT "system_contribution_records_system_account_id_fkey" FOREIGN KEY ("system_account_id") REFERENCES "system_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@ -337,36 +327,20 @@ CREATE TABLE "cdc_sync_progress" (
CREATE UNIQUE INDEX "cdc_sync_progress_source_topic_key" ON "cdc_sync_progress"("source_topic");
-- 2.0 服务间 Outbox 事件幂等表
CREATE TABLE "processed_events" (
"id" BIGSERIAL NOT NULL,
"event_id" VARCHAR(100) NOT NULL,
"event_type" VARCHAR(50) NOT NULL,
"source_service" VARCHAR(100) NOT NULL,
"source_service" VARCHAR(50),
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "processed_events_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "processed_events_source_service_event_id_key" ON "processed_events"("source_service", "event_id");
CREATE UNIQUE INDEX "processed_events_event_id_key" ON "processed_events"("event_id");
CREATE INDEX "processed_events_event_type_idx" ON "processed_events"("event_type");
CREATE INDEX "processed_events_processed_at_idx" ON "processed_events"("processed_at");
-- 1.0 CDC 事件幂等表
CREATE TABLE "processed_cdc_events" (
"id" BIGSERIAL NOT NULL,
"source_topic" VARCHAR(200) NOT NULL,
"offset" BIGINT NOT NULL,
"table_name" VARCHAR(100) NOT NULL,
"operation" VARCHAR(10) NOT NULL,
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "processed_cdc_events_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "processed_cdc_events_source_topic_offset_key" ON "processed_cdc_events"("source_topic", "offset");
CREATE INDEX "processed_cdc_events_processed_at_idx" ON "processed_cdc_events"("processed_at");
-- ============================================
-- 9. 配置表
-- ============================================

View File

@ -0,0 +1,45 @@
-- ============================================================================
-- 添加事务性幂等消费支持
-- 用于 1.0 -> 2.0 CDC 同步的 100% exactly-once 语义
-- ============================================================================
-- 1. 创建 processed_cdc_events 表(用于 CDC 事件幂等)
-- 唯一键: (source_topic, offset) - Kafka topic 名称 + 消息偏移量
-- 用于保证每个 CDC 事件只处理一次exactly-once 语义)
CREATE TABLE IF NOT EXISTS "processed_cdc_events" (
"id" BIGSERIAL NOT NULL,
"source_topic" VARCHAR(200) NOT NULL, -- Kafka topic 名称(如 cdc.identity.public.user_accounts
"offset" BIGINT NOT NULL, -- Kafka 消息偏移量(在 partition 内唯一)
"table_name" VARCHAR(100) NOT NULL, -- 源表名
"operation" VARCHAR(10) NOT NULL, -- CDC 操作类型: c(create), u(update), d(delete), r(snapshot read)
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "processed_cdc_events_pkey" PRIMARY KEY ("id")
);
-- 复合唯一索引:(source_topic, offset) 保证幂等性
-- 注意:这不是数据库自增 ID而是 Kafka 消息的唯一标识
CREATE UNIQUE INDEX "processed_cdc_events_source_topic_offset_key" ON "processed_cdc_events"("source_topic", "offset");
-- 时间索引用于清理旧数据
CREATE INDEX "processed_cdc_events_processed_at_idx" ON "processed_cdc_events"("processed_at");
-- 2. 修复 processed_events 表(用于 2.0 服务间 Outbox 事件幂等)
-- 唯一键: (source_service, event_id) - 服务名 + outbox 表的 ID
-- 不同服务的 outbox ID 可能相同,所以需要组合服务名作为复合唯一键
-- 2.1 修改 source_service 列:扩展长度 50->100且设为 NOT NULL
-- 先为已有 NULL 值设置默认值
UPDATE "processed_events" SET "source_service" = 'unknown' WHERE "source_service" IS NULL;
-- 修改列类型和约束
ALTER TABLE "processed_events"
ALTER COLUMN "source_service" SET NOT NULL,
ALTER COLUMN "source_service" TYPE VARCHAR(100);
-- 2.2 删除旧的单字段唯一索引
DROP INDEX IF EXISTS "processed_events_event_id_key";
-- 2.3 创建新的复合唯一索引
-- 索引名使用蛇形命名以与列名保持一致
CREATE UNIQUE INDEX IF NOT EXISTS "processed_events_source_service_event_id_key" ON "processed_events"("source_service", "event_id");

View File

@ -299,10 +299,9 @@ model UnallocatedContribution {
// 系统账户(运营/省/市/总部)
model SystemAccount {
id BigInt @id @default(autoincrement())
accountType String @map("account_type") // OPERATION / PROVINCE / CITY / HEADQUARTERS
regionCode String? @map("region_code") // 省/市代码,如 440000, 440100
name String
id BigInt @id @default(autoincrement())
accountType String @unique @map("account_type") @db.VarChar(20) // OPERATION / PROVINCE / CITY / HEADQUARTERS
name String @db.VarChar(100)
contributionBalance Decimal @default(0) @map("contribution_balance") @db.Decimal(30, 10)
contributionNeverExpires Boolean @default(false) @map("contribution_never_expires")
@ -314,9 +313,6 @@ model SystemAccount {
records SystemContributionRecord[]
@@unique([accountType, regionCode])
@@index([accountType])
@@index([regionCode])
@@map("system_accounts")
}
@ -327,11 +323,6 @@ model SystemContributionRecord {
sourceAdoptionId BigInt @map("source_adoption_id")
sourceAccountSequence String @map("source_account_sequence") @db.VarChar(20)
// 来源类型FIXED_RATE(固定比例分配) / LEVEL_OVERFLOW(层级溢出) / LEVEL_NO_ANCESTOR(无上线) / BONUS_TIER_1/2/3(团队奖励未解锁)
sourceType String @map("source_type") @db.VarChar(30)
// 层级深度:对于 LEVEL_OVERFLOW 和 LEVEL_NO_ANCESTOR 类型表示第几级1-15
levelDepth Int? @map("level_depth")
distributionRate Decimal @map("distribution_rate") @db.Decimal(10, 6)
amount Decimal @map("amount") @db.Decimal(30, 10)
@ -339,15 +330,12 @@ model SystemContributionRecord {
expireDate DateTime? @map("expire_date") @db.Date
isExpired Boolean @default(false) @map("is_expired")
createdAt DateTime @default(now()) @map("created_at")
deletedAt DateTime? @map("deleted_at") // 软删除标记
createdAt DateTime @default(now()) @map("created_at")
systemAccount SystemAccount @relation(fields: [systemAccountId], references: [id])
@@index([systemAccountId])
@@index([sourceAdoptionId])
@@index([deletedAt])
@@index([sourceType])
@@map("system_contribution_records")
}

View File

@ -10,8 +10,6 @@ import {
AdoptionSyncedEvent,
ContributionRecordSyncedEvent,
NetworkProgressUpdatedEvent,
SystemAccountSyncedEvent,
UnallocatedContributionSyncedEvent,
} from '../../domain/events';
import { Public } from '../../shared/guards/jwt-auth.guard';
@ -422,190 +420,4 @@ export class AdminController {
};
}
}
@Post('system-accounts/publish-all')
@Public()
@ApiOperation({ summary: '发布所有系统账户算力事件到 outbox用于同步到 mining-service' })
async publishAllSystemAccounts(): Promise<{
success: boolean;
publishedCount: number;
message: string;
}> {
try {
const systemAccounts = await this.prisma.systemAccount.findMany();
await this.unitOfWork.executeInTransaction(async () => {
const events = systemAccounts.map((account) => {
const event = new SystemAccountSyncedEvent(
account.accountType,
account.regionCode,
account.name,
account.contributionBalance.toString(),
account.createdAt,
);
return {
aggregateType: SystemAccountSyncedEvent.AGGREGATE_TYPE,
aggregateId: `${account.accountType}:${account.regionCode || 'null'}`,
eventType: SystemAccountSyncedEvent.EVENT_TYPE,
payload: event.toPayload(),
};
});
await this.outboxRepository.saveMany(events);
});
this.logger.log(`Published ${systemAccounts.length} system account events`);
return {
success: true,
publishedCount: systemAccounts.length,
message: `Published ${systemAccounts.length} system account events`,
};
} catch (error) {
this.logger.error('Failed to publish system accounts', error);
return {
success: false,
publishedCount: 0,
message: `Failed: ${error.message}`,
};
}
}
@Get('system-accounts')
@Public()
@ApiOperation({ summary: '获取所有系统账户算力' })
async getSystemAccounts() {
const systemAccounts = await this.prisma.systemAccount.findMany();
return {
accounts: systemAccounts.map((a) => ({
accountType: a.accountType,
name: a.name,
contributionBalance: a.contributionBalance.toString(),
createdAt: a.createdAt,
updatedAt: a.updatedAt,
})),
total: systemAccounts.length,
};
}
@Get('unallocated-contributions')
@Public()
@ApiOperation({ summary: '获取所有未分配算力列表,供 mining-service 定时同步' })
async getUnallocatedContributions(): Promise<{
contributions: Array<{
sourceAdoptionId: string;
sourceAccountSequence: string;
wouldBeAccountSequence: string | null;
contributionType: string;
amount: string;
reason: string | null;
effectiveDate: string;
expireDate: string;
}>;
total: number;
}> {
const unallocatedContributions = await this.prisma.unallocatedContribution.findMany({
where: { status: 'PENDING' },
select: {
sourceAdoptionId: true,
sourceAccountSequence: true,
wouldBeAccountSequence: true,
unallocType: true,
amount: true,
reason: true,
effectiveDate: true,
expireDate: true,
},
});
return {
contributions: unallocatedContributions.map((uc) => ({
sourceAdoptionId: uc.sourceAdoptionId.toString(),
sourceAccountSequence: uc.sourceAccountSequence,
wouldBeAccountSequence: uc.wouldBeAccountSequence,
contributionType: uc.unallocType,
amount: uc.amount.toString(),
reason: uc.reason,
effectiveDate: uc.effectiveDate.toISOString(),
expireDate: uc.expireDate.toISOString(),
})),
total: unallocatedContributions.length,
};
}
@Post('unallocated-contributions/publish-all')
@Public()
@ApiOperation({ summary: '发布所有未分配算力事件到 outbox用于同步到 mining-service' })
async publishAllUnallocatedContributions(): Promise<{
success: boolean;
publishedCount: number;
failedCount: number;
message: string;
}> {
const unallocatedContributions = await this.prisma.unallocatedContribution.findMany({
where: { status: 'PENDING' },
select: {
id: true,
sourceAdoptionId: true,
sourceAccountSequence: true,
wouldBeAccountSequence: true,
unallocType: true,
amount: true,
reason: true,
effectiveDate: true,
expireDate: true,
},
});
let publishedCount = 0;
let failedCount = 0;
const batchSize = 100;
for (let i = 0; i < unallocatedContributions.length; i += batchSize) {
const batch = unallocatedContributions.slice(i, i + batchSize);
try {
await this.unitOfWork.executeInTransaction(async () => {
const events = batch.map((uc) => {
const event = new UnallocatedContributionSyncedEvent(
uc.sourceAdoptionId,
uc.sourceAccountSequence,
uc.wouldBeAccountSequence,
uc.unallocType,
uc.amount.toString(),
uc.reason,
uc.effectiveDate,
uc.expireDate,
);
return {
aggregateType: UnallocatedContributionSyncedEvent.AGGREGATE_TYPE,
aggregateId: `${uc.sourceAdoptionId}-${uc.unallocType}`,
eventType: UnallocatedContributionSyncedEvent.EVENT_TYPE,
payload: event.toPayload(),
};
});
await this.outboxRepository.saveMany(events);
});
publishedCount += batch.length;
this.logger.debug(`Published unallocated contribution batch ${Math.floor(i / batchSize) + 1}: ${batch.length} events`);
} catch (error) {
failedCount += batch.length;
this.logger.error(`Failed to publish unallocated contribution batch ${Math.floor(i / batchSize) + 1}`, error);
}
}
this.logger.log(`Published ${publishedCount} unallocated contribution events, ${failedCount} failed`);
return {
success: failedCount === 0,
publishedCount,
failedCount,
message: `Published ${publishedCount} events, ${failedCount} failed out of ${unallocatedContributions.length} total`,
};
}
}

View File

@ -1,10 +1,8 @@
import { Controller, Get, Param, Query, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import { GetContributionAccountQuery } from '../../application/queries/get-contribution-account.query';
import { GetContributionStatsQuery } from '../../application/queries/get-contribution-stats.query';
import { GetContributionRankingQuery } from '../../application/queries/get-contribution-ranking.query';
import { GetPlantingLedgerQuery, PlantingLedgerDto } from '../../application/queries/get-planting-ledger.query';
import { GetTeamTreeQuery, DirectReferralsResponseDto, MyTeamInfoDto } from '../../application/queries/get-team-tree.query';
import {
ContributionAccountResponse,
ContributionRecordsResponse,
@ -13,7 +11,6 @@ import {
import { ContributionStatsResponse } from '../dto/response/contribution-stats.response';
import { ContributionRankingResponse, UserRankResponse } from '../dto/response/contribution-ranking.response';
import { GetContributionRecordsRequest } from '../dto/request/get-records.request';
import { Public } from '../../shared/guards/jwt-auth.guard';
@ApiTags('Contribution')
@Controller('contribution')
@ -22,12 +19,9 @@ export class ContributionController {
private readonly getAccountQuery: GetContributionAccountQuery,
private readonly getStatsQuery: GetContributionStatsQuery,
private readonly getRankingQuery: GetContributionRankingQuery,
private readonly getPlantingLedgerQuery: GetPlantingLedgerQuery,
private readonly getTeamTreeQuery: GetTeamTreeQuery,
) {}
@Get('stats')
@Public()
@ApiOperation({ summary: '获取算力统计数据' })
@ApiResponse({ status: 200, type: ContributionStatsResponse })
async getStats(): Promise<ContributionStatsResponse> {
@ -101,52 +95,4 @@ export class ContributionController {
}
return result;
}
@Get('accounts/:accountSequence/planting-ledger')
@ApiOperation({ summary: '获取账户认种分类账' })
@ApiParam({ name: 'accountSequence', description: '账户序号' })
@ApiQuery({ name: 'page', required: false, type: Number, description: '页码' })
@ApiQuery({ name: 'pageSize', required: false, type: Number, description: '每页数量' })
@ApiResponse({ status: 200, description: '认种分类账' })
async getPlantingLedger(
@Param('accountSequence') accountSequence: string,
@Query('page') page?: number,
@Query('pageSize') pageSize?: number,
): Promise<PlantingLedgerDto> {
return this.getPlantingLedgerQuery.execute(
accountSequence,
page ?? 1,
pageSize ?? 20,
);
}
// ========== 团队树 API ==========
@Get('accounts/:accountSequence/team')
@ApiOperation({ summary: '获取账户团队信息' })
@ApiParam({ name: 'accountSequence', description: '账户序号' })
@ApiResponse({ status: 200, description: '团队信息' })
async getMyTeamInfo(
@Param('accountSequence') accountSequence: string,
): Promise<MyTeamInfoDto> {
return this.getTeamTreeQuery.getMyTeamInfo(accountSequence);
}
@Get('accounts/:accountSequence/team/direct-referrals')
@ApiOperation({ summary: '获取账户直推列表(用于伞下树懒加载)' })
@ApiParam({ name: 'accountSequence', description: '账户序号' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: '每页数量' })
@ApiQuery({ name: 'offset', required: false, type: Number, description: '偏移量' })
@ApiResponse({ status: 200, description: '直推列表' })
async getDirectReferrals(
@Param('accountSequence') accountSequence: string,
@Query('limit') limit?: number,
@Query('offset') offset?: number,
): Promise<DirectReferralsResponseDto> {
return this.getTeamTreeQuery.getDirectReferrals(
accountSequence,
limit ?? 100,
offset ?? 0,
);
}
}

View File

@ -2,7 +2,6 @@ import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
import { RedisService } from '../../infrastructure/redis/redis.service';
import { CDCConsumerService } from '../../infrastructure/kafka/cdc-consumer.service';
import { Public } from '../../shared/guards/jwt-auth.guard';
interface HealthStatus {
@ -21,7 +20,6 @@ export class HealthController {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly cdcConsumer: CDCConsumerService,
) {}
@Get()
@ -70,15 +68,4 @@ export class HealthController {
async live(): Promise<{ alive: boolean }> {
return { alive: true };
}
@Get('cdc-sync')
@ApiOperation({ summary: 'CDC 同步状态检查' })
@ApiResponse({ status: 200, description: 'CDC 同步状态' })
async cdcSyncStatus(): Promise<{
isRunning: boolean;
sequentialMode: boolean;
allPhasesCompleted: boolean;
}> {
return this.cdcConsumer.getSyncStatus();
}
}

View File

@ -16,7 +16,6 @@ import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
envFilePath: [
`.env.${process.env.NODE_ENV || 'development'}`,
'.env',
'../.env', // 父目录共享 .env
],
ignoreEnvFile: false,
}),

Some files were not shown because too many files have changed in this diff Show More