Compare commits

...

23 Commits

Author SHA1 Message Date
shanshanzhong147 20ba514117 fix: 修复退款与仪表盘订单统计口径
Co-authored-by: multica-agent <github@multica.ai>
2026-05-24 17:48:31 -07:00
shanshanzhong147 e3f46c3614 feat: add admin order refund flow
Co-authored-by: multica-agent <github@multica.ai>
2026-05-24 09:05:27 -07:00
shanshanzhong147 fd522b6c71 path
Build docker and publish / build (20.15.1) (push) Successful in 8m56s
2026-05-19 19:35:44 -07:00
shanshanzhong147 a1184ef5ed feat: add userinfo bind-email trial use status
Build docker and publish / build (20.15.1) (push) Successful in 9m2s
2026-05-18 03:02:35 -07:00
shanshanzhong147 7c6efe9dfe x
Build docker and publish / build (20.15.1) (push) Failing after 9m23s
2026-05-16 23:58:53 -07:00
shanshanzhong147 c0ece054a0 x
Build docker and publish / build (20.15.1) (push) Failing after 9m44s
2026-05-16 04:13:05 -07:00
shanshanzhong147 0d57450283 x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-15 09:53:15 -07:00
shanshanzhong147 f2033fd4b9 feat: add rustfs direct upload endpoint
Build docker and publish / build (20.15.1) (push) Failing after 8m36s
2026-05-14 23:33:50 -07:00
shanshanzhong147 3284cb45f0 ci: trigger internal branch workflow
Build docker and publish / build (20.15.1) (push) Failing after 8m36s
2026-05-14 05:58:05 -07:00
shanshanzhong147 6041bc3419 x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-14 05:50:44 -07:00
shanshanzhong147 4581a6fc17 ci: use ssh key for main deploy
Build docker and publish / build (20.15.1) (push) Failing after 8m20s
2026-05-13 11:43:01 -07:00
shanshanzhong147 2bdce44e12 merge: add direct s3 upload flow
Build docker and publish / build (20.15.1) (push) Failing after 8m49s
2026-05-13 11:24:31 -07:00
shanshanzhong147 4fa9fcd232 feat: add direct s3 upload flow 2026-05-13 11:24:13 -07:00
shanshanzhong147 f6911965dc chore: snapshot current aws standby and backup tooling work 2026-05-13 10:59:03 -07:00
shanshanzhong147 c4b2ebf7e1 x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-10 10:41:50 -07:00
shanshanzhong147 f946504cb8 x 2026-05-08 06:19:59 -07:00
shanshanzhong147 7d2f98b7c9 fix: 修复管理员更新用户信息时意外覆盖字段的问题
Build docker and publish / build (20.15.1) (push) Has been cancelled
- Enable/IsAdmin/OnlyFirstPurchase 改为 *bool,未传时不更新
- Avatar/Remark/ReferCode/ReferralPercentage 加空值保护
- getDeviceList: 恢复 hifastday@hifast.com 家庭成员受限逻辑

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 02:21:35 -07:00
shanshanzhong147 8bc8e81e95 补单逻辑
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 05:42:48 -07:00
shanshanzhong147 54daa923da 补单逻辑
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 04:59:04 -07:00
shanshanzhong147 463d3e2315 x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 03:19:04 -07:00
shanshanzhong147 559d59b4f8 000000
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 03:09:15 -07:00
shanshanzhong147 d4f0d559cf 设备1
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 02:53:18 -07:00
shanshanzhong147 cbb451d18c x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 02:34:43 -07:00
122 changed files with 15858 additions and 3699 deletions
@@ -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)
}
+23
View File
@@ -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)
}
+36
View File
@@ -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)
}
}
+28
View File
@@ -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)
}
+7
View File
@@ -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
+27 -24
View File
@@ -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
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 "📥 拉取镜像..."
sudo docker-compose -f docker-compose.cloud.yml pull ppanel-server
echo "🚀 启动服务..."
sudo docker-compose -f docker-compose.cloud.yml up -d ppanel-server
sudo docker image prune -f || true
else
mkdir -p ${{ env.DEPLOY_PATH }}
cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
cd ${{ env.DEPLOY_PATH }} cd ${{ env.DEPLOY_PATH }}
# 创建/更新环境变量文件
# echo "PPANEL_SERVER_TAG=${{ env.DOCKER_TAG_SUFFIX }}" > .env
# 拉取最新镜像
echo "📥 拉取镜像..." echo "📥 拉取镜像..."
docker-compose -f docker-compose.cloud.yml pull ppanel-server docker-compose -f docker-compose.cloud.yml pull ppanel-server
# 启动服务
echo "🚀 启动服务..." echo "🚀 启动服务..."
docker-compose -f docker-compose.cloud.yml up -d ppanel-server docker-compose -f docker-compose.cloud.yml up -d ppanel-server
# 清理未使用的镜像
docker image prune -f || true docker image prune -f || true
fi
echo "✅ 部署命令执行完成" echo "✅ 部署命令执行完成"
+76
View File
@@ -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;
}
}
+33
View File
@@ -149,6 +149,35 @@ type (
Total int64 `json:"total"` Total int64 `json:"total"`
List []CommissionLog `json:"list"` List []CommissionLog `json:"list"`
} }
OrderRefundLog {
OrderId int64 `json:"order_id"`
OrderNo string `json:"order_no"`
OperatorUserId int64 `json:"operator_user_id"`
OperatorAuthIdentifier string `json:"operator_auth_identifier,omitempty"`
TargetUserId int64 `json:"target_user_id"`
UserSubscribeId int64 `json:"user_subscribe_id"`
RefererUserId int64 `json:"referer_user_id,omitempty"`
CommissionAmount int64 `json:"commission_amount"`
Reason string `json:"reason,omitempty"`
OrderStatusBefore uint8 `json:"order_status_before"`
OrderStatusAfter uint8 `json:"order_status_after"`
SubscribeStatusBefore uint8 `json:"subscribe_status_before"`
SubscribeStatusAfter uint8 `json:"subscribe_status_after"`
SubscribeExpireBefore int64 `json:"subscribe_expire_before"`
SubscribeExpireAfter int64 `json:"subscribe_expire_after"`
CommissionBefore int64 `json:"commission_before"`
CommissionAfter int64 `json:"commission_after"`
Timestamp int64 `json:"timestamp"`
}
FilterOrderRefundLogRequest {
FilterLogParams
OrderId int64 `form:"order_id,optional"`
UserId int64 `form:"user_id,optional"`
}
FilterOrderRefundLogResponse {
Total int64 `json:"total"`
List []OrderRefundLog `json:"list"`
}
GiftLog { GiftLog {
Type uint16 `json:"type"` Type uint16 `json:"type"`
userId int64 `json:"user_id"` userId int64 `json:"user_id"`
@@ -291,6 +320,10 @@ service ppanel {
@handler FilterCommissionLog @handler FilterCommissionLog
get /commission/list (FilterCommissionLogRequest) returns (FilterCommissionLogResponse) get /commission/list (FilterCommissionLogRequest) returns (FilterCommissionLogResponse)
@doc "Filter order refund log"
@handler FilterOrderRefundLog
get /order/refund/list (FilterOrderRefundLogRequest) returns (FilterOrderRefundLogResponse)
@doc "Filter gift log" @doc "Filter gift log"
@handler FilterGiftLog @handler FilterGiftLog
get /gift/list (FilterGiftLogRequest) returns (FilterGiftLogResponse) get /gift/list (FilterGiftLogRequest) returns (FilterGiftLogResponse)
+8
View File
@@ -33,6 +33,10 @@ type (
PaymentId int64 `json:"payment_id,omitempty"` PaymentId int64 `json:"payment_id,omitempty"`
TradeNo string `json:"trade_no,omitempty"` TradeNo string `json:"trade_no,omitempty"`
} }
RefundOrderRequest {
Id int64 `json:"id" validate:"required"`
Reason string `json:"reason,omitempty" validate:"omitempty,max=500"`
}
ActivateOrderRequest { ActivateOrderRequest {
OrderNo string `json:"order_no" validate:"required"` OrderNo string `json:"order_no" validate:"required"`
} }
@@ -68,6 +72,10 @@ service ppanel {
@handler UpdateOrderStatus @handler UpdateOrderStatus
put /status (UpdateOrderStatusRequest) put /status (UpdateOrderStatusRequest)
@doc "Refund order"
@handler RefundOrder
post /refund (RefundOrderRequest)
@doc "Manually activate order" @doc "Manually activate order"
@handler ActivateOrder @handler ActivateOrder
post /activate (ActivateOrderRequest) post /activate (ActivateOrderRequest)
+32 -4
View File
@@ -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)
}
+76
View File
@@ -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)
}
+41
View File
@@ -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)
}
+3 -1
View File
@@ -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"`
@@ -426,6 +427,7 @@ type (
FeeAmount int64 `json:"fee_amount"` FeeAmount int64 `json:"fee_amount"`
TradeNo string `json:"trade_no"` TradeNo string `json:"trade_no"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
StatusName string `json:"status_name,omitempty"`
SubscribeId int64 `json:"subscribe_id"` SubscribeId int64 `json:"subscribe_id"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
@@ -448,6 +450,7 @@ type (
FeeAmount int64 `json:"fee_amount"` FeeAmount int64 `json:"fee_amount"`
TradeNo string `json:"trade_no"` TradeNo string `json:"trade_no"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
StatusName string `json:"status_name,omitempty"`
SubscribeId int64 `json:"subscribe_id"` SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"` Subscribe Subscribe `json:"subscribe"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
@@ -1004,4 +1007,3 @@ type (
ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"` ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"`
} }
) )
+796
View File
@@ -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 ~ %sreferer_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)
}
+20
View File
@@ -0,0 +1,20 @@
EC2 SSH 连接资料
服务器名称: hifast-hk-app-01
公网 IP: 43.198.248.161
登录用户: ubuntu
私钥文件:
- hifast-hk-app-01-reset
公钥文件:
- hifast-hk-app-01-reset.pub
连接命令:
ssh -i hifast-hk-app-01-reset ubuntu@43.198.248.161
如果在 Mac / Linux 上使用,先执行:
chmod 600 hifast-hk-app-01-reset
如果要给别人使用,只需要把私钥文件 hifast-hk-app-01-reset 发给对方即可。
出于安全考虑,建议通过安全渠道传输,并在后续需要时重新轮换密钥。
@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCgAAAKjy5KDa8uSg
2gAAAAtzc2gtZWQyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCg
AAAEDwSb0b/0S6Tw8Od5hAtIKqt1JvomqQQS44Ty3xL+FjPtvqawOqZIcu/SZaAI/i9+ND
cDnnaq/3m5B2wf3hGegKAAAAIWhpZmFzdC1oay1hcHAtMDEtcmVzZXQtMjAyNi0wNS0xMA
ECAwQ=
-----END OPENSSH PRIVATE KEY-----
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvqawOqZIcu/SZaAI/i9+NDcDnnaq/3m5B2wf3hGegK hifast-hk-app-01-reset-2026-05-10
+363
View File
@@ -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`
- AsynqDB `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;
}
}
+184
View File
@@ -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
View File
@@ -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.1ppanel-server 通过 host 网络访问 # Tempo(4317) 将端口映射到 127.0.0.1ppanel-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
View File
@@ -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]
+20 -12
View File
@@ -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
) )
+36 -22
View File
@@ -52,10 +52,6 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= github.com/alibabacloud-go/tea-xml v1.1.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.';
+72
View File
@@ -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
+17
View File
@@ -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,25 @@
package log
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/log"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
func FilterOrderRefundLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.FilterOrderRefundLogRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := log.NewFilterOrderRefundLogLogic(c.Request.Context(), svcCtx)
resp, err := l.FilterOrderRefundLog(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,25 @@
package order
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/order"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
func RefundOrderHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.RefundOrderRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := order.NewRefundOrderLogic(c.Request.Context(), svcCtx)
err := l.RefundOrder(&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"
)
// 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)
}
}
+42
View File
@@ -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"
@@ -248,6 +250,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Filter commission log // Filter commission log
adminLogGroupRouter.GET("/commission/list", adminLog.FilterCommissionLogHandler(serverCtx)) adminLogGroupRouter.GET("/commission/list", adminLog.FilterCommissionLogHandler(serverCtx))
// Filter order refund log
adminLogGroupRouter.GET("/order/refund/list", adminLog.FilterOrderRefundLogHandler(serverCtx))
// Filter email log // Filter email log
adminLogGroupRouter.GET("/email/list", adminLog.FilterEmailLogHandler(serverCtx)) adminLogGroupRouter.GET("/email/list", adminLog.FilterEmailLogHandler(serverCtx))
@@ -339,6 +344,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Update order status // Update order status
adminOrderGroupRouter.PUT("/status", adminOrder.UpdateOrderStatusHandler(serverCtx)) adminOrderGroupRouter.PUT("/status", adminOrder.UpdateOrderStatusHandler(serverCtx))
// Refund order
adminOrderGroupRouter.POST("/refund", adminOrder.RefundOrderHandler(serverCtx))
// Manually activate order // Manually activate order
adminOrderGroupRouter.POST("/activate", adminOrder.ActivateOrderHandler(serverCtx)) adminOrderGroupRouter.POST("/activate", adminOrder.ActivateOrderHandler(serverCtx))
} }
@@ -705,6 +713,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 +881,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 +963,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,77 @@
package log
import (
"context"
modellog "github.com/perfect-panel/server/internal/model/log"
"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 FilterOrderRefundLogLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewFilterOrderRefundLogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FilterOrderRefundLogLogic {
return &FilterOrderRefundLogLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *FilterOrderRefundLogLogic) FilterOrderRefundLog(req *types.FilterOrderRefundLogRequest) (resp *types.FilterOrderRefundLogResponse, err error) {
data, total, err := l.svcCtx.LogModel.FilterSystemLog(l.ctx, &modellog.FilterParams{
Page: req.Page,
Size: req.Size,
Data: req.Date,
Search: req.Search,
Type: modellog.TypeOrderRefund.Uint8(),
ObjectID: req.OrderId,
})
if err != nil {
l.Errorw("Query order refund log failed", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query order refund log failed")
}
var list []types.OrderRefundLog
for _, item := range data {
var content modellog.OrderRefund
if err := content.Unmarshal([]byte(item.Content)); err != nil {
l.Errorf("unmarshal order refund log content failed: %v", err.Error())
continue
}
if req.UserId > 0 && content.TargetUserId != req.UserId && content.RefererUserId != req.UserId && content.OperatorUserId != req.UserId {
continue
}
list = append(list, types.OrderRefundLog{
OrderId: content.OrderId,
OrderNo: content.OrderNo,
OperatorUserId: content.OperatorUserId,
OperatorAuthIdentifier: content.OperatorAuthIdentifier,
TargetUserId: content.TargetUserId,
UserSubscribeId: content.UserSubscribeId,
RefererUserId: content.RefererUserId,
CommissionAmount: content.CommissionAmount,
Reason: content.Reason,
OrderStatusBefore: content.OrderStatusBefore,
OrderStatusAfter: content.OrderStatusAfter,
SubscribeStatusBefore: content.SubscribeStatusBefore,
SubscribeStatusAfter: content.SubscribeStatusAfter,
SubscribeExpireBefore: content.SubscribeExpireBefore,
SubscribeExpireAfter: content.SubscribeExpireAfter,
CommissionBefore: content.CommissionBefore,
CommissionAfter: content.CommissionAfter,
Timestamp: content.Timestamp,
})
}
return &types.FilterOrderRefundLogResponse{
Total: total,
List: list,
}, nil
}
@@ -35,6 +35,28 @@ func (l *GetOrderListLogic) GetOrderList(req *types.GetOrderListRequest) (resp *
resp = &types.GetOrderListResponse{} resp = &types.GetOrderListResponse{}
resp.List = make([]types.Order, 0) resp.List = make([]types.Order, 0)
tool.DeepCopy(&resp.List, list) tool.DeepCopy(&resp.List, list)
for i := range resp.List {
resp.List[i].StatusName = orderStatusName(resp.List[i].Status)
}
resp.Total = total resp.Total = total
return return
} }
func orderStatusName(status uint8) string {
switch status {
case 1:
return "pending"
case 2:
return "paid"
case 3:
return "closed"
case 4:
return "failed"
case 5:
return "finished"
case 6:
return "refunded"
default:
return "unknown"
}
}
@@ -0,0 +1,298 @@
package order
import (
"context"
"fmt"
"strings"
"time"
"github.com/perfect-panel/server/internal/model/log"
modelorder "github.com/perfect-panel/server/internal/model/order"
modeluser "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/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
orderStatusRefunded = 6
)
type RefundOrderLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRefundOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RefundOrderLogic {
return &RefundOrderLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RefundOrderLogic) RefundOrder(req *types.RefundOrderRequest) error {
operator, ok := l.ctx.Value(constant.CtxKeyUser).(*modeluser.User)
if !ok {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
reason := strings.TrimSpace(req.Reason)
var cachesToClear []*modeluser.Subscribe
var planCacheIDs []int64
var userCacheTargets []*modeluser.User
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
var orderInfo modelorder.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&modelorder.Order{}).
Where("id = ?", req.Id).
First(&orderInfo).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order %d not found", req.Id)
}
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query order failed: %v", err)
}
if orderInfo.Status == orderStatusRefunded {
return errors.Wrapf(xerr.NewErrCode(xerr.OrderAlreadyRefunded), "order %d already refunded", orderInfo.Id)
}
if orderInfo.Status != 2 && orderInfo.Status != 5 {
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "order %d status %d is not refundable", orderInfo.Id, orderInfo.Status)
}
userSub, err := l.lockRefundTargetSubscription(tx, &orderInfo)
if err != nil {
return err
}
orderStatusBefore := orderInfo.Status
subStatusBefore := userSub.Status
subExpireBefore := userSub.ExpireTime
now := time.Now()
referer, commissionAmount, err := l.lockCommissionSource(tx, orderInfo.OrderNo, orderInfo.Commission)
if err != nil {
return err
}
var commissionBefore int64
var commissionAfter int64
if referer != nil {
commissionBefore = referer.Commission
commissionAfter = referer.Commission - commissionAmount
if err := tx.Model(&modeluser.User{}).
Where("id = ?", referer.Id).
UpdateColumn("commission", gorm.Expr("commission - ?", commissionAmount)).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", err)
}
commissionLog := log.Commission{
Type: log.CommissionTypeRefund,
Amount: -commissionAmount,
OrderNo: orderInfo.OrderNo,
Timestamp: now.UnixMilli(),
}
content, marshalErr := commissionLog.Marshal()
if marshalErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal commission refund log failed: %v", marshalErr)
}
if err := tx.Model(&log.SystemLog{}).Create(&log.SystemLog{
Type: log.TypeCommission.Uint8(),
Date: now.Format(time.DateOnly),
ObjectID: referer.Id,
Content: string(content),
CreatedAt: now,
}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert commission refund log failed: %v", err)
}
referer.Commission = commissionAfter
userCacheTargets = append(userCacheTargets, referer)
}
if err := tx.Model(&modelorder.Order{}).
Where("id = ? AND status IN ?", orderInfo.Id, []int{2, 5}).
Update("status", orderStatusRefunded).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund order status update failed: %v", err)
}
orderInfo.Status = orderStatusRefunded
userSub.Status = 3
userSub.ExpireTime = now.Add(-time.Second)
userSub.FinishedAt = &now
if err := tx.Model(&modeluser.Subscribe{}).
Where("id = ?", userSub.Id).
Updates(map[string]interface{}{
"status": userSub.Status,
"expire_time": userSub.ExpireTime,
"finished_at": userSub.FinishedAt,
}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update subscription failed: %v", err)
}
refundLog, err := l.buildRefundAuditLog(operator, &orderInfo, userSub, referer, commissionAmount, reason, orderStatusBefore, subStatusBefore, subExpireBefore, commissionBefore, commissionAfter, now)
if err != nil {
return err
}
logContent, err := refundLog.Marshal()
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "marshal refund audit log failed: %v", err)
}
if err := tx.Model(&log.SystemLog{}).Create(&log.SystemLog{
Type: log.TypeOrderRefund.Uint8(),
Date: now.Format(time.DateOnly),
ObjectID: orderInfo.Id,
Content: string(logContent),
CreatedAt: now,
}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert refund audit log failed: %v", err)
}
cachesToClear = append(cachesToClear, userSub)
if userSub.SubscribeId > 0 {
planCacheIDs = append(planCacheIDs, userSub.SubscribeId)
}
if orderInfo.UserId > 0 {
orderUser := &modeluser.User{Id: orderInfo.UserId}
userCacheTargets = append(userCacheTargets, orderUser)
}
return nil
})
if err != nil {
l.Errorw("[RefundOrder] refund failed", logger.Field("error", err.Error()), logger.Field("order_id", req.Id))
return err
}
if len(cachesToClear) > 0 {
if clearErr := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, cachesToClear...); clearErr != nil {
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("order_id", req.Id))
}
}
for _, subscribeID := range planCacheIDs {
if clearErr := l.svcCtx.SubscribeModel.ClearCache(l.ctx, subscribeID); clearErr != nil {
l.Errorw("[RefundOrder] clear subscribe cache failed", logger.Field("error", clearErr.Error()), logger.Field("subscribe_id", subscribeID))
}
}
if len(userCacheTargets) > 0 {
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userCacheTargets...); clearErr != nil {
l.Errorw("[RefundOrder] clear user cache failed", logger.Field("error", clearErr.Error()))
}
}
return nil
}
func (l *RefundOrderLogic) lockRefundTargetSubscription(tx *gorm.DB, orderInfo *modelorder.Order) (*modeluser.Subscribe, error) {
var userSub modeluser.Subscribe
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&modeluser.Subscribe{}).
Where("order_id = ?", orderInfo.Id).
First(&userSub).Error
if err == nil {
return &userSub, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query refund subscription failed: %v", err)
}
if orderInfo.Type != 2 {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "order %d has no linked subscription", orderInfo.Id)
}
if orderInfo.ParentId == 0 {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d has no parent order", orderInfo.Id)
}
err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&modeluser.Subscribe{}).
Where("order_id = ?", orderInfo.ParentId).
First(&userSub).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundNoSubscription), "renewal order %d parent subscription not found", orderInfo.Id)
}
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query renewal subscription failed: %v", err)
}
return &userSub, nil
}
func (l *RefundOrderLogic) lockCommissionSource(tx *gorm.DB, orderNo string, orderCommission int64) (*modeluser.User, int64, error) {
var commissionLogs []log.SystemLog
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&log.SystemLog{}).
Where("type = ? AND content LIKE ?", log.TypeCommission.Uint8(), fmt.Sprintf("%%\"order_no\":\"%s\"%%", orderNo)).
Order("id DESC").
Find(&commissionLogs).Error; err != nil {
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission log failed: %v", err)
}
for _, item := range commissionLogs {
var content log.Commission
if err := content.Unmarshal([]byte(item.Content)); err != nil {
continue
}
if content.Type != log.CommissionTypePurchase && content.Type != log.CommissionTypeRenewal {
continue
}
var referer modeluser.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&modeluser.User{}).
Where("id = ?", item.ObjectID).
First(&referer).Error; err != nil {
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission owner failed: %v", err)
}
return &referer, content.Amount, nil
}
if orderCommission > 0 {
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.OrderRefundCommissionMismatch), "order %s commission owner not found", orderNo)
}
return nil, 0, nil
}
func (l *RefundOrderLogic) buildRefundAuditLog(
operator *modeluser.User,
orderInfo *modelorder.Order,
userSub *modeluser.Subscribe,
referer *modeluser.User,
commissionAmount int64,
reason string,
orderStatusBefore uint8,
subStatusBefore uint8,
subExpireBefore time.Time,
commissionBefore int64,
commissionAfter int64,
now time.Time,
) (*log.OrderRefund, error) {
refundLog := &log.OrderRefund{
OrderId: orderInfo.Id,
OrderNo: orderInfo.OrderNo,
OperatorUserId: operator.Id,
TargetUserId: userSub.UserId,
UserSubscribeId: userSub.Id,
CommissionAmount: commissionAmount,
Reason: reason,
OrderStatusBefore: orderStatusBefore,
OrderStatusAfter: orderInfo.Status,
SubscribeStatusBefore: subStatusBefore,
SubscribeStatusAfter: userSub.Status,
SubscribeExpireBefore: subExpireBefore.UnixMilli(),
SubscribeExpireAfter: userSub.ExpireTime.UnixMilli(),
CommissionBefore: commissionBefore,
CommissionAfter: commissionAfter,
Timestamp: now.UnixMilli(),
}
if referer != nil {
refundLog.RefererUserId = referer.Id
}
if len(operator.AuthMethods) > 0 {
refundLog.OperatorAuthIdentifier = operator.AuthMethods[0].AuthIdentifier
}
return refundLog, nil
}
@@ -0,0 +1,85 @@
package order
import (
"testing"
"time"
modelorder "github.com/perfect-panel/server/internal/model/order"
modeluser "github.com/perfect-panel/server/internal/model/user"
)
func TestOrderStatusName(t *testing.T) {
tests := map[uint8]string{
1: "pending",
2: "paid",
3: "closed",
4: "failed",
5: "finished",
6: "refunded",
7: "unknown",
}
for input, want := range tests {
if got := orderStatusName(input); got != want {
t.Fatalf("status %d: got %q want %q", input, got, want)
}
}
}
func TestBuildRefundAuditLog(t *testing.T) {
logic := &RefundOrderLogic{}
now := time.Unix(1710000000, 0)
expireBefore := now.Add(24 * time.Hour)
expireAfter := now.Add(-time.Second)
operator := &modeluser.User{
Id: 100,
AuthMethods: []modeluser.AuthMethods{
{AuthIdentifier: "admin@example.com"},
},
}
orderInfo := &modelorder.Order{Id: 10, OrderNo: "ORD-1", Status: orderStatusRefunded}
userSub := &modeluser.Subscribe{Id: 20, UserId: 30, Status: 3, ExpireTime: expireAfter}
referer := &modeluser.User{Id: 40}
got, err := logic.buildRefundAuditLog(
operator,
orderInfo,
userSub,
referer,
500,
"manual refund",
5,
1,
expireBefore,
900,
400,
now,
)
if err != nil {
t.Fatalf("buildRefundAuditLog error: %v", err)
}
if got.OrderId != 10 || got.OrderNo != "ORD-1" {
t.Fatalf("unexpected order info: %+v", got)
}
if got.OperatorUserId != 100 || got.OperatorAuthIdentifier != "admin@example.com" {
t.Fatalf("unexpected operator info: %+v", got)
}
if got.TargetUserId != 30 || got.UserSubscribeId != 20 {
t.Fatalf("unexpected subscription info: %+v", got)
}
if got.RefererUserId != 40 || got.CommissionAmount != 500 {
t.Fatalf("unexpected commission info: %+v", got)
}
if got.OrderStatusBefore != 5 || got.OrderStatusAfter != orderStatusRefunded {
t.Fatalf("unexpected order status: %+v", got)
}
if got.SubscribeStatusBefore != 1 || got.SubscribeStatusAfter != 3 {
t.Fatalf("unexpected subscribe status: %+v", got)
}
if got.SubscribeExpireBefore != expireBefore.UnixMilli() || got.SubscribeExpireAfter != expireAfter.UnixMilli() {
t.Fatalf("unexpected expire transition: %+v", got)
}
if got.CommissionBefore != 900 || got.CommissionAfter != 400 {
t.Fatalf("unexpected commission transition: %+v", got)
}
}
@@ -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,41 @@ 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
} }
if req.Avatar != "" {
userInfo.Avatar = req.Avatar userInfo.Avatar = req.Avatar
}
if req.ReferCode != "" {
userInfo.ReferCode = req.ReferCode userInfo.ReferCode = req.ReferCode
}
userInfo.RefererId = req.RefererId userInfo.RefererId = req.RefererId
userInfo.Enable = &req.Enable if req.Enable != nil {
userInfo.IsAdmin = &req.IsAdmin userInfo.Enable = req.Enable
}
if req.IsAdmin != nil {
userInfo.IsAdmin = req.IsAdmin
}
if req.Remark != "" {
userInfo.Remark = req.Remark userInfo.Remark = req.Remark
userInfo.OnlyFirstPurchase = &req.OnlyFirstPurchase }
if req.OnlyFirstPurchase != nil {
userInfo.OnlyFirstPurchase = req.OnlyFirstPurchase
}
if req.ReferralPercentage != 0 {
userInfo.ReferralPercentage = req.ReferralPercentage 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))
}
-25
View File
@@ -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)
}
})
}
}
+29 -17
View File
@@ -25,7 +25,7 @@ type ReportLogMessageLogic struct {
} }
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) {
@@ -35,9 +35,13 @@ func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequ
// 简单限流:设备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:" {
limitKey = "logmsg:" + ip
}
count, _ := l.svcCtx.Redis.Incr(l.ctx, limitKey).Result() count, _ := l.svcCtx.Redis.Incr(l.ctx, limitKey).Result()
if count == 1 { _ = l.svcCtx.Redis.Expire(l.ctx, limitKey, 60*time.Second).Err() } if count == 1 {
_ = l.svcCtx.Redis.Expire(l.ctx, limitKey, 60*time.Second).Err()
}
if count > 120 { // 每分钟最多120条 if count > 120 { // 每分钟最多120条
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "too many reports") return nil, errors.Wrapf(xerr.NewErrCode(xerr.TooManyRequests), "too many reports")
} }
@@ -60,18 +64,20 @@ func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequ
} }
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,
@@ -84,25 +90,31 @@ func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequ
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
}
return s[:n] 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 != "" {
return ip
}
host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr)) host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
if err == nil && host != "" { return host } if err == nil && host != "" {
return host
}
return "" 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)
}
+48
View File
@@ -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
}
+148
View File
@@ -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=0Amount=Price
func TestPurchase_NewUserOnly_AlreadyPurchased_NoBlock(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(7)
const payID = int64(1)
// 套餐:unit_price=1000discount=[{quantity:1,discount:80,new_user_only:true}]
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
// 用户 1 小时前注册(新用户),但已有一条成功订单(status=2)
u := insertTestUser(t, db, 700, time.Now().Add(-1*time.Hour))
insertTestOrder(t, db, u.Id, subID, 2)
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
// 不应被拦截
require.NoError(t, err, "24h内已购用户不应被拦截,应允许下单")
require.NotNil(t, resp)
assert.NotEmpty(t, resp.OrderNo)
// 验证订单金额:无折扣,Amount=Price=1000Discount=0
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Price, "Price 应为原价 1000")
assert.Equal(t, int64(1000), newOrder.Amount, "已购用户不享受新人折扣,Amount 应等于 Price")
assert.Equal(t, int64(0), newOrder.Discount, "Discount 应为 0")
}
// TestPurchase_NewUserOnly_FirstPurchase_HasDiscount 验证:new_user_only=true 套餐,
// 24小时内首次购买 → 允许下单且享受新人折扣
func TestPurchase_NewUserOnly_FirstPurchase_HasDiscount(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const subID = int64(8)
const payID = int64(1)
// 套餐:unit_price=1000discount=[{quantity:1,discount:80,new_user_only:true}]8折)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
// 用户 1 小时前注册,无历史订单
u := insertTestUser(t, db, 800, time.Now().Add(-1*time.Hour))
ctx := buildPurchaseCtx(u)
logic := NewPurchaseLogic(ctx, svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Price, "Price 应为原价 1000")
assert.Equal(t, int64(900), newOrder.Amount, "首次购买应享受9折,Amount=900")
assert.Equal(t, int64(100), newOrder.Discount, "折扣金额应为 100")
}
func TestPurchase_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(9)
payID = int64(1)
ownerUserID = int64(901)
memberUserID = int64(902)
familyID = int64(99)
)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-72*time.Hour))
insertTestDevice(t, db, memberUserID, "device-eligibility-old", time.Now().Add(-72*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
logic := NewPurchaseLogic(buildPurchaseCtx(owner), svcCtx)
_, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.Error(t, err)
var errCode *xerr.CodeError
require.ErrorAs(t, err, &errCode)
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode())
}
func TestPurchase_NewUserOnly_BindEmailScopeSharesHistory(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(10)
payID = int64(1)
ownerUserID = int64(1001)
memberUserID = int64(1002)
familyID = int64(109)
)
insertTestSubscribe(t, db, subID, true)
insertTestPayment(t, db, payID)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-2*time.Hour))
insertTestDevice(t, db, memberUserID, "device-eligibility-shared", time.Now().Add(-2*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
insertScopedTestOrder(t, db, "existing-scope-order", memberUserID, subID, 2)
logic := NewPurchaseLogic(buildPurchaseCtx(owner), svcCtx)
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
SubscribeId: subID,
Payment: payID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var newOrder modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
assert.Equal(t, int64(1000), newOrder.Amount)
assert.Equal(t, int64(0), newOrder.Discount)
}
func ensureRenewalSubscribeColumns(t *testing.T, db *gorm.DB) {
t.Helper()
for _, sql := range []string{
`ALTER TABLE "user_subscribe" ADD COLUMN node_group_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN group_locked TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_download INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_upload INTEGER NOT NULL DEFAULT 0`,
} {
require.NoError(t, db.Exec(sql).Error)
}
}
func insertRenewalUserSubscribe(t *testing.T, db *gorm.DB, id, userID, orderID, subscribeID int64, token, uuid string, start, expire time.Time) {
t.Helper()
require.NoError(t, db.Exec(`INSERT INTO "user_subscribe"
(id, user_id, order_id, subscribe_id, start_time, expire_time, traffic, download, upload, token, uuid, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?)`,
id,
userID,
orderID,
subscribeID,
start.UTC().Format("2006-01-02 15:04:05"),
expire.UTC().Format("2006-01-02 15:04:05"),
token,
uuid,
start.UTC().Format("2006-01-02 15:04:05"),
time.Now().UTC().Format("2006-01-02 15:04:05"),
).Error)
}
func TestRenewalMemberRequestRedirectsToOwnerSubscribe(t *testing.T) {
db := setupNewUserOnlyDB(t)
ensureRenewalSubscribeColumns(t, db)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
planID = int64(1)
paymentID = int64(2)
ownerUserID = int64(29650)
memberID = int64(20003)
familyID = int64(88001)
memberSubID = int64(10013)
ownerSubID = int64(14074)
)
insertTestSubscribe(t, db, planID, false)
insertTestPayment(t, db, paymentID)
member := insertTestUser(t, db, memberID, time.Now().Add(-24*time.Hour))
insertTestUser(t, db, ownerUserID, time.Now().Add(-24*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberID, user.FamilyRoleMember, user.FamilyMemberActive, "manual_invite")
insertRenewalUserSubscribe(t, db, memberSubID, memberID, 7448, planID, "member-token-10013", "member-uuid-10013",
time.Date(2026, 4, 23, 19, 6, 40, 0, time.UTC),
time.Date(2026, 4, 30, 19, 6, 40, 0, time.UTC),
)
insertRenewalUserSubscribe(t, db, ownerSubID, ownerUserID, 9999, planID, "owner-token-14074", "owner-uuid-14074",
time.Date(2026, 4, 30, 13, 39, 33, 0, time.UTC),
time.Date(2026, 5, 30, 16, 0, 0, 0, time.UTC),
)
resp, err := NewRenewalLogic(buildPurchaseCtx(member), svcCtx).Renewal(&types.RenewalOrderRequest{
UserSubscribeID: memberSubID,
Payment: paymentID,
Quantity: 30,
})
require.NoError(t, err)
require.NotNil(t, resp)
var created modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&created).Error)
assert.Equal(t, memberID, created.UserId)
assert.Equal(t, ownerUserID, created.SubscriptionUserId)
assert.Equal(t, int64(9999), created.ParentId)
assert.Equal(t, "owner-token-14074", created.SubscribeToken)
}
func TestPreCreateOrder_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(11)
ownerUserID = int64(1101)
memberUserID = int64(1102)
familyID = int64(119)
)
insertTestSubscribe(t, db, subID, true)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-96*time.Hour))
insertTestDevice(t, db, memberUserID, "device-precreate-old", time.Now().Add(-96*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
logic := NewPreCreateOrderLogic(buildPurchaseCtx(owner), svcCtx)
_, err := logic.PreCreateOrder(&types.PurchaseOrderRequest{
SubscribeId: subID,
Quantity: 1,
})
require.Error(t, err)
var errCode *xerr.CodeError
require.ErrorAs(t, err, &errCode)
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode())
}
func TestPreCreateOrder_NewUserOnly_OrdinaryFamilyMemberDoesNotAffectEligibility(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
subID = int64(12)
ownerUserID = int64(1201)
memberUserID = int64(1202)
familyID = int64(129)
)
insertTestSubscribe(t, db, subID, true)
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
insertTestUser(t, db, memberUserID, time.Now().Add(-96*time.Hour))
insertTestDevice(t, db, memberUserID, "device-precreate-ordinary", time.Now().Add(-96*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "manual_invite")
logic := NewPreCreateOrderLogic(buildPurchaseCtx(owner), svcCtx)
resp, err := logic.PreCreateOrder(&types.PurchaseOrderRequest{
SubscribeId: subID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, int64(900), resp.Amount)
assert.Equal(t, int64(100), resp.Discount)
}
@@ -1,130 +0,0 @@
package order
import (
"testing"
"time"
modelOrder "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/types"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func ensureRenewalInviteSubscribeColumns(t *testing.T, db *gorm.DB) {
t.Helper()
for _, sql := range []string{
`ALTER TABLE "user_subscribe" ADD COLUMN node_group_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN group_locked TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_download INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_upload INTEGER NOT NULL DEFAULT 0`,
} {
require.NoError(t, db.Exec(sql).Error)
}
}
func insertRenewalInviteUserSubscribe(t *testing.T, db *gorm.DB, id, userID, orderID, subscribeID int64, token, uuid string, start, expire time.Time) {
t.Helper()
require.NoError(t, db.Exec(`INSERT INTO "user_subscribe"
(id, user_id, order_id, subscribe_id, start_time, expire_time, traffic, download, upload, token, uuid, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?)`,
id,
userID,
orderID,
subscribeID,
start.UTC().Format("2006-01-02 15:04:05"),
expire.UTC().Format("2006-01-02 15:04:05"),
token,
uuid,
start.UTC().Format("2006-01-02 15:04:05"),
time.Now().UTC().Format("2006-01-02 15:04:05"),
).Error)
}
func TestRenewalFirstSuccessfulPaymentMarksOrderAsNew(t *testing.T) {
db := setupNewUserOnlyDB(t)
ensureRenewalInviteSubscribeColumns(t, db)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
planID = int64(1)
paymentID = int64(2)
ownerUserID = int64(31059)
memberID = int64(31057)
familyID = int64(5638)
ownerSubID = int64(14672)
)
insertTestSubscribe(t, db, planID, false)
insertTestPayment(t, db, paymentID)
member := insertTestUser(t, db, memberID, time.Now().Add(-2*time.Hour))
insertTestUser(t, db, ownerUserID, time.Now().Add(-2*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
insertRenewalInviteUserSubscribe(t, db, ownerSubID, ownerUserID, 0, planID, "owner-trial-token", "owner-trial-uuid",
time.Date(2026, 5, 2, 15, 53, 39, 0, time.UTC),
time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC),
)
resp, err := NewRenewalLogic(buildPurchaseCtx(member), svcCtx).Renewal(&types.RenewalOrderRequest{
UserSubscribeID: ownerSubID,
Payment: paymentID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var created modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&created).Error)
require.Equal(t, uint8(2), created.Type)
require.True(t, created.IsNew)
require.Equal(t, memberID, created.UserId)
require.Equal(t, ownerUserID, created.SubscriptionUserId)
require.Equal(t, "owner-trial-token", created.SubscribeToken)
}
func TestRenewalExistingSuccessfulPaymentMarksOrderAsNotNew(t *testing.T) {
db := setupNewUserOnlyDB(t)
ensureRenewalInviteSubscribeColumns(t, db)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
planID = int64(1)
paymentID = int64(2)
ownerUserID = int64(41059)
memberID = int64(41057)
familyID = int64(6638)
ownerSubID = int64(24672)
)
insertTestSubscribe(t, db, planID, false)
insertTestPayment(t, db, paymentID)
member := insertTestUser(t, db, memberID, time.Now().Add(-2*time.Hour))
insertTestUser(t, db, ownerUserID, time.Now().Add(-2*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
insertScopedTestOrder(t, db, "previous-successful-order", memberID, planID, 5)
insertRenewalInviteUserSubscribe(t, db, ownerSubID, ownerUserID, 0, planID, "owner-renewal-token", "owner-renewal-uuid",
time.Date(2026, 5, 2, 15, 53, 39, 0, time.UTC),
time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC),
)
resp, err := NewRenewalLogic(buildPurchaseCtx(member), svcCtx).Renewal(&types.RenewalOrderRequest{
UserSubscribeID: ownerSubID,
Payment: paymentID,
Quantity: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
var created modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&created).Error)
require.Equal(t, uint8(2), created.Type)
require.False(t, created.IsNew)
}
@@ -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(&currentMonthCount).Error Count(&currentMonthCount).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 受限家主用户IDhifastday@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. 当前用户本身即 restrictedOwnerUserIdhifastday@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,8 +58,11 @@ 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
} }
// 只统计佣金收入(购买331、续费332),不含提现(336)、退款(333)、调整(335)等
if content.Type == log.CommissionTypePurchase || content.Type == log.CommissionTypeRenewal {
sum += content.Amount sum += content.Amount
} }
}
return &types.QueryUserAffiliateCountResponse{ return &types.QueryUserAffiliateCountResponse{
Registers: total, Registers: total,
+57 -110
View File
@@ -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
} }
+17
View File
@@ -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,
+38
View File
@@ -23,6 +23,7 @@ const (
TypeSubscribeTraffic Type = 21 // Subscription traffic log TypeSubscribeTraffic Type = 21 // Subscription traffic log
TypeServerTraffic Type = 22 // Server traffic log TypeServerTraffic Type = 22 // Server traffic log
TypeResetSubscribe Type = 23 // Reset subscription log TypeResetSubscribe Type = 23 // Reset subscription log
TypeOrderRefund Type = 24 // Admin order refund log
TypeLogin Type = 30 // Login log TypeLogin Type = 30 // Login log
TypeRegister Type = 31 // Registration log TypeRegister Type = 31 // Registration log
TypeBalance Type = 32 // Balance log TypeBalance Type = 32 // Balance log
@@ -49,6 +50,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
) )
@@ -277,6 +279,42 @@ func (c *Commission) Unmarshal(data []byte) error {
return json.Unmarshal(data, aux) return json.Unmarshal(data, aux)
} }
type OrderRefund struct {
OrderId int64 `json:"order_id"`
OrderNo string `json:"order_no"`
OperatorUserId int64 `json:"operator_user_id"`
OperatorAuthIdentifier string `json:"operator_auth_identifier,omitempty"`
TargetUserId int64 `json:"target_user_id"`
UserSubscribeId int64 `json:"user_subscribe_id"`
RefererUserId int64 `json:"referer_user_id,omitempty"`
CommissionAmount int64 `json:"commission_amount"`
Reason string `json:"reason,omitempty"`
OrderStatusBefore uint8 `json:"order_status_before"`
OrderStatusAfter uint8 `json:"order_status_after"`
SubscribeStatusBefore uint8 `json:"subscribe_status_before"`
SubscribeStatusAfter uint8 `json:"subscribe_status_after"`
SubscribeExpireBefore int64 `json:"subscribe_expire_before"`
SubscribeExpireAfter int64 `json:"subscribe_expire_after"`
CommissionBefore int64 `json:"commission_before"`
CommissionAfter int64 `json:"commission_after"`
Timestamp int64 `json:"timestamp"`
}
func (o *OrderRefund) Marshal() ([]byte, error) {
type Alias OrderRefund
return json.Marshal(&struct {
*Alias
}{
Alias: (*Alias)(o),
})
}
func (o *OrderRefund) Unmarshal(data []byte) error {
type Alias OrderRefund
aux := (*Alias)(o)
return json.Unmarshal(data, aux)
}
// Gift represents a gift log entry. // Gift represents a gift log entry.
type Gift struct { type Gift struct {
Type uint16 `json:"type"` Type uint16 `json:"type"`
+6 -6
View File
@@ -5,14 +5,14 @@ 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"`
+16 -16
View File
@@ -156,8 +156,8 @@ func (m *customOrderModel) QueryMonthlyOrders(ctx context.Context, date time.Tim
Where("status IN ? AND created_at BETWEEN ? AND ? AND method != ?", []int64{2, 5}, firstDay, lastDay, "balance"). Where("status IN ? AND created_at BETWEEN ? AND ? AND method != ?", []int64{2, 5}, firstDay, lastDay, "balance").
Select( Select(
"SUM(amount) as amount_total, " + "SUM(amount) as amount_total, " +
"SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " + "SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " +
"SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount", "SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount",
). ).
Scan(v).Error Scan(v).Error
}) })
@@ -173,8 +173,8 @@ func (m *customOrderModel) QueryDateOrders(ctx context.Context, date time.Time)
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance"). Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance").
Select( Select(
"SUM(amount) as amount_total, " + "SUM(amount) as amount_total, " +
"SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " + "SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " +
"SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount", "SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount",
). ).
Scan(v).Error Scan(v).Error
}) })
@@ -188,8 +188,8 @@ func (m *customOrderModel) QueryTotalOrders(ctx context.Context) (OrdersTotal, e
return conn.Model(&Order{}). return conn.Model(&Order{}).
Select(` Select(`
SUM(amount) AS amount_total, SUM(amount) AS amount_total,
SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
`). `).
Where("status IN ? AND method != ?", []int64{2, 5}, "balance"). Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
Scan(&result).Error Scan(&result).Error
@@ -210,8 +210,8 @@ func (m *customOrderModel) QueryMonthlyUserCounts(ctx context.Context, date time
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error { err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
return conn.Model(&Order{}). return conn.Model(&Order{}).
Select(` Select(`
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
`). `).
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
[]int64{2, 5}, firstDay, nextMonth, "balance"). []int64{2, 5}, firstDay, nextMonth, "balance").
@@ -228,8 +228,8 @@ func (m *customOrderModel) QueryDateUserCounts(ctx context.Context, date time.Ti
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error { err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
return conn.Model(&Order{}). return conn.Model(&Order{}).
Select(` Select(`
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
`). `).
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?",
[]int64{2, 5}, dateStr, "balance"). []int64{2, 5}, dateStr, "balance").
@@ -245,8 +245,8 @@ func (m *customOrderModel) QueryTotalUserCounts(ctx context.Context) (int64, int
return conn.Model(&Order{}). return conn.Model(&Order{}).
Where("status IN ? AND method != ?", []int64{2, 5}, "balance"). Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
Select(` Select(`
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
`). `).
Scan(&counts).Error Scan(&counts).Error
}) })
@@ -278,8 +278,8 @@ func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.T
Select(` Select(`
DATE_FORMAT(created_at, '%Y-%m-%d') AS date, DATE_FORMAT(created_at, '%Y-%m-%d') AS date,
SUM(amount) AS amount_total, SUM(amount) AS amount_total,
SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
`). `).
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
[]int64{2, 5}, firstDay, nextDay, "balance"). []int64{2, 5}, firstDay, nextDay, "balance").
@@ -322,8 +322,8 @@ func (m *customOrderModel) QueryMonthlyOrdersList(ctx context.Context, date time
Select(` Select(`
DATE_FORMAT(created_at, '%Y-%m') AS date, DATE_FORMAT(created_at, '%Y-%m') AS date,
SUM(amount) AS amount_total, SUM(amount) AS amount_total,
SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
`). `).
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
[]int64{2, 5}, start, end, "balance"). []int64{2, 5}, start, end, "balance").
@@ -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"
}
+17
View File
@@ -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 {
+18
View File
@@ -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
+4 -4
View File
@@ -378,13 +378,13 @@ func (m *customUserModel) QueryDailyUserStatisticsList(ctx context.Context, date
// 子查询:统计每天的新用户订单数量 // 子查询:统计每天的新用户订单数量
newOrderSub := conn.Model(&order.Order{}). newOrderSub := conn.Model(&order.Order{}).
Select("DATE_FORMAT(created_at, '%Y-%m-%d') AS date, COUNT(DISTINCT user_id) AS new_order_users"). Select("DATE_FORMAT(created_at, '%Y-%m-%d') AS date, COUNT(DISTINCT user_id) AS new_order_users").
Where("is_new = 1 AND created_at BETWEEN ? AND ? AND status IN ?", firstDay, date, []int64{2, 5}). Where("type = 1 AND created_at BETWEEN ? AND ? AND status IN ? AND method != ?", firstDay, date, []int64{2, 5}, "balance").
Group("DATE_FORMAT(created_at, '%Y-%m-%d')") Group("DATE_FORMAT(created_at, '%Y-%m-%d')")
// 子查询:统计每天的续费订单数量 // 子查询:统计每天的续费订单数量
renewalOrderSub := conn.Model(&order.Order{}). renewalOrderSub := conn.Model(&order.Order{}).
Select("DATE_FORMAT(created_at, '%Y-%m-%d') AS date, COUNT(DISTINCT user_id) AS renewal_order_users"). Select("DATE_FORMAT(created_at, '%Y-%m-%d') AS date, COUNT(DISTINCT user_id) AS renewal_order_users").
Where("is_new = 0 AND created_at BETWEEN ? AND ? AND status IN ?", firstDay, date, []int64{2, 5}). Where("type = 2 AND created_at BETWEEN ? AND ? AND status IN ? AND method != ?", firstDay, date, []int64{2, 5}, "balance").
Group("DATE_FORMAT(created_at, '%Y-%m-%d')") Group("DATE_FORMAT(created_at, '%Y-%m-%d')")
return conn.Model(&User{}). return conn.Model(&User{}).
@@ -416,13 +416,13 @@ func (m *customUserModel) QueryMonthlyUserStatisticsList(ctx context.Context, da
// 子查询:每月新订单用户数量 // 子查询:每月新订单用户数量
newOrderSub := conn.Model(&order.Order{}). newOrderSub := conn.Model(&order.Order{}).
Select("DATE_FORMAT(created_at, '%Y-%m') AS date, COUNT(DISTINCT user_id) AS new_order_users"). Select("DATE_FORMAT(created_at, '%Y-%m') AS date, COUNT(DISTINCT user_id) AS new_order_users").
Where("is_new = 1 AND created_at >= ? AND status IN ?", sixMonthsAgo, []int64{2, 5}). Where("type = 1 AND created_at >= ? AND status IN ? AND method != ?", sixMonthsAgo, []int64{2, 5}, "balance").
Group("DATE_FORMAT(created_at, '%Y-%m')") Group("DATE_FORMAT(created_at, '%Y-%m')")
// 子查询:每月续费订单用户数量 // 子查询:每月续费订单用户数量
renewalOrderSub := conn.Model(&order.Order{}). renewalOrderSub := conn.Model(&order.Order{}).
Select("DATE_FORMAT(created_at, '%Y-%m') AS date, COUNT(DISTINCT user_id) AS renewal_order_users"). Select("DATE_FORMAT(created_at, '%Y-%m') AS date, COUNT(DISTINCT user_id) AS renewal_order_users").
Where("is_new = 0 AND created_at >= ? AND status IN ?", sixMonthsAgo, []int64{2, 5}). Where("type = 2 AND created_at >= ? AND status IN ? AND method != ?", sixMonthsAgo, []int64{2, 5}, "balance").
Group("DATE_FORMAT(created_at, '%Y-%m')") Group("DATE_FORMAT(created_at, '%Y-%m')")
return conn.Model(&User{}). return conn.Model(&User{}).
+125
View File
@@ -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
+1
View File
@@ -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
+9
View File
@@ -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
} }
-69
View File
@@ -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"`
}
+214 -34
View File
@@ -1,8 +1,17 @@
// 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 RefundOrderRequest struct {
Id int64 `json:"id" validate:"required"`
Reason string `json:"reason,omitempty" validate:"omitempty,max=500"`
}
type Ads struct { type Ads struct {
Id int `json:"id"` Id int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -295,6 +304,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 +565,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 +584,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 +705,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 +731,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 +767,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"`
@@ -760,6 +831,17 @@ type FilterCommissionLogResponse struct {
List []CommissionLog `json:"list"` List []CommissionLog `json:"list"`
} }
type FilterOrderRefundLogRequest struct {
FilterLogParams
OrderId int64 `form:"order_id,optional"`
UserId int64 `form:"user_id,optional"`
}
type FilterOrderRefundLogResponse struct {
Total int64 `json:"total"`
List []OrderRefundLog `json:"list"`
}
type FilterEmailLogResponse struct { type FilterEmailLogResponse struct {
Total int64 `json:"total"` Total int64 `json:"total"`
List []MessageLog `json:"list"` List []MessageLog `json:"list"`
@@ -1048,6 +1130,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 +1209,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 +1240,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 +1510,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 +1692,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"`
} }
@@ -1764,9 +1868,10 @@ type Order struct {
FeeAmount int64 `json:"fee_amount"` FeeAmount int64 `json:"fee_amount"`
TradeNo string `json:"trade_no"` TradeNo string `json:"trade_no"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
StatusName string `json:"status_name,omitempty"`
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 {
@@ -1787,10 +1892,32 @@ type OrderDetail struct {
FeeAmount int64 `json:"fee_amount"` FeeAmount int64 `json:"fee_amount"`
TradeNo string `json:"trade_no"` TradeNo string `json:"trade_no"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
StatusName string `json:"status_name,omitempty"`
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 OrderRefundLog struct {
OrderId int64 `json:"order_id"`
OrderNo string `json:"order_no"`
OperatorUserId int64 `json:"operator_user_id"`
OperatorAuthIdentifier string `json:"operator_auth_identifier,omitempty"`
TargetUserId int64 `json:"target_user_id"`
UserSubscribeId int64 `json:"user_subscribe_id"`
RefererUserId int64 `json:"referer_user_id,omitempty"`
CommissionAmount int64 `json:"commission_amount"`
Reason string `json:"reason,omitempty"`
OrderStatusBefore uint8 `json:"order_status_before"`
OrderStatusAfter uint8 `json:"order_status_after"`
SubscribeStatusBefore uint8 `json:"subscribe_status_before"`
SubscribeStatusAfter uint8 `json:"subscribe_status_after"`
SubscribeExpireBefore int64 `json:"subscribe_expire_before"`
SubscribeExpireAfter int64 `json:"subscribe_expire_after"`
CommissionBefore int64 `json:"commission_before"`
CommissionAfter int64 `json:"commission_after"`
Timestamp int64 `json:"timestamp"`
} }
type OrdersStatistics struct { type OrdersStatistics struct {
@@ -2196,6 +2323,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 +2383,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 +2471,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 +3168,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 +3268,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 +3338,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"`

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