merge: add direct s3 upload flow
Build docker and publish / build (20.15.1) (push) Failing after 8m49s
Build docker and publish / build (20.15.1) (push) Failing after 8m49s
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "File API"
|
||||
desc: "API for ppanel file upload"
|
||||
author: "Codex"
|
||||
email: "codex@openai.com"
|
||||
version: "0.0.1"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
type (
|
||||
FileUploadInitRequest {
|
||||
BizType string `json:"biz_type" validate:"required"`
|
||||
FileName string `json:"file_name" validate:"required"`
|
||||
ContentType string `json:"content_type" validate:"required"`
|
||||
Size int64 `json:"size" validate:"required"`
|
||||
Sha256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
FileUploadInitResponse {
|
||||
FileId string `json:"file_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
UploadURL string `json:"upload_url"`
|
||||
Method string `json:"method"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiredAt int64 `json:"expired_at"`
|
||||
}
|
||||
|
||||
FileUploadCompleteRequest {
|
||||
FileId string `json:"file_id" validate:"required"`
|
||||
}
|
||||
|
||||
FileUploadCompleteResponse {
|
||||
FileId string `json:"file_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type"`
|
||||
Etag string `json:"etag"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
prefix: v1/public/file
|
||||
group: public/file
|
||||
middleware: AuthMiddleware,DeviceMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Init file upload"
|
||||
@handler FileUploadInit
|
||||
post /upload/init (FileUploadInitRequest) returns (FileUploadInitResponse)
|
||||
|
||||
@doc "Complete file upload"
|
||||
@handler FileUploadComplete
|
||||
post /upload/complete (FileUploadCompleteRequest) returns (FileUploadCompleteResponse)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.1,ppanel-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
|
||||
|
||||
@@ -61,6 +61,21 @@ Trace: # 链路追踪配置 (OpenTelemetry)
|
||||
Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
|
||||
Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317
|
||||
|
||||
S3:
|
||||
Enable: false
|
||||
Region: ""
|
||||
Bucket: ""
|
||||
Endpoint: ""
|
||||
AccessKey: ""
|
||||
SecretKey: ""
|
||||
SessionToken: ""
|
||||
Prefix: "app-upload"
|
||||
PublicBaseURL: ""
|
||||
UsePathStyle: false
|
||||
PresignExpireSeconds: 300
|
||||
MaxUploadSize: 104857600
|
||||
AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json"
|
||||
|
||||
device:
|
||||
enable: true # 开启设备加密通信
|
||||
security_secret: "" # AES加密密钥,需要和App端一致,key=SHA256(security_secret)[:32]
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
module github.com/perfect-panel/server
|
||||
|
||||
go 1.23.3
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f
|
||||
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,15 +49,19 @@ 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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/sprig/v3 v3.3.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
|
||||
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,8 +81,21 @@ 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/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
||||
github.com/aws/smithy-go v1.25.1 // indirect
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
|
||||
github.com/bytedance/sonic v1.12.7 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
@@ -88,7 +103,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 +128,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 +159,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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -64,6 +60,42 @@ github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72 h1:
|
||||
github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72/go.mod h1:PsJICrlruG9QcJDYuZ0dO/2KtMDALzRbony8NkxZ2nE=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
|
||||
@@ -226,8 +258,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 +288,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 +392,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 +603,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=
|
||||
|
||||
@@ -38,12 +38,29 @@ type Config struct {
|
||||
Log Log `yaml:"Log"`
|
||||
Currency Currency `yaml:"Currency"`
|
||||
Trace trace.Config `yaml:"Trace"`
|
||||
S3 S3Config `yaml:"S3"`
|
||||
Administrator struct {
|
||||
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
||||
Password string `yaml:"Password" default:"password"`
|
||||
} `yaml:"Administrator"`
|
||||
}
|
||||
|
||||
type S3Config struct {
|
||||
Enable bool `yaml:"Enable" default:"false"`
|
||||
Region string `yaml:"Region" default:""`
|
||||
Bucket string `yaml:"Bucket" default:""`
|
||||
Endpoint string `yaml:"Endpoint" default:""`
|
||||
AccessKey string `yaml:"AccessKey" default:""`
|
||||
SecretKey string `yaml:"SecretKey" default:""`
|
||||
SessionToken string `yaml:"SessionToken" default:""`
|
||||
Prefix string `yaml:"Prefix" default:"app-upload"`
|
||||
PublicBaseURL string `yaml:"PublicBaseURL" default:""`
|
||||
UsePathStyle bool `yaml:"UsePathStyle" default:"false"`
|
||||
PresignExpireSeconds int64 `yaml:"PresignExpireSeconds" default:"300"`
|
||||
MaxUploadSize int64 `yaml:"MaxUploadSize" default:"104857600"`
|
||||
AllowedContentTypes string `yaml:"AllowedContentTypes" default:"application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Host string `yaml:"Host" default:"localhost:6379"`
|
||||
Pass string `yaml:"Pass" default:""`
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/file"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Complete file upload
|
||||
func FileUploadCompleteHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.FileUploadCompleteRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := file.NewFileUploadCompleteLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.FileUploadComplete(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/file"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Init file upload
|
||||
func FileUploadInitHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.FileUploadInitRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := file.NewFileUploadInitLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.FileUploadInit(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
common "github.com/perfect-panel/server/internal/handler/common"
|
||||
publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement"
|
||||
publicDocument "github.com/perfect-panel/server/internal/handler/public/document"
|
||||
publicFile "github.com/perfect-panel/server/internal/handler/public/file"
|
||||
publicIapApple "github.com/perfect-panel/server/internal/handler/public/iap/apple"
|
||||
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
|
||||
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
|
||||
@@ -865,6 +866,17 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
publicDocumentGroupRouter.GET("/list", publicDocument.QueryDocumentListHandler(serverCtx))
|
||||
}
|
||||
|
||||
publicFileGroupRouter := router.Group("/v1/public/file")
|
||||
publicFileGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||
|
||||
{
|
||||
// Init file upload
|
||||
publicFileGroupRouter.POST("/upload/init", publicFile.FileUploadInitHandler(serverCtx))
|
||||
|
||||
// Complete file upload
|
||||
publicFileGroupRouter.POST("/upload/complete", publicFile.FileUploadCompleteHandler(serverCtx))
|
||||
}
|
||||
|
||||
publicOrderGroupRouter := router.Group("/v1/public/order")
|
||||
publicOrderGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
fileUploadInitStatus = "init"
|
||||
fileUploadCompleteStatus = "completed"
|
||||
fileUploadRedisPrefix = "file:upload:"
|
||||
)
|
||||
|
||||
var safeFileNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
type uploadMeta struct {
|
||||
FileID string `json:"file_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
BizType string `json:"biz_type"`
|
||||
FileName string `json:"file_name"`
|
||||
ContentType string `json:"content_type"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Size int64 `json:"size"`
|
||||
Sha256 string `json:"sha256"`
|
||||
Status string `json:"status"`
|
||||
Etag string `json:"etag,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
CompletedAt int64 `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
func currentUserFromContext(ctx context.Context) (*user.User, error) {
|
||||
u, ok := ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok || u == nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func allowedContentTypeSet(csv string) map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, item := range strings.Split(csv, ",") {
|
||||
item = strings.TrimSpace(strings.ToLower(item))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
set[item] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func validateInitRequest(svcCtx *svc.ServiceContext, bizType, fileName, contentType string, size int64) error {
|
||||
if svcCtx.S3Store == nil || !svcCtx.Config.S3.Enable {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "S3 upload is not enabled")
|
||||
}
|
||||
if strings.TrimSpace(bizType) == "" {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "biz_type is required")
|
||||
}
|
||||
if strings.TrimSpace(fileName) == "" {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "file_name is required")
|
||||
}
|
||||
if size <= 0 {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "size must be greater than 0")
|
||||
}
|
||||
if svcCtx.Config.S3.MaxUploadSize > 0 && size > svcCtx.Config.S3.MaxUploadSize {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "file size exceeds max limit")
|
||||
}
|
||||
allowed := allowedContentTypeSet(svcCtx.Config.S3.AllowedContentTypes)
|
||||
if len(allowed) > 0 {
|
||||
if _, ok := allowed[strings.ToLower(strings.TrimSpace(contentType))]; !ok {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "content_type is not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFileID(userID int64, bizType, fileName string) string {
|
||||
h := sha1.New()
|
||||
_, _ = h.Write([]byte(fmt.Sprintf("%d:%s:%s:%d", userID, bizType, fileName, time.Now().UnixNano())))
|
||||
return hex.EncodeToString(h.Sum(nil))[:24]
|
||||
}
|
||||
|
||||
func safeFileName(name string) string {
|
||||
base := filepath.Base(strings.TrimSpace(name))
|
||||
if base == "." || base == "/" || base == "" {
|
||||
return "file.bin"
|
||||
}
|
||||
safe := safeFileNameRegexp.ReplaceAllString(base, "_")
|
||||
if safe == "" {
|
||||
return "file.bin"
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
func buildObjectKey(prefix string, userID int64, bizType, fileID, fileName string, now time.Time) string {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
if prefix == "" {
|
||||
prefix = "app-upload"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/%d/%04d/%02d/%s_%s",
|
||||
prefix,
|
||||
strings.Trim(safeFileNameRegexp.ReplaceAllString(strings.ToLower(strings.TrimSpace(bizType)), "-"), "-"),
|
||||
userID,
|
||||
now.Year(),
|
||||
int(now.Month()),
|
||||
fileID,
|
||||
safeFileName(fileName),
|
||||
)
|
||||
}
|
||||
|
||||
func uploadRedisKey(fileID string) string {
|
||||
return fileUploadRedisPrefix + fileID
|
||||
}
|
||||
|
||||
func saveUploadMeta(ctx context.Context, svcCtx *svc.ServiceContext, meta *uploadMeta, ttl time.Duration) error {
|
||||
data, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal upload meta failed")
|
||||
}
|
||||
if err := svcCtx.Redis.Set(ctx, uploadRedisKey(meta.FileID), data, ttl).Err(); err != nil {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "save upload meta failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadUploadMeta(ctx context.Context, svcCtx *svc.ServiceContext, fileID string) (*uploadMeta, error) {
|
||||
val, err := svcCtx.Redis.Get(ctx, uploadRedisKey(fileID)).Result()
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "upload meta not found")
|
||||
}
|
||||
var meta uploadMeta
|
||||
if err := json.Unmarshal([]byte(val), &meta); err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "parse upload meta failed")
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.9.2
|
||||
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type FileUploadCompleteLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// Complete file upload
|
||||
func NewFileUploadCompleteLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FileUploadCompleteLogic {
|
||||
return &FileUploadCompleteLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *FileUploadCompleteLogic) FileUploadComplete(req *types.FileUploadCompleteRequest) (resp *types.FileUploadCompleteResponse, err error) {
|
||||
u, err := currentUserFromContext(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
meta, err := loadUploadMeta(l.ctx, l.svcCtx, req.FileId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta.UserID != u.Id {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
head, err := l.svcCtx.S3Store.HeadObject(l.ctx, meta.ObjectKey)
|
||||
if err != nil {
|
||||
l.Errorw("head object failed", logger.Field("error", err.Error()), logger.Field("file_id", meta.FileID))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "uploaded file not found")
|
||||
}
|
||||
if meta.Size > 0 && head.ContentLength != meta.Size {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "uploaded file size mismatch")
|
||||
}
|
||||
|
||||
meta.Status = fileUploadCompleteStatus
|
||||
meta.Etag = head.ETag
|
||||
meta.ContentType = head.ContentType
|
||||
meta.CompletedAt = time.Now().Unix()
|
||||
if err := saveUploadMeta(l.ctx, l.svcCtx, meta, 7*24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.FileUploadCompleteResponse{
|
||||
FileId: meta.FileID,
|
||||
ObjectKey: meta.ObjectKey,
|
||||
Size: head.ContentLength,
|
||||
ContentType: head.ContentType,
|
||||
Etag: head.ETag,
|
||||
Status: meta.Status,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.9.2
|
||||
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
)
|
||||
|
||||
type FileUploadInitLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
// Init file upload
|
||||
func NewFileUploadInitLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FileUploadInitLogic {
|
||||
return &FileUploadInitLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *FileUploadInitLogic) FileUploadInit(req *types.FileUploadInitRequest) (resp *types.FileUploadInitResponse, err error) {
|
||||
u, err := currentUserFromContext(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateInitRequest(l.svcCtx, req.BizType, req.FileName, req.ContentType, req.Size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
fileID := buildFileID(u.Id, req.BizType, req.FileName)
|
||||
objectKey := buildObjectKey(l.svcCtx.Config.S3.Prefix, u.Id, req.BizType, fileID, req.FileName, now)
|
||||
expireSeconds := l.svcCtx.Config.S3.PresignExpireSeconds
|
||||
if expireSeconds <= 0 {
|
||||
expireSeconds = 300
|
||||
}
|
||||
|
||||
presigned, err := l.svcCtx.S3Store.PresignPutObject(l.ctx, objectKey, req.ContentType, time.Duration(expireSeconds)*time.Second)
|
||||
if err != nil {
|
||||
l.Errorw("presign put object failed", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
meta := &uploadMeta{
|
||||
FileID: fileID,
|
||||
UserID: u.Id,
|
||||
BizType: req.BizType,
|
||||
FileName: req.FileName,
|
||||
ContentType: req.ContentType,
|
||||
ObjectKey: objectKey,
|
||||
Size: req.Size,
|
||||
Sha256: req.Sha256,
|
||||
Status: fileUploadInitStatus,
|
||||
CreatedAt: now.Unix(),
|
||||
}
|
||||
if err := saveUploadMeta(l.ctx, l.svcCtx, meta, 24*time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.FileUploadInitResponse{
|
||||
FileId: fileID,
|
||||
ObjectKey: objectKey,
|
||||
UploadURL: presigned.URL,
|
||||
Method: presigned.Method,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": req.ContentType,
|
||||
},
|
||||
ExpiredAt: presigned.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
@@ -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=0,Amount=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=1000,discount=[{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=1000,Discount=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=1000,discount=[{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)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/perfect-panel/server/internal/model/node"
|
||||
"github.com/perfect-panel/server/internal/model/redemption"
|
||||
"github.com/perfect-panel/server/pkg/device"
|
||||
"github.com/perfect-panel/server/pkg/storage"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/model/ads"
|
||||
@@ -44,6 +45,7 @@ type ServiceContext struct {
|
||||
ExchangeRate float64
|
||||
GeoIP *IPLocation
|
||||
SignatureValidator *signature.Validator
|
||||
S3Store *storage.S3Store
|
||||
|
||||
//NodeCache *cache.NodeCacheClient
|
||||
AuthModel auth.Model
|
||||
@@ -143,6 +145,13 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||
}
|
||||
srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds)
|
||||
srv.DeviceManager = NewDeviceManager(srv)
|
||||
if c.S3.Enable {
|
||||
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
srv.S3Store = s3Store
|
||||
}
|
||||
return srv
|
||||
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package types
|
||||
|
||||
// compat_types.go — 手动补充的类型,已同步到 .api 定义
|
||||
// 下次用 goctl 重新生成 types.go 后,以下类型会自动包含在 types.go 中,届时可以删除本文件中对应的定义:
|
||||
// - ContactRequest (已加入 common.api)
|
||||
// - GetDownloadLinkRequest / GetDownloadLinkResponse (已加入 common.api)
|
||||
// - EmailLoginRequest (已更新 auth.api, 加入 form tag)
|
||||
// - ReportLogMessageRequest / ReportLogMessageResponse (已加入 common.api, 路由 POST /v1/common/log/report)
|
||||
// - LegacyCheckVerificationCodeRequest / LegacyCheckVerificationCodeResponse (已加入 common.api, 路由 POST /v1/common/check_code)
|
||||
|
||||
type ContactRequest struct {
|
||||
Name string `json:"name" validate:"required,max=100"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
OtherContact string `json:"other_contact" validate:"max=200"`
|
||||
Notes string `json:"notes" validate:"max=2000"`
|
||||
}
|
||||
|
||||
type GetDownloadLinkRequest struct {
|
||||
InviteCode string `form:"invite_code,optional"`
|
||||
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
|
||||
}
|
||||
|
||||
type GetDownloadLinkResponse struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type ReportLogMessageRequest struct {
|
||||
Platform string `json:"platform" validate:"required,max=32"`
|
||||
AppVersion string `json:"app_version" validate:"required,max=64"`
|
||||
OsName string `json:"os_name" validate:"max=64"`
|
||||
OsVersion string `json:"os_version" validate:"max=64"`
|
||||
DeviceId string `json:"device_id" validate:"required,max=255"`
|
||||
UserId int64 `json:"user_id"`
|
||||
SessionId string `json:"session_id" validate:"max=255"`
|
||||
Level uint8 `json:"level"`
|
||||
ErrorCode string `json:"error_code" validate:"max=128"`
|
||||
Message string `json:"message" validate:"required,max=65535"`
|
||||
Stack string `json:"stack" validate:"max=1048576"`
|
||||
Context interface{} `json:"context"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type ReportLogMessageResponse struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
type LegacyCheckVerificationCodeRequest struct {
|
||||
Method string `json:"method" form:"method" validate:"omitempty,oneof=email mobile"`
|
||||
Account string `json:"account" form:"account"`
|
||||
Email string `json:"email" form:"email"`
|
||||
Code string `json:"code" form:"code" validate:"required"`
|
||||
Type uint8 `json:"type" form:"type" validate:"required"`
|
||||
}
|
||||
|
||||
type LegacyCheckVerificationCodeResponse struct {
|
||||
Status bool `json:"status"`
|
||||
Exist bool `json:"exist"`
|
||||
}
|
||||
|
||||
type EmailLoginRequest struct {
|
||||
Identifier string `json:"identifier" form:"identifier"`
|
||||
Email string `json:"email" form:"email" validate:"required,email"`
|
||||
Code string `json:"code" form:"code" validate:"required"`
|
||||
Invite string `json:"invite" form:"invite"`
|
||||
IP string `header:"X-Original-Forwarded-For"`
|
||||
UserAgent string `header:"User-Agent"`
|
||||
LoginType string `header:"Login-Type"`
|
||||
CfToken string `json:"cf_token,optional" form:"cf_token"`
|
||||
}
|
||||
+125
-35
@@ -1,8 +1,12 @@
|
||||
// Code generated by goctl. DO NOT EDIT.
|
||||
// goctl 1.7.2
|
||||
// goctl 1.9.2
|
||||
|
||||
package types
|
||||
|
||||
type ActivateOrderRequest struct {
|
||||
OrderNo string `json:"order_no" validate:"required"`
|
||||
}
|
||||
|
||||
type Ads struct {
|
||||
Id int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -295,6 +299,13 @@ type ConnectionRecords struct {
|
||||
LongestSingleConnection int64 `json:"longest_single_connection"`
|
||||
}
|
||||
|
||||
type ContactRequest struct {
|
||||
Name string `json:"name" validate:"required,max=100"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
OtherContact string `json:"other_contact" validate:"max=200"`
|
||||
Notes string `json:"notes" validate:"max=2000"`
|
||||
}
|
||||
|
||||
type Coupon struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -549,6 +560,13 @@ type CurrencyConfig struct {
|
||||
CurrencySymbol string `json:"currency_symbol"`
|
||||
}
|
||||
|
||||
type DailyTrafficStats struct {
|
||||
Date string `json:"date"`
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type DeleteAccountRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
@@ -561,13 +579,6 @@ type DeleteAccountResponse struct {
|
||||
Code int64 `json:"code"`
|
||||
}
|
||||
|
||||
type DailyTrafficStats struct {
|
||||
Date string `json:"date"`
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type DeleteAdsRequest struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
@@ -689,8 +700,15 @@ type EmailAuthticateConfig struct {
|
||||
DomainSuffixList string `json:"domain_suffix_list"`
|
||||
}
|
||||
|
||||
type ExportGroupResultRequest struct {
|
||||
HistoryId *int64 `form:"history_id,omitempty"`
|
||||
type EmailLoginRequest struct {
|
||||
Identifier string `json:"identifier" form:"identifier"`
|
||||
Email string `json:"email" form:"email" validate:"required,email"`
|
||||
Code string `json:"code" form:"code" validate:"required"`
|
||||
Invite string `json:"invite,optional" form:"invite"`
|
||||
IP string `header:"X-Original-Forwarded-For"`
|
||||
UserAgent string `header:"User-Agent"`
|
||||
LoginType string `header:"Login-Type"`
|
||||
CfToken string `json:"cf_token,optional" form:"cf_token"`
|
||||
}
|
||||
|
||||
type ErrorLogMessage struct {
|
||||
@@ -708,6 +726,10 @@ type ErrorLogMessage struct {
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
type ExportGroupResultRequest struct {
|
||||
HistoryId *int64 `form:"history_id,omitempty"`
|
||||
}
|
||||
|
||||
type FamilyDetail struct {
|
||||
Summary FamilySummary `json:"summary"`
|
||||
Members []FamilyMemberItem `json:"members"`
|
||||
@@ -740,6 +762,36 @@ type FamilySummary struct {
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type FileUploadCompleteRequest struct {
|
||||
FileId string `json:"file_id" validate:"required"`
|
||||
}
|
||||
|
||||
type FileUploadCompleteResponse struct {
|
||||
FileId string `json:"file_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"content_type"`
|
||||
Etag string `json:"etag"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type FileUploadInitRequest struct {
|
||||
BizType string `json:"biz_type" validate:"required"`
|
||||
FileName string `json:"file_name" validate:"required"`
|
||||
ContentType string `json:"content_type" validate:"required"`
|
||||
Size int64 `json:"size" validate:"required"`
|
||||
Sha256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type FileUploadInitResponse struct {
|
||||
FileId string `json:"file_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
UploadURL string `json:"upload_url"`
|
||||
Method string `json:"method"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiredAt int64 `json:"expired_at"`
|
||||
}
|
||||
|
||||
type FilterBalanceLogRequest struct {
|
||||
FilterLogParams
|
||||
UserId int64 `form:"user_id,optional"`
|
||||
@@ -1048,6 +1100,15 @@ type GetDocumentListResponse struct {
|
||||
List []Document `json:"list"`
|
||||
}
|
||||
|
||||
type GetDownloadLinkRequest struct {
|
||||
InviteCode string `form:"invite_code,optional"`
|
||||
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
|
||||
}
|
||||
|
||||
type GetDownloadLinkResponse struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type GetErrorLogMessageDetailResponse struct {
|
||||
Id int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
@@ -1118,18 +1179,6 @@ type GetGlobalConfigResponse struct {
|
||||
WebAd bool `json:"web_ad"`
|
||||
}
|
||||
|
||||
type GetInviteSalesRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
StartTime int64 `form:"start_time"`
|
||||
EndTime int64 `form:"end_time"`
|
||||
}
|
||||
|
||||
type GetInviteSalesResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
List []InvitedUserSale `json:"list"`
|
||||
}
|
||||
|
||||
type GetGroupConfigRequest struct {
|
||||
Keys []string `form:"keys,omitempty"`
|
||||
}
|
||||
@@ -1161,6 +1210,18 @@ type GetGroupHistoryResponse struct {
|
||||
List []GroupHistory `json:"list"`
|
||||
}
|
||||
|
||||
type GetInviteSalesRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
StartTime int64 `form:"start_time"`
|
||||
EndTime int64 `form:"end_time"`
|
||||
}
|
||||
|
||||
type GetInviteSalesResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
List []InvitedUserSale `json:"list"`
|
||||
}
|
||||
|
||||
type GetLoginLogRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@@ -1419,7 +1480,7 @@ type GetUserListRequest struct {
|
||||
FamilyStatus string `form:"family_status,omitempty"`
|
||||
FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"`
|
||||
FamilyId *int64 `form:"family_id,omitempty"`
|
||||
SortOrder string `form:"sort_order,omitempty"` // asc or desc, default desc
|
||||
SortOrder string `form:"sort_order,omitempty"`
|
||||
}
|
||||
|
||||
type GetUserListResponse struct {
|
||||
@@ -1601,6 +1662,19 @@ type KickOfflineRequest struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
type LegacyCheckVerificationCodeRequest struct {
|
||||
Method string `json:"method" form:"method" validate:"omitempty,oneof=email mobile"`
|
||||
Account string `json:"account" form:"account"`
|
||||
Email string `json:"email" form:"email"`
|
||||
Code string `json:"code" form:"code" validate:"required"`
|
||||
Type uint8 `json:"type" form:"type" validate:"required"`
|
||||
}
|
||||
|
||||
type LegacyCheckVerificationCodeResponse struct {
|
||||
Status bool `json:"status"`
|
||||
Exist bool `json:"exist"`
|
||||
}
|
||||
|
||||
type LogResponse struct {
|
||||
List interface{} `json:"list"`
|
||||
}
|
||||
@@ -1765,8 +1839,8 @@ type Order struct {
|
||||
TradeNo string `json:"trade_no"`
|
||||
Status uint8 `json:"status"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
CreatedAt int64 `json:"created_at"` // Unix milliseconds
|
||||
UpdatedAt int64 `json:"updated_at"` // Unix milliseconds
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OrderDetail struct {
|
||||
@@ -1789,8 +1863,8 @@ type OrderDetail struct {
|
||||
Status uint8 `json:"status"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Subscribe Subscribe `json:"subscribe"`
|
||||
CreatedAt int64 `json:"created_at"` // Unix milliseconds
|
||||
UpdatedAt int64 `json:"updated_at"` // Unix milliseconds
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OrdersStatistics struct {
|
||||
@@ -2246,6 +2320,10 @@ type RecoverOrderResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type RecoverySendCodeRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
|
||||
type RedeemCodeRequest struct {
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
@@ -2319,6 +2397,26 @@ type RenewalOrderResponse struct {
|
||||
AppAccountToken string `json:"app_account_token"`
|
||||
}
|
||||
|
||||
type ReportLogMessageRequest struct {
|
||||
Platform string `json:"platform" validate:"required,max=32"`
|
||||
AppVersion string `json:"app_version" validate:"required,max=64"`
|
||||
OsName string `json:"os_name" validate:"max=64"`
|
||||
OsVersion string `json:"os_version" validate:"max=64"`
|
||||
DeviceId string `json:"device_id" validate:"required,max=255"`
|
||||
UserId int64 `json:"user_id"`
|
||||
SessionId string `json:"session_id" validate:"max=255"`
|
||||
Level uint8 `json:"level"`
|
||||
ErrorCode string `json:"error_code" validate:"max=128"`
|
||||
Message string `json:"message" validate:"required,max=65535"`
|
||||
Stack string `json:"stack" validate:"max=1048576"`
|
||||
Context interface{} `json:"context"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type ReportLogMessageResponse struct {
|
||||
Id int64 `json:"id"`
|
||||
}
|
||||
|
||||
type ResetAllSubscribeTokenResponse struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
@@ -2370,10 +2468,6 @@ type ResetTrafficOrderResponse struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
}
|
||||
|
||||
type RecoverySendCodeRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
|
||||
type ResetUserSubscribeTokenRequest struct {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
@@ -3000,10 +3094,6 @@ type UpdateOrderStatusRequest struct {
|
||||
TradeNo string `json:"trade_no,omitempty"`
|
||||
}
|
||||
|
||||
type ActivateOrderRequest struct {
|
||||
OrderNo string `json:"order_no" validate:"required"`
|
||||
}
|
||||
|
||||
type UpdatePaymentMethodRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
|
||||
@@ -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%
|
||||
@@ -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
|
||||
|
||||
处理:
|
||||
|
||||
- 不要硬跳过
|
||||
- 直接执行全量重建从库
|
||||
|
||||
#### 场景 C:SQL 执行报错
|
||||
|
||||
表现:
|
||||
|
||||
- `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 连接失败告警
|
||||
- 定期灾备切换演练
|
||||
|
||||
@@ -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` 迁移清单
|
||||
|
||||
@@ -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 待后续下线
|
||||
@@ -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 从库提升为主库
|
||||
Vendored
-183
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
)
|
||||
|
||||
type PresignUploadResult struct {
|
||||
URL string
|
||||
Method string
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
type ObjectMeta struct {
|
||||
ETag string
|
||||
ContentType string
|
||||
ContentLength int64
|
||||
}
|
||||
|
||||
type S3Store struct {
|
||||
cfg config.S3Config
|
||||
client *s3.Client
|
||||
presign *s3.PresignClient
|
||||
}
|
||||
|
||||
func NewS3Store(ctx context.Context, cfg config.S3Config) (*S3Store, error) {
|
||||
loaders := make([]func(*awsconfig.LoadOptions) error, 0, 3)
|
||||
if cfg.Region != "" {
|
||||
loaders = append(loaders, awsconfig.WithRegion(cfg.Region))
|
||||
}
|
||||
if cfg.AccessKey != "" || cfg.SecretKey != "" || cfg.SessionToken != "" {
|
||||
loaders = append(loaders, awsconfig.WithCredentialsProvider(
|
||||
credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, cfg.SessionToken),
|
||||
))
|
||||
}
|
||||
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loaders...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||
o.UsePathStyle = cfg.UsePathStyle
|
||||
if cfg.Endpoint != "" {
|
||||
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
return &S3Store{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
presign: s3.NewPresignClient(client),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *S3Store) PresignPutObject(ctx context.Context, objectKey string, contentType string, expires time.Duration) (*PresignUploadResult, error) {
|
||||
req, err := s.presign.PresignPutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.cfg.Bucket),
|
||||
Key: aws.String(objectKey),
|
||||
ContentType: aws.String(contentType),
|
||||
}, s3.WithPresignExpires(expires))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PresignUploadResult{
|
||||
URL: req.URL,
|
||||
Method: req.Method,
|
||||
ExpiresAt: time.Now().Add(expires).Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *S3Store) HeadObject(ctx context.Context, objectKey string) (*ObjectMeta, error) {
|
||||
resp, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(s.cfg.Bucket),
|
||||
Key: aws.String(objectKey),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ObjectMeta{
|
||||
ETag: strings.Trim(aws.ToString(resp.ETag), "\""),
|
||||
ContentType: aws.ToString(resp.ContentType),
|
||||
ContentLength: aws.ToInt64(resp.ContentLength),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *S3Store) BuildObjectURL(objectKey string) string {
|
||||
if s.cfg.PublicBaseURL != "" {
|
||||
return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/" + strings.TrimLeft(objectKey, "/")
|
||||
}
|
||||
|
||||
if s.cfg.Endpoint != "" {
|
||||
base := strings.TrimRight(s.cfg.Endpoint, "/")
|
||||
return fmt.Sprintf("%s/%s/%s", base, url.PathEscape(s.cfg.Bucket), strings.TrimLeft(objectKey, "/"))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", s.cfg.Bucket, s.cfg.Region, strings.TrimLeft(objectKey, "/"))
|
||||
}
|
||||
|
||||
func (s *S3Store) PutJSONTagging(ctx context.Context, objectKey string, values map[string]string) error {
|
||||
tagging := make([]types.Tag, 0, len(values))
|
||||
for k, v := range values {
|
||||
key := k
|
||||
value := v
|
||||
tagging = append(tagging, types.Tag{Key: &key, Value: &value})
|
||||
}
|
||||
|
||||
_, err := s.client.PutObjectTagging(ctx, &s3.PutObjectTaggingInput{
|
||||
Bucket: aws.String(s.cfg.Bucket),
|
||||
Key: aws.String(objectKey),
|
||||
Tagging: &types.Tagging{
|
||||
TagSet: tagging,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -40,6 +40,8 @@ import (
|
||||
"apis/public/ticket.api"
|
||||
"apis/public/payment.api"
|
||||
"apis/public/document.api"
|
||||
"apis/public/file.api"
|
||||
"apis/public/portal.api"
|
||||
"apis/public/iap.api"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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:27,referer_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] 预览完成,未执行任何写入。
|
||||
@@ -1,101 +0,0 @@
|
||||
⚠️ 强制覆盖所有代理佣金比例为 50%
|
||||
|
||||
自然流量用户池(2025-05-04 00:00 ~ 2026-05-08 17:36,referer_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
|
||||
══════════════════════════════════════════════
|
||||
@@ -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
|
||||
Binary file not shown.
-136
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user