Compare commits

..

No commits in common. "main" and "v1.0.0-before-region-contribution" have entirely different histories.

1036 changed files with 4190 additions and 128226 deletions

View File

@ -792,40 +792,7 @@
"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(dir /b /ad \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\")"
],
"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,405 +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
# ---------------------------------------------------------------------------
# Transfer Service - 树转让服务
# ---------------------------------------------------------------------------
- name: transfer-service
url: http://192.168.1.111:3013
routes:
- name: transfer-api
paths:
- /api/v1/transfers
strip_path: false
- name: transfer-admin-api
paths:
- /api/v1/admin/transfers
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

@ -97,11 +97,6 @@ services:
paths:
- /api/v1/planting
strip_path: false
# [2026-02-27] 新增3171预种计划路由预种控制器 @Controller('pre-planting')
- name: pre-planting-api
paths:
- /api/v1/pre-planting
strip_path: false
# ---------------------------------------------------------------------------
# Referral Service - 推荐服务
@ -233,22 +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
- name: admin-tree-pricing-public
paths:
- /api/v1/tree-pricing
strip_path: false
# ---------------------------------------------------------------------------
# Presence Service - 在线状态服务
@ -291,21 +270,6 @@ services:
- /api/v1/co-managed
strip_path: false
# ---------------------------------------------------------------------------
# Transfer Service - 树转让服务
# ---------------------------------------------------------------------------
- name: transfer-service
url: http://192.168.1.111:3013
routes:
- name: transfer-api
paths:
- /api/v1/transfers
strip_path: false
- name: transfer-admin-api
paths:
- /api/v1/admin/transfers
strip_path: false
# ===========================================================================
# RWA 2.0 Services - 新架构微服务
@ -391,19 +355,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/...

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

