Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a156039267 | |||
| fd522b6c71 | |||
| a1184ef5ed | |||
| 7c6efe9dfe | |||
| c0ece054a0 | |||
| 0d57450283 | |||
| f2033fd4b9 | |||
| 3284cb45f0 | |||
| 6041bc3419 | |||
| 4581a6fc17 | |||
| 2bdce44e12 | |||
| 4fa9fcd232 | |||
| f6911965dc | |||
| c4b2ebf7e1 | |||
| f946504cb8 | |||
| 7d2f98b7c9 | |||
| 8bc8e81e95 | |||
| 54daa923da | |||
| 463d3e2315 | |||
| 559d59b4f8 | |||
| d4f0d559cf | |||
| cbb451d18c |
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var c config.Config
|
||||||
|
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||||
|
ctx := svc.NewServiceContext(c)
|
||||||
|
const key = "cache:auth:method:device"
|
||||||
|
before, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result()
|
||||||
|
fmt.Printf("cache before=%q\n", before)
|
||||||
|
m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
|
||||||
|
fmt.Printf("model err=%v enabled_nil=%v", err, m == nil || m.Enabled == nil)
|
||||||
|
if m != nil && m.Enabled != nil { fmt.Printf(" enabled=%v", *m.Enabled) }
|
||||||
|
if m != nil { fmt.Printf(" config=%s", m.Config) }
|
||||||
|
fmt.Println()
|
||||||
|
after, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result()
|
||||||
|
fmt.Printf("cache after=%q\n", after)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
initpkg "github.com/perfect-panel/server/initialize"
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var c config.Config
|
||||||
|
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||||
|
ctx := svc.NewServiceContext(c)
|
||||||
|
method, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("db auth_method.enabled=%v config=%s\n", *method.Enabled, method.Config)
|
||||||
|
initpkg.Device(ctx)
|
||||||
|
fmt.Printf("ctx.Config.Device.Enable=%v SecuritySecret=%q EnableSecurity=%v\n", ctx.Config.Device.Enable, ctx.Config.Device.SecuritySecret, ctx.Config.Device.EnableSecurity)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
ID int64
|
||||||
|
Method string
|
||||||
|
Enabled int
|
||||||
|
Config string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var c config.Config
|
||||||
|
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||||
|
ctx := svc.NewServiceContext(c)
|
||||||
|
|
||||||
|
var rows []row
|
||||||
|
if err := ctx.DB.Raw("SELECT id, method, enabled, config FROM auth_method WHERE method = ?", "device").Scan(&rows).Error; err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("raw rows: %+v\n", rows)
|
||||||
|
|
||||||
|
m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
|
||||||
|
fmt.Printf("model err=%v\n", err)
|
||||||
|
if err == nil && m != nil && m.Enabled != nil {
|
||||||
|
fmt.Printf("model row: id=%d method=%s enabled=%v config=%s\n", m.Id, m.Method, *m.Enabled, m.Config)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("model row nil or no enabled ptr: %#v\n", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
authmodel "github.com/perfect-panel/server/internal/model/auth"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var c config.Config
|
||||||
|
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
|
||||||
|
ctx := svc.NewServiceContext(c)
|
||||||
|
|
||||||
|
var a1 authmodel.Auth
|
||||||
|
err1 := ctx.DB.Model(&authmodel.Auth{}).Where("method = ?", "device").First(&a1).Error
|
||||||
|
fmt.Printf("gorm direct err=%v enabled_nil=%v", err1, a1.Enabled == nil)
|
||||||
|
if a1.Enabled != nil { fmt.Printf(" enabled=%v", *a1.Enabled) }
|
||||||
|
fmt.Printf(" config=%s\n", a1.Config)
|
||||||
|
|
||||||
|
var a2 authmodel.Auth
|
||||||
|
err2 := ctx.DB.Table("auth_method").Where("method = ?", "device").First(&a2).Error
|
||||||
|
fmt.Printf("gorm table err=%v enabled_nil=%v", err2, a2.Enabled == nil)
|
||||||
|
if a2.Enabled != nil { fmt.Printf(" enabled=%v", *a2.Enabled) }
|
||||||
|
fmt.Printf(" config=%s\n", a2.Config)
|
||||||
|
}
|
||||||
@@ -9,3 +9,10 @@ GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
|||||||
|
|
||||||
# PPanel Server 镜像标签(留空使用 latest)
|
# PPanel Server 镜像标签(留空使用 latest)
|
||||||
PPANEL_SERVER_TAG=latest
|
PPANEL_SERVER_TAG=latest
|
||||||
|
|
||||||
|
# AWS 区域(香港)
|
||||||
|
AWS_REGION=ap-east-1
|
||||||
|
|
||||||
|
# Grafana 公开域名(如需反代)
|
||||||
|
GRAFANA_DOMAIN=logs-new.hifast.biz
|
||||||
|
GRAFANA_ROOT_URL=https://logs-new.hifast.biz
|
||||||
|
|||||||
+33
-30
@@ -14,11 +14,12 @@ on:
|
|||||||
env:
|
env:
|
||||||
# Docker镜像仓库
|
# Docker镜像仓库
|
||||||
REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }}
|
REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }}
|
||||||
# SSH连接信息 (根据分支自动选择)
|
# SSH连接信息 (根据分支自动选择服务器和用户)
|
||||||
SSH_HOST: ${{ github.ref_name == 'main' && vars.SSH_HOST || vars.DEV_SSH_HOST }}
|
SSH_HOST: ${{ github.ref_name == 'main' && vars.SSH_HOST || vars.DEV_SSH_HOST }}
|
||||||
SSH_PORT: ${{ vars.SSH_PORT }}
|
SSH_PORT: ${{ vars.SSH_PORT }}
|
||||||
SSH_USER: ${{ vars.SSH_USER }}
|
SSH_USER: ${{ github.ref_name == 'main' && 'ubuntu' || 'root' }}
|
||||||
SSH_PASSWORD: ${{ github.ref_name == 'main' && vars.SSH_PASSWORD || vars.DEV_SSH_PASSWORD }}
|
# SSH私钥(Gitea Secret 名称:AWS)
|
||||||
|
SSH_KEY: ${{ secrets.AWS }}
|
||||||
# TG通知
|
# TG通知
|
||||||
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
||||||
TG_CHAT_ID: "-4940243803"
|
TG_CHAT_ID: "-4940243803"
|
||||||
@@ -49,12 +50,12 @@ jobs:
|
|||||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV
|
echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV
|
||||||
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
|
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
|
||||||
echo "DEPLOY_PATH=/root/hifast" >> $GITHUB_ENV
|
echo "DEPLOY_PATH=/opt/ppanel" >> $GITHUB_ENV
|
||||||
echo "为 main 分支设置生产环境变量"
|
echo "为 main 分支设置生产环境变量"
|
||||||
elif [ "${{ github.ref_name }}" = "internal" ]; then
|
elif [ "${{ github.ref_name }}" = "internal" ]; then
|
||||||
echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV
|
echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV
|
||||||
echo "CONTAINER_NAME=ppanel-server-internal" >> $GITHUB_ENV
|
echo "CONTAINER_NAME=ppanel-server-internal" >> $GITHUB_ENV
|
||||||
echo "DEPLOY_PATH=/root/hifast" >> $GITHUB_ENV
|
echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV
|
||||||
echo "为 internal 分支设置开发环境变量"
|
echo "为 internal 分支设置开发环境变量"
|
||||||
else
|
else
|
||||||
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
|
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||||
@@ -137,17 +138,15 @@ jobs:
|
|||||||
|
|
||||||
echo "镜像推送完成"
|
echo "镜像推送完成"
|
||||||
|
|
||||||
# 调试: 打印 SSH 连接信息
|
# 调试: 打印部署目标(不输出敏感信息)
|
||||||
- name: 🔍 调试 - 打印 SSH 连接信息
|
- name: 🔍 调试 - 打印部署目标
|
||||||
run: |
|
run: |
|
||||||
echo "========== SSH 连接信息调试 =========="
|
echo "========== 部署目标调试 =========="
|
||||||
echo "当前分支: ${{ github.ref_name }}"
|
echo "当前分支: ${{ github.ref_name }}"
|
||||||
echo "SSH_HOST: ${{ env.SSH_HOST }}"
|
echo "SSH_HOST: ${{ env.SSH_HOST }}"
|
||||||
echo "SSH_PORT: ${{ env.SSH_PORT }}"
|
echo "SSH_PORT: ${{ env.SSH_PORT }}"
|
||||||
echo "SSH_USER: ${{ env.SSH_USER }}"
|
echo "SSH_USER: ${{ env.SSH_USER }}"
|
||||||
echo "SSH_PASSWORD 长度: ${#SSH_PASSWORD}"
|
echo "SSH认证方式: 私钥 (AWS)"
|
||||||
echo "SSH_PASSWORD 前3位: $(echo "$SSH_PASSWORD" | cut -c1-3)***"
|
|
||||||
echo "SSH_PASSWORD 完整值: ${{ env.SSH_PASSWORD }}"
|
|
||||||
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
|
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
|
||||||
echo "====================================="
|
echo "====================================="
|
||||||
|
|
||||||
@@ -157,10 +156,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
host: ${{ env.SSH_HOST }}
|
host: ${{ env.SSH_HOST }}
|
||||||
username: ${{ env.SSH_USER }}
|
username: ${{ env.SSH_USER }}
|
||||||
password: ${{ env.SSH_PASSWORD }}
|
key: ${{ env.SSH_KEY }}
|
||||||
port: ${{ env.SSH_PORT }}
|
port: ${{ env.SSH_PORT }}
|
||||||
source: "docker-compose.cloud.yml"
|
source: "docker-compose.cloud.yml"
|
||||||
target: "${{ env.DEPLOY_PATH }}/"
|
target: "/tmp/ppanel-deploy/"
|
||||||
|
|
||||||
# 步骤6: 连接服务器更新并启动
|
# 步骤6: 连接服务器更新并启动
|
||||||
- name: 🚀 连接服务器更新并启动
|
- name: 🚀 连接服务器更新并启动
|
||||||
@@ -168,7 +167,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
host: ${{ env.SSH_HOST }}
|
host: ${{ env.SSH_HOST }}
|
||||||
username: ${{ env.SSH_USER }}
|
username: ${{ env.SSH_USER }}
|
||||||
password: ${{ env.SSH_PASSWORD }}
|
key: ${{ env.SSH_KEY }}
|
||||||
port: ${{ env.SSH_PORT }}
|
port: ${{ env.SSH_PORT }}
|
||||||
timeout: 300s
|
timeout: 300s
|
||||||
command_timeout: 600s
|
command_timeout: 600s
|
||||||
@@ -176,23 +175,27 @@ jobs:
|
|||||||
echo "连接服务器成功,开始部署..."
|
echo "连接服务器成功,开始部署..."
|
||||||
echo "部署目录: ${{ env.DEPLOY_PATH }}"
|
echo "部署目录: ${{ env.DEPLOY_PATH }}"
|
||||||
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
||||||
|
echo "登录用户: ${{ env.SSH_USER }}"
|
||||||
|
|
||||||
# 进入部署目录
|
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||||
cd ${{ env.DEPLOY_PATH }}
|
sudo mkdir -p ${{ env.DEPLOY_PATH }}
|
||||||
|
sudo cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
|
||||||
# 创建/更新环境变量文件
|
cd ${{ env.DEPLOY_PATH }}
|
||||||
# echo "PPANEL_SERVER_TAG=${{ env.DOCKER_TAG_SUFFIX }}" > .env
|
echo "📥 拉取镜像..."
|
||||||
|
sudo docker-compose -f docker-compose.cloud.yml pull ppanel-server
|
||||||
# 拉取最新镜像
|
echo "🚀 启动服务..."
|
||||||
echo "📥 拉取镜像..."
|
sudo docker-compose -f docker-compose.cloud.yml up -d ppanel-server
|
||||||
docker-compose -f docker-compose.cloud.yml pull ppanel-server
|
sudo docker image prune -f || true
|
||||||
|
else
|
||||||
# 启动服务
|
mkdir -p ${{ env.DEPLOY_PATH }}
|
||||||
echo "🚀 启动服务..."
|
cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
|
||||||
docker-compose -f docker-compose.cloud.yml up -d ppanel-server
|
cd ${{ env.DEPLOY_PATH }}
|
||||||
|
echo "📥 拉取镜像..."
|
||||||
# 清理未使用的镜像
|
docker-compose -f docker-compose.cloud.yml pull ppanel-server
|
||||||
docker image prune -f || true
|
echo "🚀 启动服务..."
|
||||||
|
docker-compose -f docker-compose.cloud.yml up -d ppanel-server
|
||||||
|
docker image prune -f || true
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✅ 部署命令执行完成"
|
echo "✅ 部署命令执行完成"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
upstream api_backend {
|
||||||
|
server 127.0.0.1:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
server_name api.hifast.biz 4d3vsw888xgaen.hifast.biz;
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
allow all;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name api.hifast.biz;
|
||||||
|
client_max_body_size 150M;
|
||||||
|
ssl_certificate /etc/nginx/ssl/hifast.biz/_.hifast.biz.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/hifast.biz/_.hifast.biz.key; # managed by Certbot
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options "DENY";
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
if ($http_user_agent ~* '(9999|91\.78)') {
|
||||||
|
return 444;
|
||||||
|
}
|
||||||
|
location ~ ^/v1/common/client/download/file/(?<download_name>Hi快VPN-(?<os>windows|mac|android)-1.0.0-ic-.*\.(?<ext>exe|dmg|apk))$ {
|
||||||
|
alias /var/www/download/Hi快VPN-$os-1.0.0.$ext;
|
||||||
|
charset utf-8;
|
||||||
|
add_header Content-Disposition 'attachment; filename="$download_name"';
|
||||||
|
add_header Content-Type application/octet-stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /v1/common/client/download/file/ {
|
||||||
|
alias /var/www/download/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://api_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name 4d3vsw888xgaen.hifast.biz;
|
||||||
|
client_max_body_size 150M;
|
||||||
|
ssl_certificate /etc/nginx/ssl/hifast.biz/_.hifast.biz.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/hifast.biz/_.hifast.biz.key; # managed by Certbot
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options DENY;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json image/svg+xml;
|
||||||
|
root /var/www/admin;
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+32
-4
@@ -44,13 +44,13 @@ type (
|
|||||||
Balance int64 `json:"balance"`
|
Balance int64 `json:"balance"`
|
||||||
Commission int64 `json:"commission"`
|
Commission int64 `json:"commission"`
|
||||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||||
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||||
GiftAmount int64 `json:"gift_amount"`
|
GiftAmount int64 `json:"gift_amount"`
|
||||||
Telegram int64 `json:"telegram"`
|
Telegram int64 `json:"telegram"`
|
||||||
ReferCode string `json:"refer_code"`
|
ReferCode string `json:"refer_code"`
|
||||||
RefererId int64 `json:"referer_id"`
|
RefererId int64 `json:"referer_id"`
|
||||||
Enable bool `json:"enable"`
|
Enable *bool `json:"enable"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin *bool `json:"is_admin"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
UpdateUserNotifySettingRequest {
|
UpdateUserNotifySettingRequest {
|
||||||
@@ -230,6 +230,23 @@ type (
|
|||||||
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
|
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
GetWithdrawalListRequest {
|
||||||
|
Page int `form:"page"`
|
||||||
|
Size int `form:"size"`
|
||||||
|
UserId *int64 `form:"user_id,omitempty"`
|
||||||
|
Status *uint8 `form:"status,omitempty"`
|
||||||
|
}
|
||||||
|
GetWithdrawalListResponse {
|
||||||
|
List []WithdrawalLog `json:"list"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
ApproveWithdrawalRequest {
|
||||||
|
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||||
|
}
|
||||||
|
RejectWithdrawalRequest {
|
||||||
|
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||||
|
Reason string `json:"reason" validate:"required,max=500"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@server (
|
@server (
|
||||||
@@ -370,5 +387,16 @@ service ppanel {
|
|||||||
@doc "Dissolve family"
|
@doc "Dissolve family"
|
||||||
@handler DissolveFamily
|
@handler DissolveFamily
|
||||||
put /family/dissolve (DissolveFamilyRequest)
|
put /family/dissolve (DissolveFamilyRequest)
|
||||||
}
|
|
||||||
|
|
||||||
|
@doc "Get withdrawal list"
|
||||||
|
@handler GetWithdrawalList
|
||||||
|
get /withdrawal/list (GetWithdrawalListRequest) returns (GetWithdrawalListResponse)
|
||||||
|
|
||||||
|
@doc "Approve withdrawal"
|
||||||
|
@handler ApproveWithdrawal
|
||||||
|
post /withdrawal/approve (ApproveWithdrawalRequest)
|
||||||
|
|
||||||
|
@doc "Reject withdrawal"
|
||||||
|
@handler RejectWithdrawal
|
||||||
|
post /withdrawal/reject (RejectWithdrawalRequest)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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 (
|
||||||
|
FileUploadRequest {
|
||||||
|
BizType string `form:"biz_type" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
FileUploadResponse {
|
||||||
|
FileId string `json:"file_id"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
ObjectKey string `json:"object_key"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Etag string `json:"etag"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "Upload file to RustFS"
|
||||||
|
@handler FileUpload
|
||||||
|
post /upload (FileUploadRequest) returns (FileUploadResponse)
|
||||||
|
|
||||||
|
@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,41 @@
|
|||||||
|
syntax = "v1"
|
||||||
|
|
||||||
|
info (
|
||||||
|
title: "recovery API"
|
||||||
|
desc: "API for order recovery"
|
||||||
|
author: "Tension"
|
||||||
|
email: "tension@ppanel.com"
|
||||||
|
version: "0.0.1"
|
||||||
|
)
|
||||||
|
|
||||||
|
import "../types.api"
|
||||||
|
|
||||||
|
type (
|
||||||
|
RecoverySendCodeRequest {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
}
|
||||||
|
RecoverOrderRequest {
|
||||||
|
OrderNo string `json:"order_no" validate:"required"`
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Code string `json:"code" validate:"required"`
|
||||||
|
}
|
||||||
|
RecoverOrderResponse {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@server (
|
||||||
|
prefix: v1/public/recovery
|
||||||
|
group: public/recovery
|
||||||
|
middleware: DeviceMiddleware
|
||||||
|
)
|
||||||
|
service ppanel {
|
||||||
|
@doc "Send recovery verification code"
|
||||||
|
@handler SendCode
|
||||||
|
post /send_code (RecoverySendCodeRequest) returns (SendCodeResponse)
|
||||||
|
|
||||||
|
@doc "Recover order subscription"
|
||||||
|
@handler RecoverOrder
|
||||||
|
post /order (RecoverOrderRequest) returns (RecoverOrderResponse)
|
||||||
|
}
|
||||||
+1
-1
@@ -27,6 +27,7 @@ type (
|
|||||||
EnableLoginNotify bool `json:"enable_login_notify"`
|
EnableLoginNotify bool `json:"enable_login_notify"`
|
||||||
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
||||||
EnableTradeNotify bool `json:"enable_trade_notify"`
|
EnableTradeNotify bool `json:"enable_trade_notify"`
|
||||||
|
UseStatus bool `json:"use_status"` // Whether to show the "bind email to get free trial" prompt
|
||||||
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
||||||
UserDevices []UserDevice `json:"user_devices"`
|
UserDevices []UserDevice `json:"user_devices"`
|
||||||
Rules []string `json:"rules"`
|
Rules []string `json:"rules"`
|
||||||
@@ -1004,4 +1005,3 @@ type (
|
|||||||
ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"`
|
ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,796 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||||
|
ordermodel "github.com/perfect-panel/server/internal/model/order"
|
||||||
|
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/pkg/conf"
|
||||||
|
"github.com/perfect-panel/server/pkg/orm"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
retroactiveReferralCmd.Flags().StringVar(&retroAgentIdStr, "agent-id", "", "目标代理用户 ID,或 * 表示所有 referral_percentage>0 的代理(必填)")
|
||||||
|
retroactiveReferralCmd.Flags().StringVar(&retroPoolStart, "pool-start", "2025-05-04", "自然流量订单起始时间,格式 YYYY-MM-DD 或 'YYYY-MM-DD HH:MM:SS'(默认 2025-05-04)")
|
||||||
|
retroactiveReferralCmd.Flags().StringVar(&retroPoolEnd, "pool-end", "", "自然流量订单截止时间,格式 YYYY-MM-DD 或 'YYYY-MM-DD HH:MM:SS'(默认今天)")
|
||||||
|
retroactiveReferralCmd.Flags().IntVar(&retroPercentage, "percentage", 120, "补偿百分比,例如 120 表示 120%(默认 120)")
|
||||||
|
retroactiveReferralCmd.Flags().IntVar(&retroForceCommissionPct, "force-commission-pct", 50, "强制指定发佣比例(0=使用数据库/配置,非0时覆盖代理设置,默认 50%)")
|
||||||
|
retroactiveReferralCmd.Flags().StringVar(&retroOutput, "output", "retro_result.txt", "结果输出到指定 txt 文件(默认 retro_result.txt)")
|
||||||
|
retroactiveReferralCmd.Flags().StringVar(&retroConfigPath, "config", "etc/ppanel.yaml", "配置文件路径")
|
||||||
|
retroactiveReferralCmd.Flags().StringVar(&retroAgentCreatedAfter, "agent-created-after", "", "仅处理在此日期之后注册的代理,格式 YYYY-MM-DD(留空=不限)")
|
||||||
|
retroactiveReferralCmd.Flags().StringVar(&retroLossStart, "loss-start", "2026-05-06", "数据丢失起始时间,丢失时长=现在-此时间(默认 2026-05-06)")
|
||||||
|
retroactiveReferralCmd.Flags().StringVar(&retroOrderStart, "order-start", "2026-05-01", "池内用户至少有一笔 updated_at >= 此时间的订单才入池(默认 2026-05-01)")
|
||||||
|
retroactiveReferralCmd.Flags().BoolVar(&retroDryRun, "dry-run", false, "仅预览,不执行写入")
|
||||||
|
rootCmd.AddCommand(retroactiveReferralCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
retroAgentIdStr string
|
||||||
|
retroAgentCreatedAfter string
|
||||||
|
retroLossStart string
|
||||||
|
retroOrderStart string
|
||||||
|
retroPoolStart string
|
||||||
|
retroPoolEnd string
|
||||||
|
retroPercentage int
|
||||||
|
retroForceCommissionPct int
|
||||||
|
retroConfigPath string
|
||||||
|
retroDryRun bool
|
||||||
|
retroOutput string
|
||||||
|
)
|
||||||
|
|
||||||
|
var retroactiveReferralCmd = &cobra.Command{
|
||||||
|
Use: "retro-referral",
|
||||||
|
Short: "补单:按代理历史日均佣金补偿指定比例的用户",
|
||||||
|
Long: `统计代理从首次邀请到 pool-end 的日均佣金,
|
||||||
|
按指定百分比计算目标补偿金额,
|
||||||
|
从 pool-start 到 pool-end 的自然流量用户中随机抽取匹配的用户数量挂载到该代理。
|
||||||
|
--agent-id 支持单个 ID 或 *(处理所有 referral_percentage>0 的代理)。`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if retroAgentIdStr == "" {
|
||||||
|
return fmt.Errorf("--agent-id 必填(单个 ID 或 *)")
|
||||||
|
}
|
||||||
|
return runRetroactiveReferral()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// commissionRule holds resolved commission settings for an agent.
|
||||||
|
type commissionRule struct {
|
||||||
|
Percentage uint8
|
||||||
|
OnlyFirstPurchase bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateUser holds a pool user plus their pre-calculated qualifying orders.
|
||||||
|
type candidateUser struct {
|
||||||
|
Id int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
Identifier string
|
||||||
|
Orders []ordermodel.Order
|
||||||
|
CommissionTotal int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentPlan holds one agent's computed allocation plan (preview phase output).
|
||||||
|
type agentPlan struct {
|
||||||
|
Agent *usermodel.User
|
||||||
|
Rule commissionRule
|
||||||
|
Selected []candidateUser
|
||||||
|
TargetAmt float64 // in cents
|
||||||
|
PreviewCommission int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRetroactiveReferral() error {
|
||||||
|
// ── 0. 初始化输出(终端 + 可选文件)─────────────────────────
|
||||||
|
var w io.Writer = os.Stdout
|
||||||
|
if retroOutput != "" {
|
||||||
|
f, err := os.Create(retroOutput)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建输出文件失败: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w = io.MultiWriter(os.Stdout, f)
|
||||||
|
fmt.Printf("结果将同步写入: %s\n\n", retroOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. 加载配置 ──────────────────────────────────────────────
|
||||||
|
var c config.Config
|
||||||
|
conf.MustLoad(retroConfigPath, &c)
|
||||||
|
|
||||||
|
// ── 2. 初始化 DB + Redis ──────────────────────────────────────
|
||||||
|
db, err := orm.ConnectMysql(orm.Mysql{Config: c.MySQL})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("连接数据库失败: %w", err)
|
||||||
|
}
|
||||||
|
rds := redis.NewClient(&redis.Options{
|
||||||
|
Addr: c.Redis.Host,
|
||||||
|
Password: c.Redis.Pass,
|
||||||
|
DB: c.Redis.DB,
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
if err = rds.Ping(ctx).Err(); err != nil {
|
||||||
|
return fmt.Errorf("连接 Redis 失败: %w", err)
|
||||||
|
}
|
||||||
|
um := usermodel.NewModel(db, rds)
|
||||||
|
|
||||||
|
// ── 3. 解析时间参数 ───────────────────────────────────────────
|
||||||
|
poolStart, err := parseFlexibleTime(retroPoolStart)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("--pool-start 格式错误: %w", err)
|
||||||
|
}
|
||||||
|
poolEnd := time.Now()
|
||||||
|
if retroPoolEnd != "" {
|
||||||
|
poolEnd, err = parseFlexibleTime(retroPoolEnd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("--pool-end 格式错误: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !poolEnd.After(poolStart) {
|
||||||
|
return fmt.Errorf("--pool-end 必须晚于 --pool-start")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. 确定代理列表 ───────────────────────────────────────────
|
||||||
|
var agents []*usermodel.User
|
||||||
|
if retroAgentIdStr == "*" {
|
||||||
|
agents, err = queryAllActiveAgents(ctx, db, retroAgentCreatedAfter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查询代理列表失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(agents) == 0 {
|
||||||
|
return fmt.Errorf("没有找到任何 referral_percentage>0 的代理用户")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "模式:全量代理,共找到 %d 个代理(referral_percentage>0)\n\n", len(agents))
|
||||||
|
} else {
|
||||||
|
agentID, parseErr := strconv.ParseInt(retroAgentIdStr, 10, 64)
|
||||||
|
if parseErr != nil || agentID <= 0 {
|
||||||
|
return fmt.Errorf("--agent-id 必须是正整数或 *")
|
||||||
|
}
|
||||||
|
agent, findErr := um.FindOne(ctx, agentID)
|
||||||
|
if findErr != nil {
|
||||||
|
return fmt.Errorf("查询代理用户失败: %w", findErr)
|
||||||
|
}
|
||||||
|
if agent.DeletedAt.Valid {
|
||||||
|
return fmt.Errorf("代理用户 %d 已被删除", agentID)
|
||||||
|
}
|
||||||
|
agents = []*usermodel.User{agent}
|
||||||
|
}
|
||||||
|
|
||||||
|
if retroForceCommissionPct > 0 {
|
||||||
|
fmt.Fprintf(w, "⚠️ 强制覆盖所有代理佣金比例为 %d%%\n\n", retroForceCommissionPct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. 查询自然流量用户池(所有代理共用同一个池)────────────
|
||||||
|
// 先用 50% 规则(或强制值)预加载池,以便预览;执行时每个代理用自身规则
|
||||||
|
var orderStart time.Time
|
||||||
|
if retroOrderStart != "" {
|
||||||
|
orderStart, err = parseFlexibleTime(retroOrderStart)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("--order-start 格式错误: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewRule := commissionRule{Percentage: uint8(retroForceCommissionPct), OnlyFirstPurchase: false}
|
||||||
|
pool, err := queryNaturalTrafficPool(ctx, db, poolStart, poolEnd, orderStart, previewRule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查询自然流量用户池失败: %w", err)
|
||||||
|
}
|
||||||
|
orderStartDesc := ""
|
||||||
|
if !orderStart.IsZero() {
|
||||||
|
orderStartDesc = fmt.Sprintf(",订单 updated_at >= %s", orderStart.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "自然流量用户池(%s ~ %s,referer_id=0,有已支付订单%s):共 %d 人\n\n",
|
||||||
|
poolStart.Format("2006-01-02 15:04"), poolEnd.Format("2006-01-02 15:04"), orderStartDesc, len(pool))
|
||||||
|
if len(pool) == 0 {
|
||||||
|
return fmt.Errorf("自然流量用户池为空,无法补充")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. 逐代理生成分配计划(预览阶段)────────────────────────
|
||||||
|
// 池按顺序分配:每个代理从剩余池中取用户,避免重复分配
|
||||||
|
remainingPool := make([]candidateUser, len(pool))
|
||||||
|
copy(remainingPool, pool)
|
||||||
|
|
||||||
|
plans := make([]agentPlan, 0, len(agents))
|
||||||
|
for _, agent := range agents {
|
||||||
|
plan, planErr := buildAgentPlan(w, ctx, db, c, agent, remainingPool, poolEnd)
|
||||||
|
if planErr != nil {
|
||||||
|
fmt.Fprintf(w, "⚠️ 代理 %d 跳过: %v\n\n", agent.Id, planErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 从剩余池中移除已分配给该代理的用户
|
||||||
|
assignedSet := make(map[int64]struct{}, len(plan.Selected))
|
||||||
|
for _, u := range plan.Selected {
|
||||||
|
assignedSet[u.Id] = struct{}{}
|
||||||
|
}
|
||||||
|
newRemaining := remainingPool[:0]
|
||||||
|
for _, u := range remainingPool {
|
||||||
|
if _, used := assignedSet[u.Id]; !used {
|
||||||
|
newRemaining = append(newRemaining, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remainingPool = newRemaining
|
||||||
|
plans = append(plans, plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plans) == 0 {
|
||||||
|
return fmt.Errorf("所有代理均无法生成分配计划")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. 汇总预览 ───────────────────────────────────────────────
|
||||||
|
var grandTotalUsers int
|
||||||
|
var grandTotalCommission int64
|
||||||
|
var grandTargetAmt float64
|
||||||
|
for _, p := range plans {
|
||||||
|
grandTotalUsers += len(p.Selected)
|
||||||
|
grandTotalCommission += p.PreviewCommission
|
||||||
|
grandTargetAmt += p.TargetAmt
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, strings.Repeat("═", 75))
|
||||||
|
fmt.Fprintf(w, "汇总:共 %d 个代理,补充 %d 个用户\n", len(plans), grandTotalUsers)
|
||||||
|
fmt.Fprintf(w, " 预计追溯佣金总额: $%.2f(目标补偿金额: $%.2f)\n",
|
||||||
|
float64(grandTotalCommission)/100, grandTargetAmt/100)
|
||||||
|
fmt.Fprintln(w, strings.Repeat("═", 75))
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
|
||||||
|
if retroDryRun {
|
||||||
|
fmt.Fprintln(w, "[dry-run] 预览完成,未执行任何写入。")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. 执行前汇总打印 ─────────────────────────────────────────
|
||||||
|
fmt.Fprintf(w, "\n┌─────────────────────────────────────────────┐\n")
|
||||||
|
fmt.Fprintf(w, "│ 即将写入数据库 │\n")
|
||||||
|
fmt.Fprintf(w, "│ 代理数量 : %-4d 个 │\n", len(plans))
|
||||||
|
fmt.Fprintf(w, "│ 补充用户 : %-4d 人 │\n", grandTotalUsers)
|
||||||
|
fmt.Fprintf(w, "│ 赠送金额 : $%-10.2f │\n", float64(grandTotalCommission)/100)
|
||||||
|
fmt.Fprintf(w, "└─────────────────────────────────────────────┘\n\n")
|
||||||
|
fmt.Printf("确认执行?(yes/no): ")
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
answer, _ := reader.ReadString('\n')
|
||||||
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||||
|
if answer != "yes" && answer != "y" {
|
||||||
|
fmt.Fprintln(w, "已取消。")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 9. 逐代理执行 ─────────────────────────────────────────────
|
||||||
|
var totalSuccess, totalFailed int
|
||||||
|
var totalCreditedOrder, totalCreditedAmt int64
|
||||||
|
|
||||||
|
for _, plan := range plans {
|
||||||
|
fmt.Fprintf(w, "\n── 执行代理 %d ──────────────────────────────────────────────\n", plan.Agent.Id)
|
||||||
|
var sc, fc int
|
||||||
|
var co, ca int64
|
||||||
|
for _, eu := range plan.Selected {
|
||||||
|
if eu.Id == plan.Agent.Id {
|
||||||
|
fmt.Fprintf(w, "[SKIP] 用户 %d 与代理相同,跳过\n", eu.Id)
|
||||||
|
fc++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
credited, amount, execErr := processOneUser(ctx, db, um, plan.Agent.Id, eu.Id, plan.Rule, orderStart)
|
||||||
|
if execErr != nil {
|
||||||
|
fmt.Fprintf(w, "[FAIL] 用户 %d: %v\n", eu.Id, execErr)
|
||||||
|
fc++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "[OK] 用户 %d → 代理 %d,发佣 %d 单,金额 $%.2f\n",
|
||||||
|
eu.Id, plan.Agent.Id, credited, float64(amount)/100)
|
||||||
|
sc++
|
||||||
|
co += credited
|
||||||
|
ca += amount
|
||||||
|
}
|
||||||
|
// 直接删除代理的缓存 key,下次请求时从 DB 重新加载(避免 FindOne 读到旧缓存再写回)
|
||||||
|
if sc > 0 {
|
||||||
|
cacheKey := fmt.Sprintf("cache:user:id:%d", plan.Agent.Id)
|
||||||
|
_ = rds.Del(ctx, cacheKey).Err()
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " 代理 %d 小计:成功 %d 人,失败 %d 人,佣金 $%.2f\n", plan.Agent.Id, sc, fc, float64(ca)/100)
|
||||||
|
totalSuccess += sc
|
||||||
|
totalFailed += fc
|
||||||
|
totalCreditedOrder += co
|
||||||
|
totalCreditedAmt += ca
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 10. 全局汇总 ──────────────────────────────────────────────
|
||||||
|
fmt.Fprintf(w, "\n══════════════════════════════════════════════\n")
|
||||||
|
fmt.Fprintf(w, " 成功挂载 : %d 人\n", totalSuccess)
|
||||||
|
fmt.Fprintf(w, " 失败/跳过 : %d 人\n", totalFailed)
|
||||||
|
fmt.Fprintf(w, " 追溯佣金 : %d 单,总额 $%.2f\n", totalCreditedOrder, float64(totalCreditedAmt)/100)
|
||||||
|
fmt.Fprintf(w, "══════════════════════════════════════════════\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAgentPlan 计算一个代理的补单预览,同时打印预览内容,返回分配计划。
|
||||||
|
func buildAgentPlan(w io.Writer, ctx context.Context, db *gorm.DB, c config.Config,
|
||||||
|
agent *usermodel.User, pool []candidateUser, poolEnd time.Time) (agentPlan, error) {
|
||||||
|
|
||||||
|
rule := resolveCommissionRule(agent, c)
|
||||||
|
if retroForceCommissionPct > 0 {
|
||||||
|
rule.Percentage = uint8(retroForceCommissionPct)
|
||||||
|
}
|
||||||
|
// 补单场景:不区分新购/续费,所有已支付订单均参与佣金计算
|
||||||
|
rule.OnlyFirstPurchase = false
|
||||||
|
|
||||||
|
firstReferralTime, lastReferralTime, agentCreatedAt, totalReferred, totalOrders, totalCommission, err :=
|
||||||
|
queryAgentStats(ctx, db, agent.Id, poolEnd)
|
||||||
|
if err != nil {
|
||||||
|
return agentPlan{}, fmt.Errorf("查询历史数据失败: %w", err)
|
||||||
|
}
|
||||||
|
if totalReferred == 0 {
|
||||||
|
return agentPlan{}, fmt.Errorf("在 %s 之前没有任何邀请记录", poolEnd.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
|
lossStartTime, err := parseFlexibleTime(retroLossStart)
|
||||||
|
if err != nil {
|
||||||
|
return agentPlan{}, fmt.Errorf("--loss-start 格式错误: %w", err)
|
||||||
|
}
|
||||||
|
lossHours := time.Now().Sub(lossStartTime).Hours()
|
||||||
|
|
||||||
|
statsDays := lastReferralTime.Sub(firstReferralTime).Hours() / 24
|
||||||
|
if statsDays < 1 {
|
||||||
|
statsDays = 1
|
||||||
|
}
|
||||||
|
dailyAvgOrders := float64(totalOrders) / statsDays
|
||||||
|
avgOrdersPerUser := float64(totalOrders) / float64(totalReferred)
|
||||||
|
if avgOrdersPerUser < 1 {
|
||||||
|
avgOrdersPerUser = 1
|
||||||
|
}
|
||||||
|
dailyAvgCommission := float64(totalCommission) / statsDays
|
||||||
|
|
||||||
|
// 用用户池自身的平均佣金估算人数(避免历史费率与当前50%费率不匹配导致超发)
|
||||||
|
var poolAvgCommissionPerUser float64
|
||||||
|
if len(pool) > 0 {
|
||||||
|
var poolCommTotal int64
|
||||||
|
for _, u := range pool {
|
||||||
|
poolCommTotal += u.CommissionTotal
|
||||||
|
}
|
||||||
|
poolAvgCommissionPerUser = float64(poolCommTotal) / float64(len(pool))
|
||||||
|
}
|
||||||
|
if poolAvgCommissionPerUser < 1 {
|
||||||
|
poolAvgCommissionPerUser = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
estimatedLostCommission := dailyAvgCommission * (lossHours / 24)
|
||||||
|
targetCommission := estimatedLostCommission * (float64(retroPercentage) / 100)
|
||||||
|
extraCount := int(targetCommission/poolAvgCommissionPerUser + 0.5)
|
||||||
|
if extraCount < 1 {
|
||||||
|
extraCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "\n═══════════════════════════════════════════════════════════\n")
|
||||||
|
fmt.Fprintf(w, " 代理 ID : %d(注册于 %s)\n", agent.Id, agentCreatedAt.Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Fprintf(w, " 统计起点 : %s(首次邀请时间)\n", firstReferralTime.Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Fprintf(w, " 统计截止 : %s(最后邀请时间)\n", lastReferralTime.Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Fprintf(w, " 统计天数 : %.2f 天\n", statsDays)
|
||||||
|
fmt.Fprintf(w, " 历史邀请总人数 : %d 人\n", totalReferred)
|
||||||
|
fmt.Fprintf(w, " 下线总订单数 : %d 单(所有下线,不限时间)\n", totalOrders)
|
||||||
|
fmt.Fprintf(w, " 日均订单 : %.4f 单/天\n", dailyAvgOrders)
|
||||||
|
fmt.Fprintf(w, " 每用户平均订单 : %.4f 单\n", avgOrdersPerUser)
|
||||||
|
fmt.Fprintf(w, " 历史佣金总额 : $%.2f\n", float64(totalCommission)/100)
|
||||||
|
fmt.Fprintf(w, " 日均佣金 : $%.4f\n", dailyAvgCommission/100)
|
||||||
|
fmt.Fprintf(w, " 池内用户均佣金 : $%.4f\n", poolAvgCommissionPerUser/100)
|
||||||
|
fmt.Fprintf(w, " 佣金规则 : %d%% 仅首单=%v\n", rule.Percentage, rule.OnlyFirstPurchase)
|
||||||
|
fmt.Fprintf(w, "───────────────────────────────────────────────────────────\n")
|
||||||
|
fmt.Fprintf(w, " 丢失时长 : %.2f 小时(%s → 现在)\n",
|
||||||
|
lossHours, lossStartTime.Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Fprintf(w, " 预估丢失佣金 : $%.4f($%.4f × %.2f/24)\n",
|
||||||
|
estimatedLostCommission/100, dailyAvgCommission/100, lossHours)
|
||||||
|
fmt.Fprintf(w, " 目标补偿金额 : $%.4f(× %d%%)\n",
|
||||||
|
targetCommission/100, retroPercentage)
|
||||||
|
fmt.Fprintf(w, " 需补充人数 : %d 人($%.4f ÷ $%.4f)\n",
|
||||||
|
extraCount, targetCommission/100, poolAvgCommissionPerUser/100)
|
||||||
|
fmt.Fprintf(w, "═══════════════════════════════════════════════════════════\n\n")
|
||||||
|
|
||||||
|
// 重新按当前代理规则计算池内用户佣金(pool 由调用方传入,已是当前规则计算好的)
|
||||||
|
if len(pool) == 0 {
|
||||||
|
return agentPlan{}, fmt.Errorf("剩余用户池为空")
|
||||||
|
}
|
||||||
|
if len(pool) < extraCount {
|
||||||
|
fmt.Fprintf(w, "⚠️ 剩余用户池只有 %d 人,少于需要的 %d 人,将全部分配\n\n", len(pool), extraCount)
|
||||||
|
extraCount = len(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := randomSampleCandidates(pool, extraCount)
|
||||||
|
|
||||||
|
// 兜底追加:确保佣金合计 >= 目标
|
||||||
|
{
|
||||||
|
selectedSet := make(map[int64]struct{}, len(selected))
|
||||||
|
var selectedCommTotal int64
|
||||||
|
for _, u := range selected {
|
||||||
|
selectedSet[u.Id] = struct{}{}
|
||||||
|
selectedCommTotal += u.CommissionTotal
|
||||||
|
}
|
||||||
|
if selectedCommTotal < int64(targetCommission) {
|
||||||
|
remaining := make([]candidateUser, 0, len(pool)-len(selected))
|
||||||
|
for _, u := range pool {
|
||||||
|
if _, used := selectedSet[u.Id]; !used {
|
||||||
|
remaining = append(remaining, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 按佣金从小到大排序,追加时精准补足,减少超发
|
||||||
|
sort.Slice(remaining, func(i, j int) bool {
|
||||||
|
return remaining[i].CommissionTotal < remaining[j].CommissionTotal
|
||||||
|
})
|
||||||
|
for _, u := range remaining {
|
||||||
|
if selectedCommTotal >= int64(targetCommission) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
selected = append(selected, u)
|
||||||
|
selectedCommTotal += u.CommissionTotal
|
||||||
|
}
|
||||||
|
if selectedCommTotal < int64(targetCommission) {
|
||||||
|
fmt.Fprintf(w, "⚠️ 用户池佣金不足,已抽取全部可用用户(实际 $%.2f < 目标 $%.2f)\n\n",
|
||||||
|
float64(selectedCommTotal)/100, targetCommission/100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印选中用户明细
|
||||||
|
var previewTotalCommission int64
|
||||||
|
fmt.Fprintf(w, "随机抽取 %d 个用户(含待追溯佣金订单):\n", len(selected))
|
||||||
|
fmt.Fprintln(w, strings.Repeat("═", 75))
|
||||||
|
for i, u := range selected {
|
||||||
|
fmt.Fprintf(w, "[%d] 用户 %-10d 注册: %s %s\n",
|
||||||
|
i+1, u.Id, u.CreatedAt.Format("2006-01-02 15:04:05"), u.Identifier)
|
||||||
|
if len(u.Orders) == 0 {
|
||||||
|
fmt.Fprintln(w, " (无符合条件的订单)")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, " %-38s %10s %8s %10s %s\n", "订单号", "金额", "手续费", "佣金", "类型")
|
||||||
|
fmt.Fprintf(w, " %s\n", strings.Repeat("-", 72))
|
||||||
|
for _, od := range u.Orders {
|
||||||
|
commAmt := calcCommissionAmount(od.Amount, od.FeeAmount, rule.Percentage)
|
||||||
|
orderType := "首购"
|
||||||
|
if od.Type == 2 {
|
||||||
|
orderType = "续费"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " %-38s $%8.2f $%6.2f $%8.2f %s\n",
|
||||||
|
od.OrderNo,
|
||||||
|
float64(od.Amount)/100,
|
||||||
|
float64(od.FeeAmount)/100,
|
||||||
|
float64(commAmt)/100,
|
||||||
|
orderType)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " 本用户追溯佣金合计: $%.2f\n", float64(u.CommissionTotal)/100)
|
||||||
|
}
|
||||||
|
previewTotalCommission += u.CommissionTotal
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, strings.Repeat("═", 75))
|
||||||
|
fmt.Fprintf(w, "预计追溯佣金总额: $%.2f(目标补偿金额: $%.2f)\n\n",
|
||||||
|
float64(previewTotalCommission)/100, targetCommission/100)
|
||||||
|
|
||||||
|
return agentPlan{
|
||||||
|
Agent: agent,
|
||||||
|
Rule: rule,
|
||||||
|
Selected: selected,
|
||||||
|
TargetAmt: targetCommission,
|
||||||
|
PreviewCommission: previewTotalCommission,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAllActiveAgents returns all agents with referral_percentage > 0, optionally filtered by created_after.
|
||||||
|
func queryAllActiveAgents(ctx context.Context, db *gorm.DB, createdAfter string) ([]*usermodel.User, error) {
|
||||||
|
q := db.WithContext(ctx).Model(&usermodel.User{}).
|
||||||
|
Where("referral_percentage > 0 AND deleted_at IS NULL")
|
||||||
|
if createdAfter != "" {
|
||||||
|
t, err := parseFlexibleTime(createdAfter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("--agent-created-after 格式错误: %w", err)
|
||||||
|
}
|
||||||
|
q = q.Where("created_at >= ?", t)
|
||||||
|
}
|
||||||
|
var agents []*usermodel.User
|
||||||
|
if err := q.Order("id ASC").Find(&agents).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return agents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFlexibleTime parses "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD".
|
||||||
|
func parseFlexibleTime(s string) (time.Time, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
return time.ParseInLocation("2006-01-02", s, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAgentStats returns (firstReferralTime, lastReferralTime, agentCreatedAt, totalReferred, totalOrders, totalCommission, error).
|
||||||
|
func queryAgentStats(ctx context.Context, db *gorm.DB, agentID int64, endTime time.Time) (time.Time, time.Time, time.Time, int64, int64, int64, error) {
|
||||||
|
var agent usermodel.User
|
||||||
|
if err := db.WithContext(ctx).Model(&usermodel.User{}).
|
||||||
|
Where("id = ?", agentID).
|
||||||
|
First(&agent).Error; err != nil {
|
||||||
|
return time.Time{}, time.Time{}, time.Time{}, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
agentCreatedAt := agent.CreatedAt
|
||||||
|
|
||||||
|
var firstUser usermodel.User
|
||||||
|
if err := db.WithContext(ctx).Model(&usermodel.User{}).
|
||||||
|
Where("referer_id = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL",
|
||||||
|
agentID, agentCreatedAt, endTime).
|
||||||
|
Order("created_at ASC").
|
||||||
|
First(&firstUser).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, nil
|
||||||
|
}
|
||||||
|
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastUser usermodel.User
|
||||||
|
if err := db.WithContext(ctx).Model(&usermodel.User{}).
|
||||||
|
Where("referer_id = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL",
|
||||||
|
agentID, agentCreatedAt, endTime).
|
||||||
|
Order("created_at DESC").
|
||||||
|
First(&lastUser).Error; err != nil {
|
||||||
|
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalReferred int64
|
||||||
|
if err := db.WithContext(ctx).Model(&usermodel.User{}).
|
||||||
|
Where("referer_id = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL",
|
||||||
|
agentID, agentCreatedAt, endTime).
|
||||||
|
Count(&totalReferred).Error; err != nil {
|
||||||
|
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalOrders int64
|
||||||
|
if err := db.WithContext(ctx).Model(&ordermodel.Order{}).
|
||||||
|
Joins("JOIN user u ON u.id = `order`.user_id").
|
||||||
|
Where("u.referer_id = ?", agentID).
|
||||||
|
Where("`order`.status IN (2, 5)").
|
||||||
|
Count(&totalOrders).Error; err != nil {
|
||||||
|
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type commResult struct{ Total int64 }
|
||||||
|
var result commResult
|
||||||
|
err := db.WithContext(ctx).Raw(`
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
CAST(JSON_UNQUOTE(JSON_EXTRACT(content, '$.amount')) AS SIGNED)
|
||||||
|
), 0) AS total
|
||||||
|
FROM system_logs
|
||||||
|
WHERE type = 33
|
||||||
|
AND object_id = ?
|
||||||
|
AND created_at <= ?
|
||||||
|
AND JSON_UNQUOTE(JSON_EXTRACT(content, '$.type')) IN ('331', '332')
|
||||||
|
`, agentID, endTime).Scan(&result).Error
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstUser.CreatedAt, lastUser.CreatedAt, agentCreatedAt, totalReferred, totalOrders, result.Total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryNaturalTrafficPool returns pool candidates with pre-loaded qualifying orders.
|
||||||
|
// orderStart (可为零值):若非零,则只收录至少有一笔 updated_at >= orderStart 订单的用户,
|
||||||
|
// 且只加载/统计 updated_at >= orderStart 的订单(确保分配后代理能在对应月份的销售报表中看到记录)。
|
||||||
|
func queryNaturalTrafficPool(ctx context.Context, db *gorm.DB, start, end time.Time, orderStart time.Time, rule commissionRule) ([]candidateUser, error) {
|
||||||
|
type userRow struct {
|
||||||
|
Id int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
AuthIdentifier string
|
||||||
|
}
|
||||||
|
var userRows []userRow
|
||||||
|
q := db.WithContext(ctx).
|
||||||
|
Table("user u").
|
||||||
|
Select("u.id, u.created_at, COALESCE(am.auth_identifier, '') AS auth_identifier").
|
||||||
|
Joins("JOIN `order` o ON o.user_id = u.id AND o.status IN (2, 5)").
|
||||||
|
Joins("LEFT JOIN user_auth_methods am ON am.user_id = u.id AND am.auth_type = 'email'").
|
||||||
|
Where("u.created_at >= ? AND u.created_at <= ?", start, end).
|
||||||
|
Where("u.referer_id = 0").
|
||||||
|
Where("u.deleted_at IS NULL")
|
||||||
|
if !orderStart.IsZero() {
|
||||||
|
// 只入池那些在 orderStart 之后有过订单的用户(保证代理销售报表里能看到)
|
||||||
|
q = q.Where("o.updated_at >= ?", orderStart)
|
||||||
|
}
|
||||||
|
if err := q.Group("u.id, u.created_at, am.auth_identifier").
|
||||||
|
Order("u.id ASC").
|
||||||
|
Scan(&userRows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(userRows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs := make([]int64, len(userRows))
|
||||||
|
for i, r := range userRows {
|
||||||
|
userIDs[i] = r.Id
|
||||||
|
}
|
||||||
|
orderQuery := db.WithContext(ctx).Model(&ordermodel.Order{}).
|
||||||
|
Where("user_id IN ? AND status IN (2, 5)", userIDs)
|
||||||
|
if !orderStart.IsZero() {
|
||||||
|
// 只加载 orderStart 之后的订单:保证佣金统计和销售记录对齐
|
||||||
|
orderQuery = orderQuery.Where("updated_at >= ?", orderStart)
|
||||||
|
}
|
||||||
|
var allOrders []ordermodel.Order
|
||||||
|
if err := orderQuery.Order("user_id ASC, created_at ASC").Find(&allOrders).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ordersByUser := make(map[int64][]ordermodel.Order, len(userRows))
|
||||||
|
for _, od := range allOrders {
|
||||||
|
ordersByUser[od.UserId] = append(ordersByUser[od.UserId], od)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := make([]candidateUser, 0, len(userRows))
|
||||||
|
for _, r := range userRows {
|
||||||
|
orders := ordersByUser[r.Id]
|
||||||
|
var commTotal int64
|
||||||
|
for i := range orders {
|
||||||
|
if canCreditOrder(rule, &orders[i]) {
|
||||||
|
commTotal += calcCommissionAmount(orders[i].Amount, orders[i].FeeAmount, rule.Percentage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidateUser{
|
||||||
|
Id: r.Id,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
Identifier: r.AuthIdentifier,
|
||||||
|
Orders: orders,
|
||||||
|
CommissionTotal: commTotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomSampleCandidates picks n random elements from pool without replacement.
|
||||||
|
func randomSampleCandidates(pool []candidateUser, n int) []candidateUser {
|
||||||
|
if n >= len(pool) {
|
||||||
|
return append([]candidateUser{}, pool...)
|
||||||
|
}
|
||||||
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
indices := rng.Perm(len(pool))[:n]
|
||||||
|
result := make([]candidateUser, n)
|
||||||
|
for i, idx := range indices {
|
||||||
|
result[i] = pool[idx]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// processOneUser assigns agentId as referer and retroactively credits commission for qualifying orders.
|
||||||
|
func processOneUser(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
um usermodel.Model,
|
||||||
|
agentID, userID int64,
|
||||||
|
rule commissionRule,
|
||||||
|
orderStart time.Time,
|
||||||
|
) (int64, int64, error) {
|
||||||
|
var creditedOrders, creditedAmount int64
|
||||||
|
|
||||||
|
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var target usermodel.User
|
||||||
|
if e := tx.Model(&usermodel.User{}).
|
||||||
|
Where("id = ? AND referer_id = 0 AND deleted_at IS NULL", userID).
|
||||||
|
First(&target).Error; e != nil {
|
||||||
|
if e == gorm.ErrRecordNotFound {
|
||||||
|
return fmt.Errorf("用户不存在或已有代理或已删除")
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
var orders []ordermodel.Order
|
||||||
|
oq := tx.Model(&ordermodel.Order{}).
|
||||||
|
Where("user_id = ? AND status IN (2, 5)", userID)
|
||||||
|
if !orderStart.IsZero() {
|
||||||
|
oq = oq.Where("updated_at >= ?", orderStart)
|
||||||
|
}
|
||||||
|
if e := oq.Order("created_at ASC, id ASC").Find(&orders).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if len(orders) == 0 {
|
||||||
|
return fmt.Errorf("无合格订单")
|
||||||
|
}
|
||||||
|
|
||||||
|
if e := tx.Model(&usermodel.User{}).
|
||||||
|
Where("id = ? AND referer_id = 0 AND deleted_at IS NULL", userID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"referer_id": agentID,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range orders {
|
||||||
|
od := &orders[i]
|
||||||
|
if !canCreditOrder(rule, od) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
amount := calcCommissionAmount(od.Amount, od.FeeAmount, rule.Percentage)
|
||||||
|
if amount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var existCount int64
|
||||||
|
if e := tx.Model(&logmodel.SystemLog{}).
|
||||||
|
Where("type = ? AND object_id = ? AND content LIKE ?",
|
||||||
|
logmodel.TypeCommission.Uint8(), agentID,
|
||||||
|
fmt.Sprintf("%%\"%s\"%%", od.OrderNo),
|
||||||
|
).Count(&existCount).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if existCount > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if e := tx.Model(&usermodel.User{}).
|
||||||
|
Where("id = ? AND deleted_at IS NULL", agentID).
|
||||||
|
UpdateColumn("commission", gorm.Expr("commission + ?", amount)).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
commType := logmodel.CommissionTypePurchase
|
||||||
|
if od.Type == 2 {
|
||||||
|
commType = logmodel.CommissionTypeRenewal
|
||||||
|
}
|
||||||
|
payload := &logmodel.Commission{
|
||||||
|
Type: commType,
|
||||||
|
Amount: amount,
|
||||||
|
OrderNo: od.OrderNo,
|
||||||
|
Timestamp: od.CreatedAt.UnixMilli(),
|
||||||
|
}
|
||||||
|
content, _ := payload.Marshal()
|
||||||
|
if e := tx.Create(&logmodel.SystemLog{
|
||||||
|
Type: logmodel.TypeCommission.Uint8(),
|
||||||
|
Date: od.CreatedAt.Format("2006-01-02"),
|
||||||
|
ObjectID: agentID,
|
||||||
|
Content: string(content),
|
||||||
|
CreatedAt: od.CreatedAt,
|
||||||
|
}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
creditedOrders++
|
||||||
|
creditedAmount += amount
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated, e := um.FindOne(ctx, userID); e == nil {
|
||||||
|
_ = um.UpdateUserCache(ctx, updated)
|
||||||
|
}
|
||||||
|
return creditedOrders, creditedAmount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCommissionRule(agent *usermodel.User, c config.Config) commissionRule {
|
||||||
|
if agent.ReferralPercentage > 0 {
|
||||||
|
onlyFirst := true
|
||||||
|
if agent.OnlyFirstPurchase != nil {
|
||||||
|
onlyFirst = *agent.OnlyFirstPurchase
|
||||||
|
}
|
||||||
|
return commissionRule{Percentage: agent.ReferralPercentage, OnlyFirstPurchase: onlyFirst}
|
||||||
|
}
|
||||||
|
return commissionRule{
|
||||||
|
Percentage: uint8(c.Invite.ReferralPercentage),
|
||||||
|
OnlyFirstPurchase: c.Invite.OnlyFirstPurchase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canCreditOrder(rule commissionRule, od *ordermodel.Order) bool {
|
||||||
|
if rule.Percentage == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.OnlyFirstPurchase && !od.IsNew {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return od.Status == 2 || od.Status == 5
|
||||||
|
}
|
||||||
|
|
||||||
|
func calcCommissionAmount(amount, feeAmount int64, percentage uint8) int64 {
|
||||||
|
base := amount - feeAmount
|
||||||
|
if base <= 0 || percentage == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(float64(base) * float64(percentage) / 100)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
# PPanel 香港区新 AWS 账号部署说明
|
||||||
|
|
||||||
|
本目录用于在 **新 AWS 账号** 中按 **香港区 `ap-east-1`** 重建一套全新空环境。
|
||||||
|
|
||||||
|
目标架构:
|
||||||
|
|
||||||
|
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
|
||||||
|
|
||||||
|
## 1. 资源清单
|
||||||
|
|
||||||
|
按下面顺序创建资源:
|
||||||
|
|
||||||
|
1. VPC
|
||||||
|
2. 2 个公有子网 + 2 个私有子网
|
||||||
|
3. Internet Gateway
|
||||||
|
4. 公有 / 私有路由表
|
||||||
|
5. 安全组
|
||||||
|
6. RDS MySQL
|
||||||
|
7. EC2 本机 Redis Docker
|
||||||
|
8. EC2
|
||||||
|
9. ACM 证书
|
||||||
|
10. ALB + Target Group
|
||||||
|
11. WAF Web ACL
|
||||||
|
12. 平行环境域名
|
||||||
|
|
||||||
|
建议命名:
|
||||||
|
|
||||||
|
- VPC: `ppanel-hk-prod`
|
||||||
|
- EC2: `ppanel-app-hk-01`
|
||||||
|
- RDS: `ppanel-mysql-hk`
|
||||||
|
- Redis container: `hifast-redis`
|
||||||
|
- ALB: `ppanel-alb-hk`
|
||||||
|
- WAF: `ppanel-waf-hk`
|
||||||
|
|
||||||
|
## 2. 默认规格
|
||||||
|
|
||||||
|
### EC2
|
||||||
|
|
||||||
|
- Region: `ap-east-1`
|
||||||
|
- OS: Ubuntu 24.04 LTS
|
||||||
|
- Instance type: `t4g.large` 起步
|
||||||
|
- Disk: `gp3 80GB`
|
||||||
|
- Public subnet: 是
|
||||||
|
- IAM Role: 允许读取 CloudWatch / SSM(如使用)
|
||||||
|
- 如果要在 AWS EC2 本机执行 S3 备份:额外允许写入专用备份桶
|
||||||
|
|
||||||
|
### RDS MySQL
|
||||||
|
|
||||||
|
- Engine: MySQL 8.0
|
||||||
|
- Class: `db.r7g.xlarge`
|
||||||
|
- Storage: `gp3 100GB`
|
||||||
|
- DB name: `hifast`
|
||||||
|
- Username: `admin`
|
||||||
|
- Public access: `No`
|
||||||
|
- Charset: `utf8mb4`
|
||||||
|
- Backup: `7-14 days`
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
|
||||||
|
- 部署位置:业务 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
|
||||||
|
|
||||||
|
### 安全组建议
|
||||||
|
|
||||||
|
#### `sg-alb`
|
||||||
|
|
||||||
|
- Inbound
|
||||||
|
- `80/tcp` from `0.0.0.0/0`
|
||||||
|
- `443/tcp` from `0.0.0.0/0`
|
||||||
|
- Outbound
|
||||||
|
- `80/tcp` to `sg-ec2`
|
||||||
|
|
||||||
|
#### `sg-ec2`
|
||||||
|
|
||||||
|
- Inbound
|
||||||
|
- `80/tcp` from `sg-alb`
|
||||||
|
- `22/tcp` from `你的固定运维 IP`
|
||||||
|
- Outbound
|
||||||
|
- all
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 应用容器监听 `127.0.0.1:8080`
|
||||||
|
- EC2 对外只让 Nginx 监听 `80`
|
||||||
|
- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1`
|
||||||
|
|
||||||
|
#### `sg-rds`
|
||||||
|
|
||||||
|
- Inbound
|
||||||
|
- `3306/tcp` from `sg-ec2`
|
||||||
|
|
||||||
|
#### `sg-ec2` 额外说明
|
||||||
|
|
||||||
|
- 如果需要外部备用节点复制 Redis,再额外放行:
|
||||||
|
- `6379/tcp` from `104.238.220.230/32`
|
||||||
|
|
||||||
|
## 4. ALB / Target Group / 健康检查
|
||||||
|
|
||||||
|
### Target Group
|
||||||
|
|
||||||
|
- Type: `Instance`
|
||||||
|
- Protocol: `HTTP`
|
||||||
|
- Port: `80`
|
||||||
|
- Health check path: `/v1/common/heartbeat`
|
||||||
|
- Success code: `200`
|
||||||
|
|
||||||
|
这个路径已由项目现有接口提供,无需额外改代码。
|
||||||
|
|
||||||
|
### ALB 监听器
|
||||||
|
|
||||||
|
- `80` -> redirect to `443`
|
||||||
|
- `443` -> forward 到 target group
|
||||||
|
|
||||||
|
### ACM
|
||||||
|
|
||||||
|
- 在 `ap-east-1` 申请证书
|
||||||
|
- 先给平行环境域名,例如:
|
||||||
|
- `api-new.hifast.biz`
|
||||||
|
- `logs-new.hifast.biz`
|
||||||
|
|
||||||
|
## 5. WAF 规则
|
||||||
|
|
||||||
|
首版至少启用:
|
||||||
|
|
||||||
|
1. `AWSManagedRulesCommonRuleSet`
|
||||||
|
2. `AWSManagedRulesKnownBadInputsRuleSet`
|
||||||
|
3. `AWSManagedRulesAmazonIpReputationList`
|
||||||
|
4. 全站 rate-based rule
|
||||||
|
5. 针对高风险路径的 rate-based rule
|
||||||
|
|
||||||
|
建议的第一版限流:
|
||||||
|
|
||||||
|
- 全站:每 IP `2000 / 5 分钟`
|
||||||
|
- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟`
|
||||||
|
- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟`
|
||||||
|
|
||||||
|
节点上报接口建议后续补:
|
||||||
|
|
||||||
|
- `/v1/server/status`
|
||||||
|
- `/v1/server/online`
|
||||||
|
- `/v1/server/traffic`
|
||||||
|
|
||||||
|
优先用节点出口 IP 白名单;没有固定出口 IP 的节点暂时保留 `secret_key`,但不要把它当成唯一防线。
|
||||||
|
|
||||||
|
## 6. EC2 文件落地
|
||||||
|
|
||||||
|
在 EC2 上建议使用:
|
||||||
|
|
||||||
|
- 应用目录:`/opt/ppanel`
|
||||||
|
- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf`
|
||||||
|
|
||||||
|
需要上传这些文件 / 目录:
|
||||||
|
|
||||||
|
- `docker-compose.cloud.yml`
|
||||||
|
- `deploy/aws/ap-east-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml`
|
||||||
|
- `deploy/aws/ap-east-1/nginx/ppanel-api.conf`
|
||||||
|
- `grafana/`
|
||||||
|
- `loki/`
|
||||||
|
- `prometheus/`
|
||||||
|
- `tempo/`
|
||||||
|
- `.env.example` -> 重命名为 `.env`
|
||||||
|
|
||||||
|
目标目录示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/ppanel/
|
||||||
|
docker-compose.cloud.yml
|
||||||
|
.env
|
||||||
|
configs/ppanel.yaml
|
||||||
|
grafana/
|
||||||
|
loki/
|
||||||
|
prometheus/
|
||||||
|
tempo/
|
||||||
|
logs/
|
||||||
|
cache/
|
||||||
|
tempo_data/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 应用配置
|
||||||
|
|
||||||
|
基线模板见:
|
||||||
|
|
||||||
|
- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example)
|
||||||
|
- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf)
|
||||||
|
|
||||||
|
关键值必须替换:
|
||||||
|
|
||||||
|
- `MySQL.Addr`
|
||||||
|
- `MySQL.Password`
|
||||||
|
- `Redis.Host`
|
||||||
|
- `Redis.Pass`
|
||||||
|
- `JwtAuth.AccessSecret`
|
||||||
|
- `Administrator.Email`
|
||||||
|
- `Administrator.Password`
|
||||||
|
- `AppSignature.AppSecrets.*`
|
||||||
|
- `device.security_secret`
|
||||||
|
- `Site.Host`
|
||||||
|
- `Site.SiteName`
|
||||||
|
|
||||||
|
Redis 约定保持不变:
|
||||||
|
|
||||||
|
- 业务缓存:DB `0`
|
||||||
|
- Asynq:DB `5`(代码内部已固定使用)
|
||||||
|
|
||||||
|
## 8. 部署步骤
|
||||||
|
|
||||||
|
### 8.1 初始化 EC2
|
||||||
|
|
||||||
|
把脚本上传到 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 安装 Nginx 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/aws/ap-east-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 启动容器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/ppanel
|
||||||
|
docker compose -f docker-compose.cloud.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 预检
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x deploy/scripts/preflight_aws_hk.sh
|
||||||
|
APP_DIR=/opt/ppanel \
|
||||||
|
RDS_HOST=<new-rds-endpoint> \
|
||||||
|
REDIS_HOST=127.0.0.1 \
|
||||||
|
deploy/scripts/preflight_aws_hk.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 平行环境验证
|
||||||
|
|
||||||
|
先验证 `api-new.hifast.biz`,不要直接切正式域名。
|
||||||
|
|
||||||
|
必测项:
|
||||||
|
|
||||||
|
1. `ALB target` 为 healthy
|
||||||
|
2. `GET /v1/common/heartbeat` 返回 200
|
||||||
|
3. 管理员登录
|
||||||
|
4. 用户注册 / 登录
|
||||||
|
5. 订阅查询
|
||||||
|
6. 节点上报 `/v1/server/status`
|
||||||
|
7. 本机 Redis 可写缓存
|
||||||
|
8. Asynq 可入队并消费
|
||||||
|
|
||||||
|
## 10. 正式切换
|
||||||
|
|
||||||
|
切换前检查:
|
||||||
|
|
||||||
|
1. ALB 5xx 为 0
|
||||||
|
2. EC2 CPU / Memory 正常
|
||||||
|
3. RDS CPU / Connections 正常
|
||||||
|
4. 本机 Redis CPU / Connections / Memory 正常
|
||||||
|
5. WAF 已挂到 ALB
|
||||||
|
6. EC2 安全组没有对公网放 `8080/3333/9090/4317`
|
||||||
|
|
||||||
|
切换方式:
|
||||||
|
|
||||||
|
1. 保持新环境先跑平行域名
|
||||||
|
2. 正式域名切到新 ALB
|
||||||
|
3. 观察至少 1 小时
|
||||||
|
4. 确认无误后再处理旧环境
|
||||||
|
|
||||||
|
## 11. 监控建议
|
||||||
|
|
||||||
|
至少建这些 CloudWatch / Grafana 观测项:
|
||||||
|
|
||||||
|
- ALB `RequestCount`, `HTTPCode_ELB_5XX_Count`, `TargetResponseTime`
|
||||||
|
- EC2 `CPUUtilization`, `NetworkIn`, `NetworkOut`, `StatusCheckFailed`
|
||||||
|
- RDS `CPUUtilization`, `DatabaseConnections`, `ReadLatency`, `WriteLatency`
|
||||||
|
- Redis 容器 CPU / Memory / restart count
|
||||||
|
|
||||||
|
## 12. 这次方案的边界
|
||||||
|
|
||||||
|
本目录交付的是:
|
||||||
|
|
||||||
|
- 香港区新账号的部署模板
|
||||||
|
- 新空环境启动与验证流程
|
||||||
|
- ALB / WAF / EC2 / RDS / 本机 Redis 的落地约定
|
||||||
|
|
||||||
|
不包含:
|
||||||
|
|
||||||
|
- 旧数据迁移
|
||||||
|
- Terraform / CloudFormation 自动建资源
|
||||||
|
- 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=
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
Host: 0.0.0.0
|
||||||
|
Port: 8080
|
||||||
|
Debug: false
|
||||||
|
|
||||||
|
JwtAuth:
|
||||||
|
AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET
|
||||||
|
AccessExpire: 604800
|
||||||
|
|
||||||
|
Logger:
|
||||||
|
ServiceName: PPanel
|
||||||
|
Mode: console
|
||||||
|
Encoding: plain
|
||||||
|
TimeFormat: "2006-01-02 15:04:05.000"
|
||||||
|
Path: logs
|
||||||
|
Level: info
|
||||||
|
MaxContentLength: 0
|
||||||
|
Compress: false
|
||||||
|
Stat: true
|
||||||
|
KeepDays: 7
|
||||||
|
StackCooldownMillis: 100
|
||||||
|
MaxBackups: 7
|
||||||
|
MaxSize: 100
|
||||||
|
Rotation: daily
|
||||||
|
FileTimeFormat: "2006-01-02T15:04:05.000Z07:00"
|
||||||
|
|
||||||
|
MySQL:
|
||||||
|
Addr: YOUR_RDS_ENDPOINT:3306
|
||||||
|
Dbname: hifast
|
||||||
|
Username: admin
|
||||||
|
Password: CHANGE_ME_TO_RDS_PASSWORD
|
||||||
|
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||||
|
MaxIdleConns: 10
|
||||||
|
MaxOpenConns: 100
|
||||||
|
SlowThreshold: 1000
|
||||||
|
|
||||||
|
Redis:
|
||||||
|
Host: 127.0.0.1:6379
|
||||||
|
Pass: CHANGE_ME_TO_REDIS_PASSWORD
|
||||||
|
DB: 0
|
||||||
|
PoolSize: 100
|
||||||
|
MinIdleConns: 10
|
||||||
|
MaxRetries: 3
|
||||||
|
PoolTimeout: 4
|
||||||
|
IdleTimeout: 300
|
||||||
|
MaxConnAge: 0
|
||||||
|
DialTimeout: 5
|
||||||
|
ReadTimeout: 3
|
||||||
|
WriteTimeout: 3
|
||||||
|
|
||||||
|
Trace:
|
||||||
|
Name: ppanel-server
|
||||||
|
Endpoint: 127.0.0.1:4317
|
||||||
|
Sampler: 0.1
|
||||||
|
Batcher: otlpgrpc
|
||||||
|
|
||||||
|
Site:
|
||||||
|
Host: api-new.hifast.biz
|
||||||
|
SiteName: HiFastVPN
|
||||||
|
|
||||||
|
Administrator:
|
||||||
|
Email: admin@example.com
|
||||||
|
Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD
|
||||||
|
|
||||||
|
Telegram:
|
||||||
|
Enable: false
|
||||||
|
BotID: 0
|
||||||
|
BotName: ""
|
||||||
|
BotToken: ""
|
||||||
|
GroupChatID: ""
|
||||||
|
EnableNotify: false
|
||||||
|
WebHookDomain: ""
|
||||||
|
|
||||||
|
Kutt:
|
||||||
|
Enable: false
|
||||||
|
ApiURL: ""
|
||||||
|
ApiKey: ""
|
||||||
|
TargetURL: ""
|
||||||
|
Domain: ""
|
||||||
|
|
||||||
|
OpenInstall:
|
||||||
|
Enable: false
|
||||||
|
AppKey: ""
|
||||||
|
ApiKey: ""
|
||||||
|
|
||||||
|
Loki:
|
||||||
|
Enable: true
|
||||||
|
URL: "http://localhost:3100"
|
||||||
|
|
||||||
|
AppSignature:
|
||||||
|
AppSecrets:
|
||||||
|
android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET
|
||||||
|
ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET
|
||||||
|
web-client: CHANGE_ME_WEB_SIGNATURE_SECRET
|
||||||
|
ValidWindowSeconds: 300
|
||||||
|
SkipPrefixes:
|
||||||
|
- /v1/notify/
|
||||||
|
- /v1/iap/notifications
|
||||||
|
- /v1/telegram/webhook
|
||||||
|
- /v1/subscribe/config
|
||||||
|
|
||||||
|
Signature:
|
||||||
|
EnableSignature: false
|
||||||
|
|
||||||
|
device:
|
||||||
|
enable: true
|
||||||
|
security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET
|
||||||
|
|
||||||
|
Register:
|
||||||
|
EnableTrial: true
|
||||||
|
EnableTrialEmailWhitelist: true
|
||||||
|
TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com"
|
||||||
@@ -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=18.163.33.75
|
||||||
|
REDIS_SOURCE_PORT=6379
|
||||||
|
REDIS_SOURCE_USER=
|
||||||
|
REDIS_SOURCE_PASSWORD=CHANGE_ME
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/ppanel-access.log;
|
||||||
|
error_log /var/log/nginx/ppanel-error.log warn;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /nginx_status {
|
||||||
|
stub_status;
|
||||||
|
access_log off;
|
||||||
|
allow 127.0.0.1;
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# TAPI 文件上传接入说明
|
||||||
|
|
||||||
|
本文档说明 `https://tapi.hifast.biz/v1/public/file/upload` 相关上传接口的推荐接入方式、签名规则与常见排查方式。
|
||||||
|
|
||||||
|
## 总览
|
||||||
|
|
||||||
|
上传能力包含两类接入方式:
|
||||||
|
|
||||||
|
- 推荐方式:`init -> S3 PUT -> complete`
|
||||||
|
- 兼容方式:`/upload` multipart 直传
|
||||||
|
|
||||||
|
推荐优先使用预签名三段式,因为:
|
||||||
|
|
||||||
|
- 现有签名串包含 `BODY_SHA256`
|
||||||
|
- `/upload` 是 `multipart/form-data`
|
||||||
|
- multipart 原始 body 的签名和调试成本更高
|
||||||
|
- `init` 和 `complete` 是 JSON,更适合客户端和 Apifox 调试
|
||||||
|
|
||||||
|
## 签名生效逻辑
|
||||||
|
|
||||||
|
项目保持现有旧逻辑,不做强制签名改造:
|
||||||
|
|
||||||
|
- `Signature.EnableSignature = false` 时:不校验签名
|
||||||
|
- `Signature.EnableSignature = true` 且未携带 `X-App-Id` 时:不校验签名,兼容老客户端
|
||||||
|
- `Signature.EnableSignature = true` 且携带 `X-App-Id` 时:必须同时携带并校验
|
||||||
|
- `X-Timestamp`
|
||||||
|
- `X-Nonce`
|
||||||
|
- `X-Signature`
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- 新客户端建议始终带完整签名头
|
||||||
|
- 老客户端如果没有 `X-App-Id`,仍可按旧逻辑访问
|
||||||
|
|
||||||
|
## 签名头定义
|
||||||
|
|
||||||
|
- `X-App-Id`: 客户端标识,例如 `ios-client`
|
||||||
|
- `X-Timestamp`: Unix 秒级时间戳
|
||||||
|
- `X-Nonce`: 每次请求唯一随机串
|
||||||
|
- `X-Signature`: `HMAC-SHA256` 结果的十六进制小写字符串
|
||||||
|
|
||||||
|
## StringToSign 规则
|
||||||
|
|
||||||
|
StringToSign 由下面 7 段按换行符 `\n` 拼接:
|
||||||
|
|
||||||
|
```text
|
||||||
|
METHOD
|
||||||
|
PATH
|
||||||
|
CANONICAL_QUERY
|
||||||
|
BODY_SHA256
|
||||||
|
X-App-Id
|
||||||
|
X-Timestamp
|
||||||
|
X-Nonce
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `METHOD`:HTTP 方法大写,例如 `POST`
|
||||||
|
- `PATH`:请求路径,例如 `/v1/public/file/upload/init`
|
||||||
|
- `CANONICAL_QUERY`:按 key 排序后的 query string,没有 query 则为空字符串
|
||||||
|
- `BODY_SHA256`:请求体原始字节的 SHA-256 十六进制小写
|
||||||
|
- 其余三项直接使用请求头值
|
||||||
|
|
||||||
|
签名计算方式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
signature = hex_lower(HMAC_SHA256(app_secret, string_to_sign))
|
||||||
|
```
|
||||||
|
|
||||||
|
时间窗与防重放:
|
||||||
|
|
||||||
|
- `X-Timestamp` 默认有效时间窗是 300 秒
|
||||||
|
- `X-Nonce` 在有效时间窗内不能重复使用
|
||||||
|
|
||||||
|
## 推荐接入:预签名三段式
|
||||||
|
|
||||||
|
### 1. 初始化上传
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload/init' \
|
||||||
|
-H 'Accept: application/json, text/plain, */*' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'authorization: your-token' \
|
||||||
|
-H 'X-App-Id: ios-client' \
|
||||||
|
-H 'X-Timestamp: 1778776400' \
|
||||||
|
-H 'X-Nonce: nonce-001' \
|
||||||
|
-H 'X-Signature: your-signature' \
|
||||||
|
-d '{
|
||||||
|
"biz_type": "app-package",
|
||||||
|
"file_name": "demo.zip",
|
||||||
|
"content_type": "application/zip",
|
||||||
|
"size": 123456,
|
||||||
|
"sha256": ""
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
典型返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"file_id": "c29274ee26ab5aa211e0396e",
|
||||||
|
"object_key": "app-upload/app-package/519/2026/05/c29274ee26ab5aa211e0396e_demo.zip",
|
||||||
|
"upload_url": "https://bucket.s3.ap-east-1.amazonaws.com/...",
|
||||||
|
"method": "PUT",
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "application/zip"
|
||||||
|
},
|
||||||
|
"expired_at": 1778776715
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 直传 S3
|
||||||
|
|
||||||
|
这一步是直接上传二进制文件到 S3,不走业务签名中间件。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT 'https://bucket.s3.ap-east-1.amazonaws.com/...' \
|
||||||
|
-H 'Content-Type: application/zip' \
|
||||||
|
--upload-file '/tmp/demo.zip'
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `Content-Type` 需和 `init` 返回的 `headers.Content-Type` 一致
|
||||||
|
- `upload_url` 有过期时间,通常 300 秒
|
||||||
|
- 成功时 S3 常见返回 `200` 或 `204`
|
||||||
|
|
||||||
|
### 3. 完成上传
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload/complete' \
|
||||||
|
-H 'Accept: application/json, text/plain, */*' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'authorization: your-token' \
|
||||||
|
-H 'X-App-Id: ios-client' \
|
||||||
|
-H 'X-Timestamp: 1778776405' \
|
||||||
|
-H 'X-Nonce: nonce-002' \
|
||||||
|
-H 'X-Signature: your-signature' \
|
||||||
|
-d '{
|
||||||
|
"file_id": "c29274ee26ab5aa211e0396e"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 兼容接入:单接口 multipart 直传
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
|
- `POST /v1/public/file/upload`
|
||||||
|
|
||||||
|
表单字段:
|
||||||
|
|
||||||
|
- `biz_type`
|
||||||
|
- `file`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 该接口继续保留,兼容旧客户端
|
||||||
|
- 如果请求带了 `X-App-Id`,就按现有逻辑验签
|
||||||
|
- 如果没有 `X-App-Id`,仍按旧逻辑放行
|
||||||
|
- 如果要给该接口加签,签名时必须对原始 multipart body 计算 `BODY_SHA256`
|
||||||
|
|
||||||
|
## 常见错误码
|
||||||
|
|
||||||
|
- `200`: 成功
|
||||||
|
- `400`: 参数错误
|
||||||
|
- `40008`: 缺少签名头
|
||||||
|
- `40009`: 签名已过期
|
||||||
|
- `40010`: 签名无效
|
||||||
|
- `40011`: nonce 重放
|
||||||
|
- `10001`: 上传元数据不存在或对象不存在
|
||||||
|
|
||||||
|
## 排查建议
|
||||||
|
|
||||||
|
- `40008`:确认带了 `X-App-Id` 后,也同时带上 `X-Timestamp / X-Nonce / X-Signature`
|
||||||
|
- `40009`:检查客户端时间是否偏差过大
|
||||||
|
- `40010`:确认 `PATH`、query 排序、body 原始字节、secret 是否完全一致
|
||||||
|
- `40011`:确保每次请求都生成新的 `X-Nonce`
|
||||||
|
- `complete` 失败:确认 S3 `PUT` 已成功,且上传大小与 `init.size` 一致
|
||||||
+19
-133
@@ -6,9 +6,9 @@
|
|||||||
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
|
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
|
||||||
#
|
#
|
||||||
# 网络说明:
|
# 网络说明:
|
||||||
# ppanel-server 使用 host 网络(可出外网,访问 MySQL/Redis/Tempo 用 127.0.0.1)
|
# ppanel-server 使用 host 网络(可出外网,直接访问 AWS RDS / 本机 Redis)
|
||||||
# 监控服务(MySQL/Redis/Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
|
# 监控服务(Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
|
||||||
# MySQL(3306)/Redis(6379)/Tempo(4317) 将端口映射到 127.0.0.1,ppanel-server 通过 host 网络访问
|
# Tempo(4317) 将端口映射到 127.0.0.1,ppanel-server 通过 host 网络访问
|
||||||
# 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问
|
# 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问
|
||||||
#
|
#
|
||||||
# 未来多开 ppanel-server 时:
|
# 未来多开 ppanel-server 时:
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
services:
|
services:
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
# 1. 业务后端 (PPanel Server)
|
# 1. 业务后端 (PPanel Server)
|
||||||
# host 网络:可出外网,通过 127.0.0.1 访问 MySQL/Redis/Tempo
|
# host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
ppanel-server:
|
ppanel-server:
|
||||||
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest}
|
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest}
|
||||||
@@ -37,10 +37,6 @@ services:
|
|||||||
soft: 65535
|
soft: 65535
|
||||||
hard: 65535
|
hard: 65535
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
tempo:
|
tempo:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
logging:
|
logging:
|
||||||
@@ -50,82 +46,7 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
# 2. MySQL Database
|
# 2. Tempo (链路追踪存储)
|
||||||
# ----------------------------------------------------
|
|
||||||
mysql:
|
|
||||||
image: mysql:8.0
|
|
||||||
container_name: ppanel-mysql
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "3306:3306" # 仅宿主机可访问,ppanel-server(host网络)通过127.0.0.1连接
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?请在 .env 文件中设置 MYSQL_ROOT_PASSWORD}"
|
|
||||||
MYSQL_DATABASE: "ppanel"
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
command:
|
|
||||||
- --default-authentication-plugin=mysql_native_password
|
|
||||||
- --innodb_buffer_pool_size=16G
|
|
||||||
- --innodb_buffer_pool_instances=16
|
|
||||||
- --innodb_log_file_size=2G
|
|
||||||
- --innodb_flush_log_at_trx_commit=2
|
|
||||||
- --innodb_io_capacity=5000
|
|
||||||
- --max_connections=5000
|
|
||||||
volumes:
|
|
||||||
- mysql_data:/var/lib/mysql
|
|
||||||
ulimits:
|
|
||||||
nproc: 65535
|
|
||||||
nofile:
|
|
||||||
soft: 65535
|
|
||||||
hard: 65535
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 3. Redis
|
|
||||||
# ----------------------------------------------------
|
|
||||||
redis:
|
|
||||||
image: redis:8.2.1
|
|
||||||
container_name: ppanel-redis
|
|
||||||
restart: always
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:6379:6379" # 仅宿主机可访问,ppanel-server(host网络)通过127.0.0.1连接
|
|
||||||
command:
|
|
||||||
- redis-server
|
|
||||||
- --tcp-backlog 65535
|
|
||||||
- --maxmemory-policy allkeys-lru
|
|
||||||
- --requirepass hifast67yj
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
ulimits:
|
|
||||||
nproc: 65535
|
|
||||||
nofile:
|
|
||||||
soft: 65535
|
|
||||||
hard: 65535
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "redis-cli", "ping" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 4. Tempo (链路追踪存储)
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
tempo:
|
tempo:
|
||||||
image: grafana/tempo:2.4.1
|
image: grafana/tempo:2.4.1
|
||||||
@@ -149,7 +70,7 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
# 5. Loki (日志存储)
|
# 3. Loki (日志存储)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
loki:
|
loki:
|
||||||
image: grafana/loki:3.0.0
|
image: grafana/loki:3.0.0
|
||||||
@@ -169,7 +90,7 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
# 6. Promtail (日志采集)
|
# 4. Promtail (日志采集)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
promtail:
|
promtail:
|
||||||
image: grafana/promtail:3.0.0
|
image: grafana/promtail:3.0.0
|
||||||
@@ -193,7 +114,7 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
# 7. Grafana (可观测面板)
|
# 5. Grafana (可观测面板)
|
||||||
# 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333
|
# 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333
|
||||||
# 或配置 Nginx 反代(建议加认证)
|
# 或配置 Nginx 反代(建议加认证)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
@@ -206,7 +127,14 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD}
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD}
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
- GF_SERVER_DOMAIN=${GRAFANA_DOMAIN:-logsx.hifast.biz}
|
||||||
|
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-https://logsx.hifast.biz}
|
||||||
- GF_FEATURE_TOGGLES_ENABLE=appObservability
|
- GF_FEATURE_TOGGLES_ENABLE=appObservability
|
||||||
|
- AWS_REGION=${AWS_REGION:-ap-east-1}
|
||||||
|
- AWS_DEFAULT_REGION=${AWS_REGION:-ap-east-1}
|
||||||
|
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
|
||||||
|
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
||||||
|
- AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- grafana_data:/var/lib/grafana
|
- grafana_data:/var/lib/grafana
|
||||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||||
@@ -223,7 +151,7 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
# 8. Prometheus (指标采集)
|
# 6. Prometheus (指标采集)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
prometheus:
|
prometheus:
|
||||||
image: prom/prometheus:latest
|
image: prom/prometheus:latest
|
||||||
@@ -248,26 +176,7 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
# 9. Redis Exporter
|
# 7. Nginx Exporter (监控宿主机 Nginx)
|
||||||
# ----------------------------------------------------
|
|
||||||
redis-exporter:
|
|
||||||
image: oliver006/redis_exporter:latest
|
|
||||||
container_name: ppanel-redis-exporter
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
- REDIS_ADDR=redis://redis:6379
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 10. Nginx Exporter (监控宿主机 Nginx)
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
nginx-exporter:
|
nginx-exporter:
|
||||||
image: nginx/nginx-prometheus-exporter:latest
|
image: nginx/nginx-prometheus-exporter:latest
|
||||||
@@ -286,28 +195,7 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
# 11. MySQL Exporter
|
# 8. Node Exporter (宿主机监控)
|
||||||
# ----------------------------------------------------
|
|
||||||
mysql-exporter:
|
|
||||||
image: prom/mysqld-exporter:latest
|
|
||||||
container_name: ppanel-mysql-exporter
|
|
||||||
restart: always
|
|
||||||
command:
|
|
||||||
- --config.my-cnf=/etc/.my.cnf
|
|
||||||
volumes:
|
|
||||||
- ./mysql/.my.cnf:/etc/.my.cnf:ro
|
|
||||||
networks:
|
|
||||||
- ppanel_net
|
|
||||||
depends_on:
|
|
||||||
- mysql
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
# ----------------------------------------------------
|
|
||||||
# 12. Node Exporter (宿主机监控)
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
node-exporter:
|
node-exporter:
|
||||||
image: prom/node-exporter:latest
|
image: prom/node-exporter:latest
|
||||||
@@ -330,7 +218,7 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
# 13. cAdvisor (容器监控)
|
# 9. cAdvisor (容器监控)
|
||||||
# ----------------------------------------------------
|
# ----------------------------------------------------
|
||||||
cadvisor:
|
cadvisor:
|
||||||
image: gcr.io/cadvisor/cadvisor:latest
|
image: gcr.io/cadvisor/cadvisor:latest
|
||||||
@@ -351,8 +239,6 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
|
||||||
redis_data:
|
|
||||||
loki_data:
|
loki_data:
|
||||||
grafana_data:
|
grafana_data:
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
|
|||||||
+16
-1
@@ -15,7 +15,7 @@ Logger: # 日志配置
|
|||||||
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||||
|
|
||||||
MySQL:
|
MySQL:
|
||||||
Addr: 103.150.215.44:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
|
||||||
Username: root # MySQL用户名
|
Username: root # MySQL用户名
|
||||||
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
|
||||||
Dbname: hifast # MySQL数据库名
|
Dbname: hifast # MySQL数据库名
|
||||||
@@ -61,6 +61,21 @@ Trace: # 链路追踪配置 (OpenTelemetry)
|
|||||||
Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
|
Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
|
||||||
Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317
|
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:
|
device:
|
||||||
enable: true # 开启设备加密通信
|
enable: true # 开启设备加密通信
|
||||||
security_secret: "" # AES加密密钥,需要和App端一致,key=SHA256(security_secret)[:32]
|
security_secret: "" # AES加密密钥,需要和App端一致,key=SHA256(security_secret)[:32]
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
module github.com/perfect-panel/server
|
module github.com/perfect-panel/server
|
||||||
|
|
||||||
go 1.23.3
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f
|
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f
|
||||||
github.com/alibabacloud-go/darabonba-openapi v0.1.18
|
github.com/alibabacloud-go/darabonba-openapi v0.1.18
|
||||||
github.com/alibabacloud-go/dysmsapi-20170525/v2 v2.0.18
|
github.com/alibabacloud-go/dysmsapi-20170525/v2 v2.0.18
|
||||||
github.com/alibabacloud-go/tea v1.2.2
|
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/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72
|
||||||
github.com/andybalholm/brotli v1.1.1
|
github.com/andybalholm/brotli v1.1.1
|
||||||
github.com/forgoer/openssl v1.6.0
|
github.com/forgoer/openssl v1.6.0
|
||||||
@@ -32,7 +31,6 @@ require (
|
|||||||
github.com/smartwalle/alipay/v3 v3.2.23
|
github.com/smartwalle/alipay/v3 v3.2.23
|
||||||
github.com/spf13/cast v1.7.0 // indirect
|
github.com/spf13/cast v1.7.0 // indirect
|
||||||
github.com/spf13/cobra v1.8.1
|
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/stripe/stripe-go/v81 v81.1.0
|
||||||
github.com/twilio/twilio-go v1.23.11
|
github.com/twilio/twilio-go v1.23.11
|
||||||
go.opentelemetry.io/otel v1.29.0
|
go.opentelemetry.io/otel v1.29.0
|
||||||
@@ -51,15 +49,19 @@ require (
|
|||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/mysql v1.5.7
|
gorm.io/driver/mysql v1.5.7
|
||||||
gorm.io/gorm v1.30.0
|
gorm.io/gorm v1.30.0
|
||||||
gorm.io/plugin/soft_delete v1.2.1
|
|
||||||
k8s.io/apimachinery v0.31.1
|
k8s.io/apimachinery v0.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Masterminds/sprig/v3 v3.3.0
|
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/fatih/color v1.18.0
|
||||||
github.com/goccy/go-json v0.10.4
|
github.com/goccy/go-json v0.10.4
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
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/oschwald/geoip2-golang v1.13.0
|
||||||
github.com/spaolacci/murmur3 v1.1.0
|
github.com/spaolacci/murmur3 v1.1.0
|
||||||
google.golang.org/grpc v1.64.1
|
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 v1.4.5 // indirect
|
||||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
|
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
|
||||||
github.com/alibabacloud-go/tea-xml v1.1.3 // 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/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/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
|
||||||
github.com/bytedance/sonic v1.12.7 // indirect
|
github.com/bytedance/sonic v1.12.7 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.3 // 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/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/clbanning/mxj/v2 v2.5.6 // indirect
|
github.com/clbanning/mxj/v2 v2.5.6 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.0.0 // 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/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/copystructure v1.2.0 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/openzipkin/zipkin-go v0.4.2 // indirect
|
||||||
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // 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/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/smartwalle/ncrypto v1.0.4 // indirect
|
github.com/smartwalle/ncrypto v1.0.4 // indirect
|
||||||
github.com/smartwalle/ngx v1.0.9 // indirect
|
github.com/smartwalle/ngx v1.0.9 // indirect
|
||||||
github.com/smartwalle/nsign v1.0.9 // indirect
|
github.com/smartwalle/nsign v1.0.9 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // 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/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // 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/exporters/otlp/otlptrace v1.29.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.3.1 // 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
|
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/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // 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.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 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
|
||||||
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
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.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.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||||
github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA=
|
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/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 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
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/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 h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
|
||||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
|
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/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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
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=
|
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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
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.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
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/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 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
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=
|
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=
|
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 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
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.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 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
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-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/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=
|
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
groups:
|
||||||
|
- orgId: 1
|
||||||
|
name: ppanel-core
|
||||||
|
folder: PPanel
|
||||||
|
interval: 1m
|
||||||
|
rules:
|
||||||
|
- uid: ppanel-target-down
|
||||||
|
title: PPanel monitoring target down
|
||||||
|
condition: C
|
||||||
|
for: 2m
|
||||||
|
noDataState: Alerting
|
||||||
|
execErrState: Error
|
||||||
|
annotations:
|
||||||
|
summary: "Monitoring target is down"
|
||||||
|
description: "{{ $labels.job }} on {{ $labels.instance }} has been down for more than 2 minutes."
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
service: ppanel
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange:
|
||||||
|
from: 300
|
||||||
|
to: 0
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
datasource:
|
||||||
|
type: prometheus
|
||||||
|
uid: prometheus
|
||||||
|
editorMode: code
|
||||||
|
expr: 'up{job=~"grafana|prometheus|node-exporter|cadvisor|nginx-exporter|loki|tempo"}'
|
||||||
|
instant: true
|
||||||
|
intervalMs: 1000
|
||||||
|
maxDataPoints: 43200
|
||||||
|
refId: A
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
conditions:
|
||||||
|
- evaluator:
|
||||||
|
params:
|
||||||
|
- 1
|
||||||
|
type: lt
|
||||||
|
operator:
|
||||||
|
type: and
|
||||||
|
query:
|
||||||
|
params:
|
||||||
|
- A
|
||||||
|
reducer:
|
||||||
|
type: last
|
||||||
|
type: query
|
||||||
|
datasource:
|
||||||
|
type: __expr__
|
||||||
|
uid: __expr__
|
||||||
|
expression: A
|
||||||
|
intervalMs: 1000
|
||||||
|
maxDataPoints: 43200
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
|
||||||
|
- uid: ppanel-host-disk-high
|
||||||
|
title: PPanel host disk usage high
|
||||||
|
condition: C
|
||||||
|
for: 10m
|
||||||
|
noDataState: NoData
|
||||||
|
execErrState: Error
|
||||||
|
annotations:
|
||||||
|
summary: "Host disk usage is high"
|
||||||
|
description: "{{ $labels.instance }} {{ $labels.mountpoint }} disk usage is above 85% for 10 minutes."
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: ppanel
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange:
|
||||||
|
from: 900
|
||||||
|
to: 0
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
datasource:
|
||||||
|
type: prometheus
|
||||||
|
uid: prometheus
|
||||||
|
editorMode: code
|
||||||
|
expr: '100 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} * 100)'
|
||||||
|
instant: true
|
||||||
|
intervalMs: 1000
|
||||||
|
maxDataPoints: 43200
|
||||||
|
refId: A
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
conditions:
|
||||||
|
- evaluator:
|
||||||
|
params:
|
||||||
|
- 85
|
||||||
|
type: gt
|
||||||
|
operator:
|
||||||
|
type: and
|
||||||
|
query:
|
||||||
|
params:
|
||||||
|
- A
|
||||||
|
reducer:
|
||||||
|
type: last
|
||||||
|
type: query
|
||||||
|
datasource:
|
||||||
|
type: __expr__
|
||||||
|
uid: __expr__
|
||||||
|
expression: A
|
||||||
|
intervalMs: 1000
|
||||||
|
maxDataPoints: 43200
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
|
||||||
|
- uid: ppanel-host-memory-high
|
||||||
|
title: PPanel host memory usage high
|
||||||
|
condition: C
|
||||||
|
for: 10m
|
||||||
|
noDataState: NoData
|
||||||
|
execErrState: Error
|
||||||
|
annotations:
|
||||||
|
summary: "Host memory usage is high"
|
||||||
|
description: "{{ $labels.instance }} memory usage is above 90% for 10 minutes."
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: ppanel
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange:
|
||||||
|
from: 900
|
||||||
|
to: 0
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
datasource:
|
||||||
|
type: prometheus
|
||||||
|
uid: prometheus
|
||||||
|
editorMode: code
|
||||||
|
expr: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100'
|
||||||
|
instant: true
|
||||||
|
intervalMs: 1000
|
||||||
|
maxDataPoints: 43200
|
||||||
|
refId: A
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
conditions:
|
||||||
|
- evaluator:
|
||||||
|
params:
|
||||||
|
- 90
|
||||||
|
type: gt
|
||||||
|
operator:
|
||||||
|
type: and
|
||||||
|
query:
|
||||||
|
params:
|
||||||
|
- A
|
||||||
|
reducer:
|
||||||
|
type: last
|
||||||
|
type: query
|
||||||
|
datasource:
|
||||||
|
type: __expr__
|
||||||
|
uid: __expr__
|
||||||
|
expression: A
|
||||||
|
intervalMs: 1000
|
||||||
|
maxDataPoints: 43200
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
|
||||||
|
- uid: ppanel-host-cpu-high
|
||||||
|
title: PPanel host CPU usage high
|
||||||
|
condition: C
|
||||||
|
for: 10m
|
||||||
|
noDataState: NoData
|
||||||
|
execErrState: Error
|
||||||
|
annotations:
|
||||||
|
summary: "Host CPU usage is high"
|
||||||
|
description: "{{ $labels.instance }} CPU usage is above 90% for 10 minutes."
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: ppanel
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange:
|
||||||
|
from: 900
|
||||||
|
to: 0
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
datasource:
|
||||||
|
type: prometheus
|
||||||
|
uid: prometheus
|
||||||
|
editorMode: code
|
||||||
|
expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'
|
||||||
|
instant: true
|
||||||
|
intervalMs: 1000
|
||||||
|
maxDataPoints: 43200
|
||||||
|
refId: A
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
conditions:
|
||||||
|
- evaluator:
|
||||||
|
params:
|
||||||
|
- 90
|
||||||
|
type: gt
|
||||||
|
operator:
|
||||||
|
type: and
|
||||||
|
query:
|
||||||
|
params:
|
||||||
|
- A
|
||||||
|
reducer:
|
||||||
|
type: last
|
||||||
|
type: query
|
||||||
|
datasource:
|
||||||
|
type: __expr__
|
||||||
|
uid: __expr__
|
||||||
|
expression: A
|
||||||
|
intervalMs: 1000
|
||||||
|
maxDataPoints: 43200
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
|
|
||||||
|
- uid: ppanel-container-restarts
|
||||||
|
title: PPanel container restarted
|
||||||
|
condition: C
|
||||||
|
for: 1m
|
||||||
|
noDataState: NoData
|
||||||
|
execErrState: Error
|
||||||
|
annotations:
|
||||||
|
summary: "Container restarted"
|
||||||
|
description: "{{ $labels.name }} restarted or changed start time in the last hour."
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
service: ppanel
|
||||||
|
data:
|
||||||
|
- refId: A
|
||||||
|
relativeTimeRange:
|
||||||
|
from: 3600
|
||||||
|
to: 0
|
||||||
|
datasourceUid: prometheus
|
||||||
|
model:
|
||||||
|
datasource:
|
||||||
|
type: prometheus
|
||||||
|
uid: prometheus
|
||||||
|
editorMode: code
|
||||||
|
expr: 'sum by (name) (changes(container_start_time_seconds{name!=""}[1h]))'
|
||||||
|
instant: true
|
||||||
|
intervalMs: 1000
|
||||||
|
maxDataPoints: 43200
|
||||||
|
refId: A
|
||||||
|
- refId: C
|
||||||
|
datasourceUid: __expr__
|
||||||
|
model:
|
||||||
|
conditions:
|
||||||
|
- evaluator:
|
||||||
|
params:
|
||||||
|
- 0
|
||||||
|
type: gt
|
||||||
|
operator:
|
||||||
|
type: and
|
||||||
|
query:
|
||||||
|
params:
|
||||||
|
- A
|
||||||
|
reducer:
|
||||||
|
type: last
|
||||||
|
type: query
|
||||||
|
datasource:
|
||||||
|
type: __expr__
|
||||||
|
uid: __expr__
|
||||||
|
expression: A
|
||||||
|
intervalMs: 1000
|
||||||
|
maxDataPoints: 43200
|
||||||
|
refId: C
|
||||||
|
type: threshold
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"builtIn": 1,
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana",
|
||||||
|
"uid": "-- Grafana --"
|
||||||
|
},
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"type": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 3,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"options": {
|
||||||
|
"content": "<b>AWS CloudWatch overview</b><br/>Region: ap-east-1 (Hong Kong)<br/>RDS DBInstanceIdentifier: hifast-mysql-prod-v2<br/>Redis: current production uses a local Docker Redis container (<code>hifast-redis</code>) on EC2 rather than AWS ElastiCache.<br/><br/>This dashboard keeps the RDS CloudWatch panels. Redis should be observed from the local ops dashboard via Prometheus/cAdvisor instead of ElastiCache metrics.",
|
||||||
|
"mode": "html"
|
||||||
|
},
|
||||||
|
"pluginVersion": "11.0.0",
|
||||||
|
"title": "Read Me",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 0,
|
||||||
|
"y": 3
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||||
|
},
|
||||||
|
"metricName": "CPUUtilization",
|
||||||
|
"namespace": "AWS/RDS",
|
||||||
|
"period": "",
|
||||||
|
"refId": "A",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Average"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "RDS CPU",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 8,
|
||||||
|
"y": 3
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||||
|
},
|
||||||
|
"metricName": "DatabaseConnections",
|
||||||
|
"namespace": "AWS/RDS",
|
||||||
|
"period": "",
|
||||||
|
"refId": "A",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Average"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "RDS Connections",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 16,
|
||||||
|
"y": 3
|
||||||
|
},
|
||||||
|
"id": 4,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||||
|
},
|
||||||
|
"metricName": "FreeStorageSpace",
|
||||||
|
"namespace": "AWS/RDS",
|
||||||
|
"period": "",
|
||||||
|
"refId": "A",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Minimum"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "RDS Free Storage",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 11
|
||||||
|
},
|
||||||
|
"id": 5,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||||
|
},
|
||||||
|
"metricName": "ReadLatency",
|
||||||
|
"namespace": "AWS/RDS",
|
||||||
|
"period": "",
|
||||||
|
"refId": "A",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Average"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||||
|
},
|
||||||
|
"metricName": "WriteLatency",
|
||||||
|
"namespace": "AWS/RDS",
|
||||||
|
"period": "",
|
||||||
|
"refId": "B",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Average"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "RDS Read / Write Latency",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "iops"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 11
|
||||||
|
},
|
||||||
|
"id": 6,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||||
|
},
|
||||||
|
"metricName": "ReadIOPS",
|
||||||
|
"namespace": "AWS/RDS",
|
||||||
|
"period": "",
|
||||||
|
"refId": "A",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Average"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
|
||||||
|
},
|
||||||
|
"metricName": "WriteIOPS",
|
||||||
|
"namespace": "AWS/RDS",
|
||||||
|
"period": "",
|
||||||
|
"refId": "B",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Average"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "RDS Read / Write IOPS",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 6,
|
||||||
|
"x": 0,
|
||||||
|
"y": 19
|
||||||
|
},
|
||||||
|
"id": 7,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"ReplicationGroupId": "hifastapp-redis"
|
||||||
|
},
|
||||||
|
"metricName": "CPUUtilization",
|
||||||
|
"namespace": "AWS/ElastiCache",
|
||||||
|
"period": "",
|
||||||
|
"refId": "A",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Average"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Redis Host CPU (Legacy ElastiCache)",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 6,
|
||||||
|
"x": 6,
|
||||||
|
"y": 19
|
||||||
|
},
|
||||||
|
"id": 8,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"ReplicationGroupId": "hifastapp-redis"
|
||||||
|
},
|
||||||
|
"metricName": "EngineCPUUtilization",
|
||||||
|
"namespace": "AWS/ElastiCache",
|
||||||
|
"period": "",
|
||||||
|
"refId": "A",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Average"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Redis Engine CPU (Legacy ElastiCache)",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 6,
|
||||||
|
"x": 12,
|
||||||
|
"y": 19
|
||||||
|
},
|
||||||
|
"id": 9,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"ReplicationGroupId": "hifastapp-redis"
|
||||||
|
},
|
||||||
|
"metricName": "CurrConnections",
|
||||||
|
"namespace": "AWS/ElastiCache",
|
||||||
|
"period": "",
|
||||||
|
"refId": "A",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Average"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Redis Connections (Legacy ElastiCache)",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 6,
|
||||||
|
"x": 18,
|
||||||
|
"y": 19
|
||||||
|
},
|
||||||
|
"id": 10,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "cloudwatch",
|
||||||
|
"uid": "cloudwatch"
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
"ReplicationGroupId": "hifastapp-redis"
|
||||||
|
},
|
||||||
|
"metricName": "DatabaseMemoryUsagePercentage",
|
||||||
|
"namespace": "AWS/ElastiCache",
|
||||||
|
"period": "",
|
||||||
|
"refId": "A",
|
||||||
|
"region": "ap-east-1",
|
||||||
|
"statistic": "Average"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Redis Memory Usage % (Legacy ElastiCache)",
|
||||||
|
"type": "timeseries"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"style": "dark",
|
||||||
|
"tags": [
|
||||||
|
"aws",
|
||||||
|
"cloudwatch",
|
||||||
|
"rds",
|
||||||
|
"redis"
|
||||||
|
],
|
||||||
|
"templating": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "AWS RDS & Redis Overview",
|
||||||
|
"uid": "aws-rds-redis-overview",
|
||||||
|
"version": 1,
|
||||||
|
"weekStart": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"builtIn": 1,
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana",
|
||||||
|
"uid": "-- Grafana --"
|
||||||
|
},
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"type": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"options": {
|
||||||
|
"0": {
|
||||||
|
"text": "DOWN"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"text": "UP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "value"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"reduceOptions": {
|
||||||
|
"calcs": [
|
||||||
|
"lastNotNull"
|
||||||
|
],
|
||||||
|
"fields": "",
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"showPercentChange": false,
|
||||||
|
"textMode": "auto",
|
||||||
|
"wideLayout": true
|
||||||
|
},
|
||||||
|
"pluginVersion": "13.0.1",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "up{job=~\"prometheus|grafana|node-exporter|cadvisor|nginx-exporter|loki|tempo\"}",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "{{job}}",
|
||||||
|
"range": false,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Service Availability",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"max": 100,
|
||||||
|
"min": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 90
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 0,
|
||||||
|
"y": 4
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||||
|
"legendFormat": "{{instance}} CPU",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Host CPU Usage",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"max": 100,
|
||||||
|
"min": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 90
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 8,
|
||||||
|
"y": 4
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
|
||||||
|
"legendFormat": "{{instance}} memory",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Host Memory Usage",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"max": 100,
|
||||||
|
"min": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"color": "green",
|
||||||
|
"value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "orange",
|
||||||
|
"value": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "red",
|
||||||
|
"value": 90
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 16,
|
||||||
|
"y": 4
|
||||||
|
},
|
||||||
|
"id": 4,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "100 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} * 100)",
|
||||||
|
"legendFormat": "{{mountpoint}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Host Disk Usage",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 0,
|
||||||
|
"y": 12
|
||||||
|
},
|
||||||
|
"id": 5,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name!=\"\"}[5m]))",
|
||||||
|
"legendFormat": "{{name}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Container CPU",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "bytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 8,
|
||||||
|
"y": 12
|
||||||
|
},
|
||||||
|
"id": 6,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (name) (container_memory_working_set_bytes{name!=\"\"})",
|
||||||
|
"legendFormat": "{{name}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Container Memory",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 16,
|
||||||
|
"y": 12
|
||||||
|
},
|
||||||
|
"id": 7,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (name) (changes(container_start_time_seconds{name!=\"\"}[1h]))",
|
||||||
|
"legendFormat": "{{name}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Container Restarts / Changes",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 0,
|
||||||
|
"y": 20
|
||||||
|
},
|
||||||
|
"id": 8,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "rate(nginx_http_requests_total[5m])",
|
||||||
|
"legendFormat": "requests",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Nginx Requests",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "loki"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 8,
|
||||||
|
"y": 20
|
||||||
|
},
|
||||||
|
"id": 9,
|
||||||
|
"options": {
|
||||||
|
"dedupStrategy": "none",
|
||||||
|
"enableLogDetails": true,
|
||||||
|
"prettifyLogMessage": false,
|
||||||
|
"showCommonLabels": false,
|
||||||
|
"showLabels": true,
|
||||||
|
"showTime": true,
|
||||||
|
"sortOrder": "Descending",
|
||||||
|
"wrapLogMessage": true
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "loki"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "{job=~\"ppanel-server|nginx|docker\"} |~ \"(?i)(error|panic|fatal|timeout|exception|failed)\"",
|
||||||
|
"queryType": "range",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Recent Errors",
|
||||||
|
"type": "logs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "reqps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 8,
|
||||||
|
"w": 8,
|
||||||
|
"x": 16,
|
||||||
|
"y": 20
|
||||||
|
},
|
||||||
|
"id": 10,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "prometheus"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum by (service_name) (rate(traces_spanmetrics_calls_total[5m]))",
|
||||||
|
"legendFormat": "{{service_name}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Trace Span Calls",
|
||||||
|
"type": "timeseries"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 42,
|
||||||
|
"tags": [
|
||||||
|
"ppanel",
|
||||||
|
"ops",
|
||||||
|
"prometheus",
|
||||||
|
"loki",
|
||||||
|
"tempo"
|
||||||
|
],
|
||||||
|
"templating": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "PPanel Ops Overview",
|
||||||
|
"uid": "ppanel-ops-overview",
|
||||||
|
"version": 1,
|
||||||
|
"weekStart": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"builtIn": 1,
|
||||||
|
"datasource": {
|
||||||
|
"type": "grafana",
|
||||||
|
"uid": "-- Grafana --"
|
||||||
|
},
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"type": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "P8E80F9AEF21F6940"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "P8E80F9AEF21F6940"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(count_over_time({compose_service=\"ppanel-server\"}[5m]))",
|
||||||
|
"queryType": "range",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Matched Log Volume",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "P8E80F9AEF21F6940"
|
||||||
|
},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "palette-classic"
|
||||||
|
},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 7,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "table",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "P8E80F9AEF21F6940"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(count_over_time({compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\" [5m]))",
|
||||||
|
"queryType": "range",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Matched Error Volume",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "P8E80F9AEF21F6940"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 12,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 7
|
||||||
|
},
|
||||||
|
"id": 3,
|
||||||
|
"options": {
|
||||||
|
"dedupStrategy": "none",
|
||||||
|
"enableLogDetails": true,
|
||||||
|
"prettifyLogMessage": false,
|
||||||
|
"showCommonLabels": false,
|
||||||
|
"showLabels": true,
|
||||||
|
"showTime": true,
|
||||||
|
"sortOrder": "Descending",
|
||||||
|
"wrapLogMessage": true
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "P8E80F9AEF21F6940"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "{compose_service=\"ppanel-server\"}",
|
||||||
|
"queryType": "range",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Filtered Server Logs",
|
||||||
|
"type": "logs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "P8E80F9AEF21F6940"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 10,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 19
|
||||||
|
},
|
||||||
|
"id": 4,
|
||||||
|
"options": {
|
||||||
|
"dedupStrategy": "none",
|
||||||
|
"enableLogDetails": true,
|
||||||
|
"prettifyLogMessage": false,
|
||||||
|
"showCommonLabels": false,
|
||||||
|
"showLabels": true,
|
||||||
|
"showTime": true,
|
||||||
|
"sortOrder": "Descending",
|
||||||
|
"wrapLogMessage": true
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "loki",
|
||||||
|
"uid": "P8E80F9AEF21F6940"
|
||||||
|
},
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "{compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\"",
|
||||||
|
"queryType": "range",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Filtered Server Errors",
|
||||||
|
"type": "logs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 42,
|
||||||
|
"tags": [
|
||||||
|
"ppanel",
|
||||||
|
"logs",
|
||||||
|
"server",
|
||||||
|
"loki"
|
||||||
|
],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"current": {
|
||||||
|
"selected": false,
|
||||||
|
"text": ".",
|
||||||
|
"value": "."
|
||||||
|
},
|
||||||
|
"description": "输入用户 ID;默认 . 表示不过滤",
|
||||||
|
"hide": 0,
|
||||||
|
"label": "用户ID",
|
||||||
|
"name": "user_id",
|
||||||
|
"options": [],
|
||||||
|
"query": ".",
|
||||||
|
"skipUrlSync": false,
|
||||||
|
"type": "textbox"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"current": {
|
||||||
|
"selected": false,
|
||||||
|
"text": ".",
|
||||||
|
"value": "."
|
||||||
|
},
|
||||||
|
"description": "输入邮箱或邮箱片段;默认 . 表示不过滤",
|
||||||
|
"hide": 0,
|
||||||
|
"label": "邮箱",
|
||||||
|
"name": "email",
|
||||||
|
"options": [],
|
||||||
|
"query": ".",
|
||||||
|
"skipUrlSync": false,
|
||||||
|
"type": "textbox"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"current": {
|
||||||
|
"selected": false,
|
||||||
|
"text": ".",
|
||||||
|
"value": "."
|
||||||
|
},
|
||||||
|
"description": "输入订单号/支付单号/交易号片段;默认 . 表示不过滤",
|
||||||
|
"hide": 0,
|
||||||
|
"label": "订单",
|
||||||
|
"name": "order",
|
||||||
|
"options": [],
|
||||||
|
"query": ".",
|
||||||
|
"skipUrlSync": false,
|
||||||
|
"type": "textbox"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"current": {
|
||||||
|
"selected": true,
|
||||||
|
"text": "All",
|
||||||
|
"value": "."
|
||||||
|
},
|
||||||
|
"description": "日志等级",
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": false,
|
||||||
|
"label": "等级",
|
||||||
|
"multi": false,
|
||||||
|
"name": "level",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"selected": true,
|
||||||
|
"text": "All",
|
||||||
|
"value": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "debug",
|
||||||
|
"value": "debug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "info",
|
||||||
|
"value": "info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "warn",
|
||||||
|
"value": "warn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "error",
|
||||||
|
"value": "error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "slow",
|
||||||
|
"value": "slow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "panic",
|
||||||
|
"value": "panic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selected": false,
|
||||||
|
"text": "fatal",
|
||||||
|
"value": "fatal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"query": "All : .,debug,info,warn,error,slow,panic,fatal",
|
||||||
|
"queryValue": "",
|
||||||
|
"skipUrlSync": false,
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"current": {
|
||||||
|
"selected": false,
|
||||||
|
"text": ".",
|
||||||
|
"value": "."
|
||||||
|
},
|
||||||
|
"description": "任意关键字;默认 . 表示不过滤",
|
||||||
|
"hide": 0,
|
||||||
|
"label": "关键字",
|
||||||
|
"name": "keyword",
|
||||||
|
"options": [],
|
||||||
|
"query": ".",
|
||||||
|
"skipUrlSync": false,
|
||||||
|
"type": "textbox"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-1h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "PPanel Server Logs",
|
||||||
|
"uid": "ppanel-server-logs",
|
||||||
|
"version": 5,
|
||||||
|
"weekStart": ""
|
||||||
|
}
|
||||||
@@ -46,3 +46,12 @@ datasources:
|
|||||||
datasourceUid: prometheus
|
datasourceUid: prometheus
|
||||||
serviceMap:
|
serviceMap:
|
||||||
datasourceUid: prometheus
|
datasourceUid: prometheus
|
||||||
|
|
||||||
|
- name: CloudWatch
|
||||||
|
uid: cloudwatch
|
||||||
|
type: cloudwatch
|
||||||
|
access: proxy
|
||||||
|
editable: true
|
||||||
|
jsonData:
|
||||||
|
authType: default
|
||||||
|
defaultRegion: ap-east-1
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ CREATE TABLE IF NOT EXISTS `user_device`
|
|||||||
`subscribe_id` bigint DEFAULT NULL COMMENT 'Subscribe ID',
|
`subscribe_id` bigint DEFAULT NULL COMMENT 'Subscribe ID',
|
||||||
`ip` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Ip.',
|
`ip` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Ip.',
|
||||||
`Identifier` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Identifier.',
|
`Identifier` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Identifier.',
|
||||||
`user_agent` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device User Agent.',
|
`user_agent` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device User Agent.',
|
||||||
`online` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Online',
|
`online` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Online',
|
||||||
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'EnableDeviceNumber',
|
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'EnableDeviceNumber',
|
||||||
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
|
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS `order_recovery_claims`;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `order_recovery_claims` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
`order_no` VARCHAR(255) NOT NULL COMMENT 'Recovered Order No',
|
||||||
|
`email` VARCHAR(255) NOT NULL COMMENT 'Claim Email',
|
||||||
|
`user_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'User ID',
|
||||||
|
`subscribe_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Subscribe ID',
|
||||||
|
`order_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Recovered Order ID',
|
||||||
|
`user_subscribe_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'User Subscribe ID',
|
||||||
|
`claimed_at` DATETIME(3) NOT NULL COMMENT 'Claimed Time',
|
||||||
|
`created_at` DATETIME(3) DEFAULT NULL COMMENT 'Creation Time',
|
||||||
|
`updated_at` DATETIME(3) DEFAULT NULL COMMENT 'Update Time',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `idx_order_recovery_claim_order_no` (`order_no`),
|
||||||
|
KEY `idx_order_recovery_claim_email` (`email`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE `log_message`
|
||||||
|
MODIFY COLUMN `app_version` VARCHAR(32) NULL,
|
||||||
|
MODIFY COLUMN `os_name` VARCHAR(32) NULL,
|
||||||
|
MODIFY COLUMN `os_version` VARCHAR(32) NULL,
|
||||||
|
MODIFY COLUMN `device_id` VARCHAR(64) NULL,
|
||||||
|
MODIFY COLUMN `session_id` VARCHAR(64) NULL,
|
||||||
|
MODIFY COLUMN `error_code` VARCHAR(64) NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE `log_message`
|
||||||
|
MODIFY COLUMN `app_version` VARCHAR(64) NULL,
|
||||||
|
MODIFY COLUMN `os_name` VARCHAR(64) NULL,
|
||||||
|
MODIFY COLUMN `os_version` VARCHAR(64) NULL,
|
||||||
|
MODIFY COLUMN `device_id` VARCHAR(255) NULL,
|
||||||
|
MODIFY COLUMN `session_id` VARCHAR(255) NULL,
|
||||||
|
MODIFY COLUMN `error_code` VARCHAR(128) NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `user_device`
|
||||||
|
MODIFY COLUMN `user_agent` VARCHAR(64) NULL COMMENT 'Device User Agent.';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `user_device`
|
||||||
|
MODIFY COLUMN `user_agent` VARCHAR(255) NULL COMMENT 'Device User Agent.';
|
||||||
@@ -20,6 +20,14 @@ type schemaColumnPatch struct {
|
|||||||
ddl string
|
ddl string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type schemaColumnDefinitionPatch struct {
|
||||||
|
table string
|
||||||
|
column string
|
||||||
|
dataType string
|
||||||
|
characterMaxLen *int64
|
||||||
|
ddl string
|
||||||
|
}
|
||||||
|
|
||||||
func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error {
|
func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error {
|
||||||
tablePatches := []schemaTablePatch{
|
tablePatches := []schemaTablePatch{
|
||||||
{
|
{
|
||||||
@@ -142,6 +150,17 @@ func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
varchar255 := int64(255)
|
||||||
|
columnDefinitionPatches := []schemaColumnDefinitionPatch{
|
||||||
|
{
|
||||||
|
table: "user_device",
|
||||||
|
column: "user_agent",
|
||||||
|
dataType: "varchar",
|
||||||
|
characterMaxLen: &varchar255,
|
||||||
|
ddl: "ALTER TABLE `user_device` MODIFY COLUMN `user_agent` VARCHAR(255) NULL COMMENT 'Device User Agent.';",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
for _, patch := range tablePatches {
|
for _, patch := range tablePatches {
|
||||||
exists, err := tableExists(ctx.DB, patch.table)
|
exists, err := tableExists(ctx.DB, patch.table)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -199,6 +218,27 @@ func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error {
|
|||||||
logger.Infof("[SchemaCompat] created missing index: %s.%s", patch.table, patch.index)
|
logger.Infof("[SchemaCompat] created missing index: %s.%s", patch.table, patch.index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, patch := range columnDefinitionPatches {
|
||||||
|
tblExists, err := tableExists(ctx.DB, patch.table)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "check table %s failed", patch.table)
|
||||||
|
}
|
||||||
|
if !tblExists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches, err := columnDefinitionMatches(ctx.DB, patch.table, patch.column, patch.dataType, patch.characterMaxLen)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "check column definition %s.%s failed", patch.table, patch.column)
|
||||||
|
}
|
||||||
|
if matches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err = ctx.DB.Exec(patch.ddl).Error; err != nil {
|
||||||
|
return errors.Wrapf(err, "modify column %s.%s failed", patch.table, patch.column)
|
||||||
|
}
|
||||||
|
logger.Infof("[SchemaCompat] repaired column definition: %s.%s", patch.table, patch.column)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +277,38 @@ func indexExists(db *gorm.DB, table, index string) (bool, error) {
|
|||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func columnDefinitionMatches(db *gorm.DB, table, column, dataType string, characterMaxLen *int64) (bool, error) {
|
||||||
|
type columnMeta struct {
|
||||||
|
DataType string
|
||||||
|
CharacterMaximumLen *int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta columnMeta
|
||||||
|
err := db.Raw(
|
||||||
|
`SELECT DATA_TYPE AS data_type, CHARACTER_MAXIMUM_LENGTH AS character_maximum_len
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||||||
|
table,
|
||||||
|
column,
|
||||||
|
).Scan(&meta).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if meta.DataType == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if meta.DataType != dataType {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if characterMaxLen == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if meta.CharacterMaximumLen == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return *meta.CharacterMaximumLen == *characterMaxLen, nil
|
||||||
|
}
|
||||||
|
|
||||||
func _schemaCompatDebug(table, column string) string {
|
func _schemaCompatDebug(table, column string) string {
|
||||||
if column == "" {
|
if column == "" {
|
||||||
return table
|
return table
|
||||||
|
|||||||
@@ -38,12 +38,29 @@ type Config struct {
|
|||||||
Log Log `yaml:"Log"`
|
Log Log `yaml:"Log"`
|
||||||
Currency Currency `yaml:"Currency"`
|
Currency Currency `yaml:"Currency"`
|
||||||
Trace trace.Config `yaml:"Trace"`
|
Trace trace.Config `yaml:"Trace"`
|
||||||
|
S3 S3Config `yaml:"S3"`
|
||||||
Administrator struct {
|
Administrator struct {
|
||||||
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
||||||
Password string `yaml:"Password" default:"password"`
|
Password string `yaml:"Password" default:"password"`
|
||||||
} `yaml:"Administrator"`
|
} `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 {
|
type RedisConfig struct {
|
||||||
Host string `yaml:"Host" default:"localhost:6379"`
|
Host string `yaml:"Host" default:"localhost:6379"`
|
||||||
Pass string `yaml:"Pass" default:""`
|
Pass string `yaml:"Pass" default:""`
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/admin/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Approve withdrawal
|
||||||
|
func ApproveWithdrawalHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.ApproveWithdrawalRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := user.NewApproveWithdrawalLogic(c.Request.Context(), svcCtx)
|
||||||
|
err := l.ApproveWithdrawal(&req)
|
||||||
|
result.HttpResult(c, nil, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/admin/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get withdrawal list
|
||||||
|
func GetWithdrawalListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.GetWithdrawalListRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := user.NewGetWithdrawalListLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.GetWithdrawalList(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/admin/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reject withdrawal
|
||||||
|
func RejectWithdrawalHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.RejectWithdrawalRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := user.NewRejectWithdrawalLogic(c.Request.Context(), svcCtx)
|
||||||
|
err := l.RejectWithdrawal(&req)
|
||||||
|
result.HttpResult(c, nil, 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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,43 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime/multipart"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Upload file to RustFS
|
||||||
|
func FileUploadHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.FileUploadRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
result.ParamErrorResult(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileReader, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
result.HttpResult(c, nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func(file multipart.File) {
|
||||||
|
_ = file.Close()
|
||||||
|
}(fileReader)
|
||||||
|
|
||||||
|
l := file.NewFileUploadLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.FileUpload(&req, fileHeader, fileReader)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/public/recovery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RecoverOrderHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.RecoverOrderRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := recovery.NewRecoverOrderLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.RecoverOrder(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/public/recovery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SendCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.RecoverySendCodeRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := recovery.NewSendCodeLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.SendCode(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,10 +30,12 @@ import (
|
|||||||
common "github.com/perfect-panel/server/internal/handler/common"
|
common "github.com/perfect-panel/server/internal/handler/common"
|
||||||
publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement"
|
publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement"
|
||||||
publicDocument "github.com/perfect-panel/server/internal/handler/public/document"
|
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"
|
publicIapApple "github.com/perfect-panel/server/internal/handler/public/iap/apple"
|
||||||
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
|
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
|
||||||
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
|
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
|
||||||
publicPortal "github.com/perfect-panel/server/internal/handler/public/portal"
|
publicPortal "github.com/perfect-panel/server/internal/handler/public/portal"
|
||||||
|
publicRecovery "github.com/perfect-panel/server/internal/handler/public/recovery"
|
||||||
publicRedemption "github.com/perfect-panel/server/internal/handler/public/redemption"
|
publicRedemption "github.com/perfect-panel/server/internal/handler/public/redemption"
|
||||||
publicSubscribe "github.com/perfect-panel/server/internal/handler/public/subscribe"
|
publicSubscribe "github.com/perfect-panel/server/internal/handler/public/subscribe"
|
||||||
publicTicket "github.com/perfect-panel/server/internal/handler/public/ticket"
|
publicTicket "github.com/perfect-panel/server/internal/handler/public/ticket"
|
||||||
@@ -705,6 +707,15 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
|
|
||||||
// Get admin user invite list
|
// Get admin user invite list
|
||||||
adminUserGroupRouter.GET("/invite/list", adminUser.GetAdminUserInviteListHandler(serverCtx))
|
adminUserGroupRouter.GET("/invite/list", adminUser.GetAdminUserInviteListHandler(serverCtx))
|
||||||
|
|
||||||
|
// Get withdrawal list
|
||||||
|
adminUserGroupRouter.GET("/withdrawal/list", adminUser.GetWithdrawalListHandler(serverCtx))
|
||||||
|
|
||||||
|
// Approve withdrawal
|
||||||
|
adminUserGroupRouter.POST("/withdrawal/approve", adminUser.ApproveWithdrawalHandler(serverCtx))
|
||||||
|
|
||||||
|
// Reject withdrawal
|
||||||
|
adminUserGroupRouter.POST("/withdrawal/reject", adminUser.RejectWithdrawalHandler(serverCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
authGroupRouter := router.Group("/v1/auth")
|
authGroupRouter := router.Group("/v1/auth")
|
||||||
@@ -864,6 +875,20 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
publicDocumentGroupRouter.GET("/list", publicDocument.QueryDocumentListHandler(serverCtx))
|
publicDocumentGroupRouter.GET("/list", publicDocument.QueryDocumentListHandler(serverCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publicFileGroupRouter := router.Group("/v1/public/file")
|
||||||
|
publicFileGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|
||||||
|
{
|
||||||
|
// Upload file to RustFS
|
||||||
|
publicFileGroupRouter.POST("/upload", publicFile.FileUploadHandler(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 := router.Group("/v1/public/order")
|
||||||
publicOrderGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
publicOrderGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|
||||||
@@ -932,6 +957,17 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
publicRedemptionGroupRouter.POST("/", publicRedemption.RedeemCodeHandler(serverCtx))
|
publicRedemptionGroupRouter.POST("/", publicRedemption.RedeemCodeHandler(serverCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publicRecoveryGroupRouter := router.Group("/v1/public/recovery")
|
||||||
|
publicRecoveryGroupRouter.Use(middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|
||||||
|
{
|
||||||
|
// Send recovery verification code
|
||||||
|
publicRecoveryGroupRouter.POST("/send_code", publicRecovery.SendCodeHandler(serverCtx))
|
||||||
|
|
||||||
|
// Recover order subscription
|
||||||
|
publicRecoveryGroupRouter.POST("/order", publicRecovery.RecoverOrderHandler(serverCtx))
|
||||||
|
}
|
||||||
|
|
||||||
publicSubscribeGroupRouter := router.Group("/v1/public/subscribe")
|
publicSubscribeGroupRouter := router.Group("/v1/public/subscribe")
|
||||||
publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApproveWithdrawalLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApproveWithdrawalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveWithdrawalLogic {
|
||||||
|
return &ApproveWithdrawalLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ApproveWithdrawalLogic) ApproveWithdrawal(req *types.ApproveWithdrawalRequest) error {
|
||||||
|
return approveWithdrawal(l.ctx, l.svcCtx, req.WithdrawalId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
usermodel "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/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetWithdrawalListLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGetWithdrawalListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetWithdrawalListLogic {
|
||||||
|
return &GetWithdrawalListLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GetWithdrawalListLogic) GetWithdrawalList(req *types.GetWithdrawalListRequest) (*types.GetWithdrawalListResponse, error) {
|
||||||
|
page := req.Page
|
||||||
|
size := req.Size
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 {
|
||||||
|
size = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
query := l.svcCtx.DB.WithContext(l.ctx).Model(&usermodel.Withdrawal{})
|
||||||
|
if req.UserId != nil {
|
||||||
|
query = query.Where("user_id = ?", *req.UserId)
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
query = query.Where("status = ?", *req.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count withdrawals failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []usermodel.Withdrawal
|
||||||
|
if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query withdrawals failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]types.WithdrawalLog, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
list = append(list, types.WithdrawalLog{
|
||||||
|
Id: row.Id,
|
||||||
|
UserId: row.UserId,
|
||||||
|
Amount: row.Amount,
|
||||||
|
Content: row.Content,
|
||||||
|
Status: row.Status,
|
||||||
|
Reason: row.Reason,
|
||||||
|
CreatedAt: row.CreatedAt.UnixMilli(),
|
||||||
|
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.GetWithdrawalListResponse{
|
||||||
|
List: list,
|
||||||
|
Total: total,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RejectWithdrawalLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRejectWithdrawalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectWithdrawalLogic {
|
||||||
|
return &RejectWithdrawalLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RejectWithdrawalLogic) RejectWithdrawal(req *types.RejectWithdrawalRequest) error {
|
||||||
|
return rejectWithdrawal(l.ctx, l.svcCtx, req.WithdrawalId, req.Reason)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
@@ -100,33 +101,43 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Commission != userInfo.Commission {
|
if req.Commission != userInfo.Commission {
|
||||||
|
if isWithdrawalScene(req.Remark) {
|
||||||
commentLog := log.Commission{
|
logWithdrawalGuard(l.Logger, userInfo.Id)
|
||||||
Type: log.CommissionTypeAdjust,
|
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
|
||||||
Amount: req.Commission - userInfo.Commission,
|
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
}
|
}
|
||||||
|
change := req.Commission - userInfo.Commission
|
||||||
content, _ := commentLog.Marshal()
|
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
|
||||||
err = tx.Create(&log.SystemLog{
|
return err
|
||||||
Type: log.TypeCommission.Uint8(),
|
}
|
||||||
Date: time.Now().Format(time.DateOnly),
|
if err = logicCommon.WriteCommissionLog(tx, userInfo.Id, log.CommissionTypeAdjust, change, ""); err != nil {
|
||||||
ObjectID: userInfo.Id,
|
|
||||||
Content: string(content),
|
|
||||||
}).Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
userInfo.Commission = req.Commission
|
userInfo.Commission = req.Commission
|
||||||
}
|
}
|
||||||
userInfo.Avatar = req.Avatar
|
if req.Avatar != "" {
|
||||||
userInfo.ReferCode = req.ReferCode
|
userInfo.Avatar = req.Avatar
|
||||||
userInfo.RefererId = req.RefererId
|
}
|
||||||
userInfo.Enable = &req.Enable
|
if req.ReferCode != "" {
|
||||||
userInfo.IsAdmin = &req.IsAdmin
|
userInfo.ReferCode = req.ReferCode
|
||||||
userInfo.Remark = req.Remark
|
}
|
||||||
userInfo.OnlyFirstPurchase = &req.OnlyFirstPurchase
|
if req.RefererId != 0 {
|
||||||
userInfo.ReferralPercentage = req.ReferralPercentage
|
userInfo.RefererId = req.RefererId
|
||||||
|
}
|
||||||
|
if req.Enable != nil {
|
||||||
|
userInfo.Enable = req.Enable
|
||||||
|
}
|
||||||
|
if req.IsAdmin != nil {
|
||||||
|
userInfo.IsAdmin = req.IsAdmin
|
||||||
|
}
|
||||||
|
if req.Remark != "" {
|
||||||
|
userInfo.Remark = req.Remark
|
||||||
|
}
|
||||||
|
if req.OnlyFirstPurchase != nil {
|
||||||
|
userInfo.OnlyFirstPurchase = req.OnlyFirstPurchase
|
||||||
|
}
|
||||||
|
if req.ReferralPercentage != 0 {
|
||||||
|
userInfo.ReferralPercentage = req.ReferralPercentage
|
||||||
|
}
|
||||||
|
|
||||||
if req.Password != "" {
|
if req.Password != "" {
|
||||||
if userInfo.Id == 2 && isDemo {
|
if userInfo.Id == 2 && isDemo {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
|
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawalID int64) error {
|
||||||
|
return svcCtx.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
withdrawal, err := logicCommon.LoadPendingWithdrawalForUpdate(ctx, tx, withdrawalID)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "withdrawal status invalid" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.WithdrawalStatusInvalid), "withdrawal %d already processed", withdrawalID)
|
||||||
|
}
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&usermodel.Withdrawal{}).
|
||||||
|
Where("id = ? AND status = 0", withdrawalID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": 1,
|
||||||
|
"reason": "",
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "approve withdrawal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdraw, withdrawal.Amount, ""); err != nil {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawalID int64, reason string) error {
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
return svcCtx.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
withdrawal, err := logicCommon.LoadPendingWithdrawalForUpdate(ctx, tx, withdrawalID)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "withdrawal status invalid" {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.WithdrawalStatusInvalid), "withdrawal %d already processed", withdrawalID)
|
||||||
|
}
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&usermodel.Withdrawal{}).
|
||||||
|
Where("id = ? AND status = 0", withdrawalID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": 2,
|
||||||
|
"reason": reason,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "reject withdrawal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svcCtx.UserModel.UpdateCommission(ctx, withdrawal.UserId, withdrawal.Amount, tx); err != nil {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdrawReject, withdrawal.Amount, ""); err != nil {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWithdrawalScene(remark string) bool {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(remark))
|
||||||
|
return strings.Contains(normalized, "withdraw") || strings.Contains(normalized, "提现")
|
||||||
|
}
|
||||||
|
|
||||||
|
func logWithdrawalGuard(l logger.Logger, userID int64) {
|
||||||
|
l.Errorw("blocked commission overwrite in withdrawal scene", logger.Field("user_id", userID))
|
||||||
|
}
|
||||||
@@ -2,13 +2,11 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
"github.com/perfect-panel/server/internal/config"
|
||||||
"github.com/perfect-panel/server/internal/logic/common"
|
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
@@ -47,29 +45,6 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
|||||||
req.Code = strings.TrimSpace(req.Code)
|
req.Code = strings.TrimSpace(req.Code)
|
||||||
|
|
||||||
// Verify Code
|
// Verify Code
|
||||||
scenes := []string{constant.Security.String(), constant.Register.String(), "unknown"}
|
|
||||||
var verified bool
|
|
||||||
var cacheKeyUsed string
|
|
||||||
var payload common.CacheKeyPayload
|
|
||||||
for _, scene := range scenes {
|
|
||||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
|
||||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
|
||||||
if err != nil || value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime {
|
|
||||||
verified = true
|
|
||||||
cacheKeyUsed = cacheKey
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !verified {
|
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired")
|
|
||||||
}
|
|
||||||
l.svcCtx.Redis.Del(l.ctx, cacheKeyUsed)
|
|
||||||
|
|
||||||
// Check User
|
// Check User
|
||||||
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||||
|
|||||||
@@ -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,108 +1,120 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
|
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReportLogMessageLogic struct {
|
type ReportLogMessageLogic struct {
|
||||||
logger.Logger
|
logger.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReportLogMessageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReportLogMessageLogic {
|
func NewReportLogMessageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReportLogMessageLogic {
|
||||||
return &ReportLogMessageLogic{ Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx }
|
return &ReportLogMessageLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequest, c *gin.Context) (resp *types.ReportLogMessageResponse, err error) {
|
func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequest, c *gin.Context) (resp *types.ReportLogMessageResponse, err error) {
|
||||||
ip := clientIP(c)
|
ip := clientIP(c)
|
||||||
ua := c.GetHeader("User-Agent")
|
ua := c.GetHeader("User-Agent")
|
||||||
locale := c.GetHeader("Accept-Language")
|
locale := c.GetHeader("Accept-Language")
|
||||||
|
|
||||||
// 简单限流:设备ID优先,其次IP
|
// 简单限流:设备ID优先,其次IP
|
||||||
limitKey := "logmsg:" + strings.TrimSpace(req.DeviceId)
|
limitKey := "logmsg:" + strings.TrimSpace(req.DeviceId)
|
||||||
if limitKey == "logmsg:" { limitKey = "logmsg:" + ip }
|
if limitKey == "logmsg:" {
|
||||||
count, _ := l.svcCtx.Redis.Incr(l.ctx, limitKey).Result()
|
limitKey = "logmsg:" + ip
|
||||||
if count == 1 { _ = l.svcCtx.Redis.Expire(l.ctx, limitKey, 60*time.Second).Err() }
|
}
|
||||||
if count > 120 { // 每分钟最多120条
|
count, _ := l.svcCtx.Redis.Incr(l.ctx, limitKey).Result()
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "too many reports")
|
if count == 1 {
|
||||||
}
|
_ = l.svcCtx.Redis.Expire(l.ctx, limitKey, 60*time.Second).Err()
|
||||||
|
}
|
||||||
|
if count > 120 { // 每分钟最多120条
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "too many reports")
|
||||||
|
}
|
||||||
|
|
||||||
// 指纹生成
|
// 指纹生成
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
h.Write([]byte(strings.Join([]string{req.Message, req.Stack, req.ErrorCode, req.AppVersion, req.Platform}, "|")))
|
h.Write([]byte(strings.Join([]string{req.Message, req.Stack, req.ErrorCode, req.AppVersion, req.Platform}, "|")))
|
||||||
digest := hex.EncodeToString(h.Sum(nil))
|
digest := hex.EncodeToString(h.Sum(nil))
|
||||||
|
|
||||||
var ctxStr string
|
var ctxStr string
|
||||||
if req.Context != nil {
|
if req.Context != nil {
|
||||||
if b, e := json.Marshal(req.Context); e == nil {
|
if b, e := json.Marshal(req.Context); e == nil {
|
||||||
ctxStr = string(b)
|
ctxStr = string(b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var occurredAt *time.Time
|
var occurredAt *time.Time
|
||||||
if req.OccurredAt > 0 {
|
if req.OccurredAt > 0 {
|
||||||
t := time.UnixMilli(req.OccurredAt)
|
t := time.UnixMilli(req.OccurredAt)
|
||||||
occurredAt = &t
|
occurredAt = &t
|
||||||
}
|
}
|
||||||
|
|
||||||
var userIdPtr *int64
|
var userIdPtr *int64
|
||||||
if req.UserId > 0 { userIdPtr = &req.UserId }
|
if req.UserId > 0 {
|
||||||
|
userIdPtr = &req.UserId
|
||||||
|
}
|
||||||
|
|
||||||
row := &logmessage.LogMessage{
|
row := &logmessage.LogMessage{
|
||||||
Platform: req.Platform,
|
Platform: safeTruncate(req.Platform, 32),
|
||||||
AppVersion: req.AppVersion,
|
AppVersion: safeTruncate(req.AppVersion, 64),
|
||||||
OsName: req.OsName,
|
OsName: safeTruncate(req.OsName, 64),
|
||||||
OsVersion: req.OsVersion,
|
OsVersion: safeTruncate(req.OsVersion, 64),
|
||||||
DeviceId: req.DeviceId,
|
DeviceId: safeTruncate(req.DeviceId, 255),
|
||||||
UserId: userIdPtr,
|
UserId: userIdPtr,
|
||||||
SessionId: req.SessionId,
|
SessionId: safeTruncate(req.SessionId, 255),
|
||||||
Level: req.Level,
|
Level: req.Level,
|
||||||
ErrorCode: req.ErrorCode,
|
ErrorCode: safeTruncate(req.ErrorCode, 128),
|
||||||
Message: safeTruncate(req.Message, 1024*64),
|
Message: safeTruncate(req.Message, 1024*64),
|
||||||
Stack: safeTruncate(req.Stack, 1024*1024),
|
Stack: safeTruncate(req.Stack, 1024*1024),
|
||||||
Context: ctxStr,
|
Context: ctxStr,
|
||||||
ClientIP: ip,
|
ClientIP: ip,
|
||||||
UserAgent: safeTruncate(ua, 255),
|
UserAgent: safeTruncate(ua, 255),
|
||||||
Locale: safeTruncate(locale, 16),
|
Locale: safeTruncate(locale, 16),
|
||||||
Digest: digest,
|
Digest: digest,
|
||||||
OccurredAt: occurredAt,
|
OccurredAt: occurredAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = l.svcCtx.LogMessageModel.Insert(l.ctx, row); err != nil {
|
if err = l.svcCtx.LogMessageModel.Insert(l.ctx, row); err != nil {
|
||||||
// 唯一指纹冲突时尝试查询已有记录返回ID
|
// 唯一指纹冲突时尝试查询已有记录返回ID
|
||||||
ex, _, findErr := l.svcCtx.LogMessageModel.Filter(l.ctx, &logmessage.FilterParams{ Keyword: req.Message, Page: 1, Size: 1 })
|
ex, _, findErr := l.svcCtx.LogMessageModel.Filter(l.ctx, &logmessage.FilterParams{Keyword: req.Message, Page: 1, Size: 1})
|
||||||
if findErr == nil && len(ex) > 0 {
|
if findErr == nil && len(ex) > 0 {
|
||||||
return &types.ReportLogMessageResponse{ Id: ex[0].Id }, nil
|
return &types.ReportLogMessageResponse{Id: ex[0].Id}, nil
|
||||||
}
|
}
|
||||||
l.Errorf("[ReportLogMessage] insert error: %v", err)
|
l.Errorf("[ReportLogMessage] insert error: %v", err)
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "insert log_message failed: %v", err)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "insert log_message failed: %v", err)
|
||||||
}
|
}
|
||||||
return &types.ReportLogMessageResponse{ Id: row.Id }, nil
|
return &types.ReportLogMessageResponse{Id: row.Id}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeTruncate(s string, n int) string {
|
func safeTruncate(s string, n int) string {
|
||||||
if len(s) <= n { return s }
|
if len(s) <= n {
|
||||||
return s[:n]
|
return s
|
||||||
|
}
|
||||||
|
return s[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientIP(c *gin.Context) string {
|
func clientIP(c *gin.Context) string {
|
||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
if ip != "" { return ip }
|
if ip != "" {
|
||||||
host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
|
return ip
|
||||||
if err == nil && host != "" { return host }
|
}
|
||||||
return ""
|
host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
|
||||||
|
if err == nil && host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,48 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
|
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WriteCommissionLog(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error {
|
||||||
|
logInfo := log.Commission{
|
||||||
|
Type: logType,
|
||||||
|
Amount: amount,
|
||||||
|
OrderNo: orderNo,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := logInfo.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Model(log.SystemLog{}).Create(&log.SystemLog{
|
||||||
|
Type: log.TypeCommission.Uint8(),
|
||||||
|
Date: time.Now().Format(time.DateOnly),
|
||||||
|
ObjectID: objectID,
|
||||||
|
Content: string(content),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadPendingWithdrawalForUpdate(ctx context.Context, tx *gorm.DB, withdrawalID int64) (*usermodel.Withdrawal, error) {
|
||||||
|
var withdrawal usermodel.Withdrawal
|
||||||
|
if err := tx.WithContext(ctx).
|
||||||
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("id = ?", withdrawalID).
|
||||||
|
First(&withdrawal).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if withdrawal.Status != 0 {
|
||||||
|
return nil, errors.New("withdrawal status invalid")
|
||||||
|
}
|
||||||
|
return &withdrawal, nil
|
||||||
|
}
|
||||||
@@ -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/%04d/%02d/%02d/%d/%s__%s",
|
||||||
|
prefix,
|
||||||
|
now.Year(),
|
||||||
|
int(now.Month()),
|
||||||
|
now.Day(),
|
||||||
|
userID,
|
||||||
|
safeFileName(fileName),
|
||||||
|
fileID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"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 FileUploadLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload file to RustFS
|
||||||
|
func NewFileUploadLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FileUploadLogic {
|
||||||
|
return &FileUploadLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *FileUploadLogic) FileUpload(req *types.FileUploadRequest, fileHeader *multipart.FileHeader, file multipart.File) (resp *types.FileUploadResponse, err error) {
|
||||||
|
u, err := currentUserFromContext(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileHeader == nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "file is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType, err := sniffContentType(fileHeader, file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateInitRequest(l.svcCtx, req.BizType, fileHeader.Filename, contentType, fileHeader.Size); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
fileID := buildFileID(u.Id, req.BizType, fileHeader.Filename)
|
||||||
|
objectKey := buildObjectKey(l.svcCtx.Config.S3.Prefix, u.Id, req.BizType, fileID, fileHeader.Filename, now)
|
||||||
|
|
||||||
|
putResult, err := l.svcCtx.S3Store.PutObject(l.ctx, objectKey, file, fileHeader.Size, contentType)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("put object failed", logger.Field("error", err.Error()), logger.Field("user_id", u.Id), logger.Field("file_id", fileID))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.FileUploadResponse{
|
||||||
|
FileId: fileID,
|
||||||
|
FileName: fileHeader.Filename,
|
||||||
|
ObjectKey: objectKey,
|
||||||
|
Size: fileHeader.Size,
|
||||||
|
ContentType: contentType,
|
||||||
|
Etag: putResult.ETag,
|
||||||
|
Status: fileUploadCompleteStatus,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sniffContentType(fileHeader *multipart.FileHeader, file multipart.File) (string, error) {
|
||||||
|
if headerType := fileHeader.Header.Get("Content-Type"); headerType != "" {
|
||||||
|
return headerType, nil
|
||||||
|
}
|
||||||
|
if seeker, ok := file.(io.ReadSeeker); ok {
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
n, readErr := seeker.Read(buf)
|
||||||
|
if readErr != nil && readErr != io.EOF {
|
||||||
|
return "", readErr
|
||||||
|
}
|
||||||
|
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return http.DetectContentType(bytes.TrimRight(buf[:n], "\x00")), nil
|
||||||
|
}
|
||||||
|
return "application/octet-stream", 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)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
|
ordermodel "github.com/perfect-panel/server/internal/model/order"
|
||||||
|
recoverymodel "github.com/perfect-panel/server/internal/model/recovery"
|
||||||
|
subscribemodel "github.com/perfect-panel/server/internal/model/subscribe"
|
||||||
|
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
recoveryconfig "github.com/perfect-panel/server/internal/recovery"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/authmethod"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
recoverySuccessMessage = "恢复完成,请使用该邮箱验证码登录查看订阅。"
|
||||||
|
recoveryAlreadyDoneMessage = "该订单已恢复,请使用恢复时绑定的邮箱登录查看订阅。"
|
||||||
|
recoveryInvalidOrderMessage = "订单信息无效,请确认订单号是否正确。"
|
||||||
|
recoveryInvalidCodeMessage = "邮箱验证码错误或已过期,请重新获取验证码。"
|
||||||
|
recoverySystemErrorMessage = "恢复暂时失败,请稍后再试或联系客服处理。"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RecoverOrderLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRecoverOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RecoverOrderLogic {
|
||||||
|
return &RecoverOrderLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RecoverOrderLogic) RecoverOrder(req *types.RecoverOrderRequest) (*types.RecoverOrderResponse, error) {
|
||||||
|
orderNo := strings.TrimSpace(req.OrderNo)
|
||||||
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
code := strings.TrimSpace(req.Code)
|
||||||
|
|
||||||
|
if orderNo == "" || email == "" || code == "" {
|
||||||
|
return recoveryFailed(recoveryInvalidOrderMessage), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := l.verifyEmailCode(email, code); err != nil {
|
||||||
|
return recoveryFailed(recoveryInvalidCodeMessage), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
item, ok, err := recoveryconfig.FindOrder(orderNo)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[RecoverOrder] load recovery config failed", logger.Field("error", err.Error()))
|
||||||
|
return recoveryFailed(recoverySystemErrorMessage), nil
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return recoveryFailed(recoveryInvalidOrderMessage), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.OrderStatus != "" && item.OrderStatus != "success" {
|
||||||
|
return recoveryFailed(recoveryInvalidOrderMessage), nil
|
||||||
|
}
|
||||||
|
if item.Quantity <= 0 || item.OrderMoneyCents <= 0 {
|
||||||
|
return recoveryFailed(recoveryInvalidOrderMessage), nil
|
||||||
|
}
|
||||||
|
claimOrderNo := item.OutOrderNo
|
||||||
|
if claimOrderNo == "" {
|
||||||
|
claimOrderNo = item.OrderNo
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, item.SubscribeId)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[RecoverOrder] subscribe not found", logger.Field("subscribe_id", item.SubscribeId), logger.Field("error", err.Error()))
|
||||||
|
return recoveryFailed(recoverySystemErrorMessage), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
claim recoverymodel.OrderRecoveryClaim
|
||||||
|
order ordermodel.Order
|
||||||
|
userSub usermodel.Subscribe
|
||||||
|
alreadyRecovered bool
|
||||||
|
)
|
||||||
|
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Model(&recoverymodel.OrderRecoveryClaim{}).
|
||||||
|
Where("order_no = ?", claimOrderNo).
|
||||||
|
First(&claim).Error; err == nil {
|
||||||
|
alreadyRecovered = true
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := l.findOrCreateUser(tx, email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
claim = recoverymodel.OrderRecoveryClaim{
|
||||||
|
OrderNo: claimOrderNo,
|
||||||
|
Email: email,
|
||||||
|
UserId: u.Id,
|
||||||
|
SubscribeId: item.SubscribeId,
|
||||||
|
ClaimedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := tx.Model(&recoverymodel.OrderRecoveryClaim{}).Create(&claim).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err = l.createRecoveredOrder(tx, u.Id, item, sub)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userSub, err = l.createOrExtendSubscribe(tx, u.Id, order.Id, item, sub)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
claim.UserSubscribeId = userSub.Id
|
||||||
|
return tx.Model(&recoverymodel.OrderRecoveryClaim{}).
|
||||||
|
Where("id = ?", claim.Id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"order_id": order.Id,
|
||||||
|
"user_subscribe_id": userSub.Id,
|
||||||
|
}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[RecoverOrder] recover failed", logger.Field("order_no", orderNo), logger.Field("error", err.Error()))
|
||||||
|
return recoveryFailed(recoverySystemErrorMessage), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if alreadyRecovered {
|
||||||
|
return &types.RecoverOrderResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: recoveryAlreadyDoneMessage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
l.clearCaches(&userSub, item.SubscribeId, email)
|
||||||
|
l.Infof("[RecoverOrder] recovered order subscription order_no=%s email=%s user_id=%d subscribe_id=%d user_subscribe_id=%d",
|
||||||
|
orderNo, email, claim.UserId, claim.SubscribeId, claim.UserSubscribeId)
|
||||||
|
|
||||||
|
return &types.RecoverOrderResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: recoverySuccessMessage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoveryFailed(message string) *types.RecoverOrderResponse {
|
||||||
|
return &types.RecoverOrderResponse{
|
||||||
|
Success: false,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RecoverOrderLogic) createRecoveredOrder(tx *gorm.DB, userId int64, item recoveryconfig.Order, sub *subscribemodel.Subscribe) (ordermodel.Order, error) {
|
||||||
|
var existing ordermodel.Order
|
||||||
|
orderNo := item.OutOrderNo
|
||||||
|
if orderNo == "" {
|
||||||
|
orderNo = item.OrderNo
|
||||||
|
}
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Model(&ordermodel.Order{}).
|
||||||
|
Where("order_no = ?", orderNo).
|
||||||
|
First(&existing).Error; err == nil {
|
||||||
|
if existing.UserId != userId {
|
||||||
|
return ordermodel.Order{}, errors.New("recovered order number already belongs to another user")
|
||||||
|
}
|
||||||
|
return existing, nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ordermodel.Order{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
paidAt := time.Now()
|
||||||
|
if item.PaidAt > 0 {
|
||||||
|
paidAt = time.Unix(item.PaidAt, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
quantity := item.Quantity
|
||||||
|
if quantity <= 0 {
|
||||||
|
quantity = 1
|
||||||
|
}
|
||||||
|
price := sub.UnitPrice * quantity
|
||||||
|
amount := item.OrderMoneyCents
|
||||||
|
if amount <= 0 {
|
||||||
|
amount = price
|
||||||
|
}
|
||||||
|
|
||||||
|
o := ordermodel.Order{
|
||||||
|
UserId: userId,
|
||||||
|
SubscriptionUserId: userId,
|
||||||
|
OrderNo: orderNo,
|
||||||
|
Type: 1,
|
||||||
|
Quantity: quantity,
|
||||||
|
Price: price,
|
||||||
|
Amount: amount,
|
||||||
|
Discount: price - amount,
|
||||||
|
PaymentId: 2,
|
||||||
|
Method: "EPay",
|
||||||
|
TradeNo: strings.TrimSpace(item.PaymentOrderNo),
|
||||||
|
Status: 5,
|
||||||
|
SubscribeId: item.SubscribeId,
|
||||||
|
IsNew: true,
|
||||||
|
CreatedAt: paidAt,
|
||||||
|
UpdatedAt: paidAt,
|
||||||
|
}
|
||||||
|
if o.Discount < 0 {
|
||||||
|
o.Discount = 0
|
||||||
|
}
|
||||||
|
if err := tx.Model(&ordermodel.Order{}).Create(&o).Error; err != nil {
|
||||||
|
return ordermodel.Order{}, err
|
||||||
|
}
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RecoverOrderLogic) createOrExtendSubscribe(tx *gorm.DB, userId int64, orderId int64, item recoveryconfig.Order, sub *subscribemodel.Subscribe) (usermodel.Subscribe, error) {
|
||||||
|
now := time.Now()
|
||||||
|
var existing usermodel.Subscribe
|
||||||
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Model(&usermodel.Subscribe{}).
|
||||||
|
Where("user_id = ? AND subscribe_id = ?", userId, item.SubscribeId).
|
||||||
|
Order("expire_time DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
base := now
|
||||||
|
if existing.ExpireTime.After(now) {
|
||||||
|
base = existing.ExpireTime
|
||||||
|
}
|
||||||
|
existing.OrderId = orderId
|
||||||
|
existing.ExpireTime = base.Add(time.Duration(item.Quantity) * 24 * time.Hour)
|
||||||
|
existing.Status = 1
|
||||||
|
existing.FinishedAt = nil
|
||||||
|
if strings.TrimSpace(item.Note) != "" {
|
||||||
|
existing.Note = strings.TrimSpace(item.Note)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&usermodel.Subscribe{}).Where("id = ?", existing.Id).Save(&existing).Error; err != nil {
|
||||||
|
return usermodel.Subscribe{}, err
|
||||||
|
}
|
||||||
|
return existing, nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return usermodel.Subscribe{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
orderNo := item.OutOrderNo
|
||||||
|
if orderNo == "" {
|
||||||
|
orderNo = item.OrderNo
|
||||||
|
}
|
||||||
|
userSub := usermodel.Subscribe{
|
||||||
|
UserId: userId,
|
||||||
|
OrderId: orderId,
|
||||||
|
SubscribeId: item.SubscribeId,
|
||||||
|
NodeGroupId: sub.NodeGroupId,
|
||||||
|
StartTime: now,
|
||||||
|
ExpireTime: now.Add(time.Duration(item.Quantity) * 24 * time.Hour),
|
||||||
|
Traffic: sub.Traffic,
|
||||||
|
Download: 0,
|
||||||
|
Upload: 0,
|
||||||
|
Token: uuidx.SubscribeToken(orderNo),
|
||||||
|
UUID: uuid.New().String(),
|
||||||
|
Status: 1,
|
||||||
|
Note: strings.TrimSpace(item.Note),
|
||||||
|
}
|
||||||
|
if err := tx.Model(&usermodel.Subscribe{}).Create(&userSub).Error; err != nil {
|
||||||
|
return usermodel.Subscribe{}, err
|
||||||
|
}
|
||||||
|
return userSub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RecoverOrderLogic) verifyEmailCode(email string, code string) error {
|
||||||
|
checker := commonLogic.NewCheckVerificationCodeLogic(l.ctx, l.svcCtx)
|
||||||
|
resp, err := checker.CheckVerificationCodeWithBehavior(&types.CheckVerificationCodeRequest{
|
||||||
|
Method: authmethod.Email,
|
||||||
|
Account: email,
|
||||||
|
Code: code,
|
||||||
|
Type: uint8(constant.Security),
|
||||||
|
}, commonLogic.VerifyCodeCheckBehavior{
|
||||||
|
Source: "order_recovery",
|
||||||
|
Consume: true,
|
||||||
|
AllowSceneFallback: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp == nil || !resp.Status {
|
||||||
|
return errors.New("invalid verification code")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RecoverOrderLogic) findOrCreateUser(tx *gorm.DB, email string) (*usermodel.User, error) {
|
||||||
|
var auth usermodel.AuthMethods
|
||||||
|
if err := tx.Model(&usermodel.AuthMethods{}).
|
||||||
|
Where("auth_type = ? AND auth_identifier = ?", authmethod.Email, email).
|
||||||
|
First(&auth).Error; err == nil {
|
||||||
|
var u usermodel.User
|
||||||
|
if err := tx.Model(&usermodel.User{}).Where("id = ?", auth.UserId).First(&u).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.AuthMethods = []usermodel.AuthMethods{auth}
|
||||||
|
return &u, nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u := &usermodel.User{
|
||||||
|
Password: tool.EncodePassWord(uuid.NewString()),
|
||||||
|
Algo: "default",
|
||||||
|
AuthMethods: []usermodel.AuthMethods{
|
||||||
|
{
|
||||||
|
AuthType: authmethod.Email,
|
||||||
|
AuthIdentifier: email,
|
||||||
|
Verified: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := tx.Create(u).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
referCode := uuidx.UserInviteCode(u.Id)
|
||||||
|
if err := tx.Model(&usermodel.User{}).Where("id = ?", u.Id).Update("refer_code", referCode).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.ReferCode = referCode
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RecoverOrderLogic) clearCaches(userSub *usermodel.Subscribe, subscribeId int64, email string) {
|
||||||
|
if userSub != nil && userSub.Id > 0 {
|
||||||
|
if err := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, userSub); err != nil {
|
||||||
|
l.Errorw("[RecoverOrder] clear user subscribe cache failed", logger.Field("error", err.Error()))
|
||||||
|
}
|
||||||
|
if userSub.UserId > 0 {
|
||||||
|
if err := l.svcCtx.UserModel.ClearUserCache(l.ctx, &usermodel.User{
|
||||||
|
Id: userSub.UserId,
|
||||||
|
AuthMethods: []usermodel.AuthMethods{
|
||||||
|
{
|
||||||
|
UserId: userSub.UserId,
|
||||||
|
AuthType: authmethod.Email,
|
||||||
|
AuthIdentifier: email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
l.Errorw("[RecoverOrder] clear user cache failed", logger.Field("error", err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subscribeId > 0 {
|
||||||
|
if err := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeId); err != nil {
|
||||||
|
l.Errorw("[RecoverOrder] clear subscribe cache failed", logger.Field("error", err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.svcCtx.NodeModel != nil {
|
||||||
|
if err := l.svcCtx.NodeModel.ClearServerAllCache(l.ctx); err != nil {
|
||||||
|
l.Errorw("[RecoverOrder] clear server cache failed", logger.Field("error", err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
|
"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/limit"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/random"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
queue "github.com/perfect-panel/server/queue/types"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SendCodeLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSendCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SendCodeLogic {
|
||||||
|
return &SendCodeLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *SendCodeLogic) SendCode(req *types.RecoverySendCodeRequest) (*types.SendCodeResponse, error) {
|
||||||
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
if email == "" {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "email is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
scene := constant.Security.String()
|
||||||
|
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
|
||||||
|
limiter := limit.NewPeriodLimit(60, 1, l.svcCtx.Redis, fmt.Sprintf("%s:%s:%s", config.SendIntervalKeyPrefix, "email", scene))
|
||||||
|
permit, err := limiter.Take(email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to take limit")
|
||||||
|
}
|
||||||
|
if !limiter.ParsePermitState(permit) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "send email too many requests")
|
||||||
|
}
|
||||||
|
permit, err = l.svcCtx.AuthLimiter.Take(fmt.Sprintf("%s:%s:%s", "email", scene, email))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to take limit")
|
||||||
|
}
|
||||||
|
if !l.svcCtx.AuthLimiter.ParsePermitState(permit) {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TodaySendCountExceedsLimit), "send email too many requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
code := random.Key(6, 0)
|
||||||
|
expireTime := l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime
|
||||||
|
if expireTime == 0 {
|
||||||
|
expireTime = 900
|
||||||
|
}
|
||||||
|
val, _ := json.Marshal(commonLogic.CacheKeyPayload{
|
||||||
|
Code: code,
|
||||||
|
LastAt: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
if err = l.svcCtx.Redis.Set(l.ctx, cacheKey, string(val), time.Second*time.Duration(expireTime)).Err(); err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to set verification code")
|
||||||
|
}
|
||||||
|
|
||||||
|
expireMinutes := expireTime / 60
|
||||||
|
taskPayload := queue.SendEmailPayload{
|
||||||
|
Type: queue.EmailTypeVerify,
|
||||||
|
Scene: scene,
|
||||||
|
Email: email,
|
||||||
|
Subject: "Verification code",
|
||||||
|
Content: map[string]interface{}{
|
||||||
|
"Type": uint8(constant.Security),
|
||||||
|
"SiteLogo": l.svcCtx.Config.Site.SiteLogo,
|
||||||
|
"SiteName": l.svcCtx.Config.Site.SiteName,
|
||||||
|
"Expire": expireMinutes,
|
||||||
|
"Code": code,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(taskPayload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to marshal task payload")
|
||||||
|
}
|
||||||
|
task := asynq.NewTask(queue.ForthwithSendEmail, payload, asynq.MaxRetry(3))
|
||||||
|
if _, err = l.svcCtx.Queue.Enqueue(task); err != nil {
|
||||||
|
return nil, errors.Wrap(xerr.NewErrCode(xerr.ERROR), "Failed to enqueue task")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &types.SendCodeResponse{Status: true}
|
||||||
|
if l.svcCtx.Config.Model == constant.DevMode {
|
||||||
|
resp.Code = code
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -57,6 +57,9 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
|||||||
var verified bool
|
var verified bool
|
||||||
if req.Code == "202511" {
|
if req.Code == "202511" {
|
||||||
verified = true
|
verified = true
|
||||||
|
} else if req.Code == "000000" && req.Email == "hifastday@hifast.com" {
|
||||||
|
// 000000 仅允许 hifastday@hifast.com 使用
|
||||||
|
verified = true
|
||||||
}
|
}
|
||||||
scenes := []string{constant.Security.String(), constant.Register.String()}
|
scenes := []string{constant.Security.String(), constant.Register.String()}
|
||||||
for _, scene := range scenes {
|
for _, scene := range scenes {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommissionWithdrawLogic struct {
|
type CommissionWithdrawLogic struct {
|
||||||
@@ -42,38 +44,21 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
|||||||
}
|
}
|
||||||
|
|
||||||
tx := l.svcCtx.DB.WithContext(l.ctx).Begin()
|
tx := l.svcCtx.DB.WithContext(l.ctx).Begin()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
// update user commission balance
|
// Atomically deduct the requested amount so concurrent commission growth is preserved.
|
||||||
u.Commission -= req.Amount
|
if err = l.svcCtx.DB.WithContext(l.ctx).
|
||||||
if err = l.svcCtx.UserModel.Update(l.ctx, u, tx); err != nil {
|
Model(&user.User{}).
|
||||||
|
Where("id = ? AND commission >= ?", u.Id, req.Amount).
|
||||||
|
UpdateColumn("commission", gorm.Expr("commission - ?", req.Amount)).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
l.Errorf("Failed to update user %d commission balance: %v", u.Id, err)
|
l.Errorf("Failed to update user %d commission balance: %v", u.Id, err)
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to update user %d commission balance: %v", u.Id, err)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to update user %d commission balance: %v", u.Id, err)
|
||||||
}
|
}
|
||||||
|
_ = l.svcCtx.UserModel.ClearUserCache(l.ctx, u)
|
||||||
|
|
||||||
// create withdrawal log
|
// create withdrawal log
|
||||||
logInfo := log.Commission{
|
if err = logicCommon.WriteCommissionLog(tx, u.Id, log.CommissionTypeConvertBalance, req.Amount, ""); err != nil {
|
||||||
Type: log.CommissionTypeConvertBalance,
|
|
||||||
Amount: req.Amount,
|
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
b, err := logInfo.Marshal()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
l.Errorf("Failed to marshal commission log for user %d: %v", u.Id, err)
|
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Failed to marshal commission log for user %d: %v", u.Id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Model(log.SystemLog{}).Create(&log.SystemLog{
|
|
||||||
Type: log.TypeCommission.Uint8(),
|
|
||||||
Date: time.Now().Format("2006-01-02"),
|
|
||||||
ObjectID: u.Id,
|
|
||||||
Content: string(b),
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
l.Errorf("Failed to create commission log for user %d: %v", u.Id, err)
|
l.Errorf("Failed to create commission log for user %d: %v", u.Id, err)
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create commission log for user %d: %v", u.Id, err)
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create commission log for user %d: %v", u.Id, err)
|
||||||
@@ -103,6 +88,7 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
|||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Status: 0,
|
Status: 0,
|
||||||
Reason: "",
|
Reason: "",
|
||||||
CreatedAt: time.Now().UnixMilli(),
|
CreatedAt: now.UnixMilli(),
|
||||||
|
UpdatedAt: now.UnixMilli(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,6 +37,10 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||||
|
lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
|
||||||
|
|
||||||
var views, lastMonthViews int64
|
var views, lastMonthViews int64
|
||||||
var installs int64
|
var installs int64
|
||||||
var paidCount int64
|
var paidCount int64
|
||||||
@@ -45,7 +49,7 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
|||||||
lokiCfg := l.svcCtx.Config.Loki
|
lokiCfg := l.svcCtx.Config.Loki
|
||||||
if lokiCfg.Enable && lokiCfg.URL != "" && u.ReferCode != "" {
|
if lokiCfg.Enable && lokiCfg.URL != "" && u.ReferCode != "" {
|
||||||
lokiClient := loki.NewClient(lokiCfg.URL)
|
lokiClient := loki.NewClient(lokiCfg.URL)
|
||||||
lokiStats, err := lokiClient.GetInviteCodeStats(l.ctx, u.ReferCode, 30)
|
lokiStats, err := lokiClient.GetInviteCodeMonthlyStats(l.ctx, u.ReferCode, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[GetAgentRealtime] Failed to fetch Loki stats",
|
l.Errorw("[GetAgentRealtime] Failed to fetch Loki stats",
|
||||||
logger.Field("error", err.Error()),
|
logger.Field("error", err.Error()),
|
||||||
@@ -66,7 +70,7 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
|||||||
// 3. 从数据库获取安装量(被邀请注册用户数)
|
// 3. 从数据库获取安装量(被邀请注册用户数)
|
||||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||||
Model(&user.User{}).
|
Model(&user.User{}).
|
||||||
Where("referer_id = ?", u.Id).
|
Where("referer_id = ? AND created_at >= ? AND created_at < ?", u.Id, currentMonthStart, now).
|
||||||
Count(&installs).Error
|
Count(&installs).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[GetAgentRealtime] Failed to count installs",
|
l.Errorw("[GetAgentRealtime] Failed to count installs",
|
||||||
@@ -79,7 +83,8 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
|||||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||||
Table("`order`").
|
Table("`order`").
|
||||||
Joins("LEFT JOIN user ON user.id = `order`.user_id").
|
Joins("LEFT JOIN user ON user.id = `order`.user_id").
|
||||||
Where("user.referer_id = ? AND `order`.status IN ?", u.Id, []int{2, 5}).
|
Where("user.referer_id = ? AND `order`.status IN ? AND `order`.updated_at >= ? AND `order`.updated_at < ?",
|
||||||
|
u.Id, []int{2, 5}, currentMonthStart, now).
|
||||||
Distinct("`order`.user_id").
|
Distinct("`order`.user_id").
|
||||||
Count(&paidCount).Error
|
Count(&paidCount).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,7 +98,7 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
|||||||
growthRate := calculateGrowthRate([]int{int(lastMonthViews), int(views)})
|
growthRate := calculateGrowthRate([]int{int(lastMonthViews), int(views)})
|
||||||
|
|
||||||
// 6. 计算付费用户环比增长率
|
// 6. 计算付费用户环比增长率
|
||||||
paidGrowthRate := l.calculatePaidGrowthRate(u.Id)
|
paidGrowthRate := l.calculatePaidGrowthRate(u.Id, currentMonthStart, lastMonthStart)
|
||||||
|
|
||||||
return &types.GetAgentRealtimeResponse{
|
return &types.GetAgentRealtimeResponse{
|
||||||
Total: views,
|
Total: views,
|
||||||
@@ -107,19 +112,14 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
|||||||
}
|
}
|
||||||
|
|
||||||
// calculatePaidGrowthRate 计算付费用户的环比增长率
|
// calculatePaidGrowthRate 计算付费用户的环比增长率
|
||||||
func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64) string {
|
func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64, currentMonthStart, lastMonthStart time.Time) string {
|
||||||
db := l.svcCtx.DB
|
db := l.svcCtx.DB
|
||||||
|
|
||||||
// 获取本月第一天和上月第一天
|
|
||||||
now := time.Now()
|
|
||||||
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
|
||||||
lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
|
|
||||||
|
|
||||||
// 查询本月付费用户数(本月有新订单的)
|
// 查询本月付费用户数(本月有新订单的)
|
||||||
var currentMonthCount int64
|
var currentMonthCount int64
|
||||||
err := db.Table("`order` o").
|
err := db.Table("`order` o").
|
||||||
Joins("JOIN user u ON o.user_id = u.id").
|
Joins("JOIN user u ON o.user_id = u.id").
|
||||||
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.created_at >= ?",
|
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.updated_at >= ?",
|
||||||
userId, 2, 5, currentMonthStart).
|
userId, 2, 5, currentMonthStart).
|
||||||
Distinct("o.user_id").
|
Distinct("o.user_id").
|
||||||
Count(¤tMonthCount).Error
|
Count(¤tMonthCount).Error
|
||||||
@@ -135,7 +135,7 @@ func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64) string {
|
|||||||
var lastMonthCount int64
|
var lastMonthCount int64
|
||||||
err = db.Table("`order` o").
|
err = db.Table("`order` o").
|
||||||
Joins("JOIN user u ON o.user_id = u.id").
|
Joins("JOIN user u ON o.user_id = u.id").
|
||||||
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.created_at >= ? AND o.created_at < ?",
|
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.updated_at >= ? AND o.updated_at < ?",
|
||||||
userId, 2, 5, lastMonthStart, currentMonthStart).
|
userId, 2, 5, lastMonthStart, currentMonthStart).
|
||||||
Distinct("o.user_id").
|
Distinct("o.user_id").
|
||||||
Count(&lastMonthCount).Error
|
Count(&lastMonthCount).Error
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
@@ -9,8 +10,13 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
"github.com/perfect-panel/server/pkg/tool"
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// restrictedOwnerUserId 受限家主用户ID(hifastday@hifast.com)
|
||||||
|
// 该用户本人及其家庭成员只允许返回第一个设备
|
||||||
|
const restrictedOwnerUserId int64 = 37498
|
||||||
|
|
||||||
type GetDeviceListLogic struct {
|
type GetDeviceListLogic struct {
|
||||||
logger.Logger
|
logger.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -28,18 +34,59 @@ func NewGetDeviceListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Get
|
|||||||
|
|
||||||
func (l *GetDeviceListLogic) GetDeviceList() (resp *types.GetDeviceListResponse, err error) {
|
func (l *GetDeviceListLogic) GetDeviceList() (resp *types.GetDeviceListResponse, err error) {
|
||||||
userInfo := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
userInfo := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
|
||||||
scopeUserIds, err := scopeHelper.resolveScopedUserIds(userInfo.Id)
|
restricted, err := l.isRestrictedScope(userInfo.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
list, count, err := l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, scopeUserIds)
|
|
||||||
userRespList := make([]types.UserDevice, 0)
|
if restricted {
|
||||||
tool.DeepCopy(&userRespList, list)
|
// 受限逻辑:hifastday@hifast.com (userId=37498) 本人及其家庭成员
|
||||||
for i, d := range list {
|
// 只返回当前用户自己的第一个设备,其余丢弃
|
||||||
if i < len(userRespList) {
|
// 原始代码(保留以供对照):
|
||||||
userRespList[i].DeviceNo = tool.DeviceIdToHash(d.Id)
|
// scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
||||||
|
// scopeUserIds, err := scopeHelper.resolveScopedUserIds(userInfo.Id)
|
||||||
|
// if err != nil { return nil, err }
|
||||||
|
// list, count, err := l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, scopeUserIds)
|
||||||
|
// ...(for 循环为所有设备赋 DeviceNo)
|
||||||
|
var ownList []*user.Device
|
||||||
|
ownList, _, err = l.svcCtx.UserModel.QueryDeviceList(l.ctx, userInfo.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
userRespList := make([]types.UserDevice, 0, 1)
|
||||||
|
if len(ownList) > 0 {
|
||||||
|
var item types.UserDevice
|
||||||
|
tool.DeepCopy(&item, ownList[0])
|
||||||
|
item.DeviceNo = tool.DeviceIdToHash(ownList[0].Id)
|
||||||
|
userRespList = append(userRespList, item)
|
||||||
|
}
|
||||||
|
resp = &types.GetDeviceListResponse{
|
||||||
|
Total: int64(len(userRespList)),
|
||||||
|
List: userRespList,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常逻辑:返回家庭范围内所有成员的设备(含 DeviceNo)
|
||||||
|
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
||||||
|
var scopeUserIds []int64
|
||||||
|
scopeUserIds, err = scopeHelper.resolveScopedUserIds(userInfo.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var list []*user.Device
|
||||||
|
var count int64
|
||||||
|
list, count, err = l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, scopeUserIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userRespList := make([]types.UserDevice, 0, len(list))
|
||||||
|
for _, d := range list {
|
||||||
|
var item types.UserDevice
|
||||||
|
tool.DeepCopy(&item, d)
|
||||||
|
item.DeviceNo = tool.DeviceIdToHash(d.Id)
|
||||||
|
userRespList = append(userRespList, item)
|
||||||
}
|
}
|
||||||
resp = &types.GetDeviceListResponse{
|
resp = &types.GetDeviceListResponse{
|
||||||
Total: count,
|
Total: count,
|
||||||
@@ -47,3 +94,27 @@ func (l *GetDeviceListLogic) GetDeviceList() (resp *types.GetDeviceListResponse,
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isRestrictedScope 判断当前用户是否在受限范围内:
|
||||||
|
// 1. 当前用户本身即 restrictedOwnerUserId(hifastday@hifast.com)
|
||||||
|
// 2. 当前用户所在家庭的家主是 restrictedOwnerUserId(即通过该邮箱登录的其他设备用户)
|
||||||
|
func (l *GetDeviceListLogic) isRestrictedScope(currentUserId int64) (bool, error) {
|
||||||
|
if currentUserId == restrictedOwnerUserId {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var family user.UserFamily
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&user.UserFamily{}).
|
||||||
|
Joins("JOIN user_family_member ON user_family_member.family_id = user_family.id AND user_family_member.user_id = ? AND user_family_member.status = ?",
|
||||||
|
currentUserId, user.FamilyMemberActive).
|
||||||
|
Where("user_family.owner_user_id = ? AND user_family.status = ? AND user_family.deleted_at IS NULL",
|
||||||
|
restrictedOwnerUserId, user.FamilyStatusActive).
|
||||||
|
First(&family).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
|
|||||||
db := l.svcCtx.DB.WithContext(l.ctx).
|
db := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
Table("`order` o").
|
Table("`order` o").
|
||||||
Joins("JOIN user u ON o.user_id = u.id").
|
Joins("JOIN user u ON o.user_id = u.id").
|
||||||
Where("u.referer_id = ? AND o.status = ?", userId, 5)
|
Where("u.referer_id = ? AND o.status IN ?", userId, []int{2, 5})
|
||||||
|
|
||||||
if req.StartTime > 0 {
|
if req.StartTime > 0 {
|
||||||
db = db.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
|
db = db.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
|
||||||
@@ -88,7 +88,7 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
|
|||||||
Select("o.amount, CAST(UNIX_TIMESTAMP(o.updated_at) * 1000 AS SIGNED) as updated_at, u.id as user_id, s.name as product_name, o.quantity").
|
Select("o.amount, CAST(UNIX_TIMESTAMP(o.updated_at) * 1000 AS SIGNED) as updated_at, u.id as user_id, s.name as product_name, o.quantity").
|
||||||
Joins("JOIN user u ON o.user_id = u.id").
|
Joins("JOIN user u ON o.user_id = u.id").
|
||||||
Joins("LEFT JOIN subscribe s ON o.subscribe_id = s.id").
|
Joins("LEFT JOIN subscribe s ON o.subscribe_id = s.id").
|
||||||
Where("u.referer_id = ? AND o.status = ?", userId, 5) // status 5: Finished
|
Where("u.referer_id = ? AND o.status IN ?", userId, []int{2, 5}) // status 2: Active, 5: Finished
|
||||||
|
|
||||||
if req.StartTime > 0 {
|
if req.StartTime > 0 {
|
||||||
query = query.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
|
query = query.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ func (l *QueryUserAffiliateLogic) QueryUserAffiliate() (resp *types.QueryUserAff
|
|||||||
l.Errorf("[QueryUserAffiliate] unmarshal comission log failed: %v", err.Error())
|
l.Errorf("[QueryUserAffiliate] unmarshal comission log failed: %v", err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sum += content.Amount
|
// 只统计佣金收入(购买331、续费332),不含提现(336)、退款(333)、调整(335)等
|
||||||
|
if content.Type == log.CommissionTypePurchase || content.Type == log.CommissionTypeRenewal {
|
||||||
|
sum += content.Amount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &types.QueryUserAffiliateCountResponse{
|
return &types.QueryUserAffiliateCountResponse{
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
authlogic "github.com/perfect-panel/server/internal/logic/auth"
|
||||||
|
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
||||||
|
modelOrder "github.com/perfect-panel/server/internal/model/order"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/kutt"
|
|
||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -45,6 +46,7 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
}
|
}
|
||||||
tool.DeepCopy(resp, u)
|
tool.DeepCopy(resp, u)
|
||||||
|
resp.UseStatus = true
|
||||||
|
|
||||||
// 用家庭范围查设备,而不是只看当前用户自己的 UserDevices
|
// 用家庭范围查设备,而不是只看当前用户自己的 UserDevices
|
||||||
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
|
||||||
@@ -66,6 +68,14 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
|
|||||||
}
|
}
|
||||||
resp.UserDevices = userDevices
|
resp.UserDevices = userDevices
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useStatus, useStatusErr := l.resolveBindEmailTrialUseStatus(u.Id, scopeUserIds)
|
||||||
|
if useStatusErr != nil {
|
||||||
|
l.Errorw("resolve bind email trial use status failed", logger.Field("user_id", u.Id), logger.Field("error", useStatusErr.Error()))
|
||||||
|
} else {
|
||||||
|
resp.UseStatus = useStatus
|
||||||
|
}
|
||||||
|
|
||||||
// refer_code 为空时自动生成
|
// refer_code 为空时自动生成
|
||||||
if resp.ReferCode == "" {
|
if resp.ReferCode == "" {
|
||||||
resp.ReferCode = uuidx.UserInviteCode(u.Id)
|
resp.ReferCode = uuidx.UserInviteCode(u.Id)
|
||||||
@@ -99,8 +109,8 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
|
|||||||
resp.AuthMethods = userMethods
|
resp.AuthMethods = userMethods
|
||||||
|
|
||||||
// 生成邀请短链接
|
// 生成邀请短链接
|
||||||
if l.svcCtx.Config.Kutt.Enable && resp.ReferCode != "" {
|
if resp.ReferCode != "" {
|
||||||
shortLink := l.generateInviteShortLink(resp.ReferCode)
|
shortLink := logicCommon.NewInviteLinkResolver(l.ctx, l.svcCtx).ResolveInviteLink(resp.ReferCode)
|
||||||
if shortLink != "" {
|
if shortLink != "" {
|
||||||
resp.ShareLink = shortLink
|
resp.ShareLink = shortLink
|
||||||
}
|
}
|
||||||
@@ -109,6 +119,49 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveBindEmailTrialUseStatus determines whether userinfo should show the
|
||||||
|
// "bind email to get free trial" prompt. `true` means show the prompt.
|
||||||
|
func (l *QueryUserInfoLogic) resolveBindEmailTrialUseStatus(currentUserId int64, scopeUserIds []int64) (bool, error) {
|
||||||
|
if len(scopeUserIds) == 0 {
|
||||||
|
scopeUserIds = []int64{currentUserId}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasBoundEmailCount int64
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&user.AuthMethods{}).
|
||||||
|
Where("user_id IN ? AND auth_type = ? AND auth_identifier != ''", scopeUserIds, "email").
|
||||||
|
Count(&hasBoundEmailCount).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasPurchaseCount int64
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&modelOrder.Order{}).
|
||||||
|
Where("user_id IN ? AND type IN ? AND status IN ?", scopeUserIds, []int64{1, 2}, []int64{2, 5}).
|
||||||
|
Count(&hasPurchaseCount).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTrial := false
|
||||||
|
registerCfg := l.svcCtx.Config.Register
|
||||||
|
if authlogic.IsTrialConfigReady(registerCfg) && registerCfg.TrialSubscribe > 0 {
|
||||||
|
var hasTrialCount int64
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&user.Subscribe{}).
|
||||||
|
Where("user_id IN ? AND subscribe_id = ?", scopeUserIds, registerCfg.TrialSubscribe).
|
||||||
|
Count(&hasTrialCount).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
hasTrial = hasTrialCount > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return shouldShowBindEmailTrialPrompt(hasBoundEmailCount > 0, hasPurchaseCount > 0, hasTrial), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldShowBindEmailTrialPrompt(hasBoundEmail, hasPurchased, hasTrial bool) bool {
|
||||||
|
return !hasBoundEmail && !hasPurchased && !hasTrial
|
||||||
|
}
|
||||||
|
|
||||||
func (l *QueryUserInfoLogic) fillFamilyContext(resp *types.User, userId int64) *user.AuthMethods {
|
func (l *QueryUserInfoLogic) fillFamilyContext(resp *types.User, userId int64) *user.AuthMethods {
|
||||||
type familyRelation struct {
|
type familyRelation struct {
|
||||||
FamilyId int64
|
FamilyId int64
|
||||||
@@ -212,112 +265,6 @@ func getFamilyStatusName(status uint8) string {
|
|||||||
return "disabled"
|
return "disabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
// customData 用于解析 SiteConfig.CustomData JSON 字段
|
|
||||||
// 包含从自定义数据中提取所需的配置项
|
|
||||||
type customData struct {
|
|
||||||
ShareUrl string `json:"shareUrl"` // 分享链接前缀 URL(目标落地页)
|
|
||||||
Domain string `json:"domain"` // 短链接域名
|
|
||||||
}
|
|
||||||
|
|
||||||
// getShareUrl 从 SiteConfig.CustomData 中获取 shareUrl
|
|
||||||
//
|
|
||||||
// 返回:
|
|
||||||
// - string: 分享链接前缀 URL,如果获取失败则返回 Kutt.TargetURL 作为 fallback
|
|
||||||
func (l *QueryUserInfoLogic) getShareUrl() string {
|
|
||||||
siteConfig := l.svcCtx.Config.Site
|
|
||||||
if siteConfig.CustomData != "" {
|
|
||||||
var data customData
|
|
||||||
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
|
|
||||||
if data.ShareUrl != "" {
|
|
||||||
return data.ShareUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// fallback 到 Kutt.TargetURL
|
|
||||||
return l.svcCtx.Config.Kutt.TargetURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDomain 从 SiteConfig.CustomData 中获取短链接域名
|
|
||||||
//
|
|
||||||
// 返回:
|
|
||||||
// - string: 短链接域名,如果获取失败则返回 Kutt.Domain 作为 fallback
|
|
||||||
func (l *QueryUserInfoLogic) getDomain() string {
|
|
||||||
siteConfig := l.svcCtx.Config.Site
|
|
||||||
if siteConfig.CustomData != "" {
|
|
||||||
var data customData
|
|
||||||
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
|
|
||||||
if data.Domain != "" {
|
|
||||||
return data.Domain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// fallback 到 Kutt.Domain
|
|
||||||
return l.svcCtx.Config.Kutt.Domain
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateInviteShortLink 生成邀请短链接(带 Redis 缓存)
|
|
||||||
//
|
|
||||||
// 参数:
|
|
||||||
// - inviteCode: 邀请码
|
|
||||||
//
|
|
||||||
// 返回:
|
|
||||||
// - string: 短链接 URL,失败时返回空字符串
|
|
||||||
func (l *QueryUserInfoLogic) generateInviteShortLink(inviteCode string) string {
|
|
||||||
cfg := l.svcCtx.Config.Kutt
|
|
||||||
shareUrl := l.getShareUrl()
|
|
||||||
domain := l.getDomain()
|
|
||||||
|
|
||||||
// 检查必要配置
|
|
||||||
if cfg.ApiURL == "" || cfg.ApiKey == "" {
|
|
||||||
l.Sloww("Kutt config incomplete",
|
|
||||||
logger.Field("api_url", cfg.ApiURL != ""),
|
|
||||||
logger.Field("api_key", cfg.ApiKey != ""))
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if shareUrl == "" {
|
|
||||||
l.Sloww("ShareUrl not configured in CustomData or Kutt.TargetURL")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redis 缓存 key
|
|
||||||
cacheKey := "cache:invite:short_link:" + inviteCode
|
|
||||||
|
|
||||||
// 1. 尝试从 Redis 缓存读取
|
|
||||||
cachedLink, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
|
||||||
if err == nil && cachedLink != "" {
|
|
||||||
l.Debugw("Hit cache for invite short link",
|
|
||||||
logger.Field("invite_code", inviteCode),
|
|
||||||
logger.Field("short_link", cachedLink))
|
|
||||||
return cachedLink
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 缓存未命中,调用 Kutt API 创建短链接
|
|
||||||
client := kutt.NewClient(cfg.ApiURL, cfg.ApiKey)
|
|
||||||
shortLink, err := client.CreateInviteShortLink(l.ctx, shareUrl, inviteCode, domain)
|
|
||||||
if err != nil {
|
|
||||||
l.Errorw("Failed to create short link",
|
|
||||||
logger.Field("error", err.Error()),
|
|
||||||
logger.Field("invite_code", inviteCode),
|
|
||||||
logger.Field("share_url", shareUrl))
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 写入 Redis 缓存(永不过期,因为邀请码不变短链接也不会变)
|
|
||||||
if err := l.svcCtx.Redis.Set(l.ctx, cacheKey, shortLink, 0).Err(); err != nil {
|
|
||||||
l.Errorw("Failed to cache short link",
|
|
||||||
logger.Field("error", err.Error()),
|
|
||||||
logger.Field("invite_code", inviteCode))
|
|
||||||
// 缓存失败不影响返回
|
|
||||||
}
|
|
||||||
|
|
||||||
l.Infow("Created and cached invite short link",
|
|
||||||
logger.Field("invite_code", inviteCode),
|
|
||||||
logger.Field("short_link", shortLink),
|
|
||||||
logger.Field("share_url", shareUrl))
|
|
||||||
|
|
||||||
return shortLink
|
|
||||||
}
|
|
||||||
|
|
||||||
// getAuthTypePriority 获取认证类型的排序优先级
|
// getAuthTypePriority 获取认证类型的排序优先级
|
||||||
// email: 1 (第一位)
|
// email: 1 (第一位)
|
||||||
// mobile: 2 (第二位)
|
// mobile: 2 (第二位)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestShouldShowBindEmailTrialPrompt(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hasBoundEmail bool
|
||||||
|
hasPurchased bool
|
||||||
|
hasTrial bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "new user should see prompt",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bound email should hide prompt",
|
||||||
|
hasBoundEmail: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "paid purchase should hide prompt",
|
||||||
|
hasPurchased: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trial claimed should hide prompt",
|
||||||
|
hasTrial: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bound email and purchase should hide prompt",
|
||||||
|
hasBoundEmail: true,
|
||||||
|
hasPurchased: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bound email and trial should hide prompt",
|
||||||
|
hasBoundEmail: true,
|
||||||
|
hasTrial: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "purchase and trial should hide prompt",
|
||||||
|
hasPurchased: true,
|
||||||
|
hasTrial: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all blockers should hide prompt",
|
||||||
|
hasBoundEmail: true,
|
||||||
|
hasPurchased: true,
|
||||||
|
hasTrial: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := shouldShowBindEmailTrialPrompt(tt.hasBoundEmail, tt.hasPurchased, tt.hasTrial)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("shouldShowBindEmailTrialPrompt(%v, %v, %v) = %v, want %v", tt.hasBoundEmail, tt.hasPurchased, tt.hasTrial, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,13 @@ package user
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QueryWithdrawalLogLogic struct {
|
type QueryWithdrawalLogLogic struct {
|
||||||
@@ -24,7 +28,49 @@ func NewQueryWithdrawalLogLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalLogListRequest) (resp *types.QueryWithdrawalLogListResponse, err error) {
|
func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalLogListRequest) (resp *types.QueryWithdrawalLogListResponse, err error) {
|
||||||
// todo: add your logic here and delete this line
|
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
|
if !ok {
|
||||||
|
l.Error("current user is not found in context")
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
|
}
|
||||||
|
|
||||||
return
|
page := req.Page
|
||||||
|
size := req.Size
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size <= 0 {
|
||||||
|
size = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
query := l.svcCtx.DB.WithContext(l.ctx).Model(&user.Withdrawal{}).Where("user_id = ?", u.Id)
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err = query.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count withdrawal logs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []user.Withdrawal
|
||||||
|
if err = query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query withdrawal logs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]types.WithdrawalLog, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
list = append(list, types.WithdrawalLog{
|
||||||
|
Id: row.Id,
|
||||||
|
UserId: row.UserId,
|
||||||
|
Amount: row.Amount,
|
||||||
|
Content: row.Content,
|
||||||
|
Status: row.Status,
|
||||||
|
Reason: row.Reason,
|
||||||
|
CreatedAt: row.CreatedAt.UnixMilli(),
|
||||||
|
UpdatedAt: row.UpdatedAt.UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.QueryWithdrawalLogListResponse{
|
||||||
|
List: list,
|
||||||
|
Total: total,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const (
|
|||||||
ctxDecryptedQueryKey = "decrypted_query"
|
ctxDecryptedQueryKey = "decrypted_query"
|
||||||
ctxEncryptedBodyKey = "encrypted_request_body"
|
ctxEncryptedBodyKey = "encrypted_request_body"
|
||||||
ctxDecryptedBodyKey = "decrypted_request_body"
|
ctxDecryptedBodyKey = "decrypted_request_body"
|
||||||
|
|
||||||
|
deviceDecryptSkipPathPublicFileUpload = "/v1/public/file/upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
func DeviceMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) {
|
func DeviceMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
@@ -70,6 +72,14 @@ func DeviceMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rw := NewResponseWriter(c, srvCtx)
|
rw := NewResponseWriter(c, srvCtx)
|
||||||
|
if shouldSkipDeviceRequestDecrypt(c) {
|
||||||
|
c.Set(ctxDeviceDecryptStatusKey, "skipped")
|
||||||
|
c.Set(ctxDeviceDecryptReasonKey, "multipart_upload_passthrough")
|
||||||
|
c.Writer = rw
|
||||||
|
c.Next()
|
||||||
|
rw.FlushAbort()
|
||||||
|
return
|
||||||
|
}
|
||||||
if !rw.Decrypt() {
|
if !rw.Decrypt() {
|
||||||
c.Set(ctxDeviceDecryptStatusKey, "failed")
|
c.Set(ctxDeviceDecryptStatusKey, "failed")
|
||||||
if _, exists := c.Get(ctxDeviceDecryptReasonKey); !exists {
|
if _, exists := c.Get(ctxDeviceDecryptReasonKey); !exists {
|
||||||
@@ -85,6 +95,13 @@ func DeviceMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldSkipDeviceRequestDecrypt(c *gin.Context) bool {
|
||||||
|
if c.Request.URL.Path != deviceDecryptSkipPathPublicFileUpload {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "multipart/form-data")
|
||||||
|
}
|
||||||
|
|
||||||
func NewResponseWriter(c *gin.Context, srvCtx *svc.ServiceContext) (rw *ResponseWriter) {
|
func NewResponseWriter(c *gin.Context, srvCtx *svc.ServiceContext) (rw *ResponseWriter) {
|
||||||
rw = &ResponseWriter{
|
rw = &ResponseWriter{
|
||||||
c: c,
|
c: c,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const (
|
|||||||
CommissionTypeWithdraw uint16 = 334 // withdraw
|
CommissionTypeWithdraw uint16 = 334 // withdraw
|
||||||
CommissionTypeAdjust uint16 = 335 // Admin Adjust
|
CommissionTypeAdjust uint16 = 335 // Admin Adjust
|
||||||
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
|
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
|
||||||
|
CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund
|
||||||
GiftTypeIncrease uint16 = 341 // Increase
|
GiftTypeIncrease uint16 = 341 // Increase
|
||||||
GiftTypeReduce uint16 = 342 // Reduce
|
GiftTypeReduce uint16 = 342 // Reduce
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,25 +3,25 @@ package logmessage
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type LogMessage struct {
|
type LogMessage struct {
|
||||||
Id int64 `gorm:"primaryKey;AUTO_INCREMENT"`
|
Id int64 `gorm:"primaryKey;AUTO_INCREMENT"`
|
||||||
Platform string `gorm:"type:varchar(32);not null"`
|
Platform string `gorm:"type:varchar(32);not null"`
|
||||||
AppVersion string `gorm:"type:varchar(32);default:null"`
|
AppVersion string `gorm:"type:varchar(64);default:null"`
|
||||||
OsName string `gorm:"type:varchar(32);default:null"`
|
OsName string `gorm:"type:varchar(64);default:null"`
|
||||||
OsVersion string `gorm:"type:varchar(32);default:null"`
|
OsVersion string `gorm:"type:varchar(64);default:null"`
|
||||||
DeviceId string `gorm:"type:varchar(64);default:null"`
|
DeviceId string `gorm:"type:varchar(255);default:null"`
|
||||||
UserId *int64 `gorm:"type:bigint;default:null"`
|
UserId *int64 `gorm:"type:bigint;default:null"`
|
||||||
SessionId string `gorm:"type:varchar(64);default:null"`
|
SessionId string `gorm:"type:varchar(255);default:null"`
|
||||||
Level uint8 `gorm:"type:tinyint(1);not null;default:3"`
|
Level uint8 `gorm:"type:tinyint(1);not null;default:3"`
|
||||||
ErrorCode string `gorm:"type:varchar(64);default:null"`
|
ErrorCode string `gorm:"type:varchar(128);default:null"`
|
||||||
Message string `gorm:"type:text;not null"`
|
Message string `gorm:"type:text;not null"`
|
||||||
Stack string `gorm:"type:mediumtext;default:null"`
|
Stack string `gorm:"type:mediumtext;default:null"`
|
||||||
Context string `gorm:"type:json;default:null"`
|
Context string `gorm:"type:json;default:null"`
|
||||||
ClientIP string `gorm:"type:varchar(45);default:null"`
|
ClientIP string `gorm:"type:varchar(45);default:null"`
|
||||||
UserAgent string `gorm:"type:varchar(255);default:null"`
|
UserAgent string `gorm:"type:varchar(255);default:null"`
|
||||||
Locale string `gorm:"type:varchar(16);default:null"`
|
Locale string `gorm:"type:varchar(16);default:null"`
|
||||||
Digest string `gorm:"type:varchar(64);uniqueIndex:uniq_digest;default:null"`
|
Digest string `gorm:"type:varchar(64);uniqueIndex:uniq_digest;default:null"`
|
||||||
OccurredAt *time.Time `gorm:"type:datetime;default:null"`
|
OccurredAt *time.Time `gorm:"type:datetime;default:null"`
|
||||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LogMessage) TableName() string { return "log_message" }
|
func (LogMessage) TableName() string { return "log_message" }
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type OrderRecoveryClaim struct {
|
||||||
|
Id int64 `gorm:"primaryKey"`
|
||||||
|
OrderNo string `gorm:"type:varchar(255);not null;uniqueIndex:idx_order_recovery_claim_order_no;comment:Recovered Order No"`
|
||||||
|
Email string `gorm:"type:varchar(255);not null;index:idx_order_recovery_claim_email;comment:Claim Email"`
|
||||||
|
UserId int64 `gorm:"type:bigint;not null;default:0;comment:User ID"`
|
||||||
|
SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe ID"`
|
||||||
|
OrderId int64 `gorm:"type:bigint;not null;default:0;comment:Recovered Order ID"`
|
||||||
|
UserSubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:User Subscribe ID"`
|
||||||
|
ClaimedAt time.Time `gorm:"comment:Claimed Time"`
|
||||||
|
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (OrderRecoveryClaim) TableName() string {
|
||||||
|
return "order_recovery_claims"
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ type (
|
|||||||
Insert(ctx context.Context, data *User, tx ...*gorm.DB) error
|
Insert(ctx context.Context, data *User, tx ...*gorm.DB) error
|
||||||
FindOne(ctx context.Context, id int64) (*User, error)
|
FindOne(ctx context.Context, id int64) (*User, error)
|
||||||
Update(ctx context.Context, data *User, tx ...*gorm.DB) error
|
Update(ctx context.Context, data *User, tx ...*gorm.DB) error
|
||||||
|
UpdateCommission(ctx context.Context, userId int64, delta int64, tx ...*gorm.DB) error
|
||||||
Delete(ctx context.Context, id int64, tx ...*gorm.DB) error
|
Delete(ctx context.Context, id int64, tx ...*gorm.DB) error
|
||||||
Transaction(ctx context.Context, fn func(db *gorm.DB) error) error
|
Transaction(ctx context.Context, fn func(db *gorm.DB) error) error
|
||||||
}
|
}
|
||||||
@@ -111,6 +112,22 @@ func (m *defaultUserModel) Update(ctx context.Context, data *User, tx ...*gorm.D
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *defaultUserModel) UpdateCommission(ctx context.Context, userId int64, delta int64, tx ...*gorm.DB) error {
|
||||||
|
old, err := m.FindOne(ctx, userId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
|
||||||
|
if len(tx) > 0 {
|
||||||
|
conn = tx[0]
|
||||||
|
}
|
||||||
|
return conn.Model(&User{}).
|
||||||
|
Where("id = ?", userId).
|
||||||
|
UpdateColumn("commission", gorm.Expr("commission + ?", delta)).Error
|
||||||
|
}, m.getCacheKeys(old)...)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *defaultUserModel) Delete(ctx context.Context, id int64, tx ...*gorm.DB) error {
|
func (m *defaultUserModel) Delete(ctx context.Context, id int64, tx ...*gorm.DB) error {
|
||||||
data, err := m.FindOne(ctx, id)
|
data, err := m.FindOne(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,22 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const userDeviceUserAgentMaxLength = 255
|
||||||
|
|
||||||
|
func normalizeDeviceForStorage(data *Device) {
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.UserAgent = truncateForColumn(data.UserAgent, userDeviceUserAgentMaxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateForColumn(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max]
|
||||||
|
}
|
||||||
|
|
||||||
func (m *customUserModel) FindOneDevice(ctx context.Context, id int64) (*Device, error) {
|
func (m *customUserModel) FindOneDevice(ctx context.Context, id int64) (*Device, error) {
|
||||||
deviceIdKey := fmt.Sprintf("%s%v", cacheUserDeviceIdPrefix, id)
|
deviceIdKey := fmt.Sprintf("%s%v", cacheUserDeviceIdPrefix, id)
|
||||||
var resp Device
|
var resp Device
|
||||||
@@ -69,6 +85,7 @@ func (m *customUserModel) QueryDeviceListByUserIds(ctx context.Context, userIds
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *customUserModel) UpdateDevice(ctx context.Context, data *Device, tx ...*gorm.DB) error {
|
func (m *customUserModel) UpdateDevice(ctx context.Context, data *Device, tx ...*gorm.DB) error {
|
||||||
|
normalizeDeviceForStorage(data)
|
||||||
old, err := m.FindOneDevice(ctx, data.Id)
|
old, err := m.FindOneDevice(ctx, data.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -100,6 +117,7 @@ func (m *customUserModel) DeleteDevice(ctx context.Context, id int64, tx ...*gor
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *customUserModel) InsertDevice(ctx context.Context, data *Device, tx ...*gorm.DB) error {
|
func (m *customUserModel) InsertDevice(ctx context.Context, data *Device, tx ...*gorm.DB) error {
|
||||||
|
normalizeDeviceForStorage(data)
|
||||||
defer func() {
|
defer func() {
|
||||||
if clearErr := m.ClearDeviceCache(ctx, data); clearErr != nil {
|
if clearErr := m.ClearDeviceCache(ctx, data); clearErr != nil {
|
||||||
// log cache clear error
|
// log cache clear error
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed recovery_orders.json
|
||||||
|
var recoveryOrdersJSON []byte
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Orders []Order `json:"orders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Order struct {
|
||||||
|
OutOrderNo string `json:"out_order_no,omitempty"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
OrderStatus string `json:"order_status,omitempty"`
|
||||||
|
SubscribeId int64 `json:"subscribe_id"`
|
||||||
|
LegacyID string `json:"订阅id,omitempty"`
|
||||||
|
ExpireAt int64 `json:"expire_at"`
|
||||||
|
Quantity int64 `json:"quantity,omitempty"`
|
||||||
|
PaidAt int64 `json:"paid_at,omitempty"`
|
||||||
|
PaymentOrderNo string `json:"payment_order_no,omitempty"`
|
||||||
|
OrderMoneyCents int64 `json:"order_money_cents,omitempty"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
loadOnce sync.Once
|
||||||
|
orders map[string]Order
|
||||||
|
loadErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func FindOrder(orderNo string) (Order, bool, error) {
|
||||||
|
loadOnce.Do(func() {
|
||||||
|
orders, loadErr = loadOrders()
|
||||||
|
})
|
||||||
|
if loadErr != nil {
|
||||||
|
return Order{}, false, loadErr
|
||||||
|
}
|
||||||
|
item, ok := orders[strings.TrimSpace(orderNo)]
|
||||||
|
return item, ok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrders() (map[string]Order, error) {
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(recoveryOrdersJSON, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse recovery orders json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]Order, len(cfg.Orders))
|
||||||
|
for i, item := range cfg.Orders {
|
||||||
|
item.OutOrderNo = strings.TrimSpace(item.OutOrderNo)
|
||||||
|
item.OrderNo = strings.TrimSpace(item.OrderNo)
|
||||||
|
item.OrderStatus = strings.ToLower(strings.TrimSpace(item.OrderStatus))
|
||||||
|
if item.OutOrderNo == "" {
|
||||||
|
item.OutOrderNo = item.OrderNo
|
||||||
|
}
|
||||||
|
if item.OrderNo == "" {
|
||||||
|
item.OrderNo = item.OutOrderNo
|
||||||
|
}
|
||||||
|
if item.OrderStatus == "" {
|
||||||
|
item.OrderStatus = "success"
|
||||||
|
}
|
||||||
|
if item.SubscribeId == 0 && strings.TrimSpace(item.LegacyID) != "" {
|
||||||
|
id, err := strconv.ParseInt(strings.TrimSpace(item.LegacyID), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("orders[%d] legacy subscribe id is invalid", i)
|
||||||
|
}
|
||||||
|
item.SubscribeId = id
|
||||||
|
}
|
||||||
|
if item.OutOrderNo == "" {
|
||||||
|
return nil, fmt.Errorf("orders[%d] out_order_no is required", i)
|
||||||
|
}
|
||||||
|
if item.SubscribeId <= 0 {
|
||||||
|
return nil, fmt.Errorf("orders[%d] subscribe_id is required", i)
|
||||||
|
}
|
||||||
|
if item.ExpireAt <= 0 {
|
||||||
|
return nil, fmt.Errorf("orders[%d] expire_at is required", i)
|
||||||
|
}
|
||||||
|
if item.Quantity <= 0 {
|
||||||
|
item.Quantity = quantityFromAmount(item.OrderMoneyCents)
|
||||||
|
}
|
||||||
|
if item.Quantity <= 0 {
|
||||||
|
return nil, fmt.Errorf("orders[%d] quantity is required", i)
|
||||||
|
}
|
||||||
|
if item.PaidAt < 0 {
|
||||||
|
return nil, fmt.Errorf("orders[%d] paid_at is invalid", i)
|
||||||
|
}
|
||||||
|
if item.OrderMoneyCents < 0 {
|
||||||
|
return nil, fmt.Errorf("orders[%d] order_money_cents is invalid", i)
|
||||||
|
}
|
||||||
|
if _, exists := result[item.OutOrderNo]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate recovery out_order_no %s", item.OutOrderNo)
|
||||||
|
}
|
||||||
|
result[item.OutOrderNo] = item
|
||||||
|
if item.OrderNo != "" && item.OrderNo != item.OutOrderNo {
|
||||||
|
if _, exists := result[item.OrderNo]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate recovery order_no %s", item.OrderNo)
|
||||||
|
}
|
||||||
|
result[item.OrderNo] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func quantityFromAmount(amount int64) int64 {
|
||||||
|
switch amount {
|
||||||
|
case 1953:
|
||||||
|
return 7
|
||||||
|
case 4193:
|
||||||
|
return 30
|
||||||
|
case 9093:
|
||||||
|
return 90
|
||||||
|
case 31500:
|
||||||
|
return 365
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,7 @@ func initServer(svc *svc.ServiceContext) *gin.Engine {
|
|||||||
|
|
||||||
// register handlers
|
// register handlers
|
||||||
handler.RegisterHandlers(r, svc)
|
handler.RegisterHandlers(r, svc)
|
||||||
|
r.StaticFile("/order-recovery.html", "./public/order-recovery.html")
|
||||||
// register subscribe handler
|
// register subscribe handler
|
||||||
handler.RegisterSubscribeHandlers(r, svc)
|
handler.RegisterSubscribeHandlers(r, svc)
|
||||||
// register telegram handler
|
// register telegram handler
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/internal/model/node"
|
"github.com/perfect-panel/server/internal/model/node"
|
||||||
"github.com/perfect-panel/server/internal/model/redemption"
|
"github.com/perfect-panel/server/internal/model/redemption"
|
||||||
"github.com/perfect-panel/server/pkg/device"
|
"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/config"
|
||||||
"github.com/perfect-panel/server/internal/model/ads"
|
"github.com/perfect-panel/server/internal/model/ads"
|
||||||
@@ -44,6 +45,7 @@ type ServiceContext struct {
|
|||||||
ExchangeRate float64
|
ExchangeRate float64
|
||||||
GeoIP *IPLocation
|
GeoIP *IPLocation
|
||||||
SignatureValidator *signature.Validator
|
SignatureValidator *signature.Validator
|
||||||
|
S3Store *storage.S3Store
|
||||||
|
|
||||||
//NodeCache *cache.NodeCacheClient
|
//NodeCache *cache.NodeCacheClient
|
||||||
AuthModel auth.Model
|
AuthModel auth.Model
|
||||||
@@ -143,6 +145,13 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
|||||||
}
|
}
|
||||||
srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds)
|
srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds)
|
||||||
srv.DeviceManager = NewDeviceManager(srv)
|
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
|
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"`
|
|
||||||
}
|
|
||||||
+175
-34
@@ -1,8 +1,12 @@
|
|||||||
// Code generated by goctl. DO NOT EDIT.
|
// Code generated by goctl. DO NOT EDIT.
|
||||||
// goctl 1.7.2
|
// goctl 1.9.2
|
||||||
|
|
||||||
package types
|
package types
|
||||||
|
|
||||||
|
type ActivateOrderRequest struct {
|
||||||
|
OrderNo string `json:"order_no" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type Ads struct {
|
type Ads struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -295,6 +299,13 @@ type ConnectionRecords struct {
|
|||||||
LongestSingleConnection int64 `json:"longest_single_connection"`
|
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 {
|
type Coupon struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -549,6 +560,13 @@ type CurrencyConfig struct {
|
|||||||
CurrencySymbol string `json:"currency_symbol"`
|
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 {
|
type DeleteAccountRequest struct {
|
||||||
Email string `json:"email" validate:"required,email"`
|
Email string `json:"email" validate:"required,email"`
|
||||||
Code string `json:"code" validate:"required"`
|
Code string `json:"code" validate:"required"`
|
||||||
@@ -561,13 +579,6 @@ type DeleteAccountResponse struct {
|
|||||||
Code int64 `json:"code"`
|
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 {
|
type DeleteAdsRequest struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
}
|
}
|
||||||
@@ -689,8 +700,15 @@ type EmailAuthticateConfig struct {
|
|||||||
DomainSuffixList string `json:"domain_suffix_list"`
|
DomainSuffixList string `json:"domain_suffix_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportGroupResultRequest struct {
|
type EmailLoginRequest struct {
|
||||||
HistoryId *int64 `form:"history_id,omitempty"`
|
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 {
|
type ErrorLogMessage struct {
|
||||||
@@ -708,6 +726,10 @@ type ErrorLogMessage struct {
|
|||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExportGroupResultRequest struct {
|
||||||
|
HistoryId *int64 `form:"history_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type FamilyDetail struct {
|
type FamilyDetail struct {
|
||||||
Summary FamilySummary `json:"summary"`
|
Summary FamilySummary `json:"summary"`
|
||||||
Members []FamilyMemberItem `json:"members"`
|
Members []FamilyMemberItem `json:"members"`
|
||||||
@@ -740,6 +762,50 @@ type FamilySummary struct {
|
|||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileUploadRequest struct {
|
||||||
|
BizType string `form:"biz_type" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileUploadResponse struct {
|
||||||
|
FileId string `json:"file_id"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
ObjectKey string `json:"object_key"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Etag string `json:"etag"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
type FilterBalanceLogRequest struct {
|
||||||
FilterLogParams
|
FilterLogParams
|
||||||
UserId int64 `form:"user_id,optional"`
|
UserId int64 `form:"user_id,optional"`
|
||||||
@@ -1048,6 +1114,15 @@ type GetDocumentListResponse struct {
|
|||||||
List []Document `json:"list"`
|
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 {
|
type GetErrorLogMessageDetailResponse struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
@@ -1118,18 +1193,6 @@ type GetGlobalConfigResponse struct {
|
|||||||
WebAd bool `json:"web_ad"`
|
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 {
|
type GetGroupConfigRequest struct {
|
||||||
Keys []string `form:"keys,omitempty"`
|
Keys []string `form:"keys,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -1161,6 +1224,18 @@ type GetGroupHistoryResponse struct {
|
|||||||
List []GroupHistory `json:"list"`
|
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 {
|
type GetLoginLogRequest struct {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
@@ -1419,7 +1494,7 @@ type GetUserListRequest struct {
|
|||||||
FamilyStatus string `form:"family_status,omitempty"`
|
FamilyStatus string `form:"family_status,omitempty"`
|
||||||
FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"`
|
FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"`
|
||||||
FamilyId *int64 `form:"family_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 {
|
type GetUserListResponse struct {
|
||||||
@@ -1601,6 +1676,19 @@ type KickOfflineRequest struct {
|
|||||||
Id int64 `json:"id"`
|
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 {
|
type LogResponse struct {
|
||||||
List interface{} `json:"list"`
|
List interface{} `json:"list"`
|
||||||
}
|
}
|
||||||
@@ -1765,8 +1853,8 @@ type Order struct {
|
|||||||
TradeNo string `json:"trade_no"`
|
TradeNo string `json:"trade_no"`
|
||||||
Status uint8 `json:"status"`
|
Status uint8 `json:"status"`
|
||||||
SubscribeId int64 `json:"subscribe_id"`
|
SubscribeId int64 `json:"subscribe_id"`
|
||||||
CreatedAt int64 `json:"created_at"` // Unix milliseconds
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"` // Unix milliseconds
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderDetail struct {
|
type OrderDetail struct {
|
||||||
@@ -1789,8 +1877,8 @@ type OrderDetail struct {
|
|||||||
Status uint8 `json:"status"`
|
Status uint8 `json:"status"`
|
||||||
SubscribeId int64 `json:"subscribe_id"`
|
SubscribeId int64 `json:"subscribe_id"`
|
||||||
Subscribe Subscribe `json:"subscribe"`
|
Subscribe Subscribe `json:"subscribe"`
|
||||||
CreatedAt int64 `json:"created_at"` // Unix milliseconds
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"` // Unix milliseconds
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrdersStatistics struct {
|
type OrdersStatistics struct {
|
||||||
@@ -2196,6 +2284,27 @@ type QueryWithdrawalLogListResponse struct {
|
|||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetWithdrawalListRequest struct {
|
||||||
|
Page int `form:"page"`
|
||||||
|
Size int `form:"size"`
|
||||||
|
UserId *int64 `form:"user_id,omitempty"`
|
||||||
|
Status *uint8 `form:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetWithdrawalListResponse struct {
|
||||||
|
List []WithdrawalLog `json:"list"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApproveWithdrawalRequest struct {
|
||||||
|
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RejectWithdrawalRequest struct {
|
||||||
|
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
|
||||||
|
Reason string `json:"reason" validate:"required,max=500"`
|
||||||
|
}
|
||||||
|
|
||||||
type QuotaTask struct {
|
type QuotaTask struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Subscribers []int64 `json:"subscribers"`
|
Subscribers []int64 `json:"subscribers"`
|
||||||
@@ -2235,6 +2344,21 @@ type RechargeOrderResponse struct {
|
|||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecoverOrderRequest struct {
|
||||||
|
OrderNo string `json:"order_no" validate:"required"`
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Code string `json:"code" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecoverOrderResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecoverySendCodeRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
}
|
||||||
|
|
||||||
type RedeemCodeRequest struct {
|
type RedeemCodeRequest struct {
|
||||||
Code string `json:"code" validate:"required"`
|
Code string `json:"code" validate:"required"`
|
||||||
}
|
}
|
||||||
@@ -2308,6 +2432,26 @@ type RenewalOrderResponse struct {
|
|||||||
AppAccountToken string `json:"app_account_token"`
|
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 {
|
type ResetAllSubscribeTokenResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
}
|
}
|
||||||
@@ -2985,10 +3129,6 @@ type UpdateOrderStatusRequest struct {
|
|||||||
TradeNo string `json:"trade_no,omitempty"`
|
TradeNo string `json:"trade_no,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActivateOrderRequest struct {
|
|
||||||
OrderNo string `json:"order_no" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdatePaymentMethodRequest struct {
|
type UpdatePaymentMethodRequest struct {
|
||||||
Id int64 `json:"id" validate:"required"`
|
Id int64 `json:"id" validate:"required"`
|
||||||
Name string `json:"name" validate:"required"`
|
Name string `json:"name" validate:"required"`
|
||||||
@@ -3089,13 +3229,13 @@ type UpdateUserBasiceInfoRequest struct {
|
|||||||
Balance int64 `json:"balance"`
|
Balance int64 `json:"balance"`
|
||||||
Commission int64 `json:"commission"`
|
Commission int64 `json:"commission"`
|
||||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||||
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||||
GiftAmount int64 `json:"gift_amount"`
|
GiftAmount int64 `json:"gift_amount"`
|
||||||
Telegram int64 `json:"telegram"`
|
Telegram int64 `json:"telegram"`
|
||||||
ReferCode string `json:"refer_code"`
|
ReferCode string `json:"refer_code"`
|
||||||
RefererId int64 `json:"referer_id"`
|
RefererId int64 `json:"referer_id"`
|
||||||
Enable bool `json:"enable"`
|
Enable *bool `json:"enable"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin *bool `json:"is_admin"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3159,6 +3299,7 @@ type User struct {
|
|||||||
EnableLoginNotify bool `json:"enable_login_notify"`
|
EnableLoginNotify bool `json:"enable_login_notify"`
|
||||||
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
||||||
EnableTradeNotify bool `json:"enable_trade_notify"`
|
EnableTradeNotify bool `json:"enable_trade_notify"`
|
||||||
|
UseStatus bool `json:"use_status"`
|
||||||
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
||||||
UserDevices []UserDevice `json:"user_devices"`
|
UserDevices []UserDevice `json:"user_devices"`
|
||||||
Rules []string `json:"rules"`
|
Rules []string `json:"rules"`
|
||||||
|
|||||||
@@ -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%
|
|
||||||
@@ -17,6 +17,20 @@ scrape_configs:
|
|||||||
job: ppanel-server
|
job: ppanel-server
|
||||||
service_name: ppanel
|
service_name: ppanel
|
||||||
__path__: /var/log/ppanel-server/*.log
|
__path__: /var/log/ppanel-server/*.log
|
||||||
|
pipeline_stages:
|
||||||
|
- json:
|
||||||
|
expressions:
|
||||||
|
caller: caller
|
||||||
|
content: content
|
||||||
|
level: level
|
||||||
|
timestamp: timestamp
|
||||||
|
- timestamp:
|
||||||
|
source: timestamp
|
||||||
|
format: "2006-01-02 15:04:05.000"
|
||||||
|
location: Asia/Shanghai
|
||||||
|
- labels:
|
||||||
|
caller:
|
||||||
|
level:
|
||||||
|
|
||||||
- job_name: nginx-file
|
- job_name: nginx-file
|
||||||
static_configs:
|
static_configs:
|
||||||
|
|||||||
@@ -0,0 +1,652 @@
|
|||||||
|
# AWS RDS + EC2 Redis 到外部备用服务器 Runbook
|
||||||
|
|
||||||
|
目标:让 `104.238.220.230` 持续作为 AWS 主生产环境的异地备用节点,承接:
|
||||||
|
|
||||||
|
- MySQL 外部只读从库
|
||||||
|
- Redis 外部从库
|
||||||
|
- 故障时的快速提升与业务切换
|
||||||
|
|
||||||
|
本文档以 `2026-05-13` 的真实现网状态为准,覆盖:
|
||||||
|
|
||||||
|
- 当前已经落地的主从架构
|
||||||
|
- 日常验收命令
|
||||||
|
- 主从故障排查
|
||||||
|
- 全量重建从库
|
||||||
|
- “新建规范 RDS 再切换”的生产级收敛路线
|
||||||
|
- 故障切换与回滚
|
||||||
|
|
||||||
|
## 1. 当前已确认资源
|
||||||
|
|
||||||
|
### 1.1 AWS 主生产
|
||||||
|
|
||||||
|
- Region: `ap-east-1`
|
||||||
|
- AWS app EC2:
|
||||||
|
- Name: `hifast-hk-app-01`
|
||||||
|
- Public IP: `18.163.33.75`
|
||||||
|
- 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
|
||||||
|
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:18.163.33.75`
|
||||||
|
- `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
|
||||||
|
STOP REPLICA;
|
||||||
|
RESET REPLICA ALL;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 重新导入业务库
|
||||||
|
|
||||||
|
在 `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;
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 `104` 执行:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 验收
|
||||||
|
|
||||||
|
确认:
|
||||||
|
|
||||||
|
- `Replica_IO_Running: Yes`
|
||||||
|
- `Replica_SQL_Running: Yes`
|
||||||
|
- `Seconds_Behind_Source: 0`
|
||||||
|
- `read_only = ON`
|
||||||
|
- `super_read_only = ON`
|
||||||
|
|
||||||
|
## 8. 重新配置 Redis 从库
|
||||||
|
|
||||||
|
当前 `104` Redis 为原生安装,不使用 Docker。
|
||||||
|
|
||||||
|
### 8.1 临时切回从库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli CONFIG SET masterauth '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr'
|
||||||
|
redis-cli REPLICAOF 18.163.33.75 6379
|
||||||
|
redis-cli CONFIG SET replica-read-only yes
|
||||||
|
redis-cli INFO replication
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 持久化配置
|
||||||
|
|
||||||
|
检查 `/etc/redis/redis.conf` 至少包含:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
replicaof 18.163.33.75 6379
|
||||||
|
masterauth 0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr
|
||||||
|
replica-read-only yes
|
||||||
|
```
|
||||||
|
|
||||||
|
然后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 连接失败告警
|
||||||
|
- 定期灾备切换演练
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
# Hifast AWS 主生产 + 外部备用 完整部署方案与访问架构
|
||||||
|
|
||||||
|
本文档整理当前已经实际落地的生产架构、访问链路、数据库与缓存主从关系、网络边界、故障切换方案,以及后续扩展建议。
|
||||||
|
|
||||||
|
目标是让团队在一个文档里就能看清:
|
||||||
|
|
||||||
|
- 现在生产到底部署成了什么样
|
||||||
|
- 请求是怎么进来的,数据是怎么流转的
|
||||||
|
- AWS 与外部备用服务器分别承担什么角色
|
||||||
|
- 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 区域
|
||||||
|
|
||||||
|
- Region: `ap-east-1`
|
||||||
|
- 说明:香港区
|
||||||
|
|
||||||
|
### 1.2 已确认资源
|
||||||
|
|
||||||
|
#### 应用服务器
|
||||||
|
|
||||||
|
- 名称:`hifast-hk-app-01`
|
||||||
|
- Instance ID: `i-079cd9d3ef3748714`
|
||||||
|
- 角色:当前实际生产入口 / Nginx / 业务服务 / AWS 侧 Redis 主库宿主机
|
||||||
|
- 私网 IP: `10.0.1.201`
|
||||||
|
- 公网 IP: `18.163.33.75`
|
||||||
|
- 业务服务运行方式:`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-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 主库
|
||||||
|
|
||||||
|
- 部署位置:`AWS EC2 hifast-hk-app-01`
|
||||||
|
- 部署方式:`Docker`
|
||||||
|
- 容器名:`hifast-redis`
|
||||||
|
- 版本:`redis:8.2.1`
|
||||||
|
- 访问端口:`6379`
|
||||||
|
- 主库出口地址:`18.163.33.75:6379`
|
||||||
|
- 应用当前实际连接:`127.0.0.1:6379`
|
||||||
|
- 认证方式:已启用密码认证
|
||||||
|
|
||||||
|
#### 外部备用服务器
|
||||||
|
|
||||||
|
- IP: `104.238.220.230`
|
||||||
|
- 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
|
||||||
|
flowchart TB
|
||||||
|
USER["用户 / 客户端"] --> DNS["域名 / DNS / 入口层"]
|
||||||
|
DNS --> APP["AWS EC2\nhifast-hk-app-01\n18.163.33.75\n10.0.1.201"]
|
||||||
|
|
||||||
|
APP --> RDS["AWS RDS MySQL\nhifast-mysql-prod-v2\n主库"]
|
||||||
|
APP --> REDISM["AWS Redis 主库\nDocker redis:8.2.1\n18.163.33.75:6379"]
|
||||||
|
|
||||||
|
RDS -. MySQL 备用 / 同步 .-> MYSQLS["104.238.220.230\nMySQL 备用库"]
|
||||||
|
REDISM -. Redis 主从复制 .-> REDISS["104.238.220.230\n原生 Redis 8.6.3\n从库"]
|
||||||
|
|
||||||
|
subgraph AWS["AWS ap-east-1"]
|
||||||
|
APP
|
||||||
|
RDS
|
||||||
|
REDISM
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph BACKUP["异地备用节点"]
|
||||||
|
MYSQLS
|
||||||
|
REDISS
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 访问链路
|
||||||
|
|
||||||
|
### 3.1 用户访问链路
|
||||||
|
|
||||||
|
当前生产访问链路可以概括为:
|
||||||
|
|
||||||
|
`用户 -> 域名 / DNS -> AWS EC2 应用机 -> MySQL / Redis`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 当前主应用入口实际在 `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 提供。
|
||||||
|
|
||||||
|
### 3.2 应用访问数据链路
|
||||||
|
|
||||||
|
应用侧内部依赖关系如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
App / Nginx
|
||||||
|
-> RDS MySQL 主库
|
||||||
|
-> AWS EC2 Redis 主库
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 备用链路
|
||||||
|
|
||||||
|
备用服务器 `104.238.220.230` 当前已经部署同样的业务服务,但正常情况下不直接承担正式流量,而是承担:
|
||||||
|
|
||||||
|
- 备用应用节点
|
||||||
|
- MySQL 异地备用
|
||||||
|
- Redis 异地从库
|
||||||
|
|
||||||
|
也就是说,正常情况下:
|
||||||
|
|
||||||
|
- 用户正式流量默认不走 `104`
|
||||||
|
- `104` 已具备接管业务的基础应用环境
|
||||||
|
- `104` 主要处于待命同步和灾备状态
|
||||||
|
|
||||||
|
## 4. 网络与安全边界
|
||||||
|
|
||||||
|
### 4.1 当前安全组
|
||||||
|
|
||||||
|
截至 `2026-05-13`,VPC `vpc-09fc384522517debc` 内仅保留 4 个安全组:
|
||||||
|
|
||||||
|
- `default`
|
||||||
|
- `hifast-hk-app-core-sg`
|
||||||
|
- `hifast-hk-rds-core-sg`
|
||||||
|
- `hifast-hk-web-sg`
|
||||||
|
|
||||||
|
其中绑定关系已经收敛为:
|
||||||
|
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
旧组:
|
||||||
|
|
||||||
|
- `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["hifast-hk-app-core-sg"] --> REDIS["AWS Redis 主库\n18.163.33.75:6379"]
|
||||||
|
STANDBY["104.238.220.230/32"] --> SG
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 RDS 访问原则
|
||||||
|
|
||||||
|
RDS 当前状态已经比之前干净很多:
|
||||||
|
|
||||||
|
- 当前只对白名单和应用安全组开放 `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 主库:
|
||||||
|
|
||||||
|
- 目标地址:`18.163.33.75:6379`
|
||||||
|
- 连接方式:`TCP`
|
||||||
|
- 认证方式:`Redis 密码`
|
||||||
|
- 网络前提:AWS EC2 安全组已放行 `104.238.220.230/32 -> 6379`
|
||||||
|
|
||||||
|
也就是说:
|
||||||
|
|
||||||
|
```text
|
||||||
|
104 Redis 从库 -> 直连 AWS Redis 主库公网地址 -> 密码认证 -> 建立复制
|
||||||
|
```
|
||||||
|
|
||||||
|
示意命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli -h 18.163.33.75 -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 主库
|
||||||
|
|
||||||
|
- 部署方式:Docker
|
||||||
|
- 版本:`8.2.1`
|
||||||
|
- 主库地址:`18.163.33.75:6379`
|
||||||
|
- 运行容器:`hifast-redis`
|
||||||
|
|
||||||
|
### 5.2 104 Redis 从库
|
||||||
|
|
||||||
|
- 部署方式:宿主机原生安装
|
||||||
|
- 版本:`8.6.3`
|
||||||
|
- 角色:`replica / slave`
|
||||||
|
|
||||||
|
### 5.3 Redis 主从状态
|
||||||
|
|
||||||
|
最终已验证结果:
|
||||||
|
|
||||||
|
- `104` 上 Redis:`role:slave`
|
||||||
|
- `104` 上 Redis:`master_link_status:up`
|
||||||
|
- AWS Redis 主库:`connected_slaves:1`
|
||||||
|
- AWS Redis 主库识别到从库:`104.238.220.230:6379`
|
||||||
|
|
||||||
|
### 5.4 Redis 验证结果
|
||||||
|
|
||||||
|
已做过的验证:
|
||||||
|
|
||||||
|
- 从 `104` 连接 AWS Redis 主库,认证成功
|
||||||
|
- AWS 主库写入测试键
|
||||||
|
- `104` 从库成功读取测试键
|
||||||
|
- `2026-05-13` 已确认应用机本地 `ppanel-server -> 127.0.0.1:6379` 建立稳定连接
|
||||||
|
- `2026-05-13` 已确认旧 `stunnel4 -> ElastiCache` 链路下线后,应用日志不再出现 Redis `i/o timeout`
|
||||||
|
|
||||||
|
测试键:
|
||||||
|
|
||||||
|
- key: `hifast_replication_test`
|
||||||
|
- value: `ok_20260510`
|
||||||
|
|
||||||
|
### 5.5 Redis 主从拓扑
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
REDISMASTER["AWS Redis 主库\n18.163.33.75:6379\nDocker redis:8.2.1"]
|
||||||
|
REDISSLAVE["104.238.220.230\n原生 Redis 8.6.3\nrole: slave"]
|
||||||
|
REDISMASTER --> REDISSLAVE
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. MySQL 部署与备用关系
|
||||||
|
|
||||||
|
### 6.1 主库角色
|
||||||
|
|
||||||
|
- 主库在 `AWS RDS MySQL`
|
||||||
|
- 实例:`hifast-mysql-prod-v2`
|
||||||
|
|
||||||
|
### 6.2 外部备用角色
|
||||||
|
|
||||||
|
- `104.238.220.230` 上存在 MySQL 备用用途
|
||||||
|
- 目标是让 `104` 尽量实时同步 AWS 数据
|
||||||
|
|
||||||
|
### 6.3 当前文档说明
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
## 7. 当前生产方案的真实特点
|
||||||
|
|
||||||
|
这套已经落地的架构,不是传统的全 AWS 托管标准形态,而是偏实用的混合方案:
|
||||||
|
|
||||||
|
- 应用和当前实际公网入口在 AWS 同一台 EC2
|
||||||
|
- MySQL 在 AWS RDS
|
||||||
|
- Redis 在 AWS EC2 本机
|
||||||
|
- MySQL / Redis 均向外部服务器 `104` 做灾备
|
||||||
|
- 旧 ElastiCache / stunnel 链路已经退出当前生产路径
|
||||||
|
|
||||||
|
它的优点:
|
||||||
|
|
||||||
|
- 成本相对可控
|
||||||
|
- Redis 可完全自主控制
|
||||||
|
- 外部备用机可以独立接管
|
||||||
|
|
||||||
|
它的代价:
|
||||||
|
|
||||||
|
- Redis 高可用需要人工切换
|
||||||
|
- 外部灾备不是全自动故障转移
|
||||||
|
- 应用切换需要明确操作步骤
|
||||||
|
|
||||||
|
## 8. 故障切换方案
|
||||||
|
|
||||||
|
### 8.1 正常状态
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["用户访问"] --> B["AWS EC2 应用机"]
|
||||||
|
B --> C["AWS RDS MySQL 主库"]
|
||||||
|
B --> D["AWS Redis 主库"]
|
||||||
|
C -. 同步 .-> E["104 MySQL 备用"]
|
||||||
|
D -. 复制 .-> F["104 Redis 从库"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 AWS 故障后的目标切换状态
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["AWS 故障"] --> B["应用入口切到 104"]
|
||||||
|
B --> C["104 MySQL 提供主服务"]
|
||||||
|
B --> D["104 Redis 提升为主库"]
|
||||||
|
D --> E["应用连接 104 Redis"]
|
||||||
|
C --> F["应用连接 104 MySQL"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2.1 Nginx 是否可以直接切到 104
|
||||||
|
|
||||||
|
可以,但前提不是“只切 Nginx 就完成故障切换”。
|
||||||
|
|
||||||
|
因为 `104` 虽然已经部署了同样的业务服务,但如果故障发生时:
|
||||||
|
|
||||||
|
- Redis 还保持从库只读状态
|
||||||
|
- MySQL 还没有切成可写主角色
|
||||||
|
- 应用配置还没有确认指向 `104` 本机数据层
|
||||||
|
|
||||||
|
那么即使 Nginx 已经把流量转到 `104`,业务也可能仍然无法正常写入。
|
||||||
|
|
||||||
|
所以更准确的原则是:
|
||||||
|
|
||||||
|
`104` 已具备应用接管能力,Nginx 切换可以作为最后一步对外放流量动作,但不能作为唯一动作。
|
||||||
|
|
||||||
|
### 8.3 Redis 切换动作
|
||||||
|
|
||||||
|
当 AWS Redis 不可用时,`104` 上的 Redis 需要解除主从关系:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli -a '<REDIS_PASSWORD>' REPLICAOF NO ONE
|
||||||
|
```
|
||||||
|
|
||||||
|
切换后:
|
||||||
|
|
||||||
|
- `104` Redis 从库变为主库
|
||||||
|
- 业务应用把 Redis 地址改到 `104.238.220.230:6379`
|
||||||
|
|
||||||
|
### 8.4 MySQL 切换动作
|
||||||
|
|
||||||
|
当 AWS RDS 不可用时,需要让 `104` MySQL 接管写流量。
|
||||||
|
|
||||||
|
这部分是否能立即切,需要依赖:
|
||||||
|
|
||||||
|
- 当前 `104` MySQL 是否是健康从库
|
||||||
|
- 是否已经取消只读
|
||||||
|
- 应用数据库配置是否能快速切换到 `104`
|
||||||
|
|
||||||
|
### 8.5 应用切换动作
|
||||||
|
|
||||||
|
应用层需要准备至少这 2 个切换点:
|
||||||
|
|
||||||
|
- MySQL 连接地址切换到 `104`
|
||||||
|
- Redis 连接地址切换到 `104`
|
||||||
|
|
||||||
|
如果应用入口也要迁移到 `104`,还需要:
|
||||||
|
|
||||||
|
- 域名解析切换
|
||||||
|
- 或者网关 / 入口切换
|
||||||
|
|
||||||
|
### 8.6 推荐的实际切换顺序
|
||||||
|
|
||||||
|
因为 `104` 已经部署同样的应用服务,所以 AWS 故障时推荐按下面顺序操作:
|
||||||
|
|
||||||
|
1. 确认 `104` 上业务服务和 Nginx 进程正常。
|
||||||
|
2. 将 `104` 上 Redis 从库提升为主库。
|
||||||
|
3. 将 `104` 上 MySQL 从库切换为可写主库。
|
||||||
|
4. 确认 `104` 上应用配置已指向本机 MySQL / Redis。
|
||||||
|
5. 最后再把 Nginx 上游或域名流量切到 `104`。
|
||||||
|
|
||||||
|
可以把它理解成:
|
||||||
|
|
||||||
|
`先数据接管 -> 再应用确认 -> 最后入口切流量`
|
||||||
|
|
||||||
|
## 9. 建议的运维操作顺序
|
||||||
|
|
||||||
|
### 9.1 平时
|
||||||
|
|
||||||
|
平时重点看:
|
||||||
|
|
||||||
|
- AWS EC2 是否在线
|
||||||
|
- RDS 是否在线
|
||||||
|
- AWS Redis 主库是否在线
|
||||||
|
- `104` Redis 从库是否 `master_link_status:up`
|
||||||
|
- `104` MySQL 复制是否正常
|
||||||
|
- `ppanel-server` 容器是否 `Up`
|
||||||
|
- `ppanel-server` 日志中是否再次出现 Redis `i/o timeout`
|
||||||
|
- `stunnel4` 是否保持 `inactive`
|
||||||
|
|
||||||
|
### 9.2 Redis 故障时
|
||||||
|
|
||||||
|
1. 确认 AWS Redis 主库不可恢复。
|
||||||
|
2. 在 `104` 执行 `REPLICAOF NO ONE`。
|
||||||
|
3. 修改应用 Redis 地址到 `104.238.220.230:6379`。
|
||||||
|
4. 验证应用读写 Redis 正常。
|
||||||
|
|
||||||
|
### 9.3 MySQL 故障时
|
||||||
|
|
||||||
|
1. 确认 RDS 故障。
|
||||||
|
2. 确认 `104` MySQL 数据已同步到最新可用点。
|
||||||
|
3. 去掉 `104` MySQL 只读限制。
|
||||||
|
4. 修改应用 MySQL 地址到 `104`。
|
||||||
|
5. 验证应用读写数据库正常。
|
||||||
|
|
||||||
|
### 9.4 整体 AWS 故障时
|
||||||
|
|
||||||
|
1. 把 Redis 主角色切到 `104`。
|
||||||
|
2. 把 MySQL 主角色切到 `104`。
|
||||||
|
3. 确认 `104` 上同版本应用服务正常。
|
||||||
|
4. 把应用入口切到备用应用节点。
|
||||||
|
5. 更新 DNS 或 Nginx 上游流量入口。
|
||||||
|
6. 验证用户访问链路。
|
||||||
|
|
||||||
|
## 10. 当前方案与理想方案的差异
|
||||||
|
|
||||||
|
### 10.1 当前实际方案
|
||||||
|
|
||||||
|
`DNS -> hifast-hk-app-01(Nginx + App + Redis) -> RDS MySQL -> 104 灾备`
|
||||||
|
|
||||||
|
### 10.2 理想生产方案
|
||||||
|
|
||||||
|
从长期稳定性看,更推荐未来演进为:
|
||||||
|
|
||||||
|
`DNS / CDN -> ALB -> 多台 EC2 App -> RDS MySQL -> 托管 Redis / 或高可用 Redis`
|
||||||
|
|
||||||
|
异地灾备继续保留:
|
||||||
|
|
||||||
|
- AWS 生产
|
||||||
|
- 104 异地接管
|
||||||
|
|
||||||
|
### 10.3 当前最值得继续补的项
|
||||||
|
|
||||||
|
建议按优先级补齐:
|
||||||
|
|
||||||
|
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. 建议的下一版目标拓扑
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
USER["用户 / 客户端"] --> DNS["DNS / CDN / 入口层"]
|
||||||
|
DNS --> APPAWS["AWS 应用集群"]
|
||||||
|
DNS -. 故障时切换 .-> APPBK["104 备用应用节点"]
|
||||||
|
|
||||||
|
APPAWS --> RDSAWS["AWS RDS MySQL 主库"]
|
||||||
|
APPAWS --> REDISAWS["AWS Redis 主库"]
|
||||||
|
|
||||||
|
RDSAWS -. 同步 .-> MYSQLBK["104 MySQL 备用"]
|
||||||
|
REDISAWS -. 复制 .-> REDISBK["104 Redis 备用"]
|
||||||
|
|
||||||
|
APPBK --> MYSQLBK
|
||||||
|
APPBK --> REDISBK
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. 本文档结论
|
||||||
|
|
||||||
|
截至当前,已经可以确认的生产与灾备状态是:
|
||||||
|
|
||||||
|
- AWS 是主生产环境
|
||||||
|
- 应用当前跑在 `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` 链路已经退出生产路径
|
||||||
|
|
||||||
|
如果后续要继续完善这份方案,优先补充:
|
||||||
|
|
||||||
|
- 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")
|
|
||||||
}
|
|
||||||
@@ -84,6 +84,34 @@ func (c *Client) GetInviteCodeStats(ctx context.Context, inviteCode string, days
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInviteCodeMonthlyStats 获取指定邀请码在自然月维度的下载统计。
|
||||||
|
// currentTime 所在月份作为本月,上一自然月作为上月。
|
||||||
|
func (c *Client) GetInviteCodeMonthlyStats(ctx context.Context, inviteCode string, currentTime time.Time) (*InviteCodeStats, error) {
|
||||||
|
if currentTime.IsZero() {
|
||||||
|
currentTime = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMonthStart := time.Date(currentTime.Year(), currentTime.Month(), 1, 0, 0, 0, 0, currentTime.Location())
|
||||||
|
lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
|
||||||
|
|
||||||
|
thisMonthStats, err := c.queryPeriodStats(ctx, inviteCode, currentMonthStart, currentTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询本月数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMonthStats, err := c.queryPeriodStats(ctx, inviteCode, lastMonthStart, currentMonthStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询上月数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &InviteCodeStats{
|
||||||
|
MacClicks: thisMonthStats.MacClicks,
|
||||||
|
WindowsClicks: thisMonthStats.WindowsClicks,
|
||||||
|
LastMonthMac: lastMonthStats.MacClicks,
|
||||||
|
LastMonthWindows: lastMonthStats.WindowsClicks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// queryPeriodStats 查询指定时间范围的统计数据
|
// queryPeriodStats 查询指定时间范围的统计数据
|
||||||
func (c *Client) queryPeriodStats(ctx context.Context, inviteCode string, startTime, endTime time.Time) (*InviteCodeStats, error) {
|
func (c *Client) queryPeriodStats(ctx context.Context, inviteCode string, startTime, endTime time.Time) (*InviteCodeStats, error) {
|
||||||
// 构建 Loki 查询
|
// 构建 Loki 查询
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"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 PutObjectResult struct {
|
||||||
|
ETag string
|
||||||
|
}
|
||||||
|
|
||||||
|
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) PutObject(ctx context.Context, objectKey string, body io.Reader, size int64, contentType string) (*PutObjectResult, error) {
|
||||||
|
resp, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s.cfg.Bucket),
|
||||||
|
Key: aws.String(objectKey),
|
||||||
|
Body: body,
|
||||||
|
ContentLength: aws.Int64(size),
|
||||||
|
ContentType: aws.String(contentType),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PutObjectResult{ETag: strings.Trim(aws.ToString(resp.ETag), "\"")}, 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
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ const (
|
|||||||
FamilyNotExist uint32 = 20017
|
FamilyNotExist uint32 = 20017
|
||||||
FamilyStatusInvalid uint32 = 20018
|
FamilyStatusInvalid uint32 = 20018
|
||||||
FamilyOwnerOperationForbidden uint32 = 20019
|
FamilyOwnerOperationForbidden uint32 = 20019
|
||||||
|
WithdrawalStatusInvalid uint32 = 20020
|
||||||
)
|
)
|
||||||
|
|
||||||
// Node error
|
// Node error
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user