chore: snapshot current aws standby and backup tooling work

This commit is contained in:
2026-05-13 10:59:03 -07:00
parent c4b2ebf7e1
commit f6911965dc
31 changed files with 1626 additions and 12482 deletions
-9026
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
EC2 SSH 连接资料
服务器名称: hifast-hk-app-01
公网 IP: 43.198.248.161
登录用户: ubuntu
私钥文件:
- hifast-hk-app-01-reset
公钥文件:
- hifast-hk-app-01-reset.pub
连接命令:
ssh -i hifast-hk-app-01-reset ubuntu@43.198.248.161
如果在 Mac / Linux 上使用,先执行:
chmod 600 hifast-hk-app-01-reset
如果要给别人使用,只需要把私钥文件 hifast-hk-app-01-reset 发给对方即可。
出于安全考虑,建议通过安全渠道传输,并在后续需要时重新轮换密钥。
@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCgAAAKjy5KDa8uSg
2gAAAAtzc2gtZWQyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCg
AAAEDwSb0b/0S6Tw8Od5hAtIKqt1JvomqQQS44Ty3xL+FjPtvqawOqZIcu/SZaAI/i9+ND
cDnnaq/3m5B2wf3hGegKAAAAIWhpZmFzdC1oay1hcHAtMDEtcmVzZXQtMjAyNi0wNS0xMA
ECAwQ=
-----END OPENSSH PRIVATE KEY-----
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvqawOqZIcu/SZaAI/i9+NDcDnnaq/3m5B2wf3hGegK hifast-hk-app-01-reset-2026-05-10
+77 -20
View File
@@ -4,7 +4,7 @@
目标架构:
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + observability) -> RDS MySQL + ElastiCache Redis`
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
## 1. 资源清单
@@ -16,7 +16,7 @@
4. 公有 / 私有路由表
5. 安全组
6. RDS MySQL
7. ElastiCache Redis OSS
7. EC2 本机 Redis Docker
8. EC2
9. ACM 证书
10. ALB + Target Group
@@ -28,7 +28,7 @@
- VPC: `ppanel-hk-prod`
- EC2: `ppanel-app-hk-01`
- RDS: `ppanel-mysql-hk`
- Redis: `ppanel-redis-hk`
- Redis container: `hifast-redis`
- ALB: `ppanel-alb-hk`
- WAF: `ppanel-waf-hk`
@@ -42,6 +42,7 @@
- Disk: `gp3 80GB`
- Public subnet: 是
- IAM Role: 允许读取 CloudWatch / SSM(如使用)
- 如果要在 AWS EC2 本机执行 S3 备份:额外允许写入专用备份桶
### RDS MySQL
@@ -54,21 +55,21 @@
- Charset: `utf8mb4`
- Backup: `7-14 days`
### ElastiCache Redis OSS
### Redis
- Engine: Redis OSS 7.x
- Node type: `cache.m7g.large`
- Cluster mode: disabled
- Multi-AZ: 按预算决定,正式环境建议开启
- Public access: 不开放
- TLS: 当前模板按 **关闭** 规划
- 部署位置:业务 EC2 本机
- 部署方式:Docker
- 版本:`redis:8.2.1`
- 监听:`0.0.0.0:6379`
- 应用连接:`127.0.0.1:6379`
- 安全组:仅对白名单备用节点或同机应用开放
## 3. 网络与安全组
### 子网布局
- `public-a`, `public-b`: ALB / EC2
- `private-a`, `private-b`: RDS / Redis
- `private-a`, `private-b`: RDS
### 安全组建议
@@ -99,10 +100,10 @@
- Inbound
- `3306/tcp` from `sg-ec2`
#### `sg-redis`
#### `sg-ec2` 额外说明
- Inbound
- `6379/tcp` from `sg-ec2`
- 如果需要外部备用节点复制 Redis,再额外放行:
- `6379/tcp` from `104.238.220.230/32`
## 4. ALB / Target Group / 健康检查
@@ -198,6 +199,7 @@
- `MySQL.Addr`
- `MySQL.Password`
- `Redis.Host`
- `Redis.Pass`
- `JwtAuth.AccessSecret`
- `Administrator.Email`
- `Administrator.Password`
@@ -217,6 +219,41 @@ Redis 约定保持不变:
把脚本上传到 EC2 后执行:
## 9. 104 灾备节点常用运维脚本
如果你要在 `104.238.220.230` 上执行数据迁移、主从重拉、主库提升,可以直接复用仓库里的这几份脚本:
- 数据导出 / 导入交互工具:
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
- MySQL 主从运维工具:
- [`deploy/scripts/mysql_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/mysql_replica_ops.sh)
- Redis 主从运维工具:
- [`deploy/scripts/redis_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/redis_replica_ops.sh)
- 统一总入口:
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
- 主从运维环境模板:
- [`deploy/aws/ap-east-1/configs/replica-ops.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/replica-ops.env.example)
### 9.1 数据迁移工具
支持:
- 备份 MySQL 到 S3
- 备份 Redis 到 S3
- 从正式库导出 MySQL `sql.gz`
-`sql.gz` 导入 AWS RDS
- 从正式 Redis 导出 `RDB`
-`RDB` 导入 Docker Redis 或宿主机 Redis
- 查看 MySQL / Redis 当前主从状态
- 强制重拉 MySQL / Redis 主从
- 把 MySQL / Redis 从库提升为可写主库
示例:
```bash
bash deploy/scripts/hifast_data_sync_tool.sh /root/replica-ops.env
```
```bash
chmod +x deploy/scripts/bootstrap_aws_ec2.sh
sudo APP_DIR=/opt/ppanel deploy/scripts/bootstrap_aws_ec2.sh
@@ -244,7 +281,7 @@ docker compose -f docker-compose.cloud.yml up -d
chmod +x deploy/scripts/preflight_aws_hk.sh
APP_DIR=/opt/ppanel \
RDS_HOST=<new-rds-endpoint> \
REDIS_HOST=<new-redis-endpoint> \
REDIS_HOST=127.0.0.1 \
deploy/scripts/preflight_aws_hk.sh
```
@@ -260,7 +297,7 @@ deploy/scripts/preflight_aws_hk.sh
4. 用户注册 / 登录
5. 订阅查询
6. 节点上报 `/v1/server/status`
7. Redis 可写缓存
7. 本机 Redis 可写缓存
8. Asynq 可入队并消费
## 10. 正式切换
@@ -270,7 +307,7 @@ deploy/scripts/preflight_aws_hk.sh
1. ALB 5xx 为 0
2. EC2 CPU / Memory 正常
3. RDS CPU / Connections 正常
4. Redis CPU / Connections / Memory 正常
4. 本机 Redis CPU / Connections / Memory 正常
5. WAF 已挂到 ALB
6. EC2 安全组没有对公网放 `8080/3333/9090/4317`
@@ -288,7 +325,7 @@ deploy/scripts/preflight_aws_hk.sh
- ALB `RequestCount`, `HTTPCode_ELB_5XX_Count`, `TargetResponseTime`
- EC2 `CPUUtilization`, `NetworkIn`, `NetworkOut`, `StatusCheckFailed`
- RDS `CPUUtilization`, `DatabaseConnections`, `ReadLatency`, `WriteLatency`
- Redis `EngineCPUUtilization`, `CurrConnections`, `BytesUsedForCache`
- Redis 容器 CPU / Memory / restart count
## 12. 这次方案的边界
@@ -296,11 +333,31 @@ deploy/scripts/preflight_aws_hk.sh
- 香港区新账号的部署模板
- 新空环境启动与验证流程
- ALB / WAF / EC2 / RDS / Redis 的落地约定
- ALB / WAF / EC2 / RDS / 本机 Redis 的落地约定
不包含:
- 旧数据迁移
- Terraform / CloudFormation 自动建资源
- Redis TLS 改造
- Redis 托管版改造
- 多活 / 自动扩缩容
## 13. S3 备份补强
当前已落地的 S3 备份桶:
- `hifast-prod-backups-200810848252-ap-east-1`
建议与现网结合方式:
1. `RDS automated backup` 继续保留,作为第一层恢复能力
2. `104` 外部 MySQL 从库执行逻辑备份并上传到 S3,作为第二层可下载备份
3. `104` 外部 Redis 从库按需导出 `RDB` 到 S3,补齐缓存类灾备材料
仓库中已补充:
- 环境变量模板:[`configs/backup-to-s3.env.example`](./configs/backup-to-s3.env.example)
- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh)
- Redis 备份脚本:[`../../scripts/redis_rdb_backup_to_s3.sh`](../../scripts/redis_rdb_backup_to_s3.sh)
建议把 MySQL 备份脚本优先部署到 `104`,因为它直接连接本地只读从库,对 AWS 主库扰动最小。
@@ -0,0 +1,18 @@
AWS_REGION=ap-east-1
S3_BUCKET=hifast-prod-backups-200810848252-ap-east-1
S3_PREFIX=mysql
BACKUP_DIR=/var/backups/hifast
HOST_TAG=104-standby
KEEP_LOCAL_DAYS=3
CHECK_REPLICA=1
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=backup_reader
MYSQL_PASSWORD=CHANGE_ME
MYSQL_SOCKET=
MYSQL_DATABASE=hifast
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
@@ -0,0 +1,20 @@
PRIMARY_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
PRIMARY_PORT=3306
PRIMARY_USER=admin
PRIMARY_PASSWORD=CHANGE_ME
PRIMARY_DB=hifast
PRIMARY_REPL_USER=repl
PRIMARY_REPL_PASSWORD=CHANGE_ME
PRIMARY_REPL_HOST=104.238.220.230
PRIMARY_BINLOG_RETENTION_HOURS=24
REPLICA_HOST=127.0.0.1
REPLICA_PORT=3306
REPLICA_USER=root
REPLICA_PASSWORD=
REPLICA_SOCKET=/var/run/mysqld/mysqld.sock
REPLICA_DB=hifast
REPLICA_SOURCE_SSL=1
DUMP_FILE=
@@ -34,8 +34,8 @@ MySQL:
SlowThreshold: 1000
Redis:
Host: YOUR_REDIS_PRIMARY_ENDPOINT:6379
Pass:
Host: 127.0.0.1:6379
Pass: CHANGE_ME_TO_REDIS_PASSWORD
DB: 0
PoolSize: 100
MinIdleConns: 10
@@ -0,0 +1,23 @@
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=CHANGE_ME
MYSQL_SOCKET=
REPL_SOURCE_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
REPL_SOURCE_PORT=3306
REPL_SOURCE_USER=repl
REPL_SOURCE_PASSWORD=CHANGE_ME
REPL_SOURCE_SSL=1
REPL_SOURCE_LOG_FILE=
REPL_SOURCE_LOG_POS=
REPL_SOURCE_AUTO_POSITION=1
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
REDIS_SOURCE_HOST=43.198.248.161
REDIS_SOURCE_PORT=6379
REDIS_SOURCE_USER=
REDIS_SOURCE_PASSWORD=CHANGE_ME
+2 -2
View File
@@ -6,7 +6,7 @@
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
#
# 网络说明:
# ppanel-server 使用 host 网络(可出外网,直接访问 AWS RDS/ElastiCache 私网地址
# ppanel-server 使用 host 网络(可出外网,直接访问 AWS RDS / 本机 Redis
# 监控服务(Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
# Tempo(4317) 将端口映射到 127.0.0.1ppanel-server 通过 host 网络访问
# 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问
@@ -123,7 +123,7 @@ services:
container_name: ppanel-grafana
restart: always
ports:
- "127.0.0.1:3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代
- "3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD}
- GF_USERS_ALLOW_SIGN_UP=false
+1 -11
View File
@@ -7,7 +7,6 @@ require (
github.com/alibabacloud-go/darabonba-openapi v0.1.18
github.com/alibabacloud-go/dysmsapi-20170525/v2 v2.0.18
github.com/alibabacloud-go/tea v1.2.2
github.com/alicebob/miniredis/v2 v2.34.0
github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72
github.com/andybalholm/brotli v1.1.1
github.com/forgoer/openssl v1.6.0
@@ -32,7 +31,6 @@ require (
github.com/smartwalle/alipay/v3 v3.2.23
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
github.com/stripe/stripe-go/v81 v81.1.0
github.com/twilio/twilio-go v1.23.11
go.opentelemetry.io/otel v1.29.0
@@ -51,7 +49,6 @@ require (
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.30.0
gorm.io/plugin/soft_delete v1.2.1
k8s.io/apimachinery v0.31.1
)
@@ -60,6 +57,7 @@ require (
github.com/fatih/color v1.18.0
github.com/goccy/go-json v0.10.4
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/mojocn/base64Captcha v1.3.8
github.com/oschwald/geoip2-golang v1.13.0
github.com/spaolacci/murmur3 v1.1.0
google.golang.org/grpc v1.64.1
@@ -79,7 +77,6 @@ require (
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect
github.com/aliyun/credentials-go v1.3.10 // indirect
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
github.com/bytedance/sonic v1.12.7 // indirect
@@ -88,7 +85,6 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.5.6 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
@@ -114,27 +110,22 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mojocn/base64Captcha v1.3.8 // indirect
github.com/openzipkin/zipkin-go v0.4.2 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/smartwalle/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.0.9 // indirect
github.com/smartwalle/nsign v1.0.9 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
@@ -150,5 +141,4 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
)
-22
View File
@@ -52,10 +52,6 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQggTglU/0=
github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA=
@@ -226,8 +222,6 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -258,9 +252,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -365,8 +356,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
@@ -578,20 +567,9 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=
gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
@@ -1,198 +0,0 @@
package auth
import (
"testing"
"github.com/perfect-panel/server/internal/config"
"github.com/stretchr/testify/assert"
)
func TestNormalizeEmail(t *testing.T) {
tests := []struct {
input string
want string
}{
// Gmail dot trick
{"a.v.x.xx@gmail.com", "avxxx@gmail.com"},
{"john.doe@gmail.com", "johndoe@gmail.com"},
{"a.b.c.d.e@gmail.com", "abcde@gmail.com"},
// Gmail + alias
{"user+tag@gmail.com", "user@gmail.com"},
{"a.b+tag@gmail.com", "ab@gmail.com"},
// Googlemail
{"a.b@googlemail.com", "ab@googlemail.com"},
// Non-Gmail: dots preserved
{"john.doe@outlook.com", "john.doe@outlook.com"},
{"john.doe@qq.com", "john.doe@qq.com"},
// + alias stripped for all providers
{"user+spam@outlook.com", "user@outlook.com"},
{"user+spam@qq.com", "user@qq.com"},
// Case insensitive
{"User@Gmail.COM", "user@gmail.com"},
{"A.B@Gmail.com", "ab@gmail.com"},
// No change for normal non-gmail email
{"abc@163.com", "abc@163.com"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := NormalizeEmail(tt.input)
if got != tt.want {
t.Errorf("NormalizeEmail(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestNormalizeEmail_NoChangeSkipsCheck(t *testing.T) {
// These emails should NOT trigger cross-user check (normalized == original)
noChangeCases := []string{
"abc@163.com",
"john.doe@outlook.com",
"user@qq.com",
}
for _, email := range noChangeCases {
normalized := NormalizeEmail(email)
lower := email
if normalized == lower {
// correct: no normalization change, NormalizedEmailHasTrial would return false early
}
}
}
func TestShouldGrantTrialForEmail(t *testing.T) {
// 模拟线上配置:白名单开启,gmail.com 也在名单里
rcWithGmail := config.RegisterConfig{
EnableTrial: true,
EnableTrialEmailWhitelist: true,
TrialEmailDomainWhitelist: "hifastapp.com,hifastvpn.com,126.com,139.com,163.com,gmail.com",
}
// 白名单关闭
rcNoWhitelist := config.RegisterConfig{
EnableTrial: true,
EnableTrialEmailWhitelist: false,
}
tests := []struct {
name string
rc config.RegisterConfig
email string
want bool
reason string
}{
{
name: "gmail dot trick - blocked even if gmail.com in whitelist",
rc: rcWithGmail,
email: "s.m.s.n.fsmbt.d.ndny@gmail.com",
want: false,
reason: "gmail 泛域名(含点号)应拒绝",
},
{
name: "gmail plus alias - blocked",
rc: rcWithGmail,
email: "user+tag@gmail.com",
want: false,
reason: "gmail +别名应拒绝",
},
{
name: "clean gmail - allowed",
rc: rcWithGmail,
email: "normaluser@gmail.com",
want: true,
reason: "干净的 gmail 应放行",
},
{
name: "163 with dot - allowed (non-gmail dot is ok)",
rc: rcWithGmail,
email: "s.m.s.n@163.com",
want: true,
reason: "非 gmail 域点号不拦截",
},
{
name: "163 plus alias - blocked",
rc: rcWithGmail,
email: "user+spam@163.com",
want: false,
reason: "所有域名的 +别名都拦截",
},
{
name: "gmail typo squatting domain - blocked even if accidentally whitelisted",
rc: config.RegisterConfig{EnableTrial: true, EnableTrialEmailWhitelist: true, TrialEmailDomainWhitelist: "gmail.com,gmaial.com"},
email: "1.2.3.4xxx@gmaial.com",
want: false,
reason: "易混淆 Gmail 域名不应发放试用",
},
{
name: "invalid empty local - blocked",
rc: rcWithGmail,
email: "@gmail.com",
want: false,
reason: "邮箱 local 为空应拒绝",
},
{
name: "subdomain spoof - blocked",
rc: rcWithGmail,
email: "user@fake.gmail.com",
want: false,
reason: "白名单必须精确匹配域名,不匹配子域",
},
{
name: "whitelist disabled - gmail dot trick still blocked",
rc: rcNoWhitelist,
email: "s.m.s.n.fsmbt.d.ndny@gmail.com",
want: false,
reason: "白名单未启用,但泛域名仍应拒绝",
},
{
name: "trial disabled - always blocked",
rc: config.RegisterConfig{EnableTrial: false},
email: "user@163.com",
want: false,
reason: "试用未开启",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ShouldGrantTrialForEmail(tt.rc, tt.email)
if got != tt.want {
t.Errorf("ShouldGrantTrialForEmail(%q) = %v, want %v | reason: %s",
tt.email, got, tt.want, tt.reason)
}
})
}
}
func TestShouldAutoGrantTrialOnPublicEmailFlows(t *testing.T) {
assert.False(t, ShouldAutoGrantTrialOnPublicEmailFlows(config.RegisterConfig{}))
assert.False(t, ShouldAutoGrantTrialOnPublicEmailFlows(config.RegisterConfig{
EnableTrial: true,
EnableTrialEmailWhitelist: true,
TrialEmailDomainWhitelist: "gmail.com,example.com",
}))
}
func TestIsEmailDomainWhitelisted(t *testing.T) {
whitelist := "gmail.com,edu.cn,outlook.com"
tests := []struct {
email string
want bool
}{
{"user@gmail.com", true},
{"user@edu.cn", true},
{"User@Gmail.COM", true},
{"user@yahoo.com", false},
{"user@fake.gmail.com", false}, // subdomain not matched
{"user@", false},
{"notanemail", false},
{"@gmail.com", false},
}
for _, tt := range tests {
t.Run(tt.email, func(t *testing.T) {
got := IsEmailDomainWhitelisted(tt.email, whitelist)
if got != tt.want {
t.Errorf("IsEmailDomainWhitelisted(%q) = %v, want %v", tt.email, got, tt.want)
}
})
}
}
@@ -1,36 +0,0 @@
package common
import (
"context"
"testing"
"time"
"github.com/perfect-panel/server/internal/model/user"
"github.com/stretchr/testify/require"
)
func TestResolvePurchaseRoute_AllowsPlanChangeForExistingSubscription(t *testing.T) {
anchor := &user.Subscribe{
Id: 10,
UserId: 20,
OrderId: 30,
SubscribeId: 1,
Token: "existing-token",
ExpireTime: time.Now().Add(time.Hour),
}
decision, err := ResolvePurchaseRoute(
context.Background(),
true,
anchor.UserId,
2,
func(context.Context, int64) (*user.Subscribe, error) {
return anchor, nil
},
)
require.NoError(t, err)
require.Equal(t, PurchaseRoutePurchaseToRenewal, decision.Route)
require.Equal(t, int64(2), decision.ResolvedSubscribeID)
require.Equal(t, anchor, decision.Anchor)
}
@@ -1,16 +0,0 @@
package order
import (
"testing"
"github.com/perfect-panel/server/internal/types"
"github.com/stretchr/testify/require"
)
func TestGetDiscount_SkipsNewUserOnlyTierForExistingUser(t *testing.T) {
discount := getDiscount([]types.SubscribeDiscount{
{Quantity: 1, Discount: 90, NewUserOnly: true},
}, 1, false)
require.Equal(t, float64(1), discount)
}
@@ -1,845 +0,0 @@
package order
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/hibiken/asynq"
modelOrder "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/payment"
subModel "github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// setupNewUserOnlyDB 创建带必要表的 SQLite 内存数据库
func setupNewUserOnlyDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err, "failed to open in-memory SQLite")
db.Exec("PRAGMA foreign_keys = OFF")
sqls := []string{
`CREATE TABLE IF NOT EXISTS "subscribe" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL DEFAULT '',
language VARCHAR(255) NOT NULL DEFAULT '',
description TEXT,
unit_price INTEGER NOT NULL DEFAULT 0,
unit_time VARCHAR(255) NOT NULL DEFAULT '',
discount TEXT,
replacement INTEGER NOT NULL DEFAULT 0,
inventory INTEGER NOT NULL DEFAULT -1,
traffic INTEGER NOT NULL DEFAULT 0,
speed_limit INTEGER NOT NULL DEFAULT 0,
device_limit INTEGER NOT NULL DEFAULT 0,
quota INTEGER NOT NULL DEFAULT 0,
new_user_only TINYINT DEFAULT 0,
nodes VARCHAR(255),
node_tags VARCHAR(255),
show TINYINT NOT NULL DEFAULT 0,
sell TINYINT NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0,
deduction_ratio INTEGER DEFAULT 0,
allow_deduction TINYINT DEFAULT 1,
reset_cycle INTEGER DEFAULT 0,
renewal_reset TINYINT DEFAULT 0,
show_original_price TINYINT NOT NULL DEFAULT 1,
created_at DATETIME,
updated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS "order" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER DEFAULT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
subscription_user_id INTEGER NOT NULL DEFAULT 0,
order_no VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
type TINYINT NOT NULL DEFAULT 1,
quantity INTEGER NOT NULL DEFAULT 1,
price INTEGER NOT NULL DEFAULT 0,
amount INTEGER NOT NULL DEFAULT 0,
gift_amount INTEGER NOT NULL DEFAULT 0,
discount INTEGER NOT NULL DEFAULT 0,
coupon VARCHAR(255) DEFAULT NULL,
coupon_discount INTEGER NOT NULL DEFAULT 0,
commission INTEGER NOT NULL DEFAULT 0,
payment_id INTEGER NOT NULL DEFAULT 0,
method VARCHAR(255) NOT NULL DEFAULT '',
fee_amount INTEGER NOT NULL DEFAULT 0,
trade_no VARCHAR(255) DEFAULT NULL,
app_account_token VARCHAR(255) DEFAULT NULL,
status TINYINT NOT NULL DEFAULT 1,
subscribe_id INTEGER NOT NULL DEFAULT 0,
subscribe_token VARCHAR(255) DEFAULT NULL,
is_new TINYINT NOT NULL DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME DEFAULT NULL
)`,
`CREATE TABLE IF NOT EXISTS "user" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password VARCHAR(100) NOT NULL DEFAULT '',
algo VARCHAR(20) DEFAULT 'default',
salt VARCHAR(20) DEFAULT NULL,
avatar TEXT,
balance INTEGER DEFAULT 0,
refer_code VARCHAR(20) DEFAULT '',
referer_id INTEGER DEFAULT 0,
commission INTEGER DEFAULT 0,
referral_percentage INTEGER DEFAULT 0,
only_first_purchase TINYINT DEFAULT 1,
gift_amount INTEGER DEFAULT 0,
enable TINYINT DEFAULT 1,
is_admin TINYINT DEFAULT 0,
enable_balance_notify TINYINT DEFAULT 0,
enable_login_notify TINYINT DEFAULT 0,
enable_subscribe_notify TINYINT DEFAULT 0,
enable_trade_notify TINYINT DEFAULT 0,
rules TEXT,
member_status VARCHAR(20) DEFAULT '',
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME DEFAULT NULL
)`,
`CREATE TABLE IF NOT EXISTS "payment" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL DEFAULT '',
platform VARCHAR(100) NOT NULL DEFAULT '',
icon VARCHAR(255) DEFAULT '',
domain VARCHAR(255) DEFAULT '',
config TEXT NOT NULL DEFAULT '{}',
description TEXT,
fee_mode TINYINT NOT NULL DEFAULT 0,
fee_percent INTEGER DEFAULT 0,
fee_amount INTEGER DEFAULT 0,
enable TINYINT NOT NULL DEFAULT 1,
token VARCHAR(255) NOT NULL DEFAULT '' UNIQUE
)`,
`CREATE TABLE IF NOT EXISTS "user_subscribe" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
order_id INTEGER NOT NULL DEFAULT 0,
subscribe_id INTEGER NOT NULL DEFAULT 0,
start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expire_time DATETIME DEFAULT NULL,
finished_at DATETIME DEFAULT NULL,
traffic INTEGER DEFAULT 0,
download INTEGER DEFAULT 0,
upload INTEGER DEFAULT 0,
token VARCHAR(255) DEFAULT '' UNIQUE,
uuid VARCHAR(255) DEFAULT '' UNIQUE,
status TINYINT DEFAULT 0,
note VARCHAR(500) DEFAULT '',
created_at DATETIME,
updated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS "user_device" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip VARCHAR(255) NOT NULL DEFAULT '',
user_id INTEGER NOT NULL DEFAULT 0,
user_agent TEXT,
identifier VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
short_code VARCHAR(255) NOT NULL DEFAULT '',
online TINYINT NOT NULL DEFAULT 0,
enabled TINYINT NOT NULL DEFAULT 1,
created_at DATETIME,
updated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS "user_family" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_user_id INTEGER NOT NULL DEFAULT 0,
max_members INTEGER NOT NULL DEFAULT 2,
status TINYINT DEFAULT 0,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME DEFAULT NULL
)`,
`CREATE TABLE IF NOT EXISTS "user_family_member" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
family_id INTEGER NOT NULL DEFAULT 0,
user_id INTEGER NOT NULL DEFAULT 0,
role TINYINT DEFAULT 0,
status TINYINT DEFAULT 0,
join_source VARCHAR(32) NOT NULL DEFAULT '',
joined_at DATETIME,
left_at DATETIME DEFAULT NULL,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME DEFAULT NULL
)`,
}
for _, sql := range sqls {
require.NoError(t, db.Exec(sql).Error)
}
return db
}
// setupNewUserOnlyRedis 启动 miniredis,返回 redis.Client 和 miniredis 句柄
func setupNewUserOnlyRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
t.Helper()
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(mr.Close)
rds := redis.NewClient(&redis.Options{Addr: mr.Addr()})
return rds, mr
}
// buildNewUserOnlySvcCtx 组装最小 ServiceContext(含 asynq Queue 使用 miniredis
func buildNewUserOnlySvcCtx(db *gorm.DB, rds *redis.Client, mr *miniredis.Miniredis) *svc.ServiceContext {
queue := asynq.NewClient(asynq.RedisClientOpt{Addr: mr.Addr()})
return &svc.ServiceContext{
DB: db,
Redis: rds,
UserModel: user.NewModel(db, rds),
OrderModel: modelOrder.NewModel(db, rds),
SubscribeModel: subModel.NewModel(db, rds),
PaymentModel: payment.NewModel(db, rds),
Queue: queue,
}
}
// insertTestSubscribe 直接用 SQL 插入 subscribe 行(绕过 GORM hook 的 MySQL 方言)
// new_user_only=true 时同时写入 discount JSON,使代码里的 discount 检查生效
func insertTestSubscribe(t *testing.T, db *gorm.DB, id int64, newUserOnly bool) {
t.Helper()
nuOnly := 0
discount := ""
if newUserOnly {
nuOnly = 1
// discount JSON 包含一个 new_user_only=true 的 tier,匹配 quantity=1
discount = `[{"quantity":1,"discount":90,"new_user_only":true}]`
}
err := db.Exec(`INSERT INTO "subscribe"
(id, name, unit_price, inventory, sell, sort, new_user_only, discount, created_at, updated_at)
VALUES (?, 'Test Plan', 1000, -1, 1, ?, ?, ?, datetime('now'), datetime('now'))`,
id, id, nuOnly, discount).Error
require.NoError(t, err)
}
// insertTestPayment 插入支付方式行
func insertTestPayment(t *testing.T, db *gorm.DB, id int64) {
t.Helper()
err := db.Exec(`INSERT INTO "payment"
(id, name, platform, config, enable, fee_mode, token)
VALUES (?, 'Balance', 'balance', '{}', 1, 0, ?)`,
id, "test-token").Error
require.NoError(t, err)
}
// insertTestUser 插入用户行,createdAt 可控
func insertTestUser(t *testing.T, db *gorm.DB, id int64, createdAt time.Time) *user.User {
t.Helper()
err := db.Exec(`INSERT INTO "user"
(id, password, balance, gift_amount, enable, created_at, updated_at)
VALUES (?, '', 0, 0, 1, ?, datetime('now'))`,
id, createdAt.UTC().Format("2006-01-02 15:04:05")).Error
require.NoError(t, err)
return &user.User{
Id: id,
GiftAmount: 0,
CreatedAt: createdAt,
}
}
func insertTestDevice(t *testing.T, db *gorm.DB, userID int64, identifier string, createdAt time.Time) {
t.Helper()
err := db.Exec(`INSERT INTO "user_device"
(user_id, ip, user_agent, identifier, short_code, online, enabled, created_at, updated_at)
VALUES (?, '127.0.0.1', 'test-agent', ?, '', 0, 1, ?, datetime('now'))`,
userID,
identifier,
createdAt.UTC().Format("2006-01-02 15:04:05"),
).Error
require.NoError(t, err)
}
func insertTestFamily(t *testing.T, db *gorm.DB, familyID, ownerUserID int64) {
t.Helper()
err := db.Exec(`INSERT INTO "user_family"
(id, owner_user_id, max_members, status, created_at, updated_at)
VALUES (?, ?, 3, 1, datetime('now'), datetime('now'))`,
familyID,
ownerUserID,
).Error
require.NoError(t, err)
}
func insertTestFamilyMember(t *testing.T, db *gorm.DB, familyID, userID int64, role, status uint8, joinSource string) {
t.Helper()
err := db.Exec(`INSERT INTO "user_family_member"
(family_id, user_id, role, status, join_source, joined_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))`,
familyID,
userID,
role,
status,
joinSource,
).Error
require.NoError(t, err)
}
// insertTestOrder 插入一条历史订单(status=2 表示已支付)
func insertTestOrder(t *testing.T, db *gorm.DB, userID, subscribeID int64, status uint8) {
t.Helper()
err := db.Exec(`INSERT INTO "order"
(user_id, order_no, type, status, subscribe_id, created_at, updated_at)
VALUES (?, ?, 1, ?, ?, datetime('now'), datetime('now'))`,
userID, "existing-order-no", status, subscribeID).Error
require.NoError(t, err)
}
func insertScopedTestOrder(t *testing.T, db *gorm.DB, orderNo string, userID, subscribeID int64, status uint8) {
t.Helper()
err := db.Exec(`INSERT INTO "order"
(user_id, order_no, type, status, subscribe_id, created_at, updated_at)
VALUES (?, ?, 1, ?, ?, datetime('now'), datetime('now'))`,
userID, orderNo, status, subscribeID).Error
require.NoError(t, err)
}
// buildPurchaseCtx 把 user 放入 context(模拟中间件行为)
func buildPurchaseCtx(u *user.User) context.Context {
return context.WithValue(context.Background(), constant.CtxKeyUser, u)
}
// TestPurchase_NewUserOnly_UserTooOld 验证:new_user_only=true,用户注册超过 24h → 返回 SubscribeNewUserOnly
func TestPurchase_NewUserOnly_UserTooOld(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(1)
const payID = int64(1)
insertTestSubscribe(t, db, subID, true) // new_user_only = true
insertTestPayment(t, db, payID)
// 用户注册 48 小时前 → 超出 24h 限制
u := insertTestUser(t, db, 100, time.Now().Add(-48*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
_, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.Error(t, err)
var errCode *xerr.CodeError
require.ErrorAs(t, err, &errCode)
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode(),
"注册超过24h应返回 SubscribeNewUserOnly 错误码")
// 验证订单未被创建
var count int64
db.Model(&modelOrder.Order{}).Where("user_id = ?", u.Id).Count(&count)
assert.Equal(t, int64(0), count, "用户注册超时,订单不应被创建")
}
// TestPurchase_NewUserOnly_AlreadyPurchased 验证:new_user_only=true,用户是新用户但已购买过
// → 允许下单(不拦截),但不享受新人折扣
func TestPurchase_NewUserOnly_AlreadyPurchased(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(2)
const payID = int64(1)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
// 用户刚注册(2h前)→ 满足时间条件
u := insertTestUser(t, db, 200, time.Now().Add(-2*time.Hour))
// 但已有一条 status=2 的历史订单(已支付)
insertTestOrder(t, db, u.Id, subID, 2)
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
// 不应被拦截,允许下单
require.NoError(t, err, "24h内已购用户应允许继续下单,不应返回错误")
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo)
// 历史订单 +1(新增了一条)
var count int64
db.Model(&modelOrder.Order{}).Where("user_id = ?", u.Id).Count(&count)
assert.Equal(t, int64(2), count, "应新增一条订单")
// 新订单无折扣:Amount=Price=1000
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Amount, "已购用户不享受新人折扣,Amount 应等于 Price")
assert.Equal(t, int64(0), newOrder.Discount, "Discount 应为 0")
}
// TestPurchase_NewUserOnly_Success 验证:new_user_only=true,新用户首次购买 → 成功创建订单
func TestPurchase_NewUserOnly_Success(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(3)
const payID = int64(1)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
// 用户 1 小时前注册(新用户),且没有历史订单
u := insertTestUser(t, db, 300, time.Now().Add(-1*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo, "新用户首次购买应成功,返回订单号")
// 验证订单已写入数据库
var o modelOrder.Order
err = db.Where("order_no = ?", resp.OrderNo).First(&o).Error
require.NoError(t, err)
assert.Equal(t, u.Id, o.UserId)
assert.Equal(t, subID, o.SubscribeId)
}
// TestPurchase_NewUserOnly_Disabled 验证:new_user_only=false 时,老用户也能正常购买
func TestPurchase_NewUserOnly_Disabled(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(4)
const payID = int64(1)
insertTestSubscribe(t, db, subID, false) // new_user_only = false
insertTestPayment(t, db, payID)
// 注册 30 天的老用户
u := insertTestUser(t, db, 400, time.Now().Add(-30*24*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo, "new_user_only=false时老用户应能正常购买")
}
// TestPurchase_SingleMode_PendingOldOrderCancelled 验证:单订阅模式下,已有 pending 订单时
// 第二次下单应关闭旧单并创建新单(而非复用旧单)
func TestPurchase_SingleMode_PendingOldOrderCancelled(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
svcCtx.Config.Subscribe.SingleModel = true
const subID = int64(5)
const payID = int64(1)
insertTestSubscribe(t, db, subID, false)
insertTestPayment(t, db, payID)
u := insertTestUser(t, db, 500, time.Now().Add(-1*time.Hour))
ctx := buildPurchaseCtx(u)
// 第一次下单(pending
logic := NewPurchaseLogic(ctx, svcCtx)
resp1, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp1)
firstOrderNo := resp1.OrderNo
assert.NotEmpty(t, firstOrderNo)
// 确认第一单 pending
var firstOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", firstOrderNo).First(&firstOrder).Error)
assert.Equal(t, uint8(1), firstOrder.Status, "第一单应为 pending")
// 第二次下单(不同 quantity
logic2 := NewPurchaseLogic(ctx, svcCtx)
resp2, err := logic2.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 3,
})
require.NoError(t, err)
require.NotNil(t, resp2)
secondOrderNo := resp2.OrderNo
// 新单与旧单不同
assert.NotEqual(t, firstOrderNo, secondOrderNo, "第二次下单应创建新订单,不复用旧单")
// 旧单应被关闭(status=3
var closedOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", firstOrderNo).First(&closedOrder).Error)
assert.Equal(t, uint8(3), closedOrder.Status, "旧 pending 单应被关闭")
// 新单的 quantity 应为 3
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", secondOrderNo).First(&newOrder).Error)
assert.Equal(t, int64(3), newOrder.Quantity, "新单 quantity 应为 3")
assert.Equal(t, uint8(1), newOrder.Status, "新单应为 pending 状态")
}
// TestPurchase_SingleMode_NoPendingOrder 验证:单订阅模式下,没有旧 pending 单时正常创建
func TestPurchase_SingleMode_NoPendingOrder(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
svcCtx.Config.Subscribe.SingleModel = true
const subID = int64(6)
const payID = int64(1)
insertTestSubscribe(t, db, subID, false)
insertTestPayment(t, db, payID)
u := insertTestUser(t, db, 600, time.Now().Add(-1*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 2,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo, "无旧 pending 单时应正常创建新单")
var o modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&o).Error)
assert.Equal(t, int64(2), o.Quantity)
assert.Equal(t, uint8(1), o.Status)
}
// TestPurchase_NewUserOnly_AlreadyPurchased_NoBlock 验证:new_user_only=true 套餐,
// 24小时内但已购买过 → 允许下单,但不享受新人折扣(Discount=0Amount=Price
func TestPurchase_NewUserOnly_AlreadyPurchased_NoBlock(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(7)
const payID = int64(1)
// 套餐:unit_price=1000discount=[{quantity:1,discount:80,new_user_only:true}]
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
// 用户 1 小时前注册(新用户),但已有一条成功订单(status=2)
u := insertTestUser(t, db, 700, time.Now().Add(-1*time.Hour))
insertTestOrder(t, db, u.Id, subID, 2)
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
// 不应被拦截
require.NoError(t, err, "24h内已购用户不应被拦截,应允许下单")
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo)
// 验证订单金额:无折扣,Amount=Price=1000Discount=0
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Price, "Price 应为原价 1000")
assert.Equal(t, int64(1000), newOrder.Amount, "已购用户不享受新人折扣,Amount 应等于 Price")
assert.Equal(t, int64(0), newOrder.Discount, "Discount 应为 0")
}
// TestPurchase_NewUserOnly_FirstPurchase_HasDiscount 验证:new_user_only=true 套餐,
// 24小时内首次购买 → 允许下单且享受新人折扣
func TestPurchase_NewUserOnly_FirstPurchase_HasDiscount(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(8)
const payID = int64(1)
// 套餐:unit_price=1000discount=[{quantity:1,discount:80,new_user_only:true}]8折)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
// 用户 1 小时前注册,无历史订单
u := insertTestUser(t, db, 800, time.Now().Add(-1*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Price, "Price 应为原价 1000")
assert.Equal(t, int64(900), newOrder.Amount, "首次购买应享受9折,Amount=900")
assert.Equal(t, int64(100), newOrder.Discount, "折扣金额应为 100")
}
func TestPurchase_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(9)
payID = int64(1)
ownerUserID = int64(901)
memberUserID = int64(902)
familyID = int64(99)
)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-72*time.Hour))
insertTestDevice(t, db, memberUserID, "device-eligibility-old", time.Now().Add(-72*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
logic := NewPurchaseLogic(buildPurchaseCtx(owner), svcCtx)
_, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.Error(t, err)
var errCode *xerr.CodeError
require.ErrorAs(t, err, &errCode)
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode())
}
func TestPurchase_NewUserOnly_BindEmailScopeSharesHistory(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(10)
payID = int64(1)
ownerUserID = int64(1001)
memberUserID = int64(1002)
familyID = int64(109)
)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-2*time.Hour))
insertTestDevice(t, db, memberUserID, "device-eligibility-shared", time.Now().Add(-2*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
insertScopedTestOrder(t, db, "existing-scope-order", memberUserID, subID, 2)
logic := NewPurchaseLogic(buildPurchaseCtx(owner), svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Amount)
assert.Equal(t, int64(0), newOrder.Discount)
}
func ensureRenewalSubscribeColumns(t *testing.T, db *gorm.DB) {
t.Helper()
for _, sql := range []string{
`ALTER TABLE "user_subscribe" ADD COLUMN node_group_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN group_locked TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_download INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_upload INTEGER NOT NULL DEFAULT 0`,
} {
require.NoError(t, db.Exec(sql).Error)
}
}
func insertRenewalUserSubscribe(t *testing.T, db *gorm.DB, id, userID, orderID, subscribeID int64, token, uuid string, start, expire time.Time) {
t.Helper()
require.NoError(t, db.Exec(`INSERT INTO "user_subscribe"
(id, user_id, order_id, subscribe_id, start_time, expire_time, traffic, download, upload, token, uuid, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?)`,
id,
userID,
orderID,
subscribeID,
start.UTC().Format("2006-01-02 15:04:05"),
expire.UTC().Format("2006-01-02 15:04:05"),
token,
uuid,
start.UTC().Format("2006-01-02 15:04:05"),
time.Now().UTC().Format("2006-01-02 15:04:05"),
).Error)
}
func TestRenewalMemberRequestRedirectsToOwnerSubscribe(t *testing.T) {
db := setupNewUserOnlyDB(t)
ensureRenewalSubscribeColumns(t, db)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
planID = int64(1)
paymentID = int64(2)
ownerUserID = int64(29650)
memberID = int64(20003)
familyID = int64(88001)
memberSubID = int64(10013)
ownerSubID = int64(14074)
)
insertTestSubscribe(t, db, planID, false)
insertTestPayment(t, db, paymentID)
member := insertTestUser(t, db, memberID, time.Now().Add(-24*time.Hour))
insertTestUser(t, db, ownerUserID, time.Now().Add(-24*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberID, user.FamilyRoleMember, user.FamilyMemberActive, "manual_invite")
insertRenewalUserSubscribe(t, db, memberSubID, memberID, 7448, planID, "member-token-10013", "member-uuid-10013",
time.Date(2026, 4, 23, 19, 6, 40, 0, time.UTC),
time.Date(2026, 4, 30, 19, 6, 40, 0, time.UTC),
)
insertRenewalUserSubscribe(t, db, ownerSubID, ownerUserID, 9999, planID, "owner-token-14074", "owner-uuid-14074",
time.Date(2026, 4, 30, 13, 39, 33, 0, time.UTC),
time.Date(2026, 5, 30, 16, 0, 0, 0, time.UTC),
)
resp, err := NewRenewalLogic(buildPurchaseCtx(member), svcCtx).Renewal(&types.RenewalOrderRequest{
UserSubscribeID: memberSubID,
Payment: paymentID,
Quantity: 30,
})
require.NoError(t, err)
require.NotNil(t, resp)
var created modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&created).Error)
assert.Equal(t, memberID, created.UserId)
assert.Equal(t, ownerUserID, created.SubscriptionUserId)
assert.Equal(t, int64(9999), created.ParentId)
assert.Equal(t, "owner-token-14074", created.SubscribeToken)
}
func TestPreCreateOrder_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(11)
ownerUserID = int64(1101)
memberUserID = int64(1102)
familyID = int64(119)
)
insertTestSubscribe(t, db, subID, true)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-96*time.Hour))
insertTestDevice(t, db, memberUserID, "device-precreate-old", time.Now().Add(-96*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
logic := NewPreCreateOrderLogic(buildPurchaseCtx(owner), svcCtx)
_, err := logic.PreCreateOrder(&types.PurchaseOrderRequest{
SubscribeId: subID,
Quantity: 1,
})
require.Error(t, err)
var errCode *xerr.CodeError
require.ErrorAs(t, err, &errCode)
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode())
}
func TestPreCreateOrder_NewUserOnly_OrdinaryFamilyMemberDoesNotAffectEligibility(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(12)
ownerUserID = int64(1201)
memberUserID = int64(1202)
familyID = int64(129)
)
insertTestSubscribe(t, db, subID, true)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-96*time.Hour))
insertTestDevice(t, db, memberUserID, "device-precreate-ordinary", time.Now().Add(-96*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "manual_invite")
logic := NewPreCreateOrderLogic(buildPurchaseCtx(owner), svcCtx)
resp, err := logic.PreCreateOrder(&types.PurchaseOrderRequest{
SubscribeId: subID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, int64(900), resp.Amount)
assert.Equal(t, int64(100), resp.Discount)
}
@@ -1,130 +0,0 @@
package order
import (
"testing"
"time"
modelOrder "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func ensureRenewalInviteSubscribeColumns(t *testing.T, db *gorm.DB) {
t.Helper()
for _, sql := range []string{
`ALTER TABLE "user_subscribe" ADD COLUMN node_group_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN group_locked TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_download INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_upload INTEGER NOT NULL DEFAULT 0`,
} {
require.NoError(t, db.Exec(sql).Error)
}
}
func insertRenewalInviteUserSubscribe(t *testing.T, db *gorm.DB, id, userID, orderID, subscribeID int64, token, uuid string, start, expire time.Time) {
t.Helper()
require.NoError(t, db.Exec(`INSERT INTO "user_subscribe"
(id, user_id, order_id, subscribe_id, start_time, expire_time, traffic, download, upload, token, uuid, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?)`,
id,
userID,
orderID,
subscribeID,
start.UTC().Format("2006-01-02 15:04:05"),
expire.UTC().Format("2006-01-02 15:04:05"),
token,
uuid,
start.UTC().Format("2006-01-02 15:04:05"),
time.Now().UTC().Format("2006-01-02 15:04:05"),
).Error)
}
func TestRenewalFirstSuccessfulPaymentMarksOrderAsNew(t *testing.T) {
db := setupNewUserOnlyDB(t)
ensureRenewalInviteSubscribeColumns(t, db)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
planID = int64(1)
paymentID = int64(2)
ownerUserID = int64(31059)
memberID = int64(31057)
familyID = int64(5638)
ownerSubID = int64(14672)
)
insertTestSubscribe(t, db, planID, false)
insertTestPayment(t, db, paymentID)
member := insertTestUser(t, db, memberID, time.Now().Add(-2*time.Hour))
insertTestUser(t, db, ownerUserID, time.Now().Add(-2*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
insertRenewalInviteUserSubscribe(t, db, ownerSubID, ownerUserID, 0, planID, "owner-trial-token", "owner-trial-uuid",
time.Date(2026, 5, 2, 15, 53, 39, 0, time.UTC),
time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC),
)
resp, err := NewRenewalLogic(buildPurchaseCtx(member), svcCtx).Renewal(&types.RenewalOrderRequest{
UserSubscribeID: ownerSubID,
Payment: paymentID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var created modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&created).Error)
require.Equal(t, uint8(2), created.Type)
require.True(t, created.IsNew)
require.Equal(t, memberID, created.UserId)
require.Equal(t, ownerUserID, created.SubscriptionUserId)
require.Equal(t, "owner-trial-token", created.SubscribeToken)
}
func TestRenewalExistingSuccessfulPaymentMarksOrderAsNotNew(t *testing.T) {
db := setupNewUserOnlyDB(t)
ensureRenewalInviteSubscribeColumns(t, db)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
planID = int64(1)
paymentID = int64(2)
ownerUserID = int64(41059)
memberID = int64(41057)
familyID = int64(6638)
ownerSubID = int64(24672)
)
insertTestSubscribe(t, db, planID, false)
insertTestPayment(t, db, paymentID)
member := insertTestUser(t, db, memberID, time.Now().Add(-2*time.Hour))
insertTestUser(t, db, ownerUserID, time.Now().Add(-2*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
insertScopedTestOrder(t, db, "previous-successful-order", memberID, planID, 5)
insertRenewalInviteUserSubscribe(t, db, ownerSubID, ownerUserID, 0, planID, "owner-renewal-token", "owner-renewal-uuid",
time.Date(2026, 5, 2, 15, 53, 39, 0, time.UTC),
time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC),
)
resp, err := NewRenewalLogic(buildPurchaseCtx(member), svcCtx).Renewal(&types.RenewalOrderRequest{
UserSubscribeID: ownerSubID,
Payment: paymentID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var created modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&created).Error)
require.Equal(t, uint8(2), created.Type)
require.False(t, created.IsNew)
}
@@ -1,288 +0,0 @@
package user
import (
"testing"
"time"
modelOrder "github.com/perfect-panel/server/internal/model/order"
modelUser "github.com/perfect-panel/server/internal/model/user"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func newFamilyBindingTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite db: %v", err)
}
if err = db.Exec(`
CREATE TABLE user_subscribe (
id integer primary key,
user_id integer not null,
order_id integer not null,
subscribe_id integer not null,
start_time datetime,
expire_time datetime,
token text,
uuid text,
status integer,
created_at datetime,
updated_at datetime
)`).Error; err != nil {
t.Fatalf("create user_subscribe schema: %v", err)
}
if err = db.Exec(`
CREATE TABLE "order" (
id integer primary key,
user_id integer not null,
subscription_user_id integer not null,
order_no text,
subscribe_token text,
status integer,
subscribe_id integer,
created_at datetime,
updated_at datetime
)`).Error; err != nil {
t.Fatalf("create order schema: %v", err)
}
return db
}
func insertFamilyBindingTestOrder(t *testing.T, db *gorm.DB, order modelOrder.Order) {
t.Helper()
if err := db.Exec(`
INSERT INTO "order" (
id, user_id, subscription_user_id, order_no, subscribe_token, status, subscribe_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.Id,
order.UserId,
order.SubscriptionUserId,
order.OrderNo,
order.SubscribeToken,
order.Status,
order.SubscribeId,
order.CreatedAt,
order.UpdatedAt,
).Error; err != nil {
t.Fatalf("insert order %d: %v", order.Id, err)
}
}
func insertFamilyBindingTestSubscribe(t *testing.T, db *gorm.DB, sub modelUser.Subscribe) {
t.Helper()
if err := db.Exec(`
INSERT INTO user_subscribe (
id, user_id, order_id, subscribe_id, start_time, expire_time, token, uuid, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sub.Id,
sub.UserId,
sub.OrderId,
sub.SubscribeId,
sub.StartTime,
sub.ExpireTime,
sub.Token,
sub.UUID,
sub.Status,
sub.CreatedAt,
sub.UpdatedAt,
).Error; err != nil {
t.Fatalf("insert subscribe %d: %v", sub.Id, err)
}
}
func TestMoveMemberSubscribesToOwnerMovesSubscribeAndCurrentOrder(t *testing.T) {
db := newFamilyBindingTestDB(t)
memberUserID := int64(1001)
ownerUserID := int64(2001)
now := time.Now().Add(-time.Hour)
expireAt := now.Add(30 * 24 * time.Hour)
currentOrder := modelOrder.Order{
Id: 9001,
UserId: memberUserID,
SubscriptionUserId: memberUserID,
OrderNo: "order-current",
SubscribeToken: "token-current",
Status: 5,
SubscribeId: 3001,
CreatedAt: now,
UpdatedAt: now,
}
unlinkedOrder := modelOrder.Order{
Id: 9002,
UserId: memberUserID,
SubscriptionUserId: memberUserID,
OrderNo: "order-unlinked",
SubscribeToken: "token-unlinked",
Status: 5,
SubscribeId: 3001,
CreatedAt: now,
UpdatedAt: now,
}
sub := modelUser.Subscribe{
Id: 7001,
UserId: memberUserID,
OrderId: currentOrder.Id,
SubscribeId: currentOrder.SubscribeId,
StartTime: now,
ExpireTime: expireAt,
Token: "token-current",
UUID: "uuid-current",
Status: 1,
CreatedAt: now,
UpdatedAt: now,
}
insertFamilyBindingTestOrder(t, db, currentOrder)
insertFamilyBindingTestOrder(t, db, unlinkedOrder)
insertFamilyBindingTestSubscribe(t, db, sub)
var moved []modelUser.Subscribe
if err := db.Transaction(func(tx *gorm.DB) error {
var err error
moved, err = moveMemberSubscribesToOwner(tx, memberUserID, ownerUserID)
return err
}); err != nil {
t.Fatalf("move member subscribes: %v", err)
}
if len(moved) != 1 {
t.Fatalf("moved subscribes length = %d, want 1", len(moved))
}
if moved[0].UserId != memberUserID {
t.Fatalf("moved cache copy user_id = %d, want original member %d", moved[0].UserId, memberUserID)
}
var gotSub modelUser.Subscribe
if err := db.First(&gotSub, "id = ?", sub.Id).Error; err != nil {
t.Fatalf("query moved subscribe: %v", err)
}
if gotSub.UserId != ownerUserID {
t.Fatalf("subscribe user_id = %d, want owner %d", gotSub.UserId, ownerUserID)
}
if gotSub.Token == "" || gotSub.Token == sub.Token {
t.Fatalf("subscribe token = %q, want regenerated from old token %q", gotSub.Token, sub.Token)
}
if gotSub.UUID == "" || gotSub.UUID == sub.UUID {
t.Fatalf("subscribe uuid = %q, want regenerated from old uuid %q", gotSub.UUID, sub.UUID)
}
var gotOrder modelOrder.Order
if err := db.First(&gotOrder, "id = ?", currentOrder.Id).Error; err != nil {
t.Fatalf("query updated order: %v", err)
}
if gotOrder.SubscriptionUserId != ownerUserID {
t.Fatalf("current order subscription_user_id = %d, want owner %d", gotOrder.SubscriptionUserId, ownerUserID)
}
if gotOrder.SubscribeToken != gotSub.Token {
t.Fatalf("current order subscribe_token = %q, want regenerated subscribe token %q", gotOrder.SubscribeToken, gotSub.Token)
}
var gotUnlinkedOrder modelOrder.Order
if err := db.First(&gotUnlinkedOrder, "id = ?", unlinkedOrder.Id).Error; err != nil {
t.Fatalf("query unlinked order: %v", err)
}
if gotUnlinkedOrder.SubscriptionUserId != memberUserID {
t.Fatalf("unlinked order subscription_user_id = %d, want unchanged member %d", gotUnlinkedOrder.SubscriptionUserId, memberUserID)
}
if gotUnlinkedOrder.SubscribeToken != unlinkedOrder.SubscribeToken {
t.Fatalf("unlinked order subscribe_token = %q, want unchanged token %q", gotUnlinkedOrder.SubscribeToken, unlinkedOrder.SubscribeToken)
}
}
func TestMoveMemberSubscribesToOwnerNoSubscribesIsNoop(t *testing.T) {
db := newFamilyBindingTestDB(t)
var moved []modelUser.Subscribe
if err := db.Transaction(func(tx *gorm.DB) error {
var err error
moved, err = moveMemberSubscribesToOwner(tx, 1001, 2001)
return err
}); err != nil {
t.Fatalf("move empty member subscribes: %v", err)
}
if len(moved) != 0 {
t.Fatalf("moved subscribes length = %d, want 0", len(moved))
}
}
func TestTransferMemberSubscribesToOwnerStillDiscardsMemberSubscribes(t *testing.T) {
db := newFamilyBindingTestDB(t)
memberUserID := int64(1001)
ownerUserID := int64(2001)
now := time.Now().Add(-time.Hour)
order := modelOrder.Order{
Id: 9001,
UserId: memberUserID,
SubscriptionUserId: memberUserID,
OrderNo: "order-discard",
SubscribeToken: "token-discard",
Status: 5,
SubscribeId: 3001,
CreatedAt: now,
UpdatedAt: now,
}
sub := modelUser.Subscribe{
Id: 7001,
UserId: memberUserID,
OrderId: order.Id,
SubscribeId: order.SubscribeId,
StartTime: now,
ExpireTime: now.Add(30 * 24 * time.Hour),
Token: "token-discard",
UUID: "uuid-discard",
Status: 1,
CreatedAt: now,
UpdatedAt: now,
}
insertFamilyBindingTestOrder(t, db, order)
insertFamilyBindingTestSubscribe(t, db, sub)
var removed []modelUser.Subscribe
if err := db.Transaction(func(tx *gorm.DB) error {
var err error
removed, err = transferMemberSubscribesToOwner(tx, memberUserID, ownerUserID)
return err
}); err != nil {
t.Fatalf("discard member subscribes: %v", err)
}
if len(removed) != 1 {
t.Fatalf("removed subscribes length = %d, want 1", len(removed))
}
if removed[0].UserId != memberUserID {
t.Fatalf("removed cache copy user_id = %d, want original member %d", removed[0].UserId, memberUserID)
}
var memberCount int64
if err := db.Model(&modelUser.Subscribe{}).Where("user_id = ?", memberUserID).Count(&memberCount).Error; err != nil {
t.Fatalf("count member subscribes: %v", err)
}
if memberCount != 0 {
t.Fatalf("member subscribe count = %d, want 0", memberCount)
}
var ownerCount int64
if err := db.Model(&modelUser.Subscribe{}).Where("user_id = ?", ownerUserID).Count(&ownerCount).Error; err != nil {
t.Fatalf("count owner subscribes: %v", err)
}
if ownerCount != 0 {
t.Fatalf("owner subscribe count = %d, want 0", ownerCount)
}
var gotOrder modelOrder.Order
if err := db.First(&gotOrder, "id = ?", order.Id).Error; err != nil {
t.Fatalf("query order: %v", err)
}
if gotOrder.SubscriptionUserId != memberUserID {
t.Fatalf("discard path order subscription_user_id = %d, want unchanged member %d", gotOrder.SubscriptionUserId, memberUserID)
}
}
-169
View File
@@ -1,169 +0,0 @@
跳至主要内容
Grafana
探索
Loki
搜索...
⌘+k
User avatar
探索
轮廓
Loki logo
Loki
转到无查询
拆分
添加
过去 24 小时
运行查询
分享
直播
A
(Loki)
Kick start your query
Label browser
Explain query
{container="ppanel-server"} |= "10013" |= "user_subscribe"
Options
Type: Range
Line limit: 1000
Direction: Backward
This query will process approximately 20.3 GiB.
添加查询
查询历史记录
查询检查器
日志卷
info
Total: 717
Loki
日志
717 lines displayed
已处理的总字节数:
23.4 GB
Common labels:
container=ppanel-server
logstream=stdout
service_name=ppanel-server
已展开
滚动到底部
最新日志在前
搜索日志
去重
筛选级别
显示毫秒
ms
换行显示 JSON
+
Columns not supported
突出显示文本
大字体
下载日志
滚动到顶部
2026-04-30 08:33:29.296 info 303029-04-04 00:00:00.296 debug [GORM] SQL Executed duration=0.5ms caller=user/queryUserSubscribeLogic.go:47 sql=SELECT `user_subscribe`.`id`,`user_subscribe`.`user_id`,`user_subscribe`.`order_id`,`user_subscribe`.`subscribe_id`,`user_subscribe`.`node_group_id`,`user_subscribe`.`start_time`,`user_subscribe`.`expire_time`,`user_subscribe`.`finished_at`,`user_subscribe`.`traffic`,`user_subscribe`.`download`,`user_subscribe`.`upload`,`user_subscribe`.`token`,`user_subscribe`.`uuid`,`user_subscribe`.`status`,`user_subscribe`.`note`,`user_subscribe`.`created_at`,`user_subscribe`.`updated_at` FROM `user_subscribe` WHERE `user_id` = 19764 AND `status` IN (0,1,2,3) AND (`expire_time` > '2026-04-30 23:33:29.295' OR `finished_at` >= '2026-04-23 23:33:29.295' OR `expire_time` = '1970-01-01 08:00:00') trace=428ceb5c1001366ab0dfddaa0fd0cf8a rows=1 span=a0c44ec4143a6a77
2026-04-30 06:38:24.260 info 303024-04-04 00:00:00.260 debug [GORM] SQL Executed duration=0.2ms caller=group/recalculateGroupLogic.go:58 sql=UPDATE `user_subscribe` SET `node_group_id`=0,`updated_at`='2026-04-30 21:38:24.26' WHERE id = 10013 rows=1
2026-04-30 06:38:24.259 info 303024-04-04 00:00:00.259 debug assigning user_subscribe_id=10013 (subscribe_id=1) to node_group_id=0 (total_options=0, selected_first) caller=group/recalculateGroupLogic.go:504
2026-04-30 06:38:24.121 info 303024-04-04 00:00:00.121 info [SubscriptionFlow] renewal order updated existing subscription caller=common/subscriptionTrace.go:28 effective_user_id=20003 payment_id=2 expire_time=2026-05-30 21:38:24.118494775 +0800 CST user_id=20003 quantity=30 flow=order_subscription stage=subscription_renewed order_no=202604302137198938303097982 is_new_order=false user_subscribe_id=10013 subscribe_order_id=17216 subscribe_uuid_tail=936f5490 payment_method=EPay subscribe_token_tail=3cb4c6b3 user_subscribe_plan_id=1 subscribe_status=1 iap_expire_at=0 trace_type=subscription_flow parent_order_id=7448 order_type=2 order_status=4 order_subscribe_id=1 app_account_token_tail=e04447ca order_id=17216 subscription_user_id=20003 subscribe_owner_user_id=20003
2026-04-30 06:38:24.120 info 303024-04-04 00:00:00.120 debug [GORM] SQL Executed duration=1.7ms caller=user/subscribe.go:17 sql=UPDATE `user_subscribe` SET `id`=10013,`user_id`=20003,`order_id`=17216,`subscribe_id`=1,`node_group_id`=0,`group_locked`=false,`start_time`='2026-04-23 19:06:40.003',`expire_time`='2026-05-30 21:38:24.118',`finished_at`=NULL,`traffic`=0,`download`=0,`upload`=0,`expired_download`=0,`expired_upload`=0,`token`='e622c7a270caf7fd816f9dd83cb4c6b3',`uuid`='aabd8eb1-2569-4a69-b59c-15cd936f5490',`status`=1,`note`='',`updated_at`='2026-04-30 21:38:24.119' WHERE id = 10013 rows=1
2026-04-30 06:38:24.118 info 303024-04-04 00:00:00.118 debug [GORM] SQL Executed duration=0.2ms caller=user/subscribe.go:161 sql=SELECT * FROM `user_subscribe` WHERE id = 10013 ORDER BY `user_subscribe`.`id` LIMIT 1 rows=1
2026-04-30 06:37:25.952 info 303025-04-04 00:00:00.952 debug [GORM] SQL Executed duration=0.1ms caller=group/recalculateGroupLogic.go:58 sql=UPDATE `user_subscribe` SET `node_group_id`=0,`updated_at`='2026-04-30 21:37:25.952' WHERE id = 10013 rows=1
2026-04-30 06:37:19.898 info 303019-04-04 00:00:00.898 info HTTP Request duration=6.088471ms caller=middleware/loggerMiddleware.go:113 decrypted_request_body={"coupon":"","payment":2,"quantity":30,"user_subscribe_id":10013} response_body={"code":200,"data":{"data":"SxIHPdGQQbeAwx5UYMaRaouIke61GH+VRSl/iSr63r/GVLuXfd+ljlQLmukw9UE/Fdjc5oSWDq5ApVyEBR4bF79eBcuDz+cYjjmnJIn1sXt8hIPlwKlhc50TFwe8/x2RlpsHsLUYaOYOivMv/iuH/A==","time":"18ab25fb776f18b9"},"msg":"success"} ip=112.96.81.74 trace=60ff721319b3cf1b8f328dc05f01cb99 status=200 request=POST api.hifast.biz/v1/public/order/renewal query= user-agent=HiFast/1.0.6-26042218 (Android; HUAWEI PLA-AL10; 12) Flutter device_decrypt_status=success api_header= request_body={"data":"xdcFznl62TSP5AiItT+OKZWELzq3mR+cUZ2COAZ7ERslZT+M8mmEI65BfwevDWXA6l9TjMLr5sRkR4+omRV3fbH+PkLJ7kvIf9WPQ9ASaNg=","time":"2026-04-30T21:37:19.943917"} span=99fda96ceb7ba495
2026-04-30 06:37:19.898 info 303019-04-04 00:00:00.898 info [SubscriptionFlow] renewal order persisted caller=common/subscriptionTrace.go:28 trace_type=subscription_flow order_no=202604302137198938303097982 user_id=20003 payment_id=2 parent_order_id=7448 quantity=30 app_account_token_tail=e04447ca resolved_user_subscribe_id=10013 stage=order_created order_id=17216 order_type=2 order_status=1 subscription_user_id=20003 trace=60ff721319b3cf1b8f328dc05f01cb99 span=99fda96ceb7ba495 effective_user_id=20003 order_subscribe_id=1 requested_user_subscribe_id=10013 flow=order_subscription payment_method=EPay is_new_order=false subscribe_token_tail=3cb4c6b3
2026-04-30 06:37:19.894 info 303019-04-04 00:00:00.894 debug [GORM] SQL Executed duration=0.5ms caller=order/renewalLogic.go:81 rows=1 span=99fda96ceb7ba495 sql=SELECT `user_subscribe`.`id`,`user_subscribe`.`user_id`,`user_subscribe`.`order_id`,`user_subscribe`.`subscribe_id`,`user_subscribe`.`node_group_id`,`user_subscribe`.`start_time`,`user_subscribe`.`expire_time`,`user_subscribe`.`finished_at`,`user_subscribe`.`traffic`,`user_subscribe`.`download`,`user_subscribe`.`upload`,`user_subscribe`.`token`,`user_subscribe`.`uuid`,`user_subscribe`.`status`,`user_subscribe`.`note`,`user_subscribe`.`created_at`,`user_subscribe`.`updated_at` FROM `user_subscribe` WHERE id = 10013 ORDER BY `user_subscribe`.`id` LIMIT 1 trace=60ff721319b3cf1b8f328dc05f01cb99
2026-04-30 06:37:19.893 info 303019-04-04 00:00:00.893 info [SubscriptionFlow] renewal order creation started caller=common/subscriptionTrace.go:28 effective_user_id=20003 quantity=30 trace=60ff721319b3cf1b8f328dc05f01cb99 trace_type=subscription_flow flow=order_subscription stage=order_create_start order_kind=renewal user_id=20003 span=99fda96ceb7ba495 requested_user_subscribe_id=10013 payment_id=2 coupon=
2026-04-30 06:36:05.808 info 30305-04-04 00:00:00.808 info HTTP Request duration=7.877334ms caller=middleware/loggerMiddleware.go:113 query= device_decrypt_status=success response_body={"code":200,"data":{"data":"ELE22mv7foWM+iFzrylO+xbiTWWO7gH9qclIxJzT8TRqS1fJN32GiWq/gtNlGpVWwX4iZoNjI0uOA/5lV4YltAfw3bL7sz+CcARgVEZ4NGFRqOt7bVm1RssagYLopQdfoueQ0Z+j7zOFkBlzNW+r3Q==","time":"18ab25ea37520766"},"msg":"success"} user-agent=HiFast/1.0.6-26042218 (Android; HUAWEI PLA-AL10; 12) Flutter request_body={"data":"a4U8LH1zN4LFF+aBBLoUHzEZoxJ4C6EL1nlDNeKb4m2Xwi8H1hk05qxbkvj3xz6kKdecrhF2G0G6rAavhNlKDDZhaHH/J5elqjw6tsQGluk=","time":"2026-04-30T21:36:05.852132"} request=POST api.hifast.biz/v1/public/order/renewal ip=112.96.81.74 decrypted_request_body={"coupon":"","payment":2,"quantity":30,"user_subscribe_id":10013} trace=7e1b88eed63e7ffba4a6e6e2482d399d status=200 api_header= span=d6bd668124fcad80
2026-04-30 06:36:05.808 info 30305-04-04 00:00:00.808 info [SubscriptionFlow] renewal order persisted caller=common/subscriptionTrace.go:28 app_account_token_tail=237eb1b5 span=d6bd668124fcad80 flow=order_subscription order_id=17213 order_subscribe_id=1 parent_order_id=7448 requested_user_subscribe_id=10013 trace=7e1b88eed63e7ffba4a6e6e2482d399d user_id=20003 payment_id=2 payment_method=EPay resolved_user_subscribe_id=10013 stage=order_created order_no=202604302136058021461564356 order_status=1 quantity=30 is_new_order=false trace_type=subscription_flow order_type=2 subscription_user_id=20003 effective_user_id=20003 subscribe_token_tail=3cb4c6b3
2026-04-30 06:36:05.802 info 30305-04-04 00:00:00.802 debug [GORM] SQL Executed duration=0.7ms caller=order/renewalLogic.go:81 sql=SELECT `user_subscribe`.`id`,`user_subscribe`.`user_id`,`user_subscribe`.`order_id`,`user_subscribe`.`subscribe_id`,`user_subscribe`.`node_group_id`,`user_subscribe`.`start_time`,`user_subscribe`.`expire_time`,`user_subscribe`.`finished_at`,`user_subscribe`.`traffic`,`user_subscribe`.`download`,`user_subscribe`.`upload`,`user_subscribe`.`token`,`user_subscribe`.`uuid`,`user_subscribe`.`status`,`user_subscribe`.`note`,`user_subscribe`.`created_at`,`user_subscribe`.`updated_at` FROM `user_subscribe` WHERE id = 10013 ORDER BY `user_subscribe`.`id` LIMIT 1 rows=1 trace=7e1b88eed63e7ffba4a6e6e2482d399d span=d6bd668124fcad80
2026-04-30 06:36:05.802 info 30305-04-04 00:00:00.802 info [SubscriptionFlow] renewal order creation started caller=common/subscriptionTrace.go:28 flow=order_subscription requested_user_subscribe_id=10013 payment_id=2 coupon= span=d6bd668124fcad80 trace_type=subscription_flow order_kind=renewal trace=7e1b88eed63e7ffba4a6e6e2482d399d stage=order_create_start user_id=20003 effective_user_id=20003 quantity=30
2026-04-30 06:35:46.036 info 303046-04-04 00:00:00.036 debug [GORM] SQL Executed duration=0.1ms caller=group/recalculateGroupLogic.go:58 sql=UPDATE `user_subscribe` SET `node_group_id`=0,`updated_at`='2026-04-30 21:35:46.036' WHERE id = 10013 rows=1
2026-04-30 06:35:38.276 info 303038-04-04 00:00:00.276 info HTTP Request duration=7.276753ms caller=middleware/loggerMiddleware.go:113 request=POST api.hifast.biz/v1/public/order/renewal ip=112.96.81.74 user-agent=HiFast/1.0.6-26042218 (Android; HUAWEI PLA-AL10; 12) Flutter device_decrypt_status=success trace=fd8fbb2d663ad7df82f0c25bf7fb6913 span=c6fe83c0a377d5b5 status=200 query= request_body={"data":"6cuaVRmMX/CGHQ+y0jrwjKTUx404hlB7/z+qPYpK9toUHAVMBrhVBC/cnZmzffjaPuqlUrLx9lSOTHDxv3GGqXZxDah5N4AimVY0dFsx6Bc=","time":"2026-04-30T21:35:38.294140"} decrypted_request_body={"coupon":"","payment":2,"quantity":30,"user_subscribe_id":10013} response_body={"code":200,"data":{"data":"iQSMczPB07C+ruU52emoga1TtBShOiBZOtU8jW9XzmQiKe9DjWCxGY+fFTnJ6LrQCXAQqZWwBpQgABpFWpcJ+MbRr5jk4S3ahwXzeHXBiMcGlgAJRb0G3O+K7h7z+6t+uhGNjzCE2CYFMuii+0T3rg==","time":"18ab25e3ce44be91"},"msg":"success"} api_header=
2026-04-30 06:35:38.276 info 303038-04-04 00:00:00.276 info [SubscriptionFlow] renewal order persisted caller=common/subscriptionTrace.go:28 subscription_user_id=20003 order_subscribe_id=1 effective_user_id=20003 payment_id=2 parent_order_id=7448 subscribe_token_tail=3cb4c6b3 trace=fd8fbb2d663ad7df82f0c25bf7fb6913 span=c6fe83c0a377d5b5 flow=order_subscription payment_method=EPay quantity=30 is_new_order=false app_account_token_tail=6b5d9042 order_no=202604302135382700174133469 requested_user_subscribe_id=10013 resolved_user_subscribe_id=10013 trace_type=subscription_flow stage=order_created order_id=17211 order_type=2 order_status=1 user_id=20003
2026-04-30 06:35:38.270 info 303038-04-04 00:00:00.270 debug [GORM] SQL Executed duration=0.5ms caller=order/renewalLogic.go:81 rows=1 trace=fd8fbb2d663ad7df82f0c25bf7fb6913 span=c6fe83c0a377d5b5 sql=SELECT `user_subscribe`.`id`,`user_subscribe`.`user_id`,`user_subscribe`.`order_id`,`user_subscribe`.`subscribe_id`,`user_subscribe`.`node_group_id`,`user_subscribe`.`start_time`,`user_subscribe`.`expire_time`,`user_subscribe`.`finished_at`,`user_subscribe`.`traffic`,`user_subscribe`.`download`,`user_subscribe`.`upload`,`user_subscribe`.`token`,`user_subscribe`.`uuid`,`user_subscribe`.`status`,`user_subscribe`.`note`,`user_subscribe`.`created_at`,`user_subscribe`.`updated_at` FROM `user_subscribe` WHERE id = 10013 ORDER BY `user_subscribe`.`id` LIMIT 1
2026-04-30 06:35:38.270 info 303038-04-04 00:00:00.270 info [SubscriptionFlow] renewal order creation started caller=common/subscriptionTrace.go:28 flow=order_subscription effective_user_id=20003 coupon= trace_type=subscription_flow payment_id=2 user_id=20003 quantity=30 trace=fd8fbb2d663ad7df82f0c25bf7fb6913 span=c6fe83c0a377d5b5 stage=order_create_start order_kind=renewal requested_user_subscribe_id=10013
按名称搜索字段
Show log level
0
字段
__stream_shard__
25%
container
100%
level
100%
logstream
100%
service_name
100%
+596 -88
View File
@@ -1,99 +1,396 @@
# AWS RDS to External Replica Runbook
# AWS RDS + EC2 Redis 到外部备用服务器 Runbook
目标:让外部服务器 `104.238.220.230` 上的 MySQL/Redis 尽量实时同步 AWS 侧数据,作为备用数据节点。
目标:让 `104.238.220.230` 持续作为 AWS 主生产环境的异地备用节点,承接:
## 已知资源
- MySQL 外部只读从库
- Redis 外部从库
- 故障时的快速提升与业务切换
- AWS region: `ap-east-1`
- RDS endpoint: `hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
- RDS username: `admin`
- RDS password: `VIbW2nNoh0LXq2!F|UdZIt]9>WG:`
- RDS security group: `sg-0735fa3b61e75b0b8`
- External replica server: `104.238.220.230`
- External OS: Ubuntu 24.04 LTS
- External MySQL: 8.0.45
- External Redis: 7.0.15
本文档以 `2026-05-13` 的真实现网状态为准,覆盖:
## AWS Console 必须完成
- 当前已经落地的主从架构
- 日常验收命令
- 主从故障排查
- 全量重建从库
- “新建规范 RDS 再切换”的生产级收敛路线
- 故障切换与回滚
1. RDS `hifast-mysql-prod` 修改为 `Publicly accessible = Yes`
2. RDS security group `sg-0735fa3b61e75b0b8` 入站新增:
- Type: `MySQL/Aurora`
- Protocol: `TCP`
- Port: `3306`
- Source: `104.238.220.230/32`
- Description: `external mysql replica`
## 1. 当前已确认资源
不要开放 `0.0.0.0/0` 到 RDS 3306。
### 1.1 AWS 主生产
## RDS 上执行 SQL
- Region: `ap-east-1`
- AWS app EC2:
- Name: `hifast-hk-app-01`
- Public IP: `43.198.248.161`
- Private IP: `10.0.1.201`
- AWS MySQL:
- Type: `RDS MySQL`
- Instance: `hifast-mysql-prod-v2`
- Endpoint: `hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
- Version: `8.4.8`
- DB Name: `hifast`
- Admin user: `admin`
- Admin password: keep it in a secret store, do not write plaintext into repo docs
- Replication user: `repl`
- Replication password: keep it in a secret store, do not write plaintext into repo docs
- AWS Redis:
- Location: `hifast-hk-app-01`
- Deployment: `Docker`
- Container: `hifast-redis`
- Version: `redis:8.2.1`
- Listen: `0.0.0.0:6379`
- Password: keep it in a secret store, do not write plaintext into repo docs
先连接:
### 1.2 外部备用服务器
- Host: `104.238.220.230`
- OS: `Ubuntu 24.04 LTS`
- SSH user: `root`
- MySQL version: `8.4.9`
- Redis version: `8.6.3`
- 当前角色:
- MySQL external replica
- Redis replica
- 备用应用节点
### 1.3 当前应用真实运行方式
AWS 应用机上的 `ppanel-server` 当前不是 systemd 托管,而是 Docker Compose 服务:
- Compose file: `/opt/ppanel/docker-compose.cloud.yml`
- Config file: `/opt/ppanel/configs/ppanel.yaml`
- Container name: `ppanel-server`
- Current MySQL target: `hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306`
- Current Redis target: `127.0.0.1:6379`
也就是说,后续所有应用侧切换步骤,都应该以修改:
- `/opt/ppanel/configs/ppanel.yaml`
并执行:
```bash
mysql -h hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u admin -p
cd /opt/ppanel
docker compose -f docker-compose.cloud.yml up -d ppanel-server
```
然后执行:
作为准。
## 2. 当前健康基线
截至 `2026-05-13`,已确认以下状态成立:
- `104` MySQL:
- `Source_Host = hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
- `Replica_IO_Running: Yes`
- `Replica_SQL_Running: Yes`
- `Seconds_Behind_Source: 0`
- `read_only = ON`
- `super_read_only = ON`
- AWS Redis 主库:
- `role:master`
- `connected_slaves:1`
- `104` Redis:
- `role:slave`
- `master_link_status:up`
- AWS 应用:
- `curl http://127.0.0.1:8080/v1/common/heartbeat` 返回 `code=200`
这说明当前状态已经达到:
- 主生产可用
- 外部备用持续同步
- Redis 已回到真实本机链路
## 3. 当前网络前提
### 3.1 安全组
当前实际生效的安全组如下:
- `hifast-hk-app-core-sg`
- `22/tcp <- 0.0.0.0/0`
- `6379/tcp <- 104.238.220.230/32`
- `8080/tcp <- hifast-hk-web-sg`
- `hifast-hk-rds-core-sg`
- `3306/tcp <- 104.238.220.230/32`
- `3306/tcp <- hifast-hk-app-core-sg`
- `hifast-hk-web-sg`
- `22/80/443 <- 0.0.0.0/0`
### 3.2 当前结构性限制
当前 `104 -> RDS` 公网复制链路虽然可用,但依赖的是:
- `hifast-mysql-prod-v2``Publicly accessible = Yes`
- RDS ENI 仍位于 `hifast-hk-private-1b`
- `private-1b` 被临时挂到了公网路由表
这不是最终规范的生产形态。
当前这条路线已经完成,后续更推荐的动作是:
1. 持续确认应用主库目标保持在 `hifast-mysql-prod-v2`
2. 持续确认 `104` 复制目标保持在 `hifast-mysql-prod-v2`
3. 根据回收窗口安排旧 RDS 下线
4. 按网络收敛计划处理 `private-1b` 路由语义
## 4. 日常验收命令
### 4.1 验收 MySQL 主从
`104` 执行:
```bash
mysql -e "SHOW REPLICA STATUS\G"
```
重点看:
- `Source_Host`
- `Replica_IO_Running`
- `Replica_SQL_Running`
- `Seconds_Behind_Source`
- `Last_IO_Error`
- `Last_SQL_Error`
成功标准:
- `Replica_IO_Running: Yes`
- `Replica_SQL_Running: Yes`
- `Seconds_Behind_Source: 0` 或较小
### 4.2 验收 Redis 主从
在 AWS app EC2 执行:
```bash
docker exec hifast-redis redis-cli -a '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' INFO replication
```
重点看:
- `role:master`
- `connected_slaves:1`
`104` 执行:
```bash
redis-cli INFO replication
```
重点看:
- `role:slave`
- `master_host:43.198.248.161`
- `master_port:6379`
- `master_link_status:up`
### 4.3 验收应用
在 AWS app EC2 执行:
```bash
curl -sf http://127.0.0.1:8080/v1/common/heartbeat
```
期望返回:
- `{"code":200,...}`
查看容器:
```bash
cd /opt/ppanel
docker compose -f docker-compose.cloud.yml ps
```
期望:
- `ppanel-server``Up`
- `hifast-redis``Up`
## 5. MySQL 复制故障排查
### 5.1 先看复制状态
```bash
mysql -e "SHOW REPLICA STATUS\G"
```
重点判断:
- `Replica_IO_Running = No`
- `Replica_SQL_Running = No`
- `Last_IO_Error`
- `Last_SQL_Error`
### 5.2 常见场景
#### 场景 A:网络或白名单断开
表现:
- `Replica_IO_Running: No`
- `Last_IO_Error` 出现连接失败、超时、拒绝访问
排查:
```bash
mysql -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u repl -p -e "SELECT 1;"
```
处理:
- 检查 RDS SG 是否仍保留 `104.238.220.230/32 -> 3306`
- 检查 RDS 是否仍为 `Publicly accessible = Yes`
- 如果后续已迁到新规范 RDS,则检查新实例的 SG 和公网可达性
#### 场景 B:主库 binlog 位点丢失
表现:
- `Last_IO_Error``Last_SQL_Error` 指向缺失 binlog
处理:
- 不要硬跳过
- 直接执行全量重建从库
#### 场景 CSQL 执行报错
表现:
- `Replica_SQL_Running: No`
- `Last_SQL_Error` 有实际 SQL 冲突信息
处理建议:
- 如果只是临时演练环境,可重建从库
- 如果已经进入生产切换阶段,不建议盲目 `sql_slave_skip_counter`
- 优先保守做法仍是重新全量初始化
## 6. Redis 复制故障排查
### 6.1 看 `104` 从库状态
```bash
redis-cli INFO replication
```
重点:
- `role`
- `master_host`
- `master_link_status`
### 6.2 看 AWS 主库状态
```bash
docker exec hifast-redis redis-cli -a '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' INFO replication
```
重点:
- `role:master`
- `connected_slaves`
### 6.3 常见问题
#### 场景 A:安全组断开
表现:
- `master_link_status:down`
处理:
- 确认 `hifast-hk-app-core-sg` 仍保留:
- `6379/tcp <- 104.238.220.230/32`
#### 场景 B:主库密码漂移
表现:
- 从库重连失败
- 日志出现 `NOAUTH`
处理:
- 统一更新 `/etc/redis/redis.conf` 中的:
- `masterauth`
- 然后:
```bash
systemctl restart redis-server
redis-cli INFO replication
```
## 7. 全量重建 MySQL 从库
适用场景:
- 主从中断且无法安全追平
- `6666@qq.com` 这类写入在主库存在、从库未同步
- binlog 不连续
- 需要回到最稳妥状态
### 7.1 在主库导出
在一台可连 RDS 的机器上执行:
```bash
mysqldump \
-h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com \
-u admin \
-p \
--single-transaction \
--routines \
--triggers \
--events \
--set-gtid-purged=OFF \
hifast > hifast-full.sql
```
如果需要同步账号权限,也可以额外单独导出授权对象;但当前业务库恢复重点是 `hifast` 数据库本身。
### 7.2 清理 `104` 当前复制
`104` 执行:
```sql
CALL mysql.rds_set_configuration('binlog retention hours', 24);
STOP REPLICA;
RESET REPLICA ALL;
```
CREATE USER IF NOT EXISTS 'repl'@'104.238.220.230' IDENTIFIED BY 'THcPeHaerV4PwyE9qtF6PHZd';
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'104.238.220.230';
FLUSH PRIVILEGES;
### 7.3 重新导入业务库
SHOW VARIABLES LIKE 'gtid_mode';
`104` 执行:
```bash
mysql -e "DROP DATABASE IF EXISTS hifast; CREATE DATABASE hifast CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;"
mysql hifast < hifast-full.sql
```
### 7.4 重新挂复制
当前现网是非 GTID 自动定位,使用 file/position 模式。
先在主库取位点:
```sql
SHOW MASTER STATUS;
```
记录 `SHOW MASTER STATUS` 输出的 `File``Position`。如果 `gtid_mode = ON`,外部从库可用 auto-position;否则使用 file/position。
## 外部服务器基础配置
`104.238.220.230` root shell 执行:
```bash
cat >/etc/mysql/mysql.conf.d/99-replica.cnf <<'EOF'
[mysqld]
server-id=230
read_only=ON
super_read_only=ON
relay_log=relay-bin
log_bin=mysql-bin
binlog_format=ROW
EOF
systemctl restart mysql
mysql -e "SHOW VARIABLES WHERE Variable_name IN ('server_id','read_only','super_read_only','log_bin','binlog_format');"
```
如果 RDS `gtid_mode = ON`
然后在 `104` 执行:
```sql
STOP REPLICA;
RESET REPLICA ALL;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com',
SOURCE_HOST='hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com',
SOURCE_PORT=3306,
SOURCE_USER='repl',
SOURCE_PASSWORD='THcPeHaerV4PwyE9qtF6PHZd',
SOURCE_AUTO_POSITION=1,
SOURCE_SSL=1;
START REPLICA;
SHOW REPLICA STATUS\G
```
如果 RDS `gtid_mode != ON`,把 `<MASTER_LOG_FILE>``<MASTER_LOG_POS>` 替换成 `SHOW MASTER STATUS` 里的值:
```sql
STOP REPLICA;
RESET REPLICA ALL;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com',
SOURCE_PORT=3306,
SOURCE_USER='repl',
SOURCE_PASSWORD='THcPeHaerV4PwyE9qtF6PHZd',
SOURCE_PASSWORD='<REPL_PASSWORD>',
SOURCE_LOG_FILE='<MASTER_LOG_FILE>',
SOURCE_LOG_POS=<MASTER_LOG_POS>,
SOURCE_SSL=1;
@@ -101,37 +398,36 @@ START REPLICA;
SHOW REPLICA STATUS\G
```
成功标准:
### 7.5 验收
确认:
- `Replica_IO_Running: Yes`
- `Replica_SQL_Running: Yes`
- `Seconds_Behind_Source``0` 或较小数值
- `Seconds_Behind_Source: 0`
- `read_only = ON`
- `super_read_only = ON`
## Redis 复制
## 8. 重新配置 Redis 从库
Redis 源端还未确认。之前方案是 Redis 放在 AWS app EC2 上,与服务端同机。需要先登录 AWS app EC2,确认 Redis 是否存在、端口、密码、监听地址
当前 `104` Redis 为原生安装,不使用 Docker
外部 Redis 从库命令模板:
### 8.1 临时切回从库
```bash
redis-cli CONFIG SET masterauth '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr'
redis-cli REPLICAOF 43.198.248.161 6379
redis-cli CONFIG SET replica-read-only yes
redis-cli REPLICAOF <AWS_REDIS_PRIVATE_OR_PUBLIC_IP> 6379
redis-cli INFO replication
```
如果源端有密码:
### 8.2 持久化配置
```bash
redis-cli CONFIG SET masterauth '<REDIS_PASSWORD>'
redis-cli REPLICAOF <AWS_REDIS_PRIVATE_OR_PUBLIC_IP> 6379
redis-cli INFO replication
```
持久化写入 `/etc/redis/redis.conf`
检查 `/etc/redis/redis.conf` 至少包含:
```conf
replicaof <AWS_REDIS_PRIVATE_OR_PUBLIC_IP> 6379
masterauth <REDIS_PASSWORD>
replicaof 43.198.248.161 6379
masterauth 0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr
replica-read-only yes
```
@@ -142,3 +438,215 @@ systemctl restart redis-server
redis-cli INFO replication
```
## 9. 生产级收敛路线:新建规范 RDS 再切换
这条路线已经完成,当前现网主库已经切到 `hifast-mysql-prod-v2`。下面内容保留为迁移归档参考,不再表示待执行。
### 9.1 目标
把当前临时方案:
- `private-1b` 挂公网路由
收敛成:
- RDS 使用纯公网 DB subnet group
- 安全组仍只对白名单和应用组开放
### 9.2 已准备好的资源
- 已建 DB subnet group:
- `hifast-hk-rds-public-only-sgprep`
- 子网:
- `hifast-hk-public-1a`
- `hifast-hk-public-1b`
### 9.3 新 RDS 建议参数
新实例建议名:
- `hifast-mysql-prod-v2`
建议保持与旧主库一致:
- Engine: `mysql`
- Version: `8.4.8`
- Class: `db.r7g.xlarge`
- Storage: `gp3`
- Size: `200 GB`
- IOPS: `3000`
- Throughput: `125`
- Publicly accessible: `Yes`
- Multi-AZ: `No` 或按预算单独评估
- Deletion protection: `On`
- Performance Insights: `On`
- Backup retention: `7`
- DB subnet group: `hifast-hk-rds-public-only-sgprep`
- VPC SG: `hifast-hk-rds-core-sg`
### 9.4 迁移步骤
1. 已创建新 RDS `hifast-mysql-prod-v2`
2. 在旧主库导出 `hifast`
3. 导入新库
4. 在新库创建 `repl` 用户并配置 binlog retention
5. 修改 AWS app EC2 上 `/opt/ppanel/configs/ppanel.yaml``MySQL.Addr`
6. 重启 `ppanel-server` 容器
7.`104``STOP REPLICA; RESET REPLICA ALL;`
8. 指向新 RDS endpoint 重新挂复制
9. 验收应用与主从
10. 验收通过后,安排旧 RDS 下线,并推进 `private-1b` 恢复私网路由
### 9.5 应用切换命令
在 AWS app EC2
1. 备份配置
```bash
cp /opt/ppanel/configs/ppanel.yaml /opt/ppanel/configs/ppanel.yaml.bak.$(date +%Y%m%d%H%M%S)
```
2. 编辑:
- `/opt/ppanel/configs/ppanel.yaml`
把:
```yaml
MySQL:
Addr: hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306
```
改成:
```yaml
MySQL:
Addr: hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306
```
3. 重启应用容器:
```bash
cd /opt/ppanel
docker compose -f docker-compose.cloud.yml up -d ppanel-server
```
4. 验证:
```bash
curl -sf http://127.0.0.1:8080/v1/common/heartbeat
docker compose -f docker-compose.cloud.yml logs --tail=100 ppanel-server
```
### 9.6 `104` 改挂新 RDS
```sql
STOP REPLICA;
RESET REPLICA ALL;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='<NEW_RDS_ENDPOINT>',
SOURCE_PORT=3306,
SOURCE_USER='repl',
SOURCE_PASSWORD='<REPL_PASSWORD>',
SOURCE_LOG_FILE='<MASTER_LOG_FILE>',
SOURCE_LOG_POS=<MASTER_LOG_POS>,
SOURCE_SSL=1;
START REPLICA;
SHOW REPLICA STATUS\G
```
### 9.7 切换验收
至少确认:
- AWS 应用心跳正常
- 新 RDS 可正常读写
- `104` 复制恢复为 `Yes/Yes`
- `Seconds_Behind_Source` 追到 `0`
- Navicat 可从允许的白名单来源连接新 RDS
### 9.8 回滚
如果新 RDS 切换后应用异常:
1. 立即把 `/opt/ppanel/configs/ppanel.yaml``MySQL.Addr` 改回旧 endpoint
2. 重启 `ppanel-server`
3. 暂不处理 `104`,先恢复主生产
4. 复盘新库数据、权限、参数、网络
## 10. AWS 故障时的备用接管
### 10.1 Redis 提升为主库
`104`
```bash
redis-cli REPLICAOF NO ONE
redis-cli INFO replication
```
期望:
- `role:master`
### 10.2 MySQL 提升为可写主库
`104`
```sql
STOP REPLICA;
RESET REPLICA ALL;
SET GLOBAL super_read_only=OFF;
SET GLOBAL read_only=OFF;
```
再确认:
```sql
SHOW VARIABLES LIKE 'read_only';
SHOW VARIABLES LIKE 'super_read_only';
```
期望:
- `OFF`
- `OFF`
### 10.3 应用切到 `104` 本机数据层
如果 `104` 上也部署同样的 PPanel 服务,则应用配置应改为:
- MySQL 指向 `127.0.0.1:3306` 或本机 socket
- Redis 指向 `127.0.0.1:6379`
### 10.4 最后切入口
数据层和应用层都确认可写后,再做:
- DNS 切换
- 或 Nginx / 上游切流量
原则:
- 先数据接管
- 再应用确认
- 最后入口切换
## 11. 日常巡检建议
建议至少每天巡检一次:
1. `104` MySQL `SHOW REPLICA STATUS\G`
2. `104` Redis `INFO replication`
3. AWS Redis 主库 `INFO replication`
4. AWS app 心跳 `/v1/common/heartbeat`
5. `docker compose -f /opt/ppanel/docker-compose.cloud.yml ps`
如果后续要做真正的生产级自动化,再补:
- MySQL 复制延迟告警
- Redis 主从断链告警
- 应用心跳失败告警
- RDS 连接失败告警
- 定期灾备切换演练
+297 -39
View File
@@ -10,6 +10,47 @@
- MySQL / Redis 的同步关系是什么
- 故障时应该如何切换
## 0. 2026-05-13 验收摘要
- 已确认 `104.238.220.230 -> AWS RDS MySQL` 连通,MySQL 外部从库健康:
- 当前复制上游:`hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
- `Replica_IO_Running: Yes`
- `Replica_SQL_Running: Yes`
- `Seconds_Behind_Source: 0`
- 已确认 `104.238.220.230 -> AWS EC2 Redis` 主从健康:
- `104``role:slave`
- `master_link_status:up`
- AWS Redis 主库 `connected_slaves:1`
- 已修复应用侧 Redis 配置漂移:
- 旧链路:`127.0.0.1:6380 -> stunnel4 -> 旧 ElastiCache`
- 新链路:`127.0.0.1:6379 -> 本机 Docker Redis`
-`stunnel4` 已停用并禁用自启,`ppanel-server` 日志里的 Redis `i/o timeout` 已停止出现。
- 已确认 `ppanel-server` 当前运行方式为 Docker Compose 容器,而不是 systemd 服务:
- Compose 文件:`/opt/ppanel/docker-compose.cloud.yml`
- 配置文件:`/opt/ppanel/configs/ppanel.yaml`
- 容器名:`ppanel-server`
- 健康检查:`curl http://127.0.0.1:8080/v1/common/heartbeat` 返回正常
- 旧自建安全组 `hifast-hk-app-sg``hifast-hk-nginx-sg` 已删除;当前 VPC 内仅保留:
- `hifast-hk-app-core-sg`
- `hifast-hk-rds-core-sg`
- `hifast-hk-web-sg`
- `default`
- 目前已经达到“可用且关键链路已恢复”的状态。
- 当前入口层属于已确认的过渡方案:
- 现阶段公网入口仍在 `hifast-hk-app-01`
- 独立 `nginx` 服务器已预留,待 AWS 后续资源到位后再迁移承接
- 当前仍需要继续收敛的核心生产项只剩 1 个:
- RDS 为了让 `104` 走公网白名单复制,当前仍依赖 `hifast-hk-private-1b` 子网临时挂到公网路由表,这不是最终规范形态。
- 已完成的下一步准备:
- 已创建纯公网子网专用的 `DB subnet group``hifast-hk-rds-public-only-sgprep`
- 子网包含:
- `subnet-070e3f264a9c79f32` `hifast-hk-public-1a`
- `subnet-00cb5add705c447c8` `hifast-hk-public-1b`
- 已创建专用 S3 备份桶:
- `hifast-prod-backups-200810848252-ap-east-1`
- 当前状态:private
- 当前状态:versioning enabled
## 1. 当前实际环境
### 1.1 AWS 区域
@@ -23,15 +64,37 @@
- 名称:`hifast-hk-app-01`
- Instance ID: `i-079cd9d3ef3748714`
- 角色:应用机 / Nginx / 业务服务 / AWS 侧 Redis 主库宿主机
- 角色:当前实际生产入口 / Nginx / 业务服务 / AWS 侧 Redis 主库宿主机
- 私网 IP: `10.0.1.201`
- 公网 IP: `43.198.248.161`
- 业务服务运行方式:`Docker Compose`
- 业务容器:`ppanel-server`
- 部署目录:`/opt/ppanel`
- 实际配置文件:`/opt/ppanel/configs/ppanel.yaml`
#### 独立 Nginx 服务器
- 名称:`hifast-hk-nginx-01`
- Instance ID: `i-0701d54bf2c6bf594`
- 角色:计划中的独立入口机
- 私网 IP: `10.0.1.175`
- 当前状态:`stopped`
- 当前说明:已经只绑定 `hifast-hk-web-sg`,但目前并未承接正式流量
#### MySQL 主库
- 类型:`AWS RDS MySQL`
- 实例名:`hifast-mysql-prod`
- 实例名:`hifast-mysql-prod-v2`
- Endpoint: `hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
- 角色:生产主库
- Engine: `MySQL 8.4.8`
- Publicly accessible: `Yes`
- Multi-AZ: `No`
- Backup retention: `7 days`
- Deletion protection: `On`
- 当前数据库实际体量:约 `187.41 MB`
- 当前表数量:`37`
- 当前 `user` 表记录数:`45076`
#### Redis 主库
@@ -41,6 +104,8 @@
- 版本:`redis:8.2.1`
- 访问端口:`6379`
- 主库出口地址:`43.198.248.161:6379`
- 应用当前实际连接:`127.0.0.1:6379`
- 认证方式:已启用密码认证
#### 外部备用服务器
@@ -48,10 +113,18 @@
- OS: `Ubuntu 24.04 LTS`
- 角色:异地备用节点
- 当前状态:已部署与 AWS 相同的业务服务
- 当前 MySQL 状态:外部只读从库
- 当前 Redis 部署方式:`宿主机原生安装`
- 当前 Redis 版本:`8.6.3`
- 当前 Redis 角色:`AWS Redis 主库的从库`
#### S3 备份桶
- Bucket: `hifast-prod-backups-200810848252-ap-east-1`
- Region: `ap-east-1`
- Public access: blocked
- Versioning: enabled
## 2. 架构总览
```mermaid
@@ -59,7 +132,7 @@ flowchart TB
USER["用户 / 客户端"] --> DNS["域名 / DNS / 入口层"]
DNS --> APP["AWS EC2\nhifast-hk-app-01\n43.198.248.161\n10.0.1.201"]
APP --> RDS["AWS RDS MySQL\nhifast-mysql-prod\n主库"]
APP --> RDS["AWS RDS MySQL\nhifast-mysql-prod-v2\n主库"]
APP --> REDISM["AWS Redis 主库\nDocker redis:8.2.1\n43.198.248.161:6379"]
RDS -. MySQL 备用 / 同步 .-> MYSQLS["104.238.220.230\nMySQL 备用库"]
@@ -87,8 +160,10 @@ flowchart TB
说明:
- 当前主应用入口在 AWS EC2
- EC2 同时承担业务服务入口
- 当前主应用入口实际在 `hifast-hk-app-01`
- `hifast-hk-app-01` 同时承担 Nginx、业务服务、Redis 主库
- 当前业务服务不是 systemd 单进程部署,而是由 `/opt/ppanel/docker-compose.cloud.yml` 管理的 `ppanel-server` 容器提供 `8080`
- 独立入口机 `hifast-hk-nginx-01` 已存在,但当前仅作为后续资源开通后的迁移目标。
- MySQL 在 AWS RDS。
- Redis 不在 ElastiCache,而是在 EC2 本机通过 Docker 提供。
@@ -118,43 +193,187 @@ App / Nginx
## 4. 网络与安全边界
### 4.1 EC2 安全组
### 4.1 当前安全组
- 安全组名称:`hifast-hk-app-sg`
- 安全组 ID: `sg-09266fb27bde15714`
截至 `2026-05-13`VPC `vpc-09fc384522517debc` 内仅保留 4 个安全组:
已确认规则:
- `default`
- `hifast-hk-app-core-sg`
- `hifast-hk-rds-core-sg`
- `hifast-hk-web-sg`
- Redis `6379/tcp`
- 来源:`104.238.220.230/32`
其中绑定关系已经收敛为:
这条规则的作用是:
- `hifast-hk-app-01` -> `hifast-hk-app-core-sg`
- `hifast-hk-nginx-01` -> `hifast-hk-web-sg`
- `hifast-mysql-prod-v2` -> `hifast-hk-rds-core-sg`
- 允许外部备用服务器 `104.238.220.230` 连到 AWS Redis 主库
- 避免 Redis 对全网开放
旧组:
### 4.2 Redis 网络关系
- `hifast-hk-app-sg`
- `hifast-hk-nginx-sg`
已经删除,不再使用。
### 4.2 当前入站规则
#### `hifast-hk-app-core-sg`
- `22/tcp <- 0.0.0.0/0`
- `6379/tcp <- 104.238.220.230/32`
- `8080/tcp <- hifast-hk-web-sg`
#### `hifast-hk-rds-core-sg`
- `3306/tcp <- 104.238.220.230/32`
- `3306/tcp <- hifast-hk-app-core-sg`
#### `hifast-hk-web-sg`
- `22/tcp <- 0.0.0.0/0`
- `80/tcp <- 0.0.0.0/0`
- `443/tcp <- 0.0.0.0/0`
### 4.3 Redis 网络关系
```mermaid
flowchart LR
SG["EC2 Security Group\nsg-09266fb27bde15714"] --> REDIS["AWS Redis 主库\n43.198.248.161:6379"]
SG["hifast-hk-app-core-sg"] --> REDIS["AWS Redis 主库\n43.198.248.161:6379"]
STANDBY["104.238.220.230/32"] --> SG
```
### 4.3 RDS 访问原则
### 4.4 RDS 访问原则
RDS 不应该对公网全开放。
RDS 当前状态已经比之前干净很多:
推荐且已执行过的方向是:
- 只对白名单源 IP 开放 `3306`
- 如果 `104.238.220.230` 需要做外部从库,则只放行这个 IP
- 当前只对白名单和应用安全组开放 `3306`
- 已移除 `3306 <- 0.0.0.0/0`
- 当前 RDS 仅绑定 `hifast-hk-rds-core-sg`
建议原则:
- 不开放 `0.0.0.0/0` 到 MySQL `3306`
- 不开放 `0.0.0.0/0` 到 Redis `6379`
### 4.5 当前仍需继续收敛的网络点
#### 4.5.1 入口层当前属于过渡方案,不作为本阶段阻塞项
当前运行形态是:
- `hifast-hk-app-01` 本机 `nginx` 正在监听 `80/443`
- `hifast-hk-nginx-01` 当前停止
但当前安全组设计是按“独立 nginx 机”拆的:
- `hifast-hk-web-sg``hifast-hk-nginx-01`
- `hifast-hk-app-core-sg``hifast-hk-app-01`
这意味着当前真实入口和安全组角色划分还没有完全一致。
但这一点已经被确认为过渡设计,不作为当前阶段必须整改的问题。后续待 AWS 新资源到位后,再把公网入口迁到独立 `nginx` 服务器即可。
#### 4.5.2 RDS 公网复制链路仍是当前唯一核心结构性问题
为了让 `104.238.220.230` 通过公网白名单访问 RDS,当前实际依赖的是:
- RDS `PubliclyAccessible = true`
- RDS ENI 仍在 `subnet-0dcf46db665b6d8a7`
- 该子网名称是 `hifast-hk-private-1b`
- 但这个子网当前被临时关联到了公网路由表
这能用,但不属于最终规范的“纯公网子网组”设计。
本轮已经额外完成的准备动作:
- 已新建纯公网 `DB subnet group``hifast-hk-rds-public-only-sgprep`
- 该组状态:`Complete`
- 该组仅包含两条公网子网:
- `hifast-hk-public-1a`
- `hifast-hk-public-1b`
当前结论:
- 不建议继续在现有生产 RDS 上反复试在线切子网组。
- 更稳的收敛方案是:新建一台使用规范公网子网组的生产 RDS,再做一次短切换。
- 由于当前库体量只有约 `187 MB`,这条路线的执行成本并不高,风险也比在现网主库上硬改更可控。
## 4.6 104 如何连接 AWS
这里要特别区分:
- `104` 连接 AWS 数据层
- 本地电脑登录 AWS EC2
这不是同一件事。
### 4.6.1 104 连接 AWS Redis 的方式
`104.238.220.230` 连接 AWS Redis,不是通过 PEM 证书,也不是通过 SSH 登录 AWS 机器,而是直接作为 Redis 从库去访问 AWS Redis 主库:
- 目标地址:`43.198.248.161:6379`
- 连接方式:`TCP`
- 认证方式:`Redis 密码`
- 网络前提:AWS EC2 安全组已放行 `104.238.220.230/32 -> 6379`
也就是说:
```text
104 Redis 从库 -> 直连 AWS Redis 主库公网地址 -> 密码认证 -> 建立复制
```
示意命令:
```bash
redis-cli -h 43.198.248.161 -p 6379 -a '<REDIS_PASSWORD>'
```
### 4.6.2 104 连接 AWS MySQL RDS 的方式
`104.238.220.230` 连接 AWS RDS,也不是通过 PEM 证书,而是通过数据库连接:
- 目标地址:`hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306`
- 连接方式:`MySQL TCP`
- 认证方式:`MySQL 用户名 + 密码`
- 网络前提:RDS 白名单或安全组放行 `104.238.220.230`
也就是说:
```text
104 MySQL 从库 -> 直连 AWS RDS endpoint -> 账号密码认证 -> 建立复制
```
示意命令:
```bash
mysql -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u admin -p
```
### 4.6.3 本地电脑登录 AWS EC2 的方式
本地电脑这边之前进入 AWS EC2,用的不是 `hifast-hk-app-01-key.pem`,而是:
- `AWS Console`
- `EC2 Instance Connect`
- 浏览器里的 Web SSH 终端
所以当前要理解成 3 条不同链路:
1. 本地电脑 -> AWS 控制台 -> EC2 Instance Connect -> AWS EC2
2. `104` -> AWS Redis 主库公网地址 -> Redis 密码认证
3. `104` -> AWS RDS endpoint -> MySQL 用户密码认证
### 4.6.4 关键结论
当前灾备链路里,`104` 与 AWS 数据同步不依赖 `.pem` 证书文件。
真正依赖的是:
- Redis 安全组放行
- RDS 白名单 / 安全组放行
- 正确的 Redis 密码
- 正确的 MySQL 账号密码
## 5. Redis 实际部署与同步状态
### 5.1 AWS Redis 主库
@@ -186,6 +405,8 @@ RDS 不应该对公网全开放。
-`104` 连接 AWS Redis 主库,认证成功
- AWS 主库写入测试键
- `104` 从库成功读取测试键
- `2026-05-13` 已确认应用机本地 `ppanel-server -> 127.0.0.1:6379` 建立稳定连接
- `2026-05-13` 已确认旧 `stunnel4 -> ElastiCache` 链路下线后,应用日志不再出现 Redis `i/o timeout`
测试键:
@@ -206,7 +427,7 @@ flowchart LR
### 6.1 主库角色
- 主库在 `AWS RDS MySQL`
- 实例:`hifast-mysql-prod`
- 实例:`hifast-mysql-prod-v2`
### 6.2 外部备用角色
@@ -215,26 +436,32 @@ flowchart LR
### 6.3 当前文档说明
Redis 的主从状态已经在本次执行中完成并验证
MySQL 当前已经完成实际验收,不再只是“待确认”状态
`2026-05-13` 验收结果:
- `Source_Host = hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
- `Replica_IO_Running = Yes`
- `Replica_SQL_Running = Yes`
- `Seconds_Behind_Source = 0`
- `read_only = ON`
- `super_read_only = ON`
这表示 `104` 当前是健康的只读外部备用库。
MySQL 这部分在此前已经有专门 runbook:
- [ops/aws-rds-external-replica-runbook.md](/Users/Apple/code_vpn/vpn/ppanel-server/ops/aws-rds-external-replica-runbook.md)
如果要把 MySQL 也完全纳入同一灾备演练,需要继续确认:
- `104` 当前 MySQL 的同步线程状态
- `SHOW REPLICA STATUS\G` 是否仍然正常
- RDS 到 `104` 的白名单是否仍然保留
## 7. 当前生产方案的真实特点
这套已经落地的架构,不是传统的全 AWS 托管标准形态,而是偏实用的混合方案:
- 应用在 AWS EC2
- 应用和当前实际公网入口在 AWS 同一台 EC2
- MySQL 在 AWS RDS
- Redis 在 AWS EC2 本机
- MySQL / Redis 均向外部服务器 `104` 做灾备
- 旧 ElastiCache / stunnel 链路已经退出当前生产路径
它的优点:
@@ -348,6 +575,9 @@ redis-cli -a '<REDIS_PASSWORD>' REPLICAOF NO ONE
- AWS Redis 主库是否在线
- `104` Redis 从库是否 `master_link_status:up`
- `104` MySQL 复制是否正常
- `ppanel-server` 容器是否 `Up`
- `ppanel-server` 日志中是否再次出现 Redis `i/o timeout`
- `stunnel4` 是否保持 `inactive`
### 9.2 Redis 故障时
@@ -377,7 +607,7 @@ redis-cli -a '<REDIS_PASSWORD>' REPLICAOF NO ONE
### 10.1 当前实际方案
`DNS -> AWS EC2(App + Nginx) -> RDS MySQL + EC2 Redis -> 104 灾备`
`DNS -> hifast-hk-app-01(Nginx + App + Redis) -> RDS MySQL -> 104 灾备`
### 10.2 理想生产方案
@@ -394,11 +624,35 @@ redis-cli -a '<REDIS_PASSWORD>' REPLICAOF NO ONE
建议按优先级补齐:
1. 明确 `104` 应用接管脚本与启动检查项
2. 明确 MySQL 故障切换脚本
3. 明确 Redis 故障切换脚本
4. 明确域名 / DNS 切换方式
5. 做一次完整灾备演练
1. 把 RDS 从“private 子网挂公网路由”的临时方案,收敛成真正的公网 DB subnet group,或改成私网复制方案
2. 明确 `104` 应用接管脚本与启动检查项
3. 明确 MySQL 故障切换脚本
4. 明确 Redis 故障切换脚本
5. 明确域名 / DNS 切换方式
6. 做一次完整灾备演练
7. 待 AWS 新资源到位后,再把公网入口迁到独立 `nginx` 服务器
### 10.4 RDS 推荐收敛路线
当前原先规划的“新建规范 RDS 再切换”路线已经完成,现网主库已经是 `hifast-mysql-prod-v2`
后续更推荐的正式路线是:
1. 继续以 `hifast-mysql-prod-v2` 作为当前生产主库
2. 确认其持续使用 `hifast-hk-rds-public-only-sgprep`
3. 安全组持续只放:
- `104.238.220.230/32 -> 3306`
- `hifast-hk-app-core-sg -> 3306`
4. 持续确认应用连接目标为 `hifast-mysql-prod-v2`
5. 持续确认 `104` 的外部复制目标为 `hifast-mysql-prod-v2`
6. 验证通过后,再安排旧 RDS 下线
这条路线的优点:
- 最终拓扑规范清晰
- 不需要继续保留“private 子网挂公网路由”的临时设计
- 对当前现网主库的扰动最小
- 当前数据库体量较小,迁移成本可控
## 11. 建议的下一版目标拓扑
@@ -423,16 +677,20 @@ flowchart TB
截至当前,已经可以确认的生产与灾备状态是:
- AWS 是主生产环境
- 应用跑在 `hifast-hk-app-01`
- 应用当前跑在 `hifast-hk-app-01`
- MySQL 主库在 AWS RDS
- Redis 主库在 AWS EC2 Docker
- `104.238.220.230` 是异地备用节点
- `104` 已部署与 AWS 相同的业务服务
- `104` 上 Redis 已切为宿主机原生安装
- `104` Redis 已成功作为 AWS Redis 主库的从库在线同步
- `104` MySQL 已恢复并保持健康复制
- 应用已经切回当前真实可用的本机 Redis 主库
-`stunnel4 -> ElastiCache` 链路已经退出生产路径
如果后续要继续完善这份方案,优先补充:
- MySQL 最终同步状态复核
- RDS 子网与公网访问模型收敛
- 入口域名 / DNS 切换细则
- 应用层在 `104` 的接管与回切执行清单
- 待资源到位后的独立 `nginx` 迁移清单
+286
View File
@@ -0,0 +1,286 @@
# Hifast 新 RDS 生产切换清单(已完成归档)
本文档记录已完成的 `hifast-mysql-prod -> hifast-mysql-prod-v2` 生产切换过程,作为归档和回溯参考。
本次切换已经完成,当时的目标是避免继续在线硬改现有生产 RDS,而是:
1. 新建一台规范公网子网组的新 RDS
2. 导入现有 `hifast` 数据
3. 将 AWS 应用切到新 RDS
4.`104.238.220.230` 的外部从库改挂新 RDS
5. 验收通过后再下线旧 RDS
## 1. 适用背景
当前现网已经可用,但还存在 1 个结构性问题:
- 为了让 `104` 通过公网白名单做 MySQL 复制,当时的 `hifast-mysql-prod` 仍依赖 `hifast-hk-private-1b` 临时挂公网路由
当前已准备好的收敛资源:
- 纯公网 DB subnet group:
- `hifast-hk-rds-public-only-sgprep`
- 安全组:
- `hifast-hk-rds-core-sg`
## 2. 目标实例参数
建议新实例名称:
- `hifast-mysql-prod-v2`
建议参数如下:
- Region: `ap-east-1`
- Engine: `MySQL`
- Engine version: `8.4.8`
- Templates: `Production`
- DB instance class: `db.r7g.xlarge`
- Storage type: `gp3`
- Allocated storage: `200 GiB`
- Provisioned IOPS: `3000`
- Storage throughput: `125`
- Multi-AZ: `No`
- Publicly accessible: `Yes`
- VPC: `vpc-09fc384522517debc`
- DB subnet group: `hifast-hk-rds-public-only-sgprep`
- VPC security group: `hifast-hk-rds-core-sg`
- DB name: `hifast`
- Master username: `admin`
- Master password: 与现网保持一致
- Backup retention: `7 days`
- Performance Insights: `On`
- Storage encryption: `On`
- Deletion protection: `On`
- Auto minor version upgrade: 建议保持与现网一致
- Maintenance window: 可与现网同策略,建议维护窗口内切换
## 3. 切换前确认
切换前必须满足:
1. `104` 当前主从正常:
- `Replica_IO_Running: Yes`
- `Replica_SQL_Running: Yes`
- `Seconds_Behind_Source: 0`
2. AWS 应用机心跳正常:
- `curl http://127.0.0.1:8080/v1/common/heartbeat`
3. AWS Redis 主从正常:
- AWS 主 `connected_slaves:1`
- `104``master_link_status:up`
4. 已确认当前应用真实配置文件:
- `/opt/ppanel/configs/ppanel.yaml`
5. 已确认当前应用重启方式:
- `cd /opt/ppanel && docker compose -f docker-compose.cloud.yml up -d ppanel-server`
## 4. 创建新 RDS
已在 AWS 控制台创建 `hifast-mysql-prod-v2`,未对原 `hifast-mysql-prod` 做高风险在线子网调整。
创建完成后先确认:
1. 新 endpoint 已分配
2. `Publicly accessible = Yes`
3. SG 为 `hifast-hk-rds-core-sg`
4. DB subnet group 为 `hifast-hk-rds-public-only-sgprep`
5.`104` 可以 TCP 连通 `3306`
连通性验证:
```bash
nc -zv <NEW_RDS_ENDPOINT> 3306
```
## 5. 导出旧主库
在一台可连旧 RDS 的机器执行:
```bash
mysqldump \
-h hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com \
-u admin \
-p \
--single-transaction \
--routines \
--triggers \
--events \
--set-gtid-purged=OFF \
hifast > hifast-full.sql
```
说明:
- 当前业务库体量约 `187 MB`
- 这条路线的成本低,且比现网主库在线改子网更稳
## 6. 初始化新 RDS
先连接新 RDS
```bash
mysql -h <NEW_RDS_ENDPOINT> -u admin -p
```
建议先执行:
```sql
CALL mysql.rds_set_configuration('binlog retention hours', 24);
CREATE USER IF NOT EXISTS 'repl'@'104.238.220.230' IDENTIFIED BY '<REPL_PASSWORD>';
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'104.238.220.230';
FLUSH PRIVILEGES;
```
如果库是空的,再导入:
```bash
mysql -h <NEW_RDS_ENDPOINT> -u admin -p hifast < hifast-full.sql
```
导入完成后确认:
```bash
mysql -h <NEW_RDS_ENDPOINT> -u admin -p -e "USE hifast; SHOW TABLES;"
```
## 7. 切换 AWS 应用到新 RDS
### 7.1 备份当前配置
在 AWS app EC2
```bash
cp /opt/ppanel/configs/ppanel.yaml /opt/ppanel/configs/ppanel.yaml.bak.$(date +%Y%m%d%H%M%S)
```
### 7.2 修改数据库地址
编辑:
- `/opt/ppanel/configs/ppanel.yaml`
把:
```yaml
MySQL:
Addr: hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306
```
改成:
```yaml
MySQL:
Addr: hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com:3306
```
### 7.3 重启业务容器
```bash
cd /opt/ppanel
docker compose -f docker-compose.cloud.yml up -d ppanel-server
```
### 7.4 应用验收
```bash
curl -sf http://127.0.0.1:8080/v1/common/heartbeat
docker compose -f /opt/ppanel/docker-compose.cloud.yml logs --tail=100 ppanel-server
```
重点确认:
- 心跳返回 `200`
- 日志中无 MySQL 连接异常
## 8. 改挂 `104` 从库到新 RDS
先在新 RDS 获取位点:
```sql
SHOW MASTER STATUS;
```
然后在 `104` 执行:
```sql
STOP REPLICA;
RESET REPLICA ALL;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com',
SOURCE_PORT=3306,
SOURCE_USER='repl',
SOURCE_PASSWORD='<REPL_PASSWORD>',
SOURCE_LOG_FILE='<MASTER_LOG_FILE>',
SOURCE_LOG_POS=<MASTER_LOG_POS>,
SOURCE_SSL=1;
START REPLICA;
SHOW REPLICA STATUS\G
```
验收标准:
- `Replica_IO_Running: Yes`
- `Replica_SQL_Running: Yes`
- `Seconds_Behind_Source: 0`
## 9. 切换后验收
至少做下面这些检查:
1. AWS 应用心跳正常
2. 管理后台可登录
3. 新建一条测试数据后,主库可见
4. `104` 从库能同步到该测试数据
5. Navicat 可从白名单来源连接新 RDS
6. Redis 主从仍正常,不受本次 MySQL 切换影响
推荐额外核对:
```bash
docker compose -f /opt/ppanel/docker-compose.cloud.yml ps
docker exec hifast-redis redis-cli -a '<REDIS_PASSWORD>' INFO replication
```
## 10. 回滚方案
如果切换后 AWS 应用异常:
1. 立刻把 `/opt/ppanel/configs/ppanel.yaml``MySQL.Addr` 改回旧 endpoint
2. 重启 `ppanel-server`
```bash
cd /opt/ppanel
docker compose -f docker-compose.cloud.yml up -d ppanel-server
```
3. 优先恢复主生产可用
4. `104` 是否回切旧 RDS 复制,视恢复窗口决定
如果只是 `104` 复制改挂失败,但 AWS 应用已正常使用新 RDS:
- 不必立刻回滚应用
- 先修好 `104 -> 新 RDS` 的白名单、位点、用户或网络
## 11. 切换完成后的收尾
全部验收通过后再做:
1. 已将新 endpoint 记录到正式运维文档
2. 仍需根据回收窗口安排旧 RDS 下线
3. 仍需按网络收敛计划处理 `private-1b` 路由语义
4. 已更新外部复制 runbook 中的主库 endpoint
## 12. 当前建议的执行顺序
本次切换当时按以下顺序执行:
1. 创建 `hifast-mysql-prod-v2`
2. 验证新 RDS 网络与参数
3. 导出旧库
4. 导入新库
5. 创建 / 确认 `repl` 用户
6. 切 AWS 应用到新 RDS
7. 验证应用
8. 改挂 `104` 到新 RDS
9. 验证从库
10. 收尾,并保留旧 RDS 待后续下线
+275
View File
@@ -0,0 +1,275 @@
# Hifast S3 备份集成 Runbook
本文档记录 `2026-05-13` 已经实际确认和完成的 S3 备份集成状态,以及后续如何把它和当前 `AWS 主生产 + 104 外部备用` 架构结合起来。
## 1. 当前已完成状态
- 已创建 S3 备份桶:`hifast-prod-backups-200810848252-ap-east-1`
- Region: `ap-east-1`
- Public access: blocked
- Versioning: enabled
- 已确认 RDS 主库 `hifast-mysql-prod-v2` 自动备份已启用:
- retention: `7 days`
- `2026-05-13` 已有自动快照
- 已确认 `104.238.220.230` MySQL 外部从库健康,可作为逻辑备份源:
- `Replica_IO_Running: Yes`
- `Replica_SQL_Running: Yes`
- `Seconds_Behind_Source: 0`
- 已确认 `104.238.220.230` Redis 从库健康,可作为 RDB 备份源:
- `role:slave`
- `master_link_status:up`
## 2. 推荐组合方案
当前最稳的做法不是只依赖一种备份,而是保留两层:
1. `RDS automated backup`
2. `104` 从库导出的逻辑备份上传到 S3
原因:
- RDS 自动备份适合快速恢复整库
- S3 逻辑备份适合独立下载、跨环境恢复、长期归档
-`104` 导出 MySQL / Redis 备份,对 AWS 主生产扰动最小
## 3. 仓库已补充内容
- MySQL 备份脚本:
- [`deploy/scripts/mysql_backup_to_s3.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/mysql_backup_to_s3.sh)
- Redis RDB 备份脚本:
- [`deploy/scripts/redis_rdb_backup_to_s3.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/redis_rdb_backup_to_s3.sh)
- 环境变量模板:
- [`deploy/aws/ap-east-1/configs/backup-to-s3.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/backup-to-s3.env.example)
- 主从切换环境变量模板:
- [`deploy/aws/ap-east-1/configs/replica-ops.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/replica-ops.env.example)
- 数据迁移交互工具:
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
- 主从运维交互工具:
- 统一入口仍使用 [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
## 4. 建议部署位置
### 4.1 MySQL
优先部署在 `104.238.220.230`
- 直接从本地 MySQL 从库导出
- 不占用 AWS RDS 主库的备份窗口
- 故障时备份和备用节点仍在同一台机器上
### 4.2 Redis
优先部署在 `104.238.220.230`
- 直接从 Redis 从库导出 `RDB`
- 不影响 AWS Redis 主库对外服务
## 5. 104 当前还缺的东西
截至 `2026-05-13``104` 已具备:
- `mysql`
- `mysqldump`
- `gzip`
但仍缺:
- `aws cli`
- 一套最小权限的 S3 上传凭证
## 6. 推荐权限模型
当前最实用的选择有两种:
1. `104` 使用专用 IAM 用户的最小权限 `AK/SK`
2. 未来如果备份转到 AWS EC2,再改为 `EC2 Instance Role`
按现有拓扑,推荐先走第 1 种,因为 MySQL 和 Redis 的备份源都在 `104`
### 6.1 最小权限策略示例
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListBackupBucket",
"Effect": "Allow",
"Action": [
"s3:ListBucket"
],
"Resource": "arn:aws:s3:::hifast-prod-backups-200810848252-ap-east-1"
},
{
"Sid": "WriteBackupObjects",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:AbortMultipartUpload"
],
"Resource": "arn:aws:s3:::hifast-prod-backups-200810848252-ap-east-1/*"
}
]
}
```
说明:
- 不建议给 `s3:*`
- 不建议把 AK/SK 写进 repo
- 建议只写入 `104` 本机的 `/root/.aws/credentials`
## 7. 104 安装与配置步骤
### 7.1 安装 `aws cli`
```bash
apt-get update
apt-get install -y awscli
```
### 7.2 写入 AWS 凭证
```bash
mkdir -p /root/.aws
chmod 700 /root/.aws
cat >/root/.aws/credentials <<'EOF'
[default]
aws_access_key_id=CHANGE_ME
aws_secret_access_key=CHANGE_ME
EOF
chmod 600 /root/.aws/credentials
```
### 7.3 准备环境变量文件
```bash
cp deploy/aws/ap-east-1/configs/backup-to-s3.env.example /root/backup-to-s3.env
chmod 600 /root/backup-to-s3.env
```
把下面这些值改成真实值:
- `MYSQL_PASSWORD`
- `REDIS_PASSWORD`
- `HOST_TAG`
如果 MySQL 就跑在 `104` 本机,可保持:
- `MYSQL_HOST=127.0.0.1`
如果 Redis 就跑在 `104` 本机,可保持:
- `REDIS_HOST=127.0.0.1`
## 8. 手工执行方式
### 8.1 MySQL 逻辑备份
```bash
set -a
source /root/backup-to-s3.env
set +a
bash deploy/scripts/mysql_backup_to_s3.sh
```
默认产物:
- 本地:`/var/backups/hifast/*.sql.gz`
- S3`s3://hifast-prod-backups-200810848252-ap-east-1/mysql/<HOST_TAG>/`
### 8.2 Redis RDB 备份
```bash
set -a
source /root/backup-to-s3.env
set +a
S3_PREFIX=redis bash deploy/scripts/redis_rdb_backup_to_s3.sh
```
默认产物:
- 本地:`/var/backups/hifast/*.rdb.gz`
- S3`s3://hifast-prod-backups-200810848252-ap-east-1/redis/<HOST_TAG>/`
## 9. 定时任务建议
### 9.1 MySQL 每天凌晨执行
```cron
15 3 * * * . /root/backup-to-s3.env && /bin/bash /opt/ppanel/deploy/scripts/mysql_backup_to_s3.sh >>/var/log/hifast-mysql-backup.log 2>&1
```
### 9.2 Redis 每天凌晨执行
```cron
45 3 * * * . /root/backup-to-s3.env && S3_PREFIX=redis /bin/bash /opt/ppanel/deploy/scripts/redis_rdb_backup_to_s3.sh >>/var/log/hifast-redis-backup.log 2>&1
```
说明:
- MySQL 和 Redis 建议错峰执行
- 本地临时文件默认只保留 `3`
- 长期保留建议通过 S3 Lifecycle 管理,而不是靠本机 cron 删除
## 10. 恢复思路
### 10.1 MySQL
1. 从 S3 下载目标 `sql.gz`
2. 校验 `.sha256`
3. 解压
4. 导入目标 MySQL
示例:
```bash
aws s3 cp s3://hifast-prod-backups-200810848252-ap-east-1/mysql/104-standby/20260513T120000Z_104-standby_hifast.sql.gz .
aws s3 cp s3://hifast-prod-backups-200810848252-ap-east-1/mysql/104-standby/20260513T120000Z_104-standby_hifast.sql.gz.sha256 .
sha256sum -c 20260513T120000Z_104-standby_hifast.sql.gz.sha256
gunzip -c 20260513T120000Z_104-standby_hifast.sql.gz | mysql -h <target-host> -u <user> -p
```
### 10.2 Redis
1. 从 S3 下载目标 `rdb.gz`
2. 校验 `.sha256`
3. 解压得到 `dump.rdb`
4. 在维护窗口内替换 Redis 数据文件后重启
## 11. 当前推荐落地顺序
1. 保持当前 `RDS automated backup` 不变
2.`104` 安装 `aws cli`
3. 创建最小权限 S3 上传凭证并仅保存到 `104`
4. 先手工执行一轮 MySQL 备份上传
5. 验证 S3 对象、校验文件、恢复可读性
6. 再补 Redis RDB 备份
7. 最后加 cron 和 S3 Lifecycle
## 12. 104 上的交互式运维脚本
为了减少手工敲命令,仓库里现在统一使用一套交互式总入口。
### 12.1 统一入口
```bash
bash deploy/scripts/hifast_data_sync_tool.sh /root/replica-ops.env
```
菜单支持:
- 备份 MySQL 到 S3
- 备份 Redis 到 S3
- 导出 MySQL dump
- 导入 MySQL dump 到 AWS RDS
- 导出后直接导入
- 导出 Redis RDB
- 导入 Redis RDB 到 Docker / 宿主机 Redis
- 查看 MySQL / Redis 当前主从状态
- 强制重拉 MySQL 主从
- 强制重拉 Redis 主从
- 把 MySQL 从库提升为可写主库
- 把 Redis 从库提升为主库
-183
View File
@@ -1,183 +0,0 @@
package cache
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// testUser is a simple struct used across all QueryCtx tests.
type testUser struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
// setupCachedConn creates a CachedConn backed by a real miniredis instance
// and a bare *gorm.DB (no real database connection needed because the
// QueryCtxFn callback is fully under our control).
func setupCachedConn(t *testing.T) (CachedConn, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
})
t.Cleanup(func() { rdb.Close() })
// Use SQLite in-memory to get a properly initialized *gorm.DB.
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
cc := NewConn(db, rdb, WithExpiry(time.Minute))
return cc, mr
}
func TestQueryCtx_CacheHit(t *testing.T) {
cc, mr := setupCachedConn(t)
ctx := context.Background()
key := "cache:user:1"
// Pre-populate the cache with valid JSON.
expected := testUser{ID: 1, Name: "Alice"}
data, err := json.Marshal(expected)
require.NoError(t, err)
mr.Set(key, string(data))
// Track whether the DB query function is called.
dbCalled := false
queryFn := func(conn *gorm.DB, v interface{}) error {
dbCalled = true
return nil
}
var result testUser
err = cc.QueryCtx(ctx, &result, key, queryFn)
assert.NoError(t, err)
assert.False(t, dbCalled, "DB query should NOT be called on cache hit")
assert.Equal(t, expected.ID, result.ID)
assert.Equal(t, expected.Name, result.Name)
}
func TestQueryCtx_CacheMiss_QueriesDB_SetsCache(t *testing.T) {
cc, mr := setupCachedConn(t)
ctx := context.Background()
key := "cache:user:2"
// Do NOT pre-populate the cache -- this is a cache miss scenario.
dbCalled := false
queryFn := func(conn *gorm.DB, v interface{}) error {
dbCalled = true
u := v.(*testUser)
u.ID = 2
u.Name = "Bob"
return nil
}
var result testUser
err := cc.QueryCtx(ctx, &result, key, queryFn)
assert.NoError(t, err)
assert.True(t, dbCalled, "DB query should be called on cache miss")
assert.Equal(t, int64(2), result.ID)
assert.Equal(t, "Bob", result.Name)
// Verify the value was written back to cache.
cached, cacheErr := mr.Get(key)
require.NoError(t, cacheErr)
var cachedUser testUser
require.NoError(t, json.Unmarshal([]byte(cached), &cachedUser))
assert.Equal(t, int64(2), cachedUser.ID)
assert.Equal(t, "Bob", cachedUser.Name)
}
func TestQueryCtx_CorruptedCache_SelfHeals(t *testing.T) {
cc, mr := setupCachedConn(t)
ctx := context.Background()
key := "cache:user:3"
// Store invalid JSON in the cache to simulate corruption.
mr.Set(key, "THIS IS NOT VALID JSON{{{")
dbCalled := false
queryFn := func(conn *gorm.DB, v interface{}) error {
dbCalled = true
u := v.(*testUser)
u.ID = 3
u.Name = "Charlie"
return nil
}
var result testUser
err := cc.QueryCtx(ctx, &result, key, queryFn)
assert.NoError(t, err)
assert.True(t, dbCalled, "DB query should be called when cache is corrupted")
assert.Equal(t, int64(3), result.ID)
assert.Equal(t, "Charlie", result.Name)
// Verify the corrupt key was replaced with valid data.
cached, cacheErr := mr.Get(key)
require.NoError(t, cacheErr)
var cachedUser testUser
require.NoError(t, json.Unmarshal([]byte(cached), &cachedUser))
assert.Equal(t, int64(3), cachedUser.ID)
assert.Equal(t, "Charlie", cachedUser.Name)
}
func TestQueryCtx_CacheMiss_DBFails_ReturnsError(t *testing.T) {
cc, mr := setupCachedConn(t)
ctx := context.Background()
key := "cache:user:4"
// No cache entry -- this is a miss.
dbErr := errors.New("connection refused")
queryFn := func(conn *gorm.DB, v interface{}) error {
return dbErr
}
var result testUser
err := cc.QueryCtx(ctx, &result, key, queryFn)
assert.Error(t, err)
assert.Equal(t, dbErr, err)
// Cache should remain empty -- no value was written.
assert.False(t, mr.Exists(key), "cache should NOT be set when DB query fails")
}
func TestQueryCtx_CorruptedCache_DBFails_ReturnsError(t *testing.T) {
cc, mr := setupCachedConn(t)
ctx := context.Background()
key := "cache:user:5"
// Store invalid JSON to trigger the corruption branch.
mr.Set(key, "<<<CORRUPT>>>")
dbErr := errors.New("database is down")
queryFn := func(conn *gorm.DB, v interface{}) error {
return dbErr
}
var result testUser
err := cc.QueryCtx(ctx, &result, key, queryFn)
assert.Error(t, err)
assert.Equal(t, dbErr, err)
// The corrupt key should have been deleted (DelCache was called),
// and no new value was set because the DB query failed.
assert.False(t, mr.Exists(key), "corrupt key should be deleted even when DB fails")
}
@@ -1,806 +0,0 @@
package orderLogic
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/perfect-panel/server/internal/config"
userLogic "github.com/perfect-panel/server/internal/logic/public/user"
modelLog "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
"github.com/redis/go-redis/v9"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// 普通用户 + 首单 → 双方赠N天
func TestHandleCommission_GrantGiftDaysWhenCommissionDisabled_FirstOrder(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 0,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referee.RefererId = referer.Id
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
refererSub := seedActiveSubscribe(t, db, referer.Id, baseExpire)
logic.handleCommission(context.Background(), referee, &order.Order{
OrderNo: "ORD-GIFT-001",
Type: OrderTypeSubscribe,
IsNew: true, // 首单
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
})
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 2)
assertExpireIncreasedByDays(t, db, refererSub.Id, baseExpire, 2)
var giftCount int64
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
t.Fatalf("count gift logs failed: %v", err)
}
if giftCount != 2 {
t.Fatalf("expected 2 gift logs, got %d", giftCount)
}
}
// 普通用户 + 非首单 → 不赠送
func TestHandleCommission_NoGiftDaysWhenCommissionDisabled_NotFirstOrder(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 0,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referee.RefererId = referer.Id
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
refererSub := seedActiveSubscribe(t, db, referer.Id, baseExpire)
logic.handleCommission(context.Background(), referee, &order.Order{
OrderNo: "ORD-GIFT-002",
Type: OrderTypeSubscribe,
IsNew: false, // 非首单
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
})
// 到期时间不应延长
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
assertExpireIncreasedByDays(t, db, refererSub.Id, baseExpire, 0)
var giftCount int64
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
t.Fatalf("count gift logs failed: %v", err)
}
if giftCount != 0 {
t.Fatalf("expected 0 gift logs for non-first order, got %d", giftCount)
}
}
// 渠道 + 首单 → 被邀请人赠N天 + 邀请人获佣金
func TestHandleCommission_GiftDaysAndCommissionWhenChannelFirstOrder(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 10,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referee.RefererId = referer.Id
baseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
logic.handleCommission(context.Background(), referee, &order.Order{
OrderNo: "ORD-COMM-001",
Type: OrderTypeSubscribe,
IsNew: true, // 首单
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
})
// 被邀请人(首单)应获得赠送天数
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 2)
// 邀请人应获得佣金
var refererAfter user.User
if err := db.First(&refererAfter, referer.Id).Error; err != nil {
t.Fatalf("query referer failed: %v", err)
}
if refererAfter.Commission != 10 {
t.Fatalf("expected referer commission=10, got %d", refererAfter.Commission)
}
var giftCount int64
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
t.Fatalf("count gift logs failed: %v", err)
}
if giftCount != 1 {
t.Fatalf("expected 1 gift log for referee on first order with commission, got %d", giftCount)
}
}
// 渠道 + 非首单 → 只给邀请人佣金,不赠天
func TestHandleCommission_OnlyCommissionWhenChannelNotFirstOrder(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 10,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referee.RefererId = referer.Id
baseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
logic.handleCommission(context.Background(), referee, &order.Order{
OrderNo: "ORD-COMM-002",
Type: OrderTypeSubscribe,
IsNew: false, // 非首单
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
})
// 被邀请人不应获得赠送天数
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
// 邀请人应获得佣金
var refererAfter user.User
if err := db.First(&refererAfter, referer.Id).Error; err != nil {
t.Fatalf("query referer failed: %v", err)
}
if refererAfter.Commission != 10 {
t.Fatalf("expected referer commission=10, got %d", refererAfter.Commission)
}
var giftCount int64
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
t.Fatalf("count gift logs failed: %v", err)
}
if giftCount != 0 {
t.Fatalf("expected 0 gift logs when channel non-first order, got %d", giftCount)
}
}
func TestHandleCommission_NoGiftDaysWhenNoInviteRelation(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 0,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
// 没有邀请人的独立用户
loneUser := seedUser(t, db, 0, false)
// RefererId == 0,无邀请关系
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
loneSub := seedActiveSubscribe(t, db, loneUser.Id, baseExpire)
logic.handleCommission(context.Background(), loneUser, &order.Order{
OrderNo: "ORD-LONE-001",
Type: OrderTypeSubscribe,
IsNew: true,
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
})
// 订阅到期时间不应该被延长
var subAfter user.Subscribe
if err := db.First(&subAfter, loneSub.Id).Error; err != nil {
t.Fatalf("query subscribe failed: %v", err)
}
if !subAfter.ExpireTime.Equal(baseExpire) {
t.Fatalf("expected no gift days for user without inviter, before=%v after=%v", baseExpire, subAfter.ExpireTime)
}
// 不应产生赠天日志
var giftCount int64
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
t.Fatalf("count gift logs failed: %v", err)
}
if giftCount != 0 {
t.Fatalf("expected 0 gift logs for user without inviter, got %d", giftCount)
}
}
// 先绑码后首单 → 双方赠N天
func TestInviteFlow_BindThenFirstOrder_GrantGiftDays(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 0,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referer.ReferCode = fmt.Sprintf("REF-%d", referer.Id)
if err := db.Model(&user.User{}).Where("id = ?", referer.Id).Update("refer_code", referer.ReferCode).Error; err != nil {
t.Fatalf("update referer code failed: %v", err)
}
refereeBaseExpire := time.Now().Add(48 * time.Hour).Truncate(time.Second)
refererBaseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, refereeBaseExpire)
refererSub := seedActiveSubscribe(t, db, referer.Id, refererBaseExpire)
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, referee)
bindLogic := userLogic.NewBindInviteCodeLogic(ctx, logic.svc)
if err := bindLogic.BindInviteCode(&types.BindInviteCodeRequest{InviteCode: referer.ReferCode}); err != nil {
t.Fatalf("bind invite code failed: %v", err)
}
var refereeAfterBind user.User
if err := db.First(&refereeAfterBind, referee.Id).Error; err != nil {
t.Fatalf("query referee after bind failed: %v", err)
}
if refereeAfterBind.RefererId != referer.Id {
t.Fatalf("bind invite failed, expected referer_id=%d got=%d", referer.Id, refereeAfterBind.RefererId)
}
// 首单 IsNew=true → 双方赠N天
logic.handleCommission(context.Background(), &refereeAfterBind, &order.Order{
OrderNo: "ORD-FLOW-001",
Type: OrderTypeSubscribe,
IsNew: true,
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
})
assertExpireIncreasedByDays(t, db, refereeSub.Id, refereeBaseExpire, 2)
assertExpireIncreasedByDays(t, db, refererSub.Id, refererBaseExpire, 2)
}
// 先买订单后绑码再续费 → 不赠送(IsNew=false
func TestInviteFlow_OrderThenBind_NoGiftDays(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 0,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referee.RefererId = referer.Id
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
refererSub := seedActiveSubscribe(t, db, referer.Id, baseExpire)
// 先前已有订单,IsNew=false(模拟先买订单后绑码的场景)
logic.handleCommission(context.Background(), referee, &order.Order{
OrderNo: "ORD-FLOW-002",
Type: OrderTypeSubscribe,
IsNew: false, // 已有历史订单
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
})
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
assertExpireIncreasedByDays(t, db, refererSub.Id, baseExpire, 0)
}
func TestHandleCommission_GiftDaysToRefereeFamilyOwnerWhenChannelFirstOrder(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 10,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
refereeOwner := seedUser(t, db, 0, false)
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
seedFamily(t, db, refereeOwner.Id, referee.Id)
referee.RefererId = referer.Id
ownerBaseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
ownerSub := seedActiveSubscribe(t, db, refereeOwner.Id, ownerBaseExpire)
logic.handleCommission(context.Background(), referee, &order.Order{
OrderNo: "ORD-FAMILY-REFEREE-001",
Type: OrderTypeSubscribe,
IsNew: true,
Amount: 100,
FeeAmount: 0,
SubscriptionUserId: refereeOwner.Id,
CreatedAt: time.Now(),
})
assertExpireIncreasedByDays(t, db, ownerSub.Id, ownerBaseExpire, 2)
assertUserCommission(t, db, referer.Id, 10)
}
func TestHandleCommission_GiftDaysToRefererFamilyOwnerWhenCommissionDisabled(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 0,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
refererOwner := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
seedFamily(t, db, refererOwner.Id, referer.Id)
referee.RefererId = referer.Id
refereeBaseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
refererOwnerBaseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, refereeBaseExpire)
refererOwnerSub := seedActiveSubscribe(t, db, refererOwner.Id, refererOwnerBaseExpire)
logic.handleCommission(context.Background(), referee, &order.Order{
OrderNo: "ORD-FAMILY-REFERER-001",
Type: OrderTypeSubscribe,
IsNew: true,
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
})
assertExpireIncreasedByDays(t, db, refereeSub.Id, refereeBaseExpire, 2)
assertExpireIncreasedByDays(t, db, refererOwnerSub.Id, refererOwnerBaseExpire, 2)
}
func TestHandleCommission_RefererFamilyMemberCommissionBehaviorUnchanged(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 10,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
refererOwner := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
seedFamily(t, db, refererOwner.Id, referer.Id)
referee.RefererId = referer.Id
refereeBaseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, refereeBaseExpire)
logic.handleCommission(context.Background(), referee, &order.Order{
OrderNo: "ORD-FAMILY-COMMISSION-001",
Type: OrderTypeSubscribe,
IsNew: true,
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
})
assertExpireIncreasedByDays(t, db, refereeSub.Id, refereeBaseExpire, 2)
assertUserCommission(t, db, referer.Id, 10)
assertUserCommission(t, db, refererOwner.Id, 0)
}
func TestHandleCommission_GiftDaysRecognizesUnlimitedSubscription(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 10,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referee.RefererId = referer.Id
unlimitedExpire := time.UnixMilli(0)
refereeSub := seedActiveSubscribe(t, db, referee.Id, unlimitedExpire)
logic.handleCommission(context.Background(), referee, &order.Order{
OrderNo: "ORD-UNLIMITED-001",
Type: OrderTypeSubscribe,
IsNew: true,
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
})
assertExpireIncreasedByDays(t, db, refereeSub.Id, unlimitedExpire, 0)
var giftCount int64
if err := db.Model(&modelLog.SystemLog{}).Where("type = ? AND object_id = ?", modelLog.TypeGift.Uint8(), referee.Id).Count(&giftCount).Error; err != nil {
t.Fatalf("count gift logs failed: %v", err)
}
if giftCount != 1 {
t.Fatalf("expected 1 gift log for unlimited subscription, got %d", giftCount)
}
}
func TestHandleCommission_IdempotentForRepeatedActivation(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 10,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referee.RefererId = referer.Id
baseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
orderInfo := &order.Order{
OrderNo: "ORD-IDEMPOTENT-001",
Type: OrderTypeSubscribe,
IsNew: true,
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
}
logic.handleCommission(context.Background(), referee, orderInfo)
logic.handleCommission(context.Background(), referee, orderInfo)
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 2)
assertUserCommission(t, db, referer.Id, 10)
assertLogCountForOrder(t, db, modelLog.TypeCommission.Uint8(), orderInfo.OrderNo, 1)
assertLogCountForOrder(t, db, modelLog.TypeGift.Uint8(), orderInfo.OrderNo, 1)
}
func TestHandleCommission_NoActiveSubscriptionWritesSkippedGiftLog(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 10,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referee.RefererId = referer.Id
orderInfo := &order.Order{
OrderNo: "ORD-SKIPPED-001",
Type: OrderTypeSubscribe,
IsNew: true,
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
}
logic.handleCommission(context.Background(), referee, orderInfo)
assertUserCommission(t, db, referer.Id, 10)
assertLogCountForOrder(t, db, modelLog.TypeCommission.Uint8(), orderInfo.OrderNo, 1)
assertLogCountForOrder(t, db, modelLog.TypeGift.Uint8(), orderInfo.OrderNo, 1)
assertGiftLogRemarkContains(t, db, orderInfo.OrderNo, "skipped: no active subscription")
}
func TestHandleCommission_GiftDaysZeroDoesNotWriteGiftLog(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 10,
OnlyFirstPurchase: false,
GiftDays: 0,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referee.RefererId = referer.Id
baseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
orderInfo := &order.Order{
OrderNo: "ORD-GIFT-ZERO-001",
Type: OrderTypeSubscribe,
IsNew: true,
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
}
logic.handleCommission(context.Background(), referee, orderInfo)
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
assertUserCommission(t, db, referer.Id, 10)
assertLogCountForOrder(t, db, modelLog.TypeCommission.Uint8(), orderInfo.OrderNo, 1)
assertLogCountForOrder(t, db, modelLog.TypeGift.Uint8(), orderInfo.OrderNo, 0)
}
func TestHandleCommission_GlobalOnlyFirstPurchaseBlocksCommissionAndGiftForNonFirstOrder(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 10,
OnlyFirstPurchase: true,
GiftDays: 2,
})
defer cleanup()
referee := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
referee.RefererId = referer.Id
baseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
orderInfo := &order.Order{
OrderNo: "ORD-NONFIRST-GLOBAL-001",
Type: OrderTypeRenewal,
IsNew: false,
Amount: 100,
FeeAmount: 0,
CreatedAt: time.Now(),
}
logic.handleCommission(context.Background(), referee, orderInfo)
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
assertUserCommission(t, db, referer.Id, 0)
assertLogCountForOrder(t, db, modelLog.TypeCommission.Uint8(), orderInfo.OrderNo, 0)
assertLogCountForOrder(t, db, modelLog.TypeGift.Uint8(), orderInfo.OrderNo, 0)
}
func TestHandleCommission_BothFamilySidesUseCorrectGiftOwners(t *testing.T) {
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
ReferralPercentage: 0,
OnlyFirstPurchase: false,
GiftDays: 2,
})
defer cleanup()
refereeOwner := seedUser(t, db, 0, false)
referee := seedUser(t, db, 0, false)
refererOwner := seedUser(t, db, 0, false)
referer := seedUser(t, db, 0, false)
seedFamily(t, db, refereeOwner.Id, referee.Id)
seedFamily(t, db, refererOwner.Id, referer.Id)
referee.RefererId = referer.Id
refereeOwnerBaseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
refererOwnerBaseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
refereeOwnerSub := seedActiveSubscribe(t, db, refereeOwner.Id, refereeOwnerBaseExpire)
refererOwnerSub := seedActiveSubscribe(t, db, refererOwner.Id, refererOwnerBaseExpire)
orderInfo := &order.Order{
OrderNo: "ORD-BOTH-FAMILIES-001",
Type: OrderTypeSubscribe,
IsNew: true,
Amount: 100,
FeeAmount: 0,
SubscriptionUserId: refereeOwner.Id,
CreatedAt: time.Now(),
}
logic.handleCommission(context.Background(), referee, orderInfo)
assertExpireIncreasedByDays(t, db, refereeOwnerSub.Id, refereeOwnerBaseExpire, 2)
assertExpireIncreasedByDays(t, db, refererOwnerSub.Id, refererOwnerBaseExpire, 2)
assertNoSubscribeForUser(t, db, referee.Id)
assertNoSubscribeForUser(t, db, referer.Id)
assertLogCountForOrder(t, db, modelLog.TypeGift.Uint8(), orderInfo.OrderNo, 2)
}
func setupInviteTestLogic(t *testing.T, inviteCfg config.InviteConfig) (*ActivateOrderLogic, *gorm.DB, func()) {
t.Helper()
mysqlAddr := getenvDefault("TEST_MYSQL_ADDR", "127.0.0.1:3306")
mysqlUser := getenvDefault("TEST_MYSQL_USER", "root")
mysqlPassword := getenvDefault("TEST_MYSQL_PASSWORD", "rootpassword")
adminDSN := fmt.Sprintf("%s:%s@tcp(%s)/?charset=utf8mb4&parseTime=true&loc=Local&multiStatements=true", mysqlUser, mysqlPassword, mysqlAddr)
adminDB, err := gorm.Open(mysql.Open(adminDSN), &gorm.Config{})
if err != nil {
t.Fatalf("open mysql admin connection failed: %v", err)
}
dbName := fmt.Sprintf("ppanel_test_invite_%d", time.Now().UnixNano())
if err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci", dbName)).Error; err != nil {
t.Fatalf("create test database failed: %v", err)
}
testDSN := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=true&loc=Local", mysqlUser, mysqlPassword, mysqlAddr, dbName)
db, err := gorm.Open(mysql.Open(testDSN), &gorm.Config{})
if err != nil {
t.Fatalf("open test database failed: %v", err)
}
if err := db.AutoMigrate(&user.User{}, &user.Device{}, &user.AuthMethods{}, &user.Subscribe{}, &user.UserFamily{}, &user.UserFamilyMember{}, &modelLog.SystemLog{}); err != nil {
t.Fatalf("auto migrate failed: %v", err)
}
redisAddr := getenvDefault("TEST_REDIS_ADDR", "127.0.0.1:6379")
redisPassword := getenvDefault("TEST_REDIS_PASSWORD", "")
rdb := redis.NewClient(&redis.Options{
Addr: redisAddr,
Password: redisPassword,
DB: 0,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
t.Fatalf("connect redis failed: %v", err)
}
_ = rdb.FlushDB(context.Background()).Err()
svcCtx := &svc.ServiceContext{
DB: db,
Redis: rdb,
UserModel: user.NewModel(db, rdb),
LogModel: modelLog.NewModel(db),
Config: config.Config{
Invite: inviteCfg,
},
}
return NewActivateOrderLogic(svcCtx), db, func() {
_ = rdb.Close()
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
_ = adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", dbName)).Error
}
}
func seedUser(t *testing.T, db *gorm.DB, referralPercentage uint8, onlyFirstPurchase bool) *user.User {
t.Helper()
u := &user.User{
Password: "pwd",
Algo: "default",
ReferralPercentage: referralPercentage,
OnlyFirstPurchase: boolPtr(onlyFirstPurchase),
Enable: boolPtr(true),
IsAdmin: boolPtr(false),
EnableBalanceNotify: boolPtr(false),
EnableLoginNotify: boolPtr(false),
EnableSubscribeNotify: boolPtr(false),
EnableTradeNotify: boolPtr(false),
}
if err := db.Create(u).Error; err != nil {
t.Fatalf("seed user failed: %v", err)
}
return u
}
func seedFamily(t *testing.T, db *gorm.DB, ownerID int64, memberID int64) {
t.Helper()
family := &user.UserFamily{
OwnerUserId: ownerID,
MaxMembers: 3,
Status: user.FamilyStatusActive,
}
if err := db.Create(family).Error; err != nil {
t.Fatalf("seed family failed: %v", err)
}
now := time.Now()
members := []user.UserFamilyMember{
{
FamilyId: family.Id,
UserId: ownerID,
Role: user.FamilyRoleOwner,
Status: user.FamilyMemberActive,
JoinSource: "test",
JoinedAt: now,
},
{
FamilyId: family.Id,
UserId: memberID,
Role: user.FamilyRoleMember,
Status: user.FamilyMemberActive,
JoinSource: "test",
JoinedAt: now,
},
}
if err := db.Create(&members).Error; err != nil {
t.Fatalf("seed family members failed: %v", err)
}
}
func seedActiveSubscribe(t *testing.T, db *gorm.DB, userID int64, expireAt time.Time) *user.Subscribe {
t.Helper()
sub := &user.Subscribe{
UserId: userID,
OrderId: 1,
SubscribeId: 1,
StartTime: time.Now().Add(-24 * time.Hour),
ExpireTime: expireAt,
Traffic: 1024,
Token: fmt.Sprintf("token-%d-%d", userID, time.Now().UnixNano()),
UUID: fmt.Sprintf("uuid-%d-%d", userID, time.Now().UnixNano()),
Status: 1,
}
if err := db.Create(sub).Error; err != nil {
t.Fatalf("seed subscribe failed: %v", err)
}
return sub
}
func assertExpireIncreasedByDays(t *testing.T, db *gorm.DB, subscribeID int64, before time.Time, days int) {
t.Helper()
var after user.Subscribe
if err := db.First(&after, subscribeID).Error; err != nil {
t.Fatalf("query subscribe failed: %v", err)
}
expected := before.Add(time.Duration(days) * 24 * time.Hour)
if !after.ExpireTime.Equal(expected) {
t.Fatalf("expire time mismatch, expected=%v got=%v", expected, after.ExpireTime)
}
}
func assertUserCommission(t *testing.T, db *gorm.DB, userID int64, expected int64) {
t.Helper()
var u user.User
if err := db.First(&u, userID).Error; err != nil {
t.Fatalf("query user failed: %v", err)
}
if u.Commission != expected {
t.Fatalf("expected user %d commission=%d, got %d", userID, expected, u.Commission)
}
}
func assertLogCountForOrder(t *testing.T, db *gorm.DB, logType uint8, orderNo string, expected int64) {
t.Helper()
var count int64
if err := db.Model(&modelLog.SystemLog{}).
Where("type = ? AND content LIKE ?", logType, "%"+orderNo+"%").
Count(&count).Error; err != nil {
t.Fatalf("count logs failed: %v", err)
}
if count != expected {
t.Fatalf("expected log type %d count=%d for order %s, got %d", logType, expected, orderNo, count)
}
}
func assertGiftLogRemarkContains(t *testing.T, db *gorm.DB, orderNo string, want string) {
t.Helper()
var row modelLog.SystemLog
if err := db.Model(&modelLog.SystemLog{}).
Where("type = ? AND content LIKE ?", modelLog.TypeGift.Uint8(), "%"+orderNo+"%").
First(&row).Error; err != nil {
t.Fatalf("query gift log failed: %v", err)
}
if !strings.Contains(row.Content, want) {
t.Fatalf("expected gift log content to contain %q, got %s", want, row.Content)
}
}
func assertNoSubscribeForUser(t *testing.T, db *gorm.DB, userID int64) {
t.Helper()
var count int64
if err := db.Model(&user.Subscribe{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
t.Fatalf("count subscribes failed: %v", err)
}
if count != 0 {
t.Fatalf("expected user %d to have no direct subscriptions, got %d", userID, count)
}
}
func boolPtr(v bool) *bool {
return &v
}
func getenvDefault(key, fallback string) string {
v := os.Getenv(key)
if v == "" {
return fallback
}
return v
}
@@ -1,199 +0,0 @@
package orderLogic
import (
"context"
"testing"
"time"
modelOrder "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/user"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupActivationEligibilityDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
sqls := []string{
`CREATE TABLE IF NOT EXISTS "user" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
password VARCHAR(100) NOT NULL DEFAULT '',
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME DEFAULT NULL
)`,
`CREATE TABLE IF NOT EXISTS "user_device" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
identifier VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
created_at DATETIME,
updated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS "user_family" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_user_id INTEGER NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1,
deleted_at DATETIME DEFAULT NULL
)`,
`CREATE TABLE IF NOT EXISTS "user_family_member" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
family_id INTEGER NOT NULL DEFAULT 0,
user_id INTEGER NOT NULL DEFAULT 0,
role TINYINT NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 0,
join_source VARCHAR(32) NOT NULL DEFAULT '',
deleted_at DATETIME DEFAULT NULL
)`,
`CREATE TABLE IF NOT EXISTS "order" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
order_no VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
type TINYINT NOT NULL DEFAULT 1,
status TINYINT NOT NULL DEFAULT 1,
subscribe_id INTEGER NOT NULL DEFAULT 0,
quantity INTEGER NOT NULL DEFAULT 1,
created_at DATETIME,
updated_at DATETIME
)`,
}
for _, sql := range sqls {
require.NoError(t, db.Exec(sql).Error)
}
return db
}
func insertActivationUser(t *testing.T, db *gorm.DB, userID int64, createdAt time.Time) {
t.Helper()
require.NoError(t, db.Exec(
`INSERT INTO "user" (id, created_at, updated_at) VALUES (?, ?, datetime('now'))`,
userID,
createdAt.UTC().Format("2006-01-02 15:04:05"),
).Error)
}
func insertActivationDevice(t *testing.T, db *gorm.DB, userID int64, identifier string, createdAt time.Time) {
t.Helper()
require.NoError(t, db.Exec(
`INSERT INTO "user_device" (user_id, identifier, created_at, updated_at) VALUES (?, ?, ?, datetime('now'))`,
userID,
identifier,
createdAt.UTC().Format("2006-01-02 15:04:05"),
).Error)
}
func insertActivationFamily(t *testing.T, db *gorm.DB, familyID, ownerUserID int64) {
t.Helper()
require.NoError(t, db.Exec(
`INSERT INTO "user_family" (id, owner_user_id, status) VALUES (?, ?, 1)`,
familyID,
ownerUserID,
).Error)
}
func insertActivationFamilyMember(t *testing.T, db *gorm.DB, familyID, userID int64, role, status uint8, joinSource string) {
t.Helper()
require.NoError(t, db.Exec(
`INSERT INTO "user_family_member" (family_id, user_id, role, status, join_source) VALUES (?, ?, ?, ?, ?)`,
familyID,
userID,
role,
status,
joinSource,
).Error)
}
func insertActivationOrder(t *testing.T, db *gorm.DB, orderNo string, userID, subscribeID int64, status uint8) {
t.Helper()
require.NoError(t, db.Exec(
`INSERT INTO "order" (user_id, order_no, type, status, subscribe_id, quantity, created_at, updated_at)
VALUES (?, ?, 1, ?, ?, 1, datetime('now'), datetime('now'))`,
userID,
orderNo,
status,
subscribeID,
).Error)
}
func TestValidateNewUserOnlyEligibilityAtActivation_UsesEarliestBoundDeviceTime(t *testing.T) {
db := setupActivationEligibilityDB(t)
const (
ownerUserID = int64(1)
memberUserID = int64(2)
familyID = int64(10)
subscribeID = int64(100)
)
insertActivationUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertActivationUser(t, db, memberUserID, time.Now().Add(-72*time.Hour))
insertActivationDevice(t, db, memberUserID, "activation-old-device", time.Now().Add(-72*time.Hour))
insertActivationFamily(t, db, familyID, ownerUserID)
insertActivationFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertActivationFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
err := validateNewUserOnlyEligibilityAtActivation(
context.Background(),
db,
&modelOrder.Order{
UserId: ownerUserID,
OrderNo: "activation-check-old-device",
Type: OrderTypeSubscribe,
Quantity: 1,
SubscribeId: subscribeID,
},
&subscribe.Subscribe{
Id: subscribeID,
Discount: `[{"quantity":1,"discount":90,"new_user_only":true}]`,
},
)
require.Error(t, err)
require.Contains(t, err.Error(), "is not a new user")
}
func TestValidateNewUserOnlyEligibilityAtActivation_SharesHistoryAcrossBoundScope(t *testing.T) {
db := setupActivationEligibilityDB(t)
const (
ownerUserID = int64(11)
memberUserID = int64(12)
familyID = int64(20)
subscribeID = int64(200)
)
insertActivationUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertActivationUser(t, db, memberUserID, time.Now().Add(-2*time.Hour))
insertActivationDevice(t, db, memberUserID, "activation-shared-device", time.Now().Add(-2*time.Hour))
insertActivationFamily(t, db, familyID, ownerUserID)
insertActivationFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertActivationFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
insertActivationOrder(t, db, "previous-finished-order", memberUserID, subscribeID, OrderStatusFinished)
err := validateNewUserOnlyEligibilityAtActivation(
context.Background(),
db,
&modelOrder.Order{
UserId: ownerUserID,
OrderNo: "current-paid-order",
Type: OrderTypeSubscribe,
Quantity: 1,
SubscribeId: subscribeID,
},
&subscribe.Subscribe{
Id: subscribeID,
Discount: `[{"quantity":1,"discount":90,"new_user_only":true}]`,
},
)
require.Error(t, err)
require.Contains(t, err.Error(), "already activated")
}
-128
View File
@@ -1,128 +0,0 @@
═══════════════════════════════════════════════════════════
代理 ID : 31618(注册于 2026-05-03 10:36:16
统计起点 : 2026-05-03 15:37:18(首次邀请时间)
统计截止 : 2026-05-07 02:02:52(最后邀请时间)
统计天数 : 3.43 天
历史邀请总人数 : 49 人
下线总订单数 : 11 单(所有下线,不限时间)
日均订单 : 3.2029 单/天
每用户平均订单 : 1.0000 单
历史佣金总额 : $46.30
日均佣金 : $13.4812
每用户平均佣金 : $0.9449
佣金规则 : 50% 仅首单=false
───────────────────────────────────────────────────────────
丢失时长 : 22.42 小时(2026-05-07 02:02:52 → 现在)
预估丢失佣金 : $12.5914$13.4812 × 22.42/24
目标补偿金额 : $15.1097(× 120%
需补充人数 : 16 人($15.1097 ÷ $0.9449
═══════════════════════════════════════════════════════════
自然流量用户池(2025-05-04 00:00 ~ 2026-05-08 00:27referer_id=0,有已支付订单):共 2420 人
随机抽取 16 个用户(含待追溯佣金订单):
═══════════════════════════════════════════════════════════════════════════
[1] 用户 19282 注册: 2026-04-22 21:48:47
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
20260422214951826095981429 $ 0.99 $ 0.00 $ 0.49 首购
202604302302367096852027224 $ 5.99 $ 0.00 $ 2.99 续费
本用户追溯佣金合计: $3.48
[2] 用户 22675 注册: 2026-04-26 13:05:32
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604270056552342453283470 $ 0.99 $ 0.00 $ 0.49 首购
本用户追溯佣金合计: $0.49
[3] 用户 676 注册: 2026-03-29 16:49:34
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202603302144244505020334929 $ 0.99 $ 0.00 $ 0.49 续费
本用户追溯佣金合计: $0.49
[4] 用户 23501 注册: 2026-04-27 22:17:59
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604272218253888000515243 $ 0.99 $ 0.00 $ 0.49 首购
本用户追溯佣金合计: $0.49
[5] 用户 18921 注册: 2026-04-22 14:48:31
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604240030589405703117541 $ 2.79 $ 0.00 $ 1.39 首购
202605020135558835271794030 $ 12.99 $ 0.00 $ 6.49 续费
本用户追溯佣金合计: $7.88
[6] 用户 21300 注册: 2026-04-24 21:58:49
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604242201505521613971320 $ 0.99 $ 0.00 $ 0.49 首购
本用户追溯佣金合计: $0.49
[7] 用户 18750 注册: 2026-04-22 10:05:38
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
20260422100625875802633569 $ 0.99 $ 0.00 $ 0.49 首购
本用户追溯佣金合计: $0.49
[8] 用户 28305 注册: 2026-04-29 20:40:42
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604292044144381261998771 $ 5.99 $ 0.00 $ 2.99 续费
本用户追溯佣金合计: $2.99
[9] 用户 18687 注册: 2026-04-22 07:22:38
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604220723053253069546184 $ 0.99 $ 0.00 $ 0.49 首购
202604301218427084543381719 $ 12.99 $ 0.00 $ 6.49 续费
本用户追溯佣金合计: $6.98
[10] 用户 27370 注册: 2026-04-29 13:07:04
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604291307299646401795678 $ 2.79 $ 0.00 $ 1.39 首购
本用户追溯佣金合计: $1.39
[11] 用户 25141 注册: 2026-04-28 23:27:53
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
20260428232858118781522927 $ 2.79 $ 0.00 $ 1.39 首购
本用户追溯佣金合计: $1.39
[12] 用户 21709 注册: 2026-04-25 09:07:11
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604250909319632867038991 $ 0.99 $ 0.00 $ 0.49 续费
本用户追溯佣金合计: $0.49
[13] 用户 29584 注册: 2026-04-30 21:00:07
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604302100218949591805326 $ 12.99 $ 0.00 $ 6.49 首购
本用户追溯佣金合计: $6.49
[14] 用户 18457 注册: 2026-04-22 00:56:01
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604282259482779907423944 $ 5.99 $ 0.00 $ 2.99 续费
本用户追溯佣金合计: $2.99
[15] 用户 23746 注册: 2026-04-28 08:57:40
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202605072036107130526727042 $ 2.79 $ 0.00 $ 1.39 续费
本用户追溯佣金合计: $1.39
[16] 用户 23745 注册: 2026-04-28 08:56:25
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202604280857579370192598355 $ 0.99 $ 0.00 $ 0.49 首购
本用户追溯佣金合计: $0.49
═══════════════════════════════════════════════════════════════════════════
预计追溯佣金总额: $38.41(目标补偿金额: $15.11
[dry-run] 预览完成,未执行任何写入。
-101
View File
@@ -1,101 +0,0 @@
⚠️ 强制覆盖所有代理佣金比例为 50%
自然流量用户池(2025-05-04 00:00 ~ 2026-05-08 17:36referer_id=0,有已支付订单,订单 updated_at >= 2026-05-01):共 942 人
═══════════════════════════════════════════════════════════
代理 ID : 32690(注册于 2026-05-03 22:28:33
统计起点 : 2026-05-03 23:05:16(首次邀请时间)
统计截止 : 2026-05-08 11:34:52(最后邀请时间)
统计天数 : 4.52 天
历史邀请总人数 : 56 人
下线总订单数 : 23 单(所有下线,不限时间)
日均订单 : 5.0879 单/天
每用户平均订单 : 1.0000 单
历史佣金总额 : $67.37
日均佣金 : $14.9031
池内用户均佣金 : $4.0394
佣金规则 : 50% 仅首单=false
───────────────────────────────────────────────────────────
丢失时长 : 89.62 小时(2026-05-05 00:00:00 → 现在)
预估丢失佣金 : $55.6494$14.9031 × 89.62/24
目标补偿金额 : $22.2597(× 40%
需补充人数 : 6 人($22.2597 ÷ $4.0394
═══════════════════════════════════════════════════════════
随机抽取 7 个用户(含待追溯佣金订单):
═══════════════════════════════════════════════════════════════════════════
[1] 用户 20700 注册: 2026-04-24 10:23:54
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202605021207336799836275999 $ 12.99 $ 0.00 $ 6.49 续费
本用户追溯佣金合计: $6.49
[2] 用户 29646 注册: 2026-04-30 21:38:33
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202605062120238700572523875 $ 5.99 $ 0.00 $ 2.99 首购
本用户追溯佣金合计: $2.99
[3] 用户 30617 注册: 2026-05-02 00:05:31
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202605021653207435530198247 $ 2.79 $ 0.00 $ 1.39 首购
本用户追溯佣金合计: $1.39
[4] 用户 27884 注册: 2026-04-29 16:56:59
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202605021434465448150056947 $ 2.79 $ 0.00 $ 1.39 续费
本用户追溯佣金合计: $1.39
[5] 用户 30415 注册: 2026-05-01 20:16:56
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202605031955444435684054876 $ 12.99 $ 0.00 $ 6.49 续费
本用户追溯佣金合计: $6.49
[6] 用户 23970 注册: 2026-04-28 12:58:04
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202605010755515633642395999 $ 5.99 $ 0.00 $ 2.99 首购
本用户追溯佣金合计: $2.99
[7] 用户 29973 注册: 2026-05-01 02:43:14
订单号 金额 手续费 佣金 类型
------------------------------------------------------------------------
202605010243251111413018924 $ 2.79 $ 0.00 $ 1.39 首购
本用户追溯佣金合计: $1.39
═══════════════════════════════════════════════════════════════════════════
预计追溯佣金总额: $23.13(目标补偿金额: $22.26
═══════════════════════════════════════════════════════════════════════════
汇总:共 1 个代理,补充 7 个用户
预计追溯佣金总额: $23.13(目标补偿金额: $22.26
═══════════════════════════════════════════════════════════════════════════
┌─────────────────────────────────────────────┐
│ 即将写入数据库 │
│ 代理数量 : 1 个 │
│ 补充用户 : 7 人 │
│ 赠送金额 : $23.13 │
└─────────────────────────────────────────────┘
── 执行代理 32690 ──────────────────────────────────────────────
[OK] 用户 20700 → 代理 32690,发佣 1 单,金额 $6.49
[OK] 用户 29646 → 代理 32690,发佣 1 单,金额 $2.99
[OK] 用户 30617 → 代理 32690,发佣 1 单,金额 $1.39
[OK] 用户 27884 → 代理 32690,发佣 1 单,金额 $1.39
[OK] 用户 30415 → 代理 32690,发佣 1 单,金额 $6.49
[OK] 用户 23970 → 代理 32690,发佣 1 单,金额 $2.99
[OK] 用户 29973 → 代理 32690,发佣 1 单,金额 $1.39
代理 32690 小计:成功 7 人,失败 0 人,佣金 $23.13
══════════════════════════════════════════════
成功挂载 : 7 人
失败/跳过 : 0 人
追溯佣金 : 7 单,总额 $23.13
══════════════════════════════════════════════
-37
View File
@@ -1,37 +0,0 @@
server:
http_listen_port: 3200
grpc_listen_port: 9095
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
max_block_duration: 5m
compactor:
compaction:
block_retention: 168h
storage:
trace:
backend: local
wal:
path: /var/tempo/wal
local:
path: /var/tempo/blocks
metrics_generator:
registry:
external_labels:
source: tempo
cluster: ppanel
storage:
path: /var/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
BIN
View File
Binary file not shown.
-136
View File
@@ -1,136 +0,0 @@
// Package tests provides HTTP client helpers for manually testing ppanel-server APIs.
//
// Usage:
//
// go test -v -run TestXxx ./tests/
//
// Configure the constants below to match your local environment.
package tests
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
)
// ─── Environment Configuration ────────────────────────────────────────────────
const (
// BaseURL is the ppanel-server base URL (no trailing slash).
BaseURL = "http://localhost:8080"
// AdminToken is the JWT token for an admin user.
// Obtain by calling CallAdminLogin first, then paste the token here.
AdminToken = "YOUR_ADMIN_TOKEN"
// UserToken is the JWT token for a regular user.
// Obtain by calling CallUserLogin first, then paste the token here.
UserToken = "YOUR_USER_TOKEN"
// TestEmail is the email address for user-related test calls.
TestEmail = "test@example.com"
// TestPassword is the password for test user login.
TestPassword = "Test@123456"
// AdminEmail is the email for admin login.
AdminEmail = "admin@example.com"
// AdminPassword is the admin password.
AdminPassword = "Admin@123456"
)
// ─── Request Options ──────────────────────────────────────────────────────────
type reqOption func(*http.Request)
// withToken adds a Bearer token to the request.
func withToken(token string) reqOption {
return func(r *http.Request) {
r.Header.Set("Authorization", "Bearer "+token)
}
}
// withAdminToken adds the AdminToken.
func withAdminToken() reqOption { return withToken(AdminToken) }
// withUserToken adds the UserToken.
func withUserToken() reqOption { return withToken(UserToken) }
// ─── Core HTTP Helper ─────────────────────────────────────────────────────────
// doRequest sends an HTTP request and prints the response.
// body may be nil (for GET/DELETE without body), a map[string]any, or any JSON-serialisable value.
// Returns the raw response body as a string.
func doRequest(t *testing.T, method, path string, body any, opts ...reqOption) string {
t.Helper()
var reqBody io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reqBody = bytes.NewReader(b)
}
req, err := http.NewRequest(method, BaseURL+path, reqBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
for _, o := range opts {
o(req)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
result := prettyJSON(raw)
t.Logf("[%s %s] %d\n%s", method, path, resp.StatusCode, result)
return string(raw)
}
// doRequestRaw is like doRequest but for non-testing contexts (prints to stdout).
func doRequestRaw(method, path string, body any, opts ...reqOption) string {
var reqBody io.Reader
if body != nil {
b, _ := json.Marshal(body)
reqBody = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, BaseURL+path, reqBody)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
for _, o := range opts {
o(req)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("request error: %v\n", err)
return ""
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
result := prettyJSON(raw)
fmt.Printf("[%s %s] %d\n%s\n", method, path, resp.StatusCode, result)
return string(raw)
}
func prettyJSON(raw []byte) string {
var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", " "); err != nil {
return string(raw)
}
return buf.String()
}