@ -65,19 +65,6 @@ server {
client_max_body_size 500M;
# 反向代理到 Kong API Gateway
# Snapshot Service 代理 (admin-web Next.js rewrites 使用)
location /snapshot-api/ {
proxy_pass http://192.168.1.111:3099/;
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;
proxy_connect_timeout 30s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;

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

@ -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

@ -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

@ -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

@ -78,7 +78,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,
@ -196,9 +196,9 @@ fun TransferScreen(
toAddress = toAddress,
amount = amount,
error = error,
onConfirm = { includeServerBackup ->
onConfirm = {
validationError = null
onConfirmTransaction(includeServerBackup) // 传递服务器备份选项
onConfirmTransaction()
},
onBack = onCancel
)
@ -651,15 +651,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 +733,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 +774,7 @@ private fun TransferConfirmScreen(
Text("返回")
}
Button(
onClick = { onConfirm(includeServerBackup) }, // 传递服务器备份选项
onClick = onConfirm,
modifier = Modifier.weight(1f)
) {
Icon(

View File

@ -57,7 +57,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 +157,6 @@ fun WalletsScreen(
onTransfer = {
onTransfer?.invoke(share.id)
},
onHistory = {
onHistory?.invoke(share.id, share.address)
},
onDelete = { onDeleteShare(share.id) }
)
}
@ -229,7 +225,6 @@ private fun WalletItemCard(
walletBalance: WalletBalance? = null,
onViewDetails: () -> Unit,
onTransfer: () -> Unit,
onHistory: () -> Unit,
onDelete: () -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
@ -440,16 +435,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

@ -23,50 +23,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

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

@ -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

@ -1,14 +0,0 @@
-- CreateTable: 预种计划开关配置
CREATE TABLE "pre_planting_configs" (
"id" TEXT NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT false,
"activated_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" VARCHAR(50),
CONSTRAINT "pre_planting_configs_pkey" PRIMARY KEY ("id")
);
-- 插入默认配置(关闭状态)
INSERT INTO "pre_planting_configs" ("id", "is_active", "updated_at")
VALUES (gen_random_uuid(), false, NOW());

View File

@ -1,41 +0,0 @@
-- CreateTable: 认种树定价配置
-- 基础价 15831 USDT 不变supplement 作为加价全额归总部 (S0000000001)
-- 涨价原因:总部运营成本压力
CREATE TABLE "tree_pricing_configs" (
"id" TEXT NOT NULL,
"current_supplement" INTEGER NOT NULL DEFAULT 0,
"auto_increase_enabled" BOOLEAN NOT NULL DEFAULT false,
"auto_increase_amount" INTEGER NOT NULL DEFAULT 0,
"auto_increase_interval_days" INTEGER NOT NULL DEFAULT 0,
"last_auto_increase_at" TIMESTAMP(3),
"next_auto_increase_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL,
"updated_by" VARCHAR(50),
CONSTRAINT "tree_pricing_configs_pkey" PRIMARY KEY ("id")
);
-- 插入默认配置加价为0自动涨价关闭
INSERT INTO "tree_pricing_configs" ("id", "current_supplement", "auto_increase_enabled", "updated_at")
VALUES (gen_random_uuid(), 0, false, NOW());
-- CreateTable: 认种树价格变更审计日志
-- 每次价格变更(手动或自动)都会记录一条不可修改的日志,用于审计追踪
CREATE TYPE "PriceChangeType" AS ENUM ('MANUAL', 'AUTO');
CREATE TABLE "tree_price_change_logs" (
"id" TEXT NOT NULL,
"change_type" "PriceChangeType" NOT NULL,
"previous_supplement" INTEGER NOT NULL,
"new_supplement" INTEGER NOT NULL,
"change_amount" INTEGER NOT NULL,
"reason" VARCHAR(500),
"operator_id" VARCHAR(50),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tree_price_change_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "tree_price_change_logs_created_at_idx" ON "tree_price_change_logs"("created_at");
CREATE INDEX "tree_price_change_logs_change_type_idx" ON "tree_price_change_logs"("change_type");

View File

@ -1,5 +0,0 @@
-- Migration: add requiresForceRead to notifications
-- 为通知表添加"是否需要强制弹窗阅读"字段
-- 管理员可在创建通知时配置此字段,标记为 true 的通知将在用户打开 App 时强制弹窗展示
ALTER TABLE "notifications" ADD COLUMN "requiresForceRead" BOOLEAN NOT NULL DEFAULT false;

View File

@ -59,8 +59,7 @@ model Notification {
targetLogic TargetLogic @default(ANY) @map("target_logic") // 多标签匹配逻辑
imageUrl String? // 可选的图片URL
linkUrl String? // 可选的跳转链接
isEnabled Boolean @default(true) // 是否启用
requiresForceRead Boolean @default(false) // 是否需要强制弹窗阅读(管理员可配置)
isEnabled Boolean @default(true) // 是否启用
publishedAt DateTime? // 发布时间null表示草稿
expiresAt DateTime? // 过期时间null表示永不过期
createdAt DateTime @default(now())
@ -796,9 +795,6 @@ model AuthorizationRoleQueryView {
lastAssessmentMonth String? @map("last_assessment_month")
monthlyTreesAdded Int @default(0) @map("monthly_trees_added")
// 申请时提供的办公室照片MinIO URL
officePhotoUrls String[] @default([]) @map("office_photo_urls")
// 时间戳
createdAt DateTime @map("created_at")
syncedAt DateTime @default(now()) @map("synced_at")
@ -1154,169 +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")
}
// =============================================================================
// 预种计划开关配置
// 控制预种功能的开启/关闭,不影响已完成的业务流程
// =============================================================================
model PrePlantingConfig {
id String @id @default(uuid())
isActive Boolean @default(false) @map("is_active")
activatedAt DateTime? @map("activated_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by") @db.VarChar(50)
@@map("pre_planting_configs")
}
// =============================================================================
// 认种树定价配置 (Tree Pricing Supplement)
// 基础价 15831 USDT 不变supplement 作为加价全额归总部 (S0000000001)
// 正式认种总价 = 15831 + currentSupplement
// 预种总价 = (15831 + currentSupplement) / 5
// =============================================================================
model TreePricingConfig {
id String @id @default(uuid())
currentSupplement Int @default(0) @map("current_supplement")
autoIncreaseEnabled Boolean @default(false) @map("auto_increase_enabled")
autoIncreaseAmount Int @default(0) @map("auto_increase_amount")
autoIncreaseIntervalDays Int @default(0) @map("auto_increase_interval_days")
lastAutoIncreaseAt DateTime? @map("last_auto_increase_at")
nextAutoIncreaseAt DateTime? @map("next_auto_increase_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by") @db.VarChar(50)
@@map("tree_pricing_configs")
}
// =============================================================================
// 认种树价格变更审计日志 (Tree Price Change Audit Log)
// 每次价格变更(手动或自动)都会记录一条不可修改的日志
// =============================================================================
model TreePriceChangeLog {
id String @id @default(uuid())
changeType PriceChangeType @map("change_type")
previousSupplement Int @map("previous_supplement")
newSupplement Int @map("new_supplement")
changeAmount Int @map("change_amount")
reason String? @db.VarChar(500)
operatorId String? @map("operator_id") @db.VarChar(50)
createdAt DateTime @default(now()) @map("created_at")
@@index([createdAt])
@@index([changeType])
@@map("tree_price_change_logs")
}
enum PriceChangeType {
MANUAL // 管理员手动调整
AUTO // 自动涨价任务
}

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,42 +0,0 @@
/**
*
* [2026-03-02]
*
* === ===
* admin-web AuthorizationProxyService authorization-service API
* CDC officePhotoUrls 100%
*/
import { Controller, Get, Query, Logger, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthorizationProxyService, SelfApplyPhotosResponse } from '../../authorization/authorization-proxy.service';
@Controller('admin/authorization-photos')
export class AuthorizationPhotosController {
private readonly logger = new Logger(AuthorizationPhotosController.name);
constructor(
private readonly authorizationProxyService: AuthorizationProxyService,
) {}
/**
*
* GET /admin/authorization-photos?page=1&limit=20&roleType=COMMUNITY
*/
@Get()
@HttpCode(HttpStatus.OK)
async getSelfApplyPhotos(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('roleType') roleType?: string,
): Promise<SelfApplyPhotosResponse> {
this.logger.debug(
`[getSelfApplyPhotos] page=${page}, limit=${limit}, roleType=${roleType || 'ALL'}`,
);
return this.authorizationProxyService.getSelfApplyPhotos({
page: Number(page) || 1,
limit: Number(limit) || 20,
roleType: roleType || undefined,
});
}
}

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

@ -74,7 +74,6 @@ export class AdminNotificationController {
targetConfig,
imageUrl: dto.imageUrl,
linkUrl: dto.linkUrl,
requiresForceRead: dto.requiresForceRead,
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
createdBy: 'admin', // TODO: 从认证信息获取
@ -150,7 +149,6 @@ export class AdminNotificationController {
imageUrl: dto.imageUrl,
linkUrl: dto.linkUrl,
isEnabled: dto.isEnabled,
requiresForceRead: dto.requiresForceRead,
publishedAt: dto.publishedAt !== undefined
? dto.publishedAt
? new Date(dto.publishedAt)

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

@ -26,7 +26,6 @@ import {
IUserDetailQueryRepository,
USER_DETAIL_QUERY_REPOSITORY,
} from '../../domain/repositories/user-detail-query.repository';
import { ReferralProxyService } from '../../referral/referral-proxy.service';
/**
*
@ -41,7 +40,6 @@ export class UserDetailController {
private readonly userQueryRepository: IUserQueryRepository,
@Inject(USER_DETAIL_QUERY_REPOSITORY)
private readonly userDetailRepository: IUserDetailQueryRepository,
private readonly referralProxyService: ReferralProxyService,
) {}
/**
@ -60,12 +58,11 @@ export class UserDetailController {
}
// 并行获取所有相关数据
const [referralInfo, personalAdoptions, teamStats, directReferralCount, prePlantingStats] = await Promise.all([
const [referralInfo, personalAdoptions, teamStats, directReferralCount] = await Promise.all([
this.userDetailRepository.getReferralInfo(accountSequence),
this.userDetailRepository.getPersonalAdoptionCount(accountSequence),
this.userDetailRepository.getTeamStats(accountSequence),
this.userDetailRepository.getDirectReferralCount(accountSequence),
this.referralProxyService.getPrePlantingStats(accountSequence),
]);
// 获取推荐人昵称
@ -90,8 +87,6 @@ export class UserDetailController {
registeredAt: user.registeredAt.toISOString(),
lastActiveAt: user.lastActiveAt?.toISOString() || null,
personalAdoptions: personalAdoptions,
selfPrePlantingPortions: prePlantingStats.selfPrePlantingPortions,
teamPrePlantingPortions: prePlantingStats.teamPrePlantingPortions,
teamAddresses: teamStats.teamAddressCount,
teamAdoptions: teamStats.teamAdoptionCount,
provincialAdoptions: {
@ -138,12 +133,11 @@ export class UserDetailController {
}
// 获取引荐信息和实时统计
const [referralInfo, personalAdoptionCount, directReferralCount, teamStats, prePlantingStats] = await Promise.all([
const [referralInfo, personalAdoptionCount, directReferralCount, teamStats] = await Promise.all([
this.userDetailRepository.getReferralInfo(accountSequence),
this.userDetailRepository.getPersonalAdoptionCount(accountSequence),
this.userDetailRepository.getDirectReferralCount(accountSequence),
this.userDetailRepository.getBatchUserStats([accountSequence]),
this.referralProxyService.getPrePlantingStats(accountSequence),
]);
const currentUserStats = teamStats.get(accountSequence);
@ -154,8 +148,6 @@ export class UserDetailController {
avatar: user.avatarUrl,
personalAdoptions: personalAdoptionCount,
teamAdoptions: currentUserStats?.teamAdoptionCount || 0,
selfPrePlantingPortions: prePlantingStats.selfPrePlantingPortions,
teamPrePlantingPortions: prePlantingStats.teamPrePlantingPortions,
depth: referralInfo?.depth || 0,
directReferralCount: directReferralCount,
isCurrentUser: true,
@ -164,56 +156,34 @@ export class UserDetailController {
let ancestors: ReferralNodeDto[] = [];
let directReferrals: ReferralNodeDto[] = [];
// 收集所有需要查预种的 accountSequences
const allNodeSeqs: string[] = [];
// 向上查询
let ancestorNodes: typeof ancestors extends (infer T)[] ? any[] : never = [];
if (query.direction === 'up' || query.direction === 'both') {
ancestorNodes = await this.userDetailRepository.getAncestors(
const ancestorNodes = await this.userDetailRepository.getAncestors(
accountSequence,
query.depth || 1,
);
allNodeSeqs.push(...ancestorNodes.map((n: any) => n.accountSequence));
}
// 向下查询
let referralNodes: typeof directReferrals extends (infer T)[] ? any[] : never = [];
if (query.direction === 'down' || query.direction === 'both') {
referralNodes = await this.userDetailRepository.getDirectReferrals(accountSequence);
allNodeSeqs.push(...referralNodes.map((n: any) => n.accountSequence));
}
// 批量获取所有节点的预种统计
const batchPrePlanting = allNodeSeqs.length > 0
? await this.referralProxyService.batchGetPrePlantingStats(allNodeSeqs)
: {};
if (ancestorNodes.length > 0) {
ancestors = ancestorNodes.map((node: any) => ({
ancestors = ancestorNodes.map((node) => ({
accountSequence: node.accountSequence,
userId: node.userId.toString(),
nickname: node.nickname,
avatar: node.avatarUrl,
personalAdoptions: node.personalAdoptionCount,
teamAdoptions: node.teamAdoptionCount,
selfPrePlantingPortions: batchPrePlanting[node.accountSequence]?.selfPrePlantingPortions ?? 0,
teamPrePlantingPortions: batchPrePlanting[node.accountSequence]?.teamPrePlantingPortions ?? 0,
depth: node.depth,
directReferralCount: node.directReferralCount,
}));
}
if (referralNodes.length > 0) {
directReferrals = referralNodes.map((node: any) => ({
// 向下查询
if (query.direction === 'down' || query.direction === 'both') {
const referralNodes = await this.userDetailRepository.getDirectReferrals(accountSequence);
directReferrals = referralNodes.map((node) => ({
accountSequence: node.accountSequence,
userId: node.userId.toString(),
nickname: node.nickname,
avatar: node.avatarUrl,
personalAdoptions: node.personalAdoptionCount,
teamAdoptions: node.teamAdoptionCount,
selfPrePlantingPortions: batchPrePlanting[node.accountSequence]?.selfPrePlantingPortions ?? 0,
teamPrePlantingPortions: batchPrePlanting[node.accountSequence]?.teamPrePlantingPortions ?? 0,
depth: node.depth,
directReferralCount: node.directReferralCount,
}));
@ -401,7 +371,6 @@ export class UserDetailController {
monthlyTargetType: role.monthlyTargetType,
lastAssessmentMonth: role.lastAssessmentMonth,
monthlyTreesAdded: role.monthlyTreesAdded,
officePhotoUrls: role.officePhotoUrls,
createdAt: role.createdAt.toISOString(),
})),
assessments: assessments.map((assessment) => ({

View File

@ -21,7 +21,6 @@ import {
IUserDetailQueryRepository,
USER_DETAIL_QUERY_REPOSITORY,
} from '../../domain/repositories/user-detail-query.repository';
import { ReferralProxyService } from '../../referral/referral-proxy.service';
/**
*
@ -35,7 +34,6 @@ export class UserController {
private readonly userQueryRepository: IUserQueryRepository,
@Inject(USER_DETAIL_QUERY_REPOSITORY)
private readonly userDetailRepository: IUserDetailQueryRepository,
private readonly referralProxyService: ReferralProxyService,
) {}
/**
@ -72,10 +70,7 @@ export class UserController {
// 批量获取实时统计数据
const accountSequences = result.items.map(item => item.accountSequence);
const [statsMap, prePlantingStatsMap] = await Promise.all([
this.userDetailRepository.getBatchUserStats(accountSequences),
this.referralProxyService.batchGetPrePlantingStats(accountSequences),
]);
const statsMap = await this.userDetailRepository.getBatchUserStats(accountSequences);
// 获取所有用户的团队总认种数用于计算百分比(使用实时数据)
let totalTeamAdoptions = 0;
@ -84,7 +79,7 @@ export class UserController {
}
return {
items: result.items.map((item) => this.mapToListItem(item, totalTeamAdoptions, statsMap.get(item.accountSequence), prePlantingStatsMap[item.accountSequence])),
items: result.items.map((item) => this.mapToListItem(item, totalTeamAdoptions, statsMap.get(item.accountSequence))),
total: result.total,
page: result.page,
pageSize: result.pageSize,
@ -162,10 +157,6 @@ export class UserController {
provinceAdoptionCount: number;
cityAdoptionCount: number;
},
prePlantingStats?: {
selfPrePlantingPortions: number;
teamPrePlantingPortions: number;
},
): UserListItemDto {
// 使用实时统计数据(如果有),否则使用预计算数据
const personalAdoptions = realTimeStats?.personalAdoptionCount ?? item.personalAdoptionCount;
@ -190,8 +181,6 @@ export class UserController {
nickname: item.nickname,
phoneNumberMasked: item.phoneNumberMasked,
personalAdoptions,
selfPrePlantingPortions: prePlantingStats?.selfPrePlantingPortions ?? 0,
teamPrePlantingPortions: prePlantingStats?.teamPrePlantingPortions ?? 0,
teamAddresses,
teamAdoptions,
provincialAdoptions: {

View File

@ -70,10 +70,6 @@ export class CreateNotificationDto {
@IsString()
linkUrl?: string;
@IsOptional()
@IsBoolean()
requiresForceRead?: boolean;
@IsOptional()
@IsDateString()
publishedAt?: string;
@ -124,10 +120,6 @@ export class UpdateNotificationDto {
@IsBoolean()
isEnabled?: boolean;
@IsOptional()
@IsBoolean()
requiresForceRead?: boolean;
@IsOptional()
@IsDateString()
publishedAt?: string;

View File

@ -31,7 +31,6 @@ export class NotificationResponseDto {
imageUrl: string | null;
linkUrl: string | null;
isEnabled: boolean;
requiresForceRead: boolean;
publishedAt: string | null;
expiresAt: string | null;
createdAt: string;
@ -48,7 +47,6 @@ export class NotificationResponseDto {
imageUrl: entity.imageUrl,
linkUrl: entity.linkUrl,
isEnabled: entity.isEnabled,
requiresForceRead: entity.requiresForceRead,
publishedAt: entity.publishedAt?.toISOString() ?? null,
expiresAt: entity.expiresAt?.toISOString() ?? null,
createdAt: entity.createdAt.toISOString(),
@ -70,7 +68,6 @@ export class UserNotificationResponseDto {
publishedAt: string | null;
isRead: boolean;
readAt: string | null;
requiresForceRead: boolean;
static fromEntity(item: NotificationWithReadStatus): UserNotificationResponseDto {
return {
@ -84,7 +81,6 @@ export class UserNotificationResponseDto {
publishedAt: item.notification.publishedAt?.toISOString() ?? null,
isRead: item.isRead,
readAt: item.readAt?.toISOString() ?? null,
requiresForceRead: item.notification.requiresForceRead,
};
}
}

View File

@ -49,10 +49,6 @@ export class UserFullDetailDto {
percentage: number;
};
// 预种统计
selfPrePlantingPortions!: number;
teamPrePlantingPortions!: number;
// 排名
ranking!: number | null;
@ -74,8 +70,6 @@ export class ReferralNodeDto {
avatar!: string | null;
personalAdoptions!: number;
teamAdoptions!: number; // 团队认种量
selfPrePlantingPortions!: number; // 个人预种份数
teamPrePlantingPortions!: number; // 团队预种份数
depth!: number;
directReferralCount!: number;
isCurrentUser?: boolean;
@ -221,7 +215,6 @@ export class AuthorizationRoleDto {
monthlyTargetType!: string;
lastAssessmentMonth!: string | null;
monthlyTreesAdded!: number;
officePhotoUrls!: string[];
createdAt!: string;
}

View File

@ -8,8 +8,6 @@ export class UserListItemDto {
nickname!: string | null;
phoneNumberMasked!: string | null;
personalAdoptions!: number;
selfPrePlantingPortions!: number;
teamPrePlantingPortions!: number;
teamAddresses!: number;
teamAdoptions!: number;
provincialAdoptions!: {

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,27 +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';
// [2026-02-17] 新增:预种计划开关管理
import { PrePlantingConfigController, PublicPrePlantingConfigController } from './pre-planting/pre-planting-config.controller';
import { PrePlantingConfigService } from './pre-planting/pre-planting-config.service';
// [2026-02-27] 新增预种计划数据代理admin-service → planting-service 内部 HTTP
import { PrePlantingProxyService } from './pre-planting/pre-planting-proxy.service';
// [2026-03-02] 新增推荐链预种统计代理admin-service → referral-service 内部 HTTP
import { ReferralProxyService } from './referral/referral-proxy.service';
// [2026-03-02] 纯新增授权自助申请照片代理admin-service → authorization-service 内部 HTTP
import { AuthorizationProxyService } from './authorization/authorization-proxy.service';
import { AuthorizationPhotosController } from './api/controllers/authorization-photos.controller';
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
import { AdminTreePricingController, PublicTreePricingController } from './pricing/tree-pricing.controller';
import { TreePricingService } from './pricing/tree-pricing.service';
import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.job';
@Module({
imports: [
@ -133,22 +111,6 @@ import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.
// System Maintenance Controllers
AdminMaintenanceController,
MobileMaintenanceController,
// App Asset Controllers
AdminAppAssetController,
PublicAppAssetController,
// Customer Service Contact Controllers
AdminCustomerServiceContactController,
PublicCustomerServiceContactController,
// [2026-02-05] 新增:合同管理控制器
ContractController,
// [2026-02-17] 新增:预种计划开关管理
PrePlantingConfigController,
PublicPrePlantingConfigController,
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
AdminTreePricingController,
PublicTreePricingController,
// [2026-03-02] 纯新增:自助申请照片管理
AuthorizationPhotosController,
],
providers: [
PrismaService,
@ -214,7 +176,6 @@ import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.
AudienceSegmentService,
// Scheduled Jobs
AutoTagSyncJob,
ContractBatchDownloadJob,
// Co-Managed Wallet
CoManagedWalletMapper,
CoManagedWalletService,
@ -236,19 +197,6 @@ import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.
provide: APP_INTERCEPTOR,
useClass: MaintenanceInterceptor,
},
// [2026-02-05] 新增:合同管理服务
ContractService,
// [2026-02-17] 新增:预种计划开关管理
PrePlantingConfigService,
// [2026-02-27] 新增:预种计划数据代理
PrePlantingProxyService,
// [2026-03-02] 新增:推荐链预种统计代理
ReferralProxyService,
// [2026-03-02] 纯新增:授权自助申请照片代理
AuthorizationProxyService,
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
TreePricingService,
AutoPriceIncreaseJob,
],
})
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,122 +0,0 @@
/**
*
* [2026-03-02] HTTP authorization-service
*
* === ===
* admin-web admin-service () authorization-service /authorization/self-apply-photos
* ReferralProxyService axios
*/
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
export interface SelfApplyPhotoItem {
id: string;
accountSequence: string;
nickname: string;
avatar: string | null;
roleType: string;
regionName: string;
status: string;
officePhotoUrls: string[];
createdAt: string;
}
export interface SelfApplyPhotosResponse {
items: SelfApplyPhotoItem[];
total: number;
page: number;
limit: number;
}
@Injectable()
export class AuthorizationProxyService {
private readonly logger = new Logger(AuthorizationProxyService.name);
private readonly httpClient: AxiosInstance;
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
const authorizationServiceUrl = this.configService.get<string>(
'AUTHORIZATION_SERVICE_URL',
'http://rwa-authorization-service:3009',
);
this.httpClient = axios.create({
baseURL: authorizationServiceUrl,
timeout: 30000,
});
this.logger.log(
`AuthorizationProxyService initialized, authorization-service URL: ${authorizationServiceUrl}`,
);
}
/**
* /
*/
async getSelfApplyPhotos(params: {
page?: number;
limit?: number;
roleType?: string;
}): Promise<SelfApplyPhotosResponse> {
const { page = 1, limit = 20, roleType } = params;
try {
// 1. 从 authorization-service 获取有照片的授权记录
const queryParams = new URLSearchParams();
queryParams.set('page', String(page));
queryParams.set('limit', String(limit));
if (roleType) {
queryParams.set('roleType', roleType);
}
const url = `/api/v1/authorization/self-apply-photos?${queryParams.toString()}`;
this.logger.debug(`[getSelfApplyPhotos] 请求: ${url}`);
const response = await this.httpClient.get(url);
// authorization-service 全局拦截器将响应包装为 {success, data, timestamp}
// 实际数据在 response.data.data 中
const raw = response.data;
const data = raw?.data ?? raw;
if (!data?.items?.length) {
return { items: [], total: data?.total ?? 0, page, limit };
}
// 2. 批量查 user_query_view 补充 nickname + avatarUrl
const accountSequences = data.items.map((item: any) => item.accountSequence);
const users = await this.prisma.userQueryView.findMany({
where: { accountSequence: { in: accountSequences } },
select: { accountSequence: true, nickname: true, avatarUrl: true },
});
const userMap = new Map(
users.map((u) => [u.accountSequence, { nickname: u.nickname, avatar: u.avatarUrl }]),
);
// 3. 合并数据
const items: SelfApplyPhotoItem[] = data.items.map((item: any) => {
const user = userMap.get(item.accountSequence);
return {
id: item.id,
accountSequence: item.accountSequence,
nickname: user?.nickname ?? item.accountSequence,
avatar: user?.avatar ?? null,
roleType: item.roleType,
regionName: item.regionName,
status: item.status,
officePhotoUrls: item.officePhotoUrls ?? [],
createdAt: item.createdAt,
};
});
return { items, total: data.total, page, limit };
} catch (error) {
this.logger.error(`[getSelfApplyPhotos] 失败: ${error.message}`);
return { items: [], total: 0, page, limit };
}
}
}

View File

@ -23,8 +23,6 @@ export class NotificationEntity {
public readonly imageUrl: string | null,
public readonly linkUrl: string | null,
public readonly isEnabled: boolean,
/** 是否需要强制弹窗阅读(由管理员创建时配置) */
public readonly requiresForceRead: boolean,
public readonly publishedAt: Date | null,
public readonly expiresAt: Date | null,
public readonly createdAt: Date,
@ -101,7 +99,6 @@ export class NotificationEntity {
targetConfig?: NotificationTarget | null;
imageUrl?: string | null;
linkUrl?: string | null;
requiresForceRead?: boolean;
publishedAt?: Date | null;
expiresAt?: Date | null;
createdBy: string;
@ -131,7 +128,6 @@ export class NotificationEntity {
params.imageUrl ?? null,
params.linkUrl ?? null,
true,
params.requiresForceRead ?? false,
params.publishedAt ?? null,
params.expiresAt ?? null,
now,
@ -153,7 +149,6 @@ export class NotificationEntity {
imageUrl?: string | null;
linkUrl?: string | null;
isEnabled?: boolean;
requiresForceRead?: boolean;
publishedAt?: Date | null;
expiresAt?: Date | null;
}): NotificationEntity {
@ -168,7 +163,6 @@ export class NotificationEntity {
params.imageUrl !== undefined ? params.imageUrl : this.imageUrl,
params.linkUrl !== undefined ? params.linkUrl : this.linkUrl,
params.isEnabled ?? this.isEnabled,
params.requiresForceRead ?? this.requiresForceRead,
params.publishedAt !== undefined ? params.publishedAt : this.publishedAt,
params.expiresAt !== undefined ? params.expiresAt : this.expiresAt,
this.createdAt,

View File

@ -138,7 +138,6 @@ export interface AuthorizationRole {
monthlyTargetType: string;
lastAssessmentMonth: string | null;
monthlyTreesAdded: number;
officePhotoUrls: string[];
createdAt: Date;
}

View File

@ -1,42 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { TreePricingService } from '../../pricing/tree-pricing.service';
/**
*
*
*
*
*
*/
@Injectable()
export class AutoPriceIncreaseJob implements OnModuleInit {
private readonly logger = new Logger(AutoPriceIncreaseJob.name);
private isRunning = false;
constructor(private readonly pricingService: TreePricingService) {}
onModuleInit() {
this.logger.log('AutoPriceIncreaseJob initialized');
}
@Cron('0 * * * *') // 每小时第0分钟执行
async checkAndExecuteAutoIncrease(): Promise<void> {
if (this.isRunning) {
this.logger.warn('Auto price increase check already running, skipping...');
return;
}
this.isRunning = true;
try {
const executed = await this.pricingService.executeAutoIncrease();
if (executed) {
this.logger.log('Auto price increase executed successfully');
}
} catch (error) {
this.logger.error(`Auto price increase failed: ${error}`);
} finally {
this.isRunning = false;
}
}
}

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

@ -56,7 +56,6 @@ export class NotificationMapper {
prisma.imageUrl,
prisma.linkUrl,
prisma.isEnabled,
prisma.requiresForceRead,
prisma.publishedAt,
prisma.expiresAt,
prisma.createdAt,
@ -79,7 +78,6 @@ export class NotificationMapper {
imageUrl: entity.imageUrl,
linkUrl: entity.linkUrl,
isEnabled: entity.isEnabled,
requiresForceRead: entity.requiresForceRead,
publishedAt: entity.publishedAt,
expiresAt: entity.expiresAt,
createdAt: entity.createdAt,

View File

@ -86,16 +86,14 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
// 注意:优先从 referrals 获取 accountSequences因为用户可能不存在于 user_query_view
const referralAccountSequences = referrals.map(r => r.accountSequence);
const [adoptionCounts, directReferralCounts, teamStats] = await Promise.all([
// 统计每个用户的认种棵数(状态为 MINING_ENABLED
// 注意:使用 _sum.treeCount 而非 _count.id因为一笔订单可以认种多棵
// 显示的是棵数(认种数量),不是订单条数。
// 统计每个用户的认种订单数量(状态为 MINING_ENABLED
this.prisma.plantingOrderQueryView.groupBy({
by: ['accountSequence'],
where: {
accountSequence: { in: referralAccountSequences },
status: 'MINING_ENABLED',
},
_sum: { treeCount: true },
_count: { id: true },
}),
// 统计每个用户的直推数量
this.prisma.referralQueryView.groupBy({
@ -107,7 +105,7 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
this.getBatchUserStats(referralAccountSequences),
]);
const adoptionCountMap = new Map(adoptionCounts.map(a => [a.accountSequence, a._sum.treeCount ?? 0]));
const adoptionCountMap = new Map(adoptionCounts.map(a => [a.accountSequence, a._count.id]));
const directCountMap = new Map(
directReferralCounts
.filter(d => d.referrerId !== null)
@ -171,16 +169,14 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
// 实时统计:获取每个用户的认种数量、团队认种量和直推数量
const userAccountSequences = directReferrals.map(r => r.accountSequence);
const [adoptionCounts, directReferralCounts, teamStats] = await Promise.all([
// 统计每个用户的认种棵数(状态为 MINING_ENABLED
// 注意:使用 _sum.treeCount 而非 _count.id因为一笔订单可以认种多棵
// 显示的是棵数(认种数量),不是订单条数。
// 统计每个用户的认种订单数量(状态为 MINING_ENABLED
this.prisma.plantingOrderQueryView.groupBy({
by: ['accountSequence'],
where: {
accountSequence: { in: userAccountSequences },
status: 'MINING_ENABLED',
},
_sum: { treeCount: true },
_count: { id: true },
}),
// 统计每个用户的直推数量
this.prisma.referralQueryView.groupBy({
@ -192,7 +188,7 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
this.getBatchUserStats(userAccountSequences),
]);
const adoptionCountMap = new Map(adoptionCounts.map(a => [a.accountSequence, a._sum.treeCount ?? 0]));
const adoptionCountMap = new Map(adoptionCounts.map(a => [a.accountSequence, a._count.id]));
const directCountMap = new Map(
directReferralCounts
.filter(d => d.referrerId !== null)
@ -452,7 +448,6 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
monthlyTargetType: role.monthlyTargetType,
lastAssessmentMonth: role.lastAssessmentMonth,
monthlyTreesAdded: role.monthlyTreesAdded,
officePhotoUrls: role.officePhotoUrls ?? [],
createdAt: role.createdAt,
}));
}
@ -532,18 +527,15 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
}
async getPersonalAdoptionCount(accountSequence: string): Promise<number> {
// 统计用户的认种棵数(状态为 MINING_ENABLED
// 注意:使用 aggregate._sum.treeCount 而非 count(),因为一笔订单可以认种多棵,
// 返回的是总棵数(认种数量),不是订单条数。
const result = await this.prisma.plantingOrderQueryView.aggregate({
// 统计用户的认种订单数量(状态为 MINING_ENABLED
const count = await this.prisma.plantingOrderQueryView.count({
where: {
accountSequence,
status: 'MINING_ENABLED',
},
_sum: { treeCount: true },
});
return result._sum.treeCount ?? 0;
return count;
}
async getDirectReferralCount(accountSequence: string): Promise<number> {
@ -585,19 +577,17 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
const teamAddressCount = teamMembers.length;
// 2. 获取团队认种量:汇总所有团队成员的有效认种棵数
// 注意:使用 aggregate._sum.treeCount 而非 count(),因为一笔订单可以认种多棵。
// 2. 获取团队认种量:汇总所有团队成员的有效认种订单数
let teamAdoptionCount = 0;
if (teamMembers.length > 0) {
const result = await this.prisma.plantingOrderQueryView.aggregate({
const count = await this.prisma.plantingOrderQueryView.count({
where: {
accountSequence: { in: teamMembers.map((m) => m.accountSequence) },
status: 'MINING_ENABLED',
},
_sum: { treeCount: true },
});
teamAdoptionCount = result._sum.treeCount ?? 0;
teamAdoptionCount = count;
}
return { teamAddressCount, teamAdoptionCount };
@ -620,19 +610,17 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
if (accountSequences.length === 0) return result;
// 1. 批量获取个人认种棵数
// 注意:使用 _sum.treeCount 而非 _count.id因为一笔订单可以认种多棵
// 显示的是棵数(认种数量),不是订单条数。
// 1. 批量获取个人认种量
const personalAdoptionCounts = await this.prisma.plantingOrderQueryView.groupBy({
by: ['accountSequence'],
where: {
accountSequence: { in: accountSequences },
status: 'MINING_ENABLED',
},
_sum: { treeCount: true },
_count: { id: true },
});
const personalAdoptionMap = new Map(
personalAdoptionCounts.map(p => [p.accountSequence, p._sum.treeCount ?? 0])
personalAdoptionCounts.map(p => [p.accountSequence, p._count.id])
);
// 2. 批量获取用户的省市信息(从认种订单中获取第一个订单的省市)
@ -685,41 +673,35 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
if (teamMembers.length > 0) {
const teamAccountSequences = teamMembers.map(m => m.accountSequence);
// 团队总认种棵数(使用 sum treeCount不是 count orders
const teamResult = await this.prisma.plantingOrderQueryView.aggregate({
// 团队总认种
teamAdoptionCount = await this.prisma.plantingOrderQueryView.count({
where: {
accountSequence: { in: teamAccountSequences },
status: 'MINING_ENABLED',
},
_sum: { treeCount: true },
});
teamAdoptionCount = teamResult._sum.treeCount ?? 0;
// 如果用户有省市信息,统计同省同市的认种棵数
// 如果用户有省市信息,统计同省同市的认种
if (userLocation?.province) {
// 同省认种棵数
const provinceResult = await this.prisma.plantingOrderQueryView.aggregate({
// 同省认种
provinceAdoptionCount = await this.prisma.plantingOrderQueryView.count({
where: {
accountSequence: { in: teamAccountSequences },
status: 'MINING_ENABLED',
selectedProvince: userLocation.province,
},
_sum: { treeCount: true },
});
provinceAdoptionCount = provinceResult._sum.treeCount ?? 0;
// 同市认种棵数
// 同市认种
if (userLocation.city) {
const cityResult = await this.prisma.plantingOrderQueryView.aggregate({
cityAdoptionCount = await this.prisma.plantingOrderQueryView.count({
where: {
accountSequence: { in: teamAccountSequences },
status: 'MINING_ENABLED',
selectedProvince: userLocation.province,
selectedCity: userLocation.city,
},
_sum: { treeCount: true },
});
cityAdoptionCount = cityResult._sum.treeCount ?? 0;
}
}
}

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,230 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Body,
Query,
HttpCode,
HttpStatus,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { PrePlantingConfigService } from './pre-planting-config.service';
import { PrePlantingProxyService } from './pre-planting-proxy.service';
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
class UpdatePrePlantingConfigDto {
@IsBoolean()
isActive: boolean;
@IsOptional()
@IsString()
updatedBy?: string;
}
class TogglePrePlantingConfigDto {
@IsBoolean()
isActive: boolean;
}
class UpdatePrePlantingAgreementDto {
@IsString()
text: string;
}
@ApiTags('预种计划配置')
@Controller('admin/pre-planting')
export class PrePlantingConfigController {
private readonly logger = new Logger(PrePlantingConfigController.name);
constructor(
private readonly configService: PrePlantingConfigService,
private readonly proxyService: PrePlantingProxyService,
private readonly prisma: PrismaService,
) {}
/**
* accountSequence accountSequence
* userId ancestorPath userId
*
*/
private async resolveTeamAccountSequences(teamOfAccountSeq: string): Promise<string[]> {
// 1. 找到 teamOf 用户的 userId
const leader = await this.prisma.referralQueryView.findUnique({
where: { accountSequence: teamOfAccountSeq },
select: { userId: true, accountSequence: true },
});
if (!leader) {
this.logger.warn(`[resolveTeamAccountSequences] 未找到用户: ${teamOfAccountSeq}`);
return [];
}
// 2. 查找 ancestorPath 包含该 userId 的所有下级用户PostgreSQL array contains
const teamMembers = await this.prisma.referralQueryView.findMany({
where: {
ancestorPath: { has: leader.userId },
},
select: { accountSequence: true },
});
// 3. 包含团队领导本人
const sequences = [leader.accountSequence, ...teamMembers.map((m) => m.accountSequence)];
this.logger.debug(`[resolveTeamAccountSequences] ${teamOfAccountSeq} 团队成员数: ${sequences.length}`);
return sequences;
}
@Get('config')
@ApiOperation({ summary: '获取预种计划开关状态(含协议文本)' })
@ApiResponse({ status: HttpStatus.OK, description: '开关状态' })
async getConfig() {
const config = await this.configService.getConfig();
const agreementText = await this.configService.getAgreement();
return { ...config, agreementText };
}
@Post('config')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新预种计划开关状态' })
@ApiResponse({ status: HttpStatus.OK, description: '更新成功' })
async updateConfig(@Body() dto: UpdatePrePlantingConfigDto) {
return this.configService.updateConfig(dto.isActive, dto.updatedBy);
}
// ============================================
// [2026-02-27] 新增预种管理端点toggle + 数据查询代理)
// ============================================
@Put('config/toggle')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '切换预种计划开关' })
@ApiResponse({ status: HttpStatus.OK, description: '切换成功' })
async toggleConfig(@Body() dto: TogglePrePlantingConfigDto) {
return this.configService.updateConfig(dto.isActive);
}
@Get('orders')
@ApiOperation({ summary: '预种订单列表(管理员视角)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'teamOf', required: false, description: '团队筛选:指定用户 accountSequence只显示其团队成员的订单' })
async getOrders(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('teamOf') teamOf?: string,
) {
let accountSequences: string[] | undefined;
if (teamOf) {
accountSequences = await this.resolveTeamAccountSequences(teamOf);
if (accountSequences.length === 0) {
return { items: [], total: 0, page: page ? parseInt(page, 10) : 1, pageSize: pageSize ? parseInt(pageSize, 10) : 20 };
}
}
return this.proxyService.getOrders({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword: keyword || undefined,
status: status || undefined,
accountSequences,
});
}
@Get('positions')
@ApiOperation({ summary: '预种持仓列表(管理员视角)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'teamOf', required: false, description: '团队筛选:指定用户 accountSequence只显示其团队成员的持仓' })
async getPositions(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('teamOf') teamOf?: string,
) {
let accountSequences: string[] | undefined;
if (teamOf) {
accountSequences = await this.resolveTeamAccountSequences(teamOf);
if (accountSequences.length === 0) {
return { items: [], total: 0, page: page ? parseInt(page, 10) : 1, pageSize: pageSize ? parseInt(pageSize, 10) : 20 };
}
}
return this.proxyService.getPositions({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword: keyword || undefined,
accountSequences,
});
}
@Get('merges')
@ApiOperation({ summary: '预种合并记录列表(管理员视角)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'keyword', required: false })
@ApiQuery({ name: 'status', required: false })
async getMerges(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
) {
return this.proxyService.getMerges({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
keyword: keyword || undefined,
status: status || undefined,
});
}
@Get('stats')
@ApiOperation({ summary: '预种统计汇总' })
async getStats() {
return this.proxyService.getStats();
}
// ============================================
// [2026-02-28] 新增:预种协议管理
// ============================================
@Get('agreement')
@ApiOperation({ summary: '获取预种协议文本' })
@ApiResponse({ status: HttpStatus.OK, description: '协议文本' })
async getAgreement() {
const text = await this.configService.getAgreement();
return { text };
}
@Put('agreement')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新预种协议文本' })
@ApiResponse({ status: HttpStatus.OK, description: '更新成功' })
async updateAgreement(@Body() dto: UpdatePrePlantingAgreementDto) {
return this.configService.updateAgreement(dto.text);
}
}
/**
* API planting-service
*/
@ApiTags('预种计划配置-内部API')
@Controller('api/v1/admin/pre-planting')
export class PublicPrePlantingConfigController {
constructor(
private readonly configService: PrePlantingConfigService,
) {}
@Get('config')
@ApiOperation({ summary: '获取预种计划开关状态内部API含协议文本' })
async getConfig() {
const config = await this.configService.getConfig();
const agreementText = await this.configService.getAgreement();
return { ...config, agreementText };
}
}

View File

@ -1,110 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
@Injectable()
export class PrePlantingConfigService {
private readonly logger = new Logger(PrePlantingConfigService.name);
constructor(private readonly prisma: PrismaService) {}
async getConfig(): Promise<{
isActive: boolean;
activatedAt: Date | null;
}> {
const config = await this.prisma.prePlantingConfig.findFirst({
orderBy: { updatedAt: 'desc' },
});
if (!config) {
return { isActive: false, activatedAt: null };
}
return {
isActive: config.isActive,
activatedAt: config.activatedAt,
};
}
/**
* system_configs
*/
async getAgreement(): Promise<string | null> {
const config = await this.prisma.systemConfig.findUnique({
where: { key: 'pre_planting_agreement' },
});
return config?.value ?? null;
}
/**
* upsert system_configs
*/
async updateAgreement(text: string, updatedBy?: string): Promise<{ text: string }> {
await this.prisma.systemConfig.upsert({
where: { key: 'pre_planting_agreement' },
create: {
key: 'pre_planting_agreement',
value: text,
description: '预种计划购买协议文本',
updatedBy: updatedBy || null,
},
update: {
value: text,
updatedBy: updatedBy || null,
},
});
this.logger.log(`[PRE-PLANTING] Agreement text updated by ${updatedBy || 'unknown'}`);
return { text };
}
async updateConfig(
isActive: boolean,
updatedBy?: string,
): Promise<{
isActive: boolean;
activatedAt: Date | null;
}> {
const existing = await this.prisma.prePlantingConfig.findFirst({
orderBy: { updatedAt: 'desc' },
});
const activatedAt = isActive ? new Date() : null;
if (existing) {
const updated = await this.prisma.prePlantingConfig.update({
where: { id: existing.id },
data: {
isActive,
activatedAt: isActive ? (existing.activatedAt || activatedAt) : existing.activatedAt,
updatedBy: updatedBy || null,
},
});
this.logger.log(
`[PRE-PLANTING] Config updated: isActive=${updated.isActive} by ${updatedBy || 'unknown'}`,
);
return {
isActive: updated.isActive,
activatedAt: updated.activatedAt,
};
}
const created = await this.prisma.prePlantingConfig.create({
data: {
isActive,
activatedAt,
updatedBy: updatedBy || null,
},
});
this.logger.log(
`[PRE-PLANTING] Config created: isActive=${created.isActive} by ${updatedBy || 'unknown'}`,
);
return {
isActive: created.isActive,
activatedAt: created.activatedAt,
};
}
}

View File

@ -1,125 +0,0 @@
/**
*
* [2026-02-27] HTTP planting-service
*
* === ===
* admin-web admin-service () planting-service /internal/pre-planting/admin/*
* ContractService axios
*/
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
@Injectable()
export class PrePlantingProxyService {
private readonly logger = new Logger(PrePlantingProxyService.name);
private readonly httpClient: AxiosInstance;
constructor(private readonly configService: ConfigService) {
const plantingServiceUrl = this.configService.get<string>(
'PLANTING_SERVICE_URL',
'http://rwa-planting-service:3003',
);
this.httpClient = axios.create({
baseURL: plantingServiceUrl,
timeout: 30000,
});
this.logger.log(
`PrePlantingProxyService initialized, planting-service URL: ${plantingServiceUrl}`,
);
}
async getOrders(params: {
page?: number;
pageSize?: number;
keyword?: string;
status?: string;
accountSequences?: string[];
}) {
try {
const qp = new URLSearchParams();
if (params.page) qp.append('page', params.page.toString());
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
if (params.keyword) qp.append('keyword', params.keyword);
if (params.status) qp.append('status', params.status);
if (params.accountSequences?.length) qp.append('accountSequences', params.accountSequences.join(','));
const url = `/api/v1/internal/pre-planting/admin/orders?${qp.toString()}`;
this.logger.debug(`[getOrders] 请求: ${url}`);
const response = await this.httpClient.get(url);
return response.data;
} catch (error) {
this.logger.error(`[getOrders] 失败: ${error.message}`);
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
}
}
async getPositions(params: {
page?: number;
pageSize?: number;
keyword?: string;
accountSequences?: string[];
}) {
try {
const qp = new URLSearchParams();
if (params.page) qp.append('page', params.page.toString());
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
if (params.keyword) qp.append('keyword', params.keyword);
if (params.accountSequences?.length) qp.append('accountSequences', params.accountSequences.join(','));
const url = `/api/v1/internal/pre-planting/admin/positions?${qp.toString()}`;
this.logger.debug(`[getPositions] 请求: ${url}`);
const response = await this.httpClient.get(url);
return response.data;
} catch (error) {
this.logger.error(`[getPositions] 失败: ${error.message}`);
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
}
}
async getMerges(params: {
page?: number;
pageSize?: number;
keyword?: string;
status?: string;
}) {
try {
const qp = new URLSearchParams();
if (params.page) qp.append('page', params.page.toString());
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
if (params.keyword) qp.append('keyword', params.keyword);
if (params.status) qp.append('status', params.status);
const url = `/api/v1/internal/pre-planting/admin/merges?${qp.toString()}`;
this.logger.debug(`[getMerges] 请求: ${url}`);
const response = await this.httpClient.get(url);
return response.data;
} catch (error) {
this.logger.error(`[getMerges] 失败: ${error.message}`);
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
}
}
async getStats() {
try {
const url = '/api/v1/internal/pre-planting/admin/stats';
this.logger.debug(`[getStats] 请求: ${url}`);
const response = await this.httpClient.get(url);
return response.data;
} catch (error) {
this.logger.error(`[getStats] 失败: ${error.message}`);
return {
totalOrders: 0,
totalPortions: 0,
totalAmount: 0,
totalMerges: 0,
totalTreesMerged: 0,
totalUsers: 0,
pendingContracts: 0,
};
}
}
}

View File

@ -1,126 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Body,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { IsNumber, IsString, IsBoolean, IsOptional } from 'class-validator';
import { TreePricingService } from './tree-pricing.service';
// ======================== DTO ========================
class UpdateSupplementDto {
/** 新的加价金额(整数 USDT */
@IsNumber()
newSupplement: number;
/** 变更原因 */
@IsString()
reason: string;
/** 操作人ID */
@IsOptional()
@IsString()
operatorId?: string;
}
class UpdateAutoIncreaseDto {
/** 是否启用自动涨价 */
@IsBoolean()
enabled: boolean;
/** 每次自动涨价金额(整数 USDT */
@IsOptional()
@IsNumber()
amount?: number;
/** 自动涨价间隔天数 */
@IsOptional()
@IsNumber()
intervalDays?: number;
/** 操作人ID */
@IsOptional()
@IsString()
operatorId?: string;
}
class ChangeLogQueryDto {
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
@IsNumber()
pageSize?: number;
}
// ======================== Admin Controller ========================
@ApiTags('认种定价配置')
@Controller('admin/tree-pricing')
export class AdminTreePricingController {
constructor(
private readonly pricingService: TreePricingService,
) {}
@Get('config')
@ApiOperation({ summary: '获取当前定价配置' })
@ApiResponse({ status: HttpStatus.OK, description: '定价配置信息' })
async getConfig() {
return this.pricingService.getConfig();
}
@Post('supplement')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '手动修改加价金额(总部运营成本压力涨价)' })
@ApiResponse({ status: HttpStatus.OK, description: '更新成功,返回最新配置' })
async updateSupplement(@Body() dto: UpdateSupplementDto) {
return this.pricingService.updateSupplement(
dto.newSupplement,
dto.reason,
dto.operatorId || 'admin',
);
}
@Put('auto-increase')
@ApiOperation({ summary: '设置自动涨价(总部运营成本压力自动涨价)' })
@ApiResponse({ status: HttpStatus.OK, description: '设置成功,返回最新配置' })
async updateAutoIncrease(@Body() dto: UpdateAutoIncreaseDto) {
return this.pricingService.updateAutoIncreaseSettings(
dto.enabled,
dto.amount,
dto.intervalDays,
dto.operatorId || 'admin',
);
}
@Get('change-log')
@ApiOperation({ summary: '获取价格变更审计日志' })
@ApiResponse({ status: HttpStatus.OK, description: '分页审计日志' })
async getChangeLog(@Query() query: ChangeLogQueryDto) {
return this.pricingService.getChangeLog(
Number(query.page) || 1,
Number(query.pageSize) || 20,
);
}
}
// ======================== Public Controller ========================
/**
* API planting-service mobile-app
*
*/
@ApiTags('认种定价配置-公开API')
@Controller('tree-pricing')
export class PublicTreePricingController {
constructor(
private readonly pricingService: TreePricingService,
) {}
@Get('config')
@ApiOperation({ summary: '获取当前定价配置(公开接口)' })
async getConfig() {
return this.pricingService.getConfig();
}
}

View File

@ -1,256 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
/** 基础价常量 */
const BASE_PRICE = 15831; // 正式认种基础价(不变)
const BASE_PORTION_PRICE = 1887; // 预种基础价 [2026-03-01 调整] 9项 floor(18870/10) 取整 + 总部吸收余额
const PORTIONS_PER_TREE = 10; // [2026-03-01 调整] 5 → 10 份/棵
export interface TreePricingConfigResponse {
basePrice: number;
basePortionPrice: number;
currentSupplement: number;
totalPrice: number;
totalPortionPrice: number;
autoIncreaseEnabled: boolean;
autoIncreaseAmount: number;
autoIncreaseIntervalDays: number;
lastAutoIncreaseAt: Date | null;
nextAutoIncreaseAt: Date | null;
updatedAt: Date;
}
export interface TreePriceChangeLogItem {
id: string;
changeType: string;
previousSupplement: number;
newSupplement: number;
changeAmount: number;
reason: string | null;
operatorId: string | null;
createdAt: Date;
}
@Injectable()
export class TreePricingService {
private readonly logger = new Logger(TreePricingService.name);
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
async getConfig(): Promise<TreePricingConfigResponse> {
let config = await this.prisma.treePricingConfig.findFirst({
orderBy: { updatedAt: 'desc' },
});
if (!config) {
config = await this.prisma.treePricingConfig.create({
data: { currentSupplement: 0, autoIncreaseEnabled: false },
});
this.logger.log('[TREE-PRICING] Default config created');
}
const totalPrice = BASE_PRICE + config.currentSupplement;
// 预种价格 = 基础预种价(1887) + floor(加价部分/10)
const totalPortionPrice = BASE_PORTION_PRICE + Math.floor(config.currentSupplement / PORTIONS_PER_TREE);
return {
basePrice: BASE_PRICE,
basePortionPrice: BASE_PORTION_PRICE,
currentSupplement: config.currentSupplement,
totalPrice,
totalPortionPrice,
autoIncreaseEnabled: config.autoIncreaseEnabled,
autoIncreaseAmount: config.autoIncreaseAmount,
autoIncreaseIntervalDays: config.autoIncreaseIntervalDays,
lastAutoIncreaseAt: config.lastAutoIncreaseAt,
nextAutoIncreaseAt: config.nextAutoIncreaseAt,
updatedAt: config.updatedAt,
};
}
/**
* +
*
*/
async updateSupplement(
newSupplement: number,
reason: string,
operatorId: string,
): Promise<TreePricingConfigResponse> {
// 允许负数(降价对冲),但总价不能低于 0
if (BASE_PRICE + newSupplement < 0) {
throw new Error(`调价金额不能低于 -${BASE_PRICE},否则总价为负`);
}
const config = await this.getOrCreateConfig();
const previousSupplement = config.currentSupplement;
const changeAmount = newSupplement - previousSupplement;
if (changeAmount === 0) {
return this.getConfig();
}
await this.prisma.$transaction([
this.prisma.treePricingConfig.update({
where: { id: config.id },
data: {
currentSupplement: newSupplement,
updatedBy: operatorId,
},
}),
this.prisma.treePriceChangeLog.create({
data: {
changeType: 'MANUAL',
previousSupplement,
newSupplement,
changeAmount,
reason: reason || '总部运营成本压力调价',
operatorId,
},
}),
]);
this.logger.log(
`[TREE-PRICING] Manual supplement update: ${previousSupplement}${newSupplement} (${changeAmount > 0 ? '+' : ''}${changeAmount}) by ${operatorId}, reason: ${reason}`,
);
return this.getConfig();
}
/**
*
*/
async updateAutoIncreaseSettings(
enabled: boolean,
amount?: number,
intervalDays?: number,
operatorId?: string,
): Promise<TreePricingConfigResponse> {
const config = await this.getOrCreateConfig();
const data: Record<string, unknown> = {
autoIncreaseEnabled: enabled,
updatedBy: operatorId || null,
};
if (amount !== undefined) {
if (amount < 0) throw new Error('自动涨价金额不能为负数');
data.autoIncreaseAmount = amount;
}
if (intervalDays !== undefined) {
if (intervalDays < 1) throw new Error('自动涨价间隔天数不能小于1');
data.autoIncreaseIntervalDays = intervalDays;
}
// 启用时计算下次涨价时间
if (enabled) {
const interval = intervalDays ?? config.autoIncreaseIntervalDays;
if (interval > 0) {
const nextDate = new Date();
nextDate.setDate(nextDate.getDate() + interval);
data.nextAutoIncreaseAt = nextDate;
}
} else {
data.nextAutoIncreaseAt = null;
}
await this.prisma.treePricingConfig.update({
where: { id: config.id },
data,
});
this.logger.log(
`[TREE-PRICING] Auto-increase settings updated: enabled=${enabled}, amount=${amount ?? config.autoIncreaseAmount}, intervalDays=${intervalDays ?? config.autoIncreaseIntervalDays} by ${operatorId || 'unknown'}`,
);
return this.getConfig();
}
/**
*
*/
async getChangeLog(
page: number = 1,
pageSize: number = 20,
): Promise<{ items: TreePriceChangeLogItem[]; total: number }> {
const [items, total] = await this.prisma.$transaction([
this.prisma.treePriceChangeLog.findMany({
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.treePriceChangeLog.count(),
]);
return { items, total };
}
/**
*
*
* @returns true false
*/
async executeAutoIncrease(): Promise<boolean> {
const config = await this.prisma.treePricingConfig.findFirst({
orderBy: { updatedAt: 'desc' },
});
if (!config) return false;
if (!config.autoIncreaseEnabled) return false;
if (!config.nextAutoIncreaseAt) return false;
if (config.autoIncreaseAmount <= 0) return false;
const now = new Date();
if (now < config.nextAutoIncreaseAt) return false;
const previousSupplement = config.currentSupplement;
const newSupplement = previousSupplement + config.autoIncreaseAmount;
const nextDate = new Date(now);
nextDate.setDate(nextDate.getDate() + config.autoIncreaseIntervalDays);
await this.prisma.$transaction([
this.prisma.treePricingConfig.update({
where: { id: config.id },
data: {
currentSupplement: newSupplement,
lastAutoIncreaseAt: now,
nextAutoIncreaseAt: nextDate,
},
}),
this.prisma.treePriceChangeLog.create({
data: {
changeType: 'AUTO',
previousSupplement,
newSupplement,
changeAmount: config.autoIncreaseAmount,
reason: '系统自动涨价(总部运营成本压力)',
operatorId: 'SYSTEM',
},
}),
]);
this.logger.log(
`[TREE-PRICING] Auto-increase executed: ${previousSupplement}${newSupplement} (+${config.autoIncreaseAmount}), next: ${nextDate.toISOString()}`,
);
return true;
}
/** 获取或创建配置(内部方法) */
private async getOrCreateConfig() {
let config = await this.prisma.treePricingConfig.findFirst({
orderBy: { updatedAt: 'desc' },
});
if (!config) {
config = await this.prisma.treePricingConfig.create({
data: { currentSupplement: 0, autoIncreaseEnabled: false },
});
}
return config;
}
}

View File

@ -1,83 +0,0 @@
/**
*
* [2026-03-02] HTTP referral-service
*
* === ===
* admin-web admin-service () referral-service /internal/referral/pre-planting-stats/*
* PrePlantingProxyService axios
*/
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
export interface PrePlantingStats {
selfPrePlantingPortions: number;
teamPrePlantingPortions: number;
}
@Injectable()
export class ReferralProxyService {
private readonly logger = new Logger(ReferralProxyService.name);
private readonly httpClient: AxiosInstance;
constructor(private readonly configService: ConfigService) {
const referralServiceUrl = this.configService.get<string>(
'REFERRAL_SERVICE_URL',
'http://rwa-referral-service:3004',
);
this.httpClient = axios.create({
baseURL: referralServiceUrl,
timeout: 30000,
});
this.logger.log(
`ReferralProxyService initialized, referral-service URL: ${referralServiceUrl}`,
);
}
/**
* +
*/
async getPrePlantingStats(accountSequence: string): Promise<PrePlantingStats> {
try {
const url = `/api/v1/internal/referral/pre-planting-stats/${accountSequence}`;
this.logger.debug(`[getPrePlantingStats] 请求: ${url}`);
const response = await this.httpClient.get(url);
return {
selfPrePlantingPortions: response.data?.selfPrePlantingPortions ?? 0,
teamPrePlantingPortions: response.data?.teamPrePlantingPortions ?? 0,
};
} catch (error) {
this.logger.error(`[getPrePlantingStats] 失败 (${accountSequence}): ${error.message}`);
return { selfPrePlantingPortions: 0, teamPrePlantingPortions: 0 };
}
}
/**
*
*/
async batchGetPrePlantingStats(
accountSequences: string[],
): Promise<Record<string, PrePlantingStats>> {
if (accountSequences.length === 0) {
return {};
}
try {
const url = '/api/v1/internal/referral/pre-planting-stats/batch';
this.logger.debug(`[batchGetPrePlantingStats] 请求: ${url}, 数量: ${accountSequences.length}`);
const response = await this.httpClient.post(url, { accountSequences });
return response.data ?? {};
} catch (error) {
this.logger.error(`[batchGetPrePlantingStats] 失败: ${error.message}`);
// 返回所有用户的零值默认
const defaults: Record<string, PrePlantingStats> = {};
for (const seq of accountSequences) {
defaults[seq] = { selfPrePlantingPortions: 0, teamPrePlantingPortions: 0 };
}
return defaults;
}
}
}

View File

@ -9,8 +9,6 @@
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.5.0",
"@alicloud/openapi-client": "^0.4.15",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
@ -60,198 +58,6 @@
"typescript": "^5.1.3"
}
},
"node_modules/@alicloud/credentials": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@alicloud/credentials/-/credentials-2.4.4.tgz",
"integrity": "sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q==",
"license": "MIT",
"dependencies": {
"@alicloud/tea-typescript": "^1.8.0",
"httpx": "^2.3.3",
"ini": "^1.3.5",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/darabonba-array": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-array/-/darabonba-array-0.1.2.tgz",
"integrity": "sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1"
}
},
"node_modules/@alicloud/darabonba-encode-util": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.2.tgz",
"integrity": "sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==",
"license": "ISC",
"dependencies": {
"moment": "^2.29.1"
}
},
"node_modules/@alicloud/darabonba-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-map/-/darabonba-map-0.0.1.tgz",
"integrity": "sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1"
}
},
"node_modules/@alicloud/darabonba-signature-util": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-signature-util/-/darabonba-signature-util-0.0.4.tgz",
"integrity": "sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==",
"license": "ISC",
"dependencies": {
"@alicloud/darabonba-encode-util": "^0.0.1"
}
},
"node_modules/@alicloud/darabonba-signature-util/node_modules/@alicloud/darabonba-encode-util": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.1.tgz",
"integrity": "sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1",
"moment": "^2.29.1"
}
},
"node_modules/@alicloud/darabonba-string": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-string/-/darabonba-string-1.0.3.tgz",
"integrity": "sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1"
}
},
"node_modules/@alicloud/dysmsapi20170525": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@alicloud/dysmsapi20170525/-/dysmsapi20170525-4.5.0.tgz",
"integrity": "sha512-nhKdRDLRDhTVxr7VbMbBi6UtJWmVFgwySU2ohkJ1zL7jd98DEGGy8CE/n7W44ZP9+yTBBmLhM8qW1C12kHDEIg==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/openapi-core": "^1.0.0",
"@darabonba/typescript": "^1.0.0"
}
},
"node_modules/@alicloud/endpoint-util": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz",
"integrity": "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/gateway-pop": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@alicloud/gateway-pop/-/gateway-pop-0.0.6.tgz",
"integrity": "sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==",
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2",
"@alicloud/darabonba-array": "^0.1.0",
"@alicloud/darabonba-encode-util": "^0.0.2",
"@alicloud/darabonba-map": "^0.0.1",
"@alicloud/darabonba-signature-util": "^0.0.4",
"@alicloud/darabonba-string": "^1.0.2",
"@alicloud/endpoint-util": "^0.0.1",
"@alicloud/gateway-spi": "^0.0.8",
"@alicloud/openapi-util": "^0.3.2",
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "^1.4.8"
}
},
"node_modules/@alicloud/gateway-spi": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz",
"integrity": "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==",
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2",
"@alicloud/tea-typescript": "^1.7.1"
}
},
"node_modules/@alicloud/openapi-client": {
"version": "0.4.15",
"resolved": "https://registry.npmjs.org/@alicloud/openapi-client/-/openapi-client-0.4.15.tgz",
"integrity": "sha512-4VE0/k5ZdQbAhOSTqniVhuX1k5DUeUMZv74degn3wIWjLY6Bq+hxjaGsaHYlLZ2gA5wUrs8NcI5TE+lIQS3iiA==",
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2.4.2",
"@alicloud/gateway-spi": "^0.0.8",
"@alicloud/openapi-util": "^0.3.2",
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "1.4.9",
"@alicloud/tea-xml": "0.0.3"
}
},
"node_modules/@alicloud/openapi-core": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@alicloud/openapi-core/-/openapi-core-1.0.7.tgz",
"integrity": "sha512-I80PQVfmlzRiXGHwutMp2zTpiqUVv8ts30nWAfksfHUSTIapk3nj9IXaPbULMPGNV6xqEyshO2bj2a+pmwc2tQ==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@alicloud/credentials": "^2.4.2",
"@alicloud/gateway-pop": "0.0.6",
"@alicloud/gateway-spi": "^0.0.8",
"@darabonba/typescript": "^1.0.2"
}
},
"node_modules/@alicloud/openapi-util": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.3.tgz",
"integrity": "sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==",
"license": "ISC",
"dependencies": {
"@alicloud/tea-typescript": "^1.7.1",
"@alicloud/tea-util": "^1.3.0",
"kitx": "^2.1.0",
"sm3": "^1.0.3"
}
},
"node_modules/@alicloud/tea-typescript": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz",
"integrity": "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==",
"license": "ISC",
"dependencies": {
"@types/node": "^12.0.2",
"httpx": "^2.2.6"
}
},
"node_modules/@alicloud/tea-typescript/node_modules/@types/node": {
"version": "12.20.55",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
"license": "MIT"
},
"node_modules/@alicloud/tea-util": {
"version": "1.4.9",
"resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.9.tgz",
"integrity": "sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"kitx": "^2.0.0"
}
},
"node_modules/@alicloud/tea-xml": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@alicloud/tea-xml/-/tea-xml-0.0.3.tgz",
"integrity": "sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==",
"license": "Apache-2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1",
"@types/xml2js": "^0.4.5",
"xml2js": "^0.6.0"
}
},
"node_modules/@angular-devkit/core": {
"version": "17.3.11",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz",
@ -974,20 +780,6 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@darabonba/typescript": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.4.tgz",
"integrity": "sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w==",
"license": "Apache License 2.0",
"dependencies": {
"@alicloud/tea-typescript": "^1.5.1",
"httpx": "^2.3.2",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"xml2js": "^0.6.2"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@ -2873,15 +2665,6 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/xml2js": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz",
"integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@ -6043,16 +5826,6 @@
"node": ">= 6"
}
},
"node_modules/httpx": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz",
"integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==",
"license": "MIT",
"dependencies": {
"@types/node": "^20",
"debug": "^4.1.1"
}
},
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@ -6169,12 +5942,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/inquirer": {
"version": "8.2.6",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
@ -7365,24 +7132,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kitx": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/kitx/-/kitx-2.2.0.tgz",
"integrity": "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.5.4"
}
},
"node_modules/kitx/node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@ -7838,27 +7587,6 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -8991,15 +8719,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/schema-utils": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@ -9285,12 +9004,6 @@
"node": ">=8"
}
},
"node_modules/sm3": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sm3/-/sm3-1.0.3.tgz",
"integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@ -10582,28 +10295,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -25,8 +25,6 @@
"prisma:studio": "prisma studio"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.5.0",
"@alicloud/openapi-client": "^0.4.15",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",

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")
}
// ============================================================================
// 刷新令牌
// ============================================================================
@ -285,44 +257,6 @@ enum OutboxStatus {
FAILED
}
// ============================================================================
// 用户能力控制 (Capability-based permissions)
// ============================================================================
model UserCapability {
id BigInt @id @default(autoincrement())
accountSequence String @map("account_sequence")
capability String // LOGIN, TRADING, C2C, TRANSFER_IN, TRANSFER_OUT, P2P_SEND, P2P_RECEIVE, MINING_CLAIM, KYC, PROFILE_EDIT, VIEW_ASSET, VIEW_TEAM, VIEW_RECORDS
enabled Boolean @default(true)
reason String? // 禁用原因
disabledBy String? @map("disabled_by") // 操作人
disabledAt DateTime? @map("disabled_at")
expiresAt DateTime? @map("expires_at") // null=永久
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([accountSequence, capability])
@@index([accountSequence])
@@index([expiresAt])
@@map("user_capabilities")
}
model CapabilityLog {
id BigInt @id @default(autoincrement())
accountSequence String @map("account_sequence")
capability String
action String // DISABLE, ENABLE, EXPIRE
reason String?
operatorId String? @map("operator_id")
previousValue Boolean @map("previous_value")
newValue Boolean @map("new_value")
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([accountSequence, createdAt(sort: Desc)])
@@map("capability_logs")
}
// ============================================================================
// CDC 幂等消费追踪
// ============================================================================

View File

@ -5,17 +5,13 @@ import {
AuthController,
SmsController,
PasswordController,
TradePasswordController,
KycController,
UserController,
HealthController,
AdminController,
InternalController,
CapabilityController,
} from './controllers';
import { ApplicationModule } from '@/application';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
import { CapabilityGuard } from '@/shared/guards/capability.guard';
@Module({
imports: [
@ -35,14 +31,11 @@ import { CapabilityGuard } from '@/shared/guards/capability.guard';
AuthController,
SmsController,
PasswordController,
TradePasswordController,
KycController,
UserController,
HealthController,
AdminController,
InternalController,
CapabilityController,
],
providers: [JwtAuthGuard, CapabilityGuard],
providers: [JwtAuthGuard],
})
export class ApiModule {}

View File

@ -1,24 +0,0 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
import { CapabilityService } from '@/application/services/capability.service';
/**
* API
*/
@Controller('auth/user')
@UseGuards(JwtAuthGuard)
export class CapabilityController {
constructor(private readonly capabilityService: CapabilityService) {}
/**
*
* mining-app
*/
@Get('capabilities')
async getCapabilities(
@CurrentUser('accountSequence') accountSequence: string,
) {
return this.capabilityService.getCapabilities(accountSequence);
}
}

View File

@ -1,10 +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';
export * from './capability.controller';

View File

@ -1,149 +0,0 @@
import { Controller, Get, Put, Param, Body, Query, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { CapabilityService } from '@/application/services/capability.service';
import { Capability, ALL_CAPABILITIES } from '@/domain/value-objects/capability.vo';
/**
* API - 2.0 JWT
*/
@Controller('internal')
export class InternalController {
private readonly logger = new Logger(InternalController.name);
constructor(
private readonly prisma: PrismaService,
private readonly capabilityService: CapabilityService,
) {}
/**
* 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 };
}
// =========================================================================
// 能力权限管理 (供 mining-admin-service 调用)
// =========================================================================
/**
*
*/
@Get('capabilities/:accountSequence')
async getUserCapabilities(
@Param('accountSequence') accountSequence: string,
) {
return this.capabilityService.getCapabilities(accountSequence);
}
/**
*
*/
@Put('capabilities/:accountSequence')
async setCapability(
@Param('accountSequence') accountSequence: string,
@Body() body: {
capability: string;
enabled: boolean;
reason?: string;
operatorId?: string;
expiresAt?: string;
},
) {
this.validateCapability(body.capability);
return this.capabilityService.setCapability({
accountSequence,
capability: body.capability as Capability,
enabled: body.enabled,
reason: body.reason,
operatorId: body.operatorId,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined,
});
}
/**
*
*/
@Put('capabilities/:accountSequence/bulk')
async bulkSetCapabilities(
@Param('accountSequence') accountSequence: string,
@Body() body: {
capabilities: Array<{
capability: string;
enabled: boolean;
reason?: string;
expiresAt?: string;
}>;
operatorId?: string;
},
) {
for (const c of body.capabilities) {
this.validateCapability(c.capability);
}
return this.capabilityService.setCapabilities({
accountSequence,
capabilities: body.capabilities.map((c) => ({
capability: c.capability as Capability,
enabled: c.enabled,
reason: c.reason,
expiresAt: c.expiresAt ? new Date(c.expiresAt) : undefined,
})),
operatorId: body.operatorId,
});
}
/**
*
*/
@Get('capabilities/:accountSequence/logs')
async getCapabilityLogs(
@Param('accountSequence') accountSequence: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.capabilityService.getCapabilityLogs(
accountSequence,
parseInt(page || '1', 10),
parseInt(pageSize || '20', 10),
);
}
private validateCapability(capability: string): void {
if (!ALL_CAPABILITIES.includes(capability as Capability)) {
throw new BadRequestException(
`无效的能力类型: ${capability},有效值: ${ALL_CAPABILITIES.join(', ')}`,
);
}
}
}

View File

@ -12,9 +12,7 @@ import {
import { FilesInterceptor } from '@nestjs/platform-express';
import { KycService, KycStatusResult } from '@/application/services';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
import { CapabilityGuard } from '@/shared/guards/capability.guard';
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
import { RequireCapability } from '@/shared/decorators/require-capability.decorator';
class SubmitKycDto {
realName: string;
@ -22,7 +20,7 @@ class SubmitKycDto {
}
@Controller('kyc')
@UseGuards(JwtAuthGuard, CapabilityGuard)
@UseGuards(JwtAuthGuard)
export class KycController {
constructor(private readonly kycService: KycService) {}
@ -43,7 +41,6 @@ export class KycController {
* POST /kyc/submit
*/
@Post('submit')
@RequireCapability('KYC')
@HttpCode(HttpStatus.OK)
@UseInterceptors(FilesInterceptor('files', 2))
async submitKyc(

View File

@ -7,38 +7,18 @@ import {
UseGuards,
} from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { IsString, IsNotEmpty, Matches, MinLength } from 'class-validator';
import { PasswordService } from '@/application/services';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
import { CapabilityGuard } from '@/shared/guards/capability.guard';
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
import { RequireCapability } from '@/shared/decorators/require-capability.decorator';
class ResetPasswordDto {
@IsString()
@IsNotEmpty()
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
phone: string;
@IsString()
@IsNotEmpty()
@Matches(/^\d{6}$/, { message: '验证码格式不正确' })
smsCode: string;
@IsString()
@IsNotEmpty()
@MinLength(6, { message: '密码至少6位' })
newPassword: string;
}
class ChangePasswordDto {
@IsString()
@IsNotEmpty()
oldPassword: string;
@IsString()
@IsNotEmpty()
@MinLength(6, { message: '密码至少6位' })
newPassword: string;
}
@ -66,8 +46,7 @@ export class PasswordController {
*/
@Post('change')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, CapabilityGuard)
@RequireCapability('PROFILE_EDIT')
@UseGuards(JwtAuthGuard)
async changePassword(
@CurrentUser() user: { accountSequence: string },
@Body() dto: ChangePasswordDto,

View File

@ -7,33 +7,18 @@ import {
UseGuards,
} from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { IsString, IsNotEmpty, IsEnum, Matches } from 'class-validator';
import { SmsService } from '@/application/services';
import { SmsVerificationType } from '@/domain';
class SendSmsDto {
@IsString()
@IsNotEmpty({ message: '手机号不能为空' })
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
phone: string;
@IsEnum(SmsVerificationType, { message: '验证码类型无效' })
type: SmsVerificationType;
type: 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
}
class VerifySmsDto {
@IsString()
@IsNotEmpty({ message: '手机号不能为空' })
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
phone: string;
@IsString()
@IsNotEmpty({ message: '验证码不能为空' })
@Matches(/^\d{6}$/, { message: '验证码格式不正确' })
code: string;
@IsEnum(SmsVerificationType, { message: '验证码类型无效' })
type: SmsVerificationType;
type: 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
}
@Controller('auth/sms')

View File

@ -1,122 +0,0 @@
import {
Controller,
Post,
Get,
Body,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { IsString, IsNotEmpty } from 'class-validator';
import { ThrottlerGuard } from '@nestjs/throttler';
import { TradePasswordService } from '@/application/services/trade-password.service';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
import { CapabilityGuard } from '@/shared/guards/capability.guard';
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
import { RequireCapability } from '@/shared/decorators/require-capability.decorator';
class SetTradePasswordDto {
@IsString()
@IsNotEmpty()
loginPassword: string;
@IsString()
@IsNotEmpty()
tradePassword: string;
}
class ChangeTradePasswordDto {
@IsString()
@IsNotEmpty()
oldTradePassword: string;
@IsString()
@IsNotEmpty()
newTradePassword: string;
}
class VerifyTradePasswordDto {
@IsString()
@IsNotEmpty()
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, CapabilityGuard)
@RequireCapability('PROFILE_EDIT')
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, CapabilityGuard)
@RequireCapability('PROFILE_EDIT')
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, CapabilityGuard)
@RequireCapability('TRADING')
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,9 +1,7 @@
import {
Controller,
Get,
Query,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { UserService, UserProfileResult } from '@/application/services';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
@ -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

@ -5,15 +5,13 @@ import { ScheduleModule } from '@nestjs/schedule';
import {
AuthService,
PasswordService,
TradePasswordService,
SmsService,
KycService,
UserService,
OutboxService,
AdminSyncService,
CapabilityService,
} from './services';
import { OutboxScheduler, CapabilityExpiryScheduler } from './schedulers';
import { OutboxScheduler } from './schedulers';
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
@Module({
@ -34,26 +32,21 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
providers: [
AuthService,
PasswordService,
TradePasswordService,
SmsService,
KycService,
UserService,
OutboxService,
AdminSyncService,
CapabilityService,
OutboxScheduler,
CapabilityExpiryScheduler,
],
exports: [
AuthService,
PasswordService,
TradePasswordService,
SmsService,
KycService,
UserService,
AdminSyncService,
OutboxService,
CapabilityService,
],
})
export class ApplicationModule {}

View File

@ -1,35 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { CapabilityService } from '../services/capability.service';
import { RedisService } from '@/infrastructure/redis';
@Injectable()
export class CapabilityExpiryScheduler {
private readonly logger = new Logger(CapabilityExpiryScheduler.name);
private readonly LOCK_KEY = 'auth:capability:expiry:lock';
constructor(
private readonly capabilityService: CapabilityService,
private readonly redis: RedisService,
) {}
/**
* 60
*/
@Cron('*/60 * * * * *')
async processExpiredRestrictions(): Promise<void> {
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 55);
if (!lockValue) return;
try {
const count = await this.capabilityService.processExpiredRestrictions();
if (count > 0) {
this.logger.log(`已恢复 ${count} 个到期的能力限制`);
}
} catch (error) {
this.logger.error('处理到期限制失败', error);
} finally {
await this.redis.releaseLock(this.LOCK_KEY, lockValue);
}
}
}

View File

@ -1,2 +1 @@
export * from './outbox.scheduler';
export * from './capability-expiry.scheduler';

View File

@ -1,11 +1,10 @@
import { Injectable, Inject, UnauthorizedException, ForbiddenException, ConflictException, BadRequestException } from '@nestjs/common';
import { Injectable, Inject, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import {
UserAggregate,
Phone,
AccountSequence,
Capability,
USER_REPOSITORY,
UserRepository,
SYNCED_LEGACY_USER_REPOSITORY,
@ -19,7 +18,6 @@ import {
LegacyUserMigratedEvent,
} from '@/domain';
import { OutboxService } from './outbox.service';
import { CapabilityService } from './capability.service';
export interface LoginResult {
accessToken: string;
@ -67,7 +65,6 @@ export class AuthService {
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly outboxService: OutboxService,
private readonly capabilityService: CapabilityService,
) {}
/**
@ -152,16 +149,6 @@ export class AuthService {
}
throw new UnauthorizedException('账户已被禁用');
}
// 检查 LOGIN 能力是否被限制
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
user.accountSequence.value,
Capability.LOGIN,
);
if (!loginEnabled) {
throw new ForbiddenException('您的登录功能已被限制,请联系客服');
}
user.recordLoginSuccess(dto.ipAddress);
await this.userRepository.save(user);
return this.generateTokens(user, dto.deviceInfo, dto.ipAddress);
@ -213,15 +200,6 @@ export class AuthService {
throw new UnauthorizedException('账户已被禁用');
}
// 检查 LOGIN 能力是否被限制
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
user.accountSequence.value,
Capability.LOGIN,
);
if (!loginEnabled) {
throw new ForbiddenException('您的登录功能已被限制,请联系客服');
}
const isValid = await user.verifyPassword(password);
if (!isValid) {
const result = user.recordLoginFailure();
@ -331,15 +309,6 @@ export class AuthService {
throw new UnauthorizedException('账户不可用');
}
// 检查 LOGIN 能力是否被限制
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
user.accountSequence.value,
Capability.LOGIN,
);
if (!loginEnabled) {
throw new ForbiddenException('您的登录功能已被限制,请联系客服');
}
const accessToken = this.generateAccessToken(user);
const expiresIn = this.configService.get<number>('JWT_EXPIRES_IN_SECONDS', 3600);
@ -378,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,
},
@ -391,9 +360,6 @@ export class AuthService {
private generateAccessToken(user: UserAggregate): string {
const payload = {
sub: user.accountSequence.value,
type: 'access',
userId: user.accountSequence.value,
accountSequence: user.accountSequence.value,
phone: user.phone.value,
source: user.source,
};

View File

@ -1,201 +0,0 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { RedisService } from '@/infrastructure/redis';
import {
CAPABILITY_REPOSITORY,
CapabilityRepository,
} from '@/domain/repositories/capability.repository.interface';
import {
Capability,
CapabilityMap,
defaultCapabilityMap,
} from '@/domain/value-objects/capability.vo';
@Injectable()
export class CapabilityService {
private readonly logger = new Logger(CapabilityService.name);
private readonly REDIS_PREFIX = 'cap:';
private readonly REDIS_TTL = 3600; // 1 hour
constructor(
@Inject(CAPABILITY_REPOSITORY)
private readonly capabilityRepo: CapabilityRepository,
private readonly redis: RedisService,
) {}
/**
*
* Redis DB fallback Redis
* =
*/
async getCapabilities(accountSequence: string): Promise<CapabilityMap> {
const cached = await this.redis.getJson<CapabilityMap>(
`${this.REDIS_PREFIX}${accountSequence}`,
);
if (cached) return cached;
const map = await this.buildCapabilityMap(accountSequence);
await this.redis.setJson(
`${this.REDIS_PREFIX}${accountSequence}`,
map,
this.REDIS_TTL,
);
return map;
}
/**
*
*/
async isCapabilityEnabled(
accountSequence: string,
capability: Capability,
): Promise<boolean> {
const map = await this.getCapabilities(accountSequence);
return map[capability] ?? true;
}
/**
*
*/
async setCapability(params: {
accountSequence: string;
capability: Capability;
enabled: boolean;
reason?: string;
operatorId?: string;
expiresAt?: Date;
}): Promise<CapabilityMap> {
const current = await this.capabilityRepo.findByAccountSequence(params.accountSequence);
const existing = current.find((c) => c.capability === params.capability);
const previousValue = existing ? existing.enabled : true;
await this.capabilityRepo.upsertWithLog(
{
accountSequence: params.accountSequence,
capability: params.capability,
enabled: params.enabled,
reason: params.reason,
disabledBy: params.enabled ? undefined : params.operatorId,
expiresAt: params.expiresAt,
},
{
accountSequence: params.accountSequence,
capability: params.capability,
action: params.enabled ? 'ENABLE' : 'DISABLE',
reason: params.reason,
operatorId: params.operatorId,
previousValue,
newValue: params.enabled,
expiresAt: params.expiresAt,
},
);
return this.refreshCache(params.accountSequence);
}
/**
*
*/
async setCapabilities(params: {
accountSequence: string;
capabilities: Array<{
capability: Capability;
enabled: boolean;
reason?: string;
expiresAt?: Date;
}>;
operatorId?: string;
}): Promise<CapabilityMap> {
for (const cap of params.capabilities) {
await this.setCapability({
accountSequence: params.accountSequence,
capability: cap.capability,
enabled: cap.enabled,
reason: cap.reason,
operatorId: params.operatorId,
expiresAt: cap.expiresAt,
});
}
return this.refreshCache(params.accountSequence);
}
/**
* cron
*/
async processExpiredRestrictions(): Promise<number> {
const expired = await this.capabilityRepo.findExpired();
let count = 0;
for (const record of expired) {
await this.capabilityRepo.upsertWithLog(
{
accountSequence: record.accountSequence,
capability: record.capability,
enabled: true,
reason: '临时限制已到期,自动恢复',
},
{
accountSequence: record.accountSequence,
capability: record.capability,
action: 'EXPIRE',
reason: '临时限制到期自动恢复',
previousValue: false,
newValue: true,
},
);
await this.refreshCache(record.accountSequence);
count++;
}
return count;
}
/**
*
*/
async getCapabilityLogs(
accountSequence: string,
page: number,
pageSize: number,
) {
return this.capabilityRepo.findLogsByAccountSequence(
accountSequence,
page,
pageSize,
);
}
private async buildCapabilityMap(accountSequence: string): Promise<CapabilityMap> {
const records = await this.capabilityRepo.findByAccountSequence(accountSequence);
const map = defaultCapabilityMap();
for (const record of records) {
if (record.capability in Capability) {
// 已过期的限制视为开启
if (!record.enabled && record.expiresAt && record.expiresAt <= new Date()) {
map[record.capability as Capability] = true;
} else {
map[record.capability as Capability] = record.enabled;
}
}
}
return map;
}
private async refreshCache(accountSequence: string): Promise<CapabilityMap> {
const map = await this.buildCapabilityMap(accountSequence);
try {
await this.redis.setJson(
`${this.REDIS_PREFIX}${accountSequence}`,
map,
this.REDIS_TTL,
);
} catch (error) {
this.logger.warn(`Redis 缓存刷新失败 (${accountSequence}): ${error?.message}`);
}
return map;
}
}

View File

@ -1,9 +1,7 @@
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';
export * from './outbox.service';
export * from './admin-sync.service';
export * from './capability.service';

View File

@ -45,15 +45,8 @@ export class PasswordService {
SmsVerificationType.RESET_PASSWORD,
);
if (!verification) {
throw new BadRequestException('验证码已过期或不存在');
}
if (verification.attempts >= 5) {
throw new BadRequestException('验证码尝试次数过多,请重新获取');
}
if (verification.code !== dto.smsCode) {
await this.smsVerificationRepository.incrementAttempts(verification.id);
throw new BadRequestException('验证码错误');
if (!verification || verification.code !== dto.smsCode) {
throw new BadRequestException('验证码错误或已过期');
}
// 标记验证码已使用
@ -70,9 +63,8 @@ export class PasswordService {
throw new NotFoundException('用户不存在');
}
// 修改密码,同时解除锁定(短信验证身份已通过,清除失败计数)
// 修改密码
await user.changePassword(dto.newPassword);
user.unlock();
await this.userRepository.save(user);
}

View File

@ -1,7 +1,5 @@
import { Injectable, Inject, BadRequestException, Logger } from '@nestjs/common';
import { Injectable, Inject, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
import * as $OpenApi from '@alicloud/openapi-client';
import {
Phone,
SmsCode,
@ -27,7 +25,6 @@ export interface VerifySmsDto {
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
private readonly codeExpireSeconds: number;
private readonly dailyLimit: number;
private readonly maxAttempts = 5;
@ -138,42 +135,22 @@ export class SmsService {
}
/**
*
*
*/
private async sendSmsToProvider(
phone: string,
code: string,
type: SmsVerificationType,
): Promise<void> {
const smsEnabled = this.configService.get<string>('SMS_ENABLED');
if (smsEnabled !== 'true') {
this.logger.log(`[SMS DISABLED] Phone: ${phone}, Code: ${code}, Type: ${type}`);
return;
// TODO: 实现阿里云短信发送
// const accessKeyId = this.configService.get<string>('SMS_ACCESS_KEY_ID');
// const accessKeySecret = this.configService.get<string>('SMS_ACCESS_KEY_SECRET');
// const signName = this.configService.get<string>('SMS_SIGN_NAME');
// const templateCode = this.configService.get<string>('SMS_TEMPLATE_CODE');
// 开发环境打印验证码
if (this.configService.get('NODE_ENV') === 'development') {
console.log(`[SMS] Phone: ${phone}, Code: ${code}, Type: ${type}`);
}
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
const signName = this.configService.get<string>('ALIYUN_SMS_SIGN_NAME');
const templateCode = this.configService.get<string>('ALIYUN_SMS_TEMPLATE_CODE');
const endpoint = this.configService.get<string>('ALIYUN_SMS_ENDPOINT', 'dysmsapi.aliyuncs.com');
const config = new $OpenApi.Config({ accessKeyId, accessKeySecret, endpoint });
const client = new Dysmsapi20170525(config);
const request = new $Dysmsapi20170525.SendSmsRequest({
phoneNumbers: phone,
signName,
templateCode,
templateParam: JSON.stringify({ code }),
});
const response = await client.sendSms(request);
const body = response.body;
if (!body || body.code !== 'OK') {
this.logger.error(`阿里云短信发送失败: ${body?.code} - ${body?.message}`);
throw new BadRequestException('短信发送失败,请稍后重试');
}
this.logger.log(`短信发送成功: ${phone}, BizId: ${body.bizId}`);
}
}

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();
}
/**
* 6使
*/
async setTradePassword(newPlainPassword: string): Promise<void> {
const password = await Password.createWithoutValidation(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

@ -1,78 +0,0 @@
export const CAPABILITY_REPOSITORY = Symbol('CAPABILITY_REPOSITORY');
export interface UserCapabilityRecord {
id: bigint;
accountSequence: string;
capability: string;
enabled: boolean;
reason: string | null;
disabledBy: string | null;
disabledAt: Date | null;
expiresAt: Date | null;
}
export interface CapabilityLogRecord {
id: bigint;
accountSequence: string;
capability: string;
action: string;
reason: string | null;
operatorId: string | null;
previousValue: boolean;
newValue: boolean;
expiresAt: Date | null;
createdAt: Date;
}
export interface CapabilityRepository {
findByAccountSequence(accountSequence: string): Promise<UserCapabilityRecord[]>;
upsert(data: {
accountSequence: string;
capability: string;
enabled: boolean;
reason?: string;
disabledBy?: string;
expiresAt?: Date;
}): Promise<UserCapabilityRecord>;
upsertWithLog(
upsertData: {
accountSequence: string;
capability: string;
enabled: boolean;
reason?: string;
disabledBy?: string;
expiresAt?: Date;
},
logData: {
accountSequence: string;
capability: string;
action: string;
reason?: string;
operatorId?: string;
previousValue: boolean;
newValue: boolean;
expiresAt?: Date;
},
): Promise<UserCapabilityRecord>;
findExpired(): Promise<UserCapabilityRecord[]>;
createLog(data: {
accountSequence: string;
capability: string;
action: string;
reason?: string;
operatorId?: string;
previousValue: boolean;
newValue: boolean;
expiresAt?: Date;
}): Promise<void>;
findLogsByAccountSequence(
accountSequence: string,
page: number,
pageSize: number,
): Promise<{ data: CapabilityLogRecord[]; total: number }>;
}

View File

@ -2,4 +2,3 @@ export * from './user.repository.interface';
export * from './synced-legacy-user.repository.interface';
export * from './refresh-token.repository.interface';
export * from './sms-verification.repository.interface';
export * from './capability.repository.interface';

View File

@ -1,47 +0,0 @@
/**
*
* Stripe Capability
*/
export enum Capability {
LOGIN = 'LOGIN',
TRADING = 'TRADING',
C2C = 'C2C',
TRANSFER_IN = 'TRANSFER_IN',
TRANSFER_OUT = 'TRANSFER_OUT',
P2P_SEND = 'P2P_SEND',
P2P_RECEIVE = 'P2P_RECEIVE',
MINING_CLAIM = 'MINING_CLAIM',
KYC = 'KYC',
PROFILE_EDIT = 'PROFILE_EDIT',
VIEW_ASSET = 'VIEW_ASSET',
VIEW_TEAM = 'VIEW_TEAM',
VIEW_RECORDS = 'VIEW_RECORDS',
}
export const ALL_CAPABILITIES = Object.values(Capability);
export type CapabilityMap = Record<Capability, boolean>;
export function defaultCapabilityMap(): CapabilityMap {
const map = {} as CapabilityMap;
for (const cap of ALL_CAPABILITIES) {
map[cap] = true;
}
return map;
}
export const CAPABILITY_LABELS: Record<Capability, string> = {
[Capability.LOGIN]: '登录',
[Capability.TRADING]: '交易',
[Capability.C2C]: 'C2C交易',
[Capability.TRANSFER_IN]: '划入',
[Capability.TRANSFER_OUT]: '划出',
[Capability.P2P_SEND]: 'P2P转出',
[Capability.P2P_RECEIVE]: 'P2P收款',
[Capability.MINING_CLAIM]: '挖矿领取',
[Capability.KYC]: '实名认证',
[Capability.PROFILE_EDIT]: '编辑资料',
[Capability.VIEW_ASSET]: '查看资产',
[Capability.VIEW_TEAM]: '查看团队',
[Capability.VIEW_RECORDS]: '查看记录',
};

View File

@ -2,4 +2,3 @@ export * from './account-sequence.vo';
export * from './phone.vo';
export * from './password.vo';
export * from './sms-code.vo';
export * from './capability.vo';

View File

@ -20,14 +20,6 @@ export class Password {
return new Password(hash);
}
/**
*
*/
static async createWithoutValidation(plainPassword: string): Promise<Password> {
const hash = await bcrypt.hash(plainPassword, Password.SALT_ROUNDS);
return new Password(hash);
}
/**
* hash
*/

View File

@ -6,9 +6,8 @@ import {
PrismaSyncedLegacyUserRepository,
PrismaRefreshTokenRepository,
PrismaSmsVerificationRepository,
PrismaCapabilityRepository,
} from './persistence/repositories';
import { LegacyUserCdcConsumer, WalletAddressCdcConsumer } from './messaging/cdc';
import { LegacyUserCdcConsumer } from './messaging/cdc';
import { KafkaModule, KafkaProducerService } from './kafka';
import { RedisService } from './redis';
import {
@ -16,7 +15,6 @@ import {
SYNCED_LEGACY_USER_REPOSITORY,
REFRESH_TOKEN_REPOSITORY,
SMS_VERIFICATION_REPOSITORY,
CAPABILITY_REPOSITORY,
} from '@/domain';
import { ApplicationModule } from '@/application/application.module';
@ -26,7 +24,6 @@ import { ApplicationModule } from '@/application/application.module';
providers: [
// CDC
LegacyUserCdcConsumer,
WalletAddressCdcConsumer,
// Kafka Producer
KafkaProducerService,
@ -61,10 +58,6 @@ import { ApplicationModule } from '@/application/application.module';
provide: SMS_VERIFICATION_REPOSITORY,
useClass: PrismaSmsVerificationRepository,
},
{
provide: CAPABILITY_REPOSITORY,
useClass: PrismaCapabilityRepository,
},
],
exports: [
PrismaModule,
@ -74,7 +67,6 @@ import { ApplicationModule } from '@/application/application.module';
SYNCED_LEGACY_USER_REPOSITORY,
REFRESH_TOKEN_REPOSITORY,
SMS_VERIFICATION_REPOSITORY,
CAPABILITY_REPOSITORY,
],
})
export class InfrastructureModule {}

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);
}
}
}

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