merge: pull internal/internal with secrets for TG notifications
Build docker and publish / build (20.15.1) (push) Failing after 8m22s

Resolved conflict in .gitea/workflows/docker.yml by keeping
secrets-based TG_BOT_TOKEN and TG_CHAT_ID instead of hardcoded values.
This commit is contained in:
2026-05-26 09:25:13 -07:00
55 changed files with 1721 additions and 274 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)
}
+2 -2
View File
@@ -7,8 +7,8 @@ MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
# Grafana 管理员密码
GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
# PPanel Server 镜像标签(留空使用 latest
PPANEL_SERVER_TAG=latest
# PPanel Server 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA
PPANEL_SERVER_TAG=CHANGE_ME_TO_GIT_SHA
# AWS 区域(香港)
AWS_REGION=ap-east-1
+129 -31
View File
@@ -21,8 +21,8 @@ env:
# SSH私钥(Gitea Secret 名称:AWS
SSH_KEY: ${{ secrets.AWS }}
# TG通知
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
TG_CHAT_ID: "-494024380311"
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
# Go构建变量
SERVICE: vpn
SERVICE_STYLE: vpn
@@ -51,16 +51,19 @@ jobs:
echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
echo "DEPLOY_PATH=/opt/ppanel" >> $GITHUB_ENV
echo "DEPLOY_ENV_LABEL=🚀 服务已成功部署到生产环境" >> $GITHUB_ENV
echo "为 main 分支设置生产环境变量"
elif [ "${{ github.ref_name }}" = "internal" ]; then
echo "DOCKER_TAG_SUFFIX=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 "DEPLOY_ENV_LABEL=🧪 服务已成功部署到测试环境" >> $GITHUB_ENV
echo "为 internal 分支设置开发环境变量"
else
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
echo "CONTAINER_NAME=ppanel-server-${{ github.ref_name }}" >> $GITHUB_ENV
echo "DEPLOY_PATH=/root/vpn_server_other" >> $GITHUB_ENV
echo "DEPLOY_ENV_LABEL=🔧 服务已成功部署到其他环境" >> $GITHUB_ENV
echo "为其他分支 (${{ github.ref_name }}) 设置环境变量"
fi
@@ -112,24 +115,37 @@ jobs:
docker version || true
echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}')
# 步骤4: 构建并发布到镜像仓库
- name: 📤 构建并发布到镜像仓库
# 步骤4: 构建镜像
- name: 🏗️ 构建镜像
run: |
echo "开始构建并推送镜像..."
echo "开始构建镜像..."
echo "仓库: ${{ env.REPO }}"
echo "版本标签: ${{ env.VERSION }}"
echo "分支标签: ${{ env.DOCKER_TAG_SUFFIX }}"
# 构建镜像,同时打上版本和分支两个标签
BUILD_TAG_ARGS="-t ${{ env.REPO }}:${{ env.VERSION }}"
if [ "${{ github.event_name }}" = "push" ]; then
BUILD_TAG_ARGS="$BUILD_TAG_ARGS -t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
else
echo "PR事件仅构建版本标签,不推送镜像、不部署"
fi
docker build -f Dockerfile \
--platform linux/amd64 \
--build-arg TARGETARCH=amd64 \
--build-arg VERSION=${{ env.VERSION }} \
--build-arg BUILDTIME=${{ env.BUILDTIME }} \
-t ${{ env.REPO }}:${{ env.VERSION }} \
-t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }} \
$BUILD_TAG_ARGS \
.
echo "镜像构建完成"
# 步骤5: 发布到镜像仓库
- name: 📤 发布到镜像仓库
if: github.event_name == 'push'
run: |
echo "开始推送镜像..."
echo "推送版本标签镜像: ${{ env.REPO }}:${{ env.VERSION }}"
docker push ${{ env.REPO }}:${{ env.VERSION }}
@@ -138,8 +154,9 @@ jobs:
echo "镜像推送完成"
# 调试: 打印部署目标(不输出敏感信息)
# 步骤6: 调试 - 打印部署目标(不输出敏感信息)
- name: 🔍 调试 - 打印部署目标
if: github.event_name == 'push'
run: |
echo "========== 部署目标调试 =========="
echo "当前分支: ${{ github.ref_name }}"
@@ -150,8 +167,9 @@ jobs:
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
echo "====================================="
# 步骤5: 传输配置文件
# 步骤7: 传输配置文件
- name: 📂 传输配置文件
if: github.event_name == 'push'
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ env.SSH_HOST }}
@@ -161,8 +179,9 @@ jobs:
source: "docker-compose.cloud.yml"
target: "/tmp/ppanel-deploy/"
# 步骤6: 连接服务器更新并启动
# 步骤8: 连接服务器更新、健康检查并按需回滚
- name: 🚀 连接服务器更新并启动
if: github.event_name == 'push'
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ env.SSH_HOST }}
@@ -172,36 +191,114 @@ jobs:
timeout: 300s
command_timeout: 600s
script: |
set -e
echo "连接服务器成功,开始部署..."
echo "部署目录: ${{ env.DEPLOY_PATH }}"
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
echo "登录用户: ${{ env.SSH_USER }}"
HEALTHCHECK_URL="http://127.0.0.1:8080/v1/common/heartbeat"
NEW_TAG="${{ env.DOCKER_TAG_SUFFIX }}"
ROLLBACK_TAG="rollback-${{ env.VERSION }}"
SUDO=""
if [ "${{ github.ref_name }}" = "main" ]; then
SUDO="sudo"
fi
docker_cmd() {
if [ -n "$SUDO" ]; then
sudo docker "$@"
else
docker "$@"
fi
}
compose_with_tag() {
tag="$1"
shift
if [ -n "$SUDO" ]; then
sudo env PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
else
PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
fi
}
write_previous_tag() {
if [ -n "$SUDO" ]; then
printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" | sudo tee .previous-ppanel-image-tag >/dev/null
else
printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" > .previous-ppanel-image-tag
fi
}
health_check() {
attempt=1
while [ "$attempt" -le 3 ]; do
if curl -sf "$HEALTHCHECK_URL"; then
echo
return 0
fi
echo "健康检查第 ${attempt}/3 次失败,10s 后重试..."
attempt=$((attempt + 1))
sleep 10
done
return 1
}
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 }}
echo "📥 拉取镜像..."
docker-compose -f docker-compose.cloud.yml pull ppanel-server
echo "🚀 启动服务..."
docker-compose -f docker-compose.cloud.yml up -d ppanel-server
docker image prune -f || true
fi
echo "✅ 部署命令执行完成"
cd ${{ env.DEPLOY_PATH }}
# 步骤6: TG通知 (成功)
PREVIOUS_IMAGE_TAG="$(docker_cmd inspect --format '{{.Config.Image}}' ppanel-server 2>/dev/null || true)"
PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)"
echo "上一版本镜像tag: ${PREVIOUS_IMAGE_TAG:-未发现}"
echo "上一版本镜像ID: ${PREVIOUS_IMAGE_ID:-未发现}"
write_previous_tag
echo "📥 拉取镜像: ${{ env.REPO }}:${NEW_TAG}"
compose_with_tag "$NEW_TAG" pull ppanel-server
echo "🚀 启动服务..."
compose_with_tag "$NEW_TAG" up -d ppanel-server
echo "🩺 部署后健康检查: ${HEALTHCHECK_URL}"
if health_check; then
docker_cmd image prune -f || true
echo "✅ 部署后健康检查通过"
echo "✅ 部署命令执行完成"
exit 0
fi
echo "❌ 部署后健康检查连续 3 次失败,开始回滚..."
if [ -n "$PREVIOUS_IMAGE_ID" ]; then
docker_cmd tag "$PREVIOUS_IMAGE_ID" "${{ env.REPO }}:${ROLLBACK_TAG}"
echo "回滚镜像tag: ${{ env.REPO }}:${ROLLBACK_TAG}"
compose_with_tag "$ROLLBACK_TAG" up -d ppanel-server
echo "🩺 回滚后健康检查: ${HEALTHCHECK_URL}"
if health_check; then
echo "✅ 回滚后健康检查通过"
else
echo "❌ 回滚后健康检查仍失败"
fi
else
echo "未找到上一版本镜像ID,无法自动回滚"
fi
docker_cmd image prune -f || true
exit 1
# 步骤9: TG通知 (成功)
- name: 📱 发送成功通知到Telegram
if: success()
if: success() && github.event_name == 'push'
uses: appleboy/telegram-action@master
with:
token: ${{ env.TG_BOT_TOKEN }}
@@ -215,12 +312,13 @@ jobs:
👤 提交者: ${{ github.actor }}
🕐 时间: ${{ github.event.head_commit.timestamp }}
🚀 服务已成功部署到生产环境
${{ env.DEPLOY_ENV_LABEL }}
🩺 健康检查: 通过 (http://127.0.0.1:8080/v1/common/heartbeat)
parse_mode: Markdown
# 步骤5: TG通知 (失败)
# 步骤10: TG通知 (失败)
- name: 📱 发送失败通知到Telegram
if: failure()
if: failure() && github.event_name == 'push'
uses: appleboy/telegram-action@master
with:
token: ${{ env.TG_BOT_TOKEN }}
@@ -234,6 +332,6 @@ jobs:
👤 提交者: ${{ github.actor }}
🕐 时间: ${{ github.event.head_commit.timestamp }}
🩺 健康检查: 失败或未完成;若新版本健康检查连续 3 次失败,已自动尝试回滚并重新检查
⚠️ 请检查构建日志获取详细信息
parse_mode: Markdown
+28
View File
@@ -268,6 +268,30 @@ type (
OccurredAt int64 `json:"occurred_at"`
CreatedAt int64 `json:"created_at"`
}
GetLogMessageRawRequest {
Id int64 `form:"id" validate:"required"`
}
GetLogMessageRawResponse {
Id int64 `json:"id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
OsName string `json:"os_name"`
OsVersion string `json:"os_version"`
DeviceId string `json:"device_id"`
UserId *int64 `json:"user_id"`
SessionId string `json:"session_id"`
Level uint8 `json:"level"`
ErrorCode string `json:"error_code"`
Message string `json:"message"`
Stack string `json:"stack"`
Context interface{} `json:"context"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
Locale string `json:"locale"`
Digest string `json:"digest"`
OccurredAt int64 `json:"occurred_at"`
CreatedAt int64 `json:"created_at"`
}
)
@server (
@@ -347,5 +371,9 @@ service ppanel {
@doc "Get error log message detail"
@handler GetErrorLogMessageDetail
get /error_message/detail returns (GetErrorLogMessageDetailResponse)
@doc "Get log message raw detail (temporary)"
@handler GetLogMessageRaw
get /message/detail (GetLogMessageRawRequest) returns (GetLogMessageRawResponse)
}
+35 -6
View File
@@ -41,17 +41,17 @@ type (
UserId int64 `json:"user_id" validate:"required"`
Password string `json:"password"`
Avatar string `json:"avatar"`
Balance int64 `json:"balance"`
Commission int64 `json:"commission"`
Balance *int64 `json:"balance"`
Commission *int64 `json:"commission"`
ReferralPercentage uint8 `json:"referral_percentage"`
OnlyFirstPurchase *bool `json:"only_first_purchase"`
GiftAmount int64 `json:"gift_amount"`
GiftAmount *int64 `json:"gift_amount"`
Telegram int64 `json:"telegram"`
ReferCode string `json:"refer_code"`
RefererId int64 `json:"referer_id"`
RefererId *int64 `json:"referer_id"`
Enable *bool `json:"enable"`
IsAdmin *bool `json:"is_admin"`
Remark string `json:"remark"`
Remark *string `json:"remark"`
}
UpdateUserNotifySettingRequest {
UserId int64 `json:"user_id" validate:"required"`
@@ -230,6 +230,24 @@ type (
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
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"`
Method *uint8 `form:"method,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 (
@@ -370,5 +388,16 @@ service ppanel {
@doc "Dissolve family"
@handler DissolveFamily
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)
}
+13
View File
@@ -111,6 +111,9 @@ type (
CommissionWithdrawRequest {
Amount int64 `json:"amount"`
Content string `json:"content"`
Method uint8 `json:"method" validate:"oneof=0 1 2 3"`
Account string `json:"account,omitempty"`
QrCodeUrl string `json:"qr_code_url,omitempty"`
}
WithdrawalLog {
Id int64 `json:"id"`
@@ -119,9 +122,15 @@ type (
Content string `json:"content"`
Status uint8 `json:"status"`
Reason string `json:"reason,omitempty"`
Method uint8 `json:"method"`
Account string `json:"account"`
QrCodeUrl string `json:"qr_code_url"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
CancelWithdrawalRequest {
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
}
QueryWithdrawalLogListRequest {
Page int `form:"page"`
Size int `form:"size"`
@@ -352,6 +361,10 @@ service ppanel {
@handler CommissionWithdraw
post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog)
@doc "Cancel pending withdrawal"
@handler CancelWithdrawal
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
@doc "Query Withdrawal Log"
@handler QueryWithdrawalLog
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
+1 -1
View File
@@ -27,6 +27,7 @@ type (
EnableLoginNotify bool `json:"enable_login_notify"`
EnableSubscribeNotify bool `json:"enable_subscribe_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"`
UserDevices []UserDevice `json:"user_devices"`
Rules []string `json:"rules"`
@@ -1006,4 +1007,3 @@ type (
ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"`
}
)
@@ -17,7 +17,7 @@ REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
REDIS_SOURCE_HOST=43.198.248.161
REDIS_SOURCE_HOST=18.163.33.75
REDIS_SOURCE_PORT=6379
REDIS_SOURCE_USER=
REDIS_SOURCE_PASSWORD=CHANGE_ME
+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` 一致
+7 -6
View File
@@ -19,9 +19,10 @@ services:
# ----------------------------------------------------
# 1. 业务后端 (PPanel Server)
# host 网络:可出外网,直接访问 AWS RDS/Redis;通过 127.0.0.1 访问 Tempo
# PPANEL_SERVER_TAG 由 CI/CD 传入不可变镜像标签(如 git SHA)
# ----------------------------------------------------
ppanel-server:
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest}
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag}
container_name: ppanel-server
restart: always
volumes:
@@ -119,7 +120,7 @@ services:
# 或配置 Nginx 反代(建议加认证)
# ----------------------------------------------------
grafana:
image: grafana/grafana:latest
image: grafana/grafana:13.0.1
container_name: ppanel-grafana
restart: always
ports:
@@ -154,7 +155,7 @@ services:
# 6. Prometheus (指标采集)
# ----------------------------------------------------
prometheus:
image: prom/prometheus:latest
image: prom/prometheus:v3.11.3
container_name: ppanel-prometheus
restart: always
ports:
@@ -179,7 +180,7 @@ services:
# 7. Nginx Exporter (监控宿主机 Nginx)
# ----------------------------------------------------
nginx-exporter:
image: nginx/nginx-prometheus-exporter:latest
image: nginx/nginx-prometheus-exporter:1.5.0
container_name: ppanel-nginx-exporter
restart: always
command:
@@ -198,7 +199,7 @@ services:
# 8. Node Exporter (宿主机监控)
# ----------------------------------------------------
node-exporter:
image: prom/node-exporter:latest
image: prom/node-exporter:v1.11.1
container_name: ppanel-node-exporter
restart: always
volumes:
@@ -221,7 +222,7 @@ services:
# 9. cAdvisor (容器监控)
# ----------------------------------------------------
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
image: gcr.io/cadvisor/cadvisor:v0.55.1
container_name: ppanel-cadvisor
restart: always
volumes:
@@ -449,7 +449,7 @@ CREATE TABLE IF NOT EXISTS `user_device`
`subscribe_id` bigint DEFAULT NULL COMMENT 'Subscribe ID',
`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.',
`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',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'EnableDeviceNumber',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
@@ -0,0 +1,4 @@
ALTER TABLE `withdrawals`
DROP COLUMN `qr_code_url`,
DROP COLUMN `account`,
DROP COLUMN `method`;
@@ -0,0 +1,4 @@
ALTER TABLE `withdrawals`
ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '收款方式 0:其他 1:支付宝 2:微信 3:银行卡' AFTER `content`,
ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '收款账号' AFTER `method`,
ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '收款码图片URL' AFTER `account`;
@@ -6,7 +6,7 @@
UPDATE `user` u
JOIN (
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
FROM user_withdrawal
FROM withdrawals
WHERE status = 0
GROUP BY user_id
) p ON u.id = p.user_id
@@ -14,6 +14,6 @@ SET u.commission = u.commission - p.pending_total
WHERE p.pending_total > 0;
-- Remove the migration log entries written by the up migration.
DELETE FROM system_log
WHERE type = 3
DELETE FROM system_logs
WHERE type = 33
AND content LIKE '%migration: refund pending withdrawal commission (HIF-22)%';
@@ -11,11 +11,31 @@
-- Running this script multiple times is safe only if no new pending
-- withdrawals are created between runs; deploy new code immediately after.
-- Compatibility: some historical databases missed migration 02122, so the
-- withdrawals table may not exist yet. Create it idempotently before the
-- refund logic so this migration can self-heal older installations.
CREATE TABLE IF NOT EXISTS `withdrawals` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key',
`user_id` BIGINT NOT NULL COMMENT 'User ID',
`amount` BIGINT NOT NULL COMMENT 'Withdrawal Amount',
`content` TEXT COMMENT 'Withdrawal Content',
`status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Withdrawal Status',
`reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Rejection Reason',
`created_at` DATETIME NOT NULL COMMENT 'Creation Time',
`updated_at` DATETIME NOT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
VALUES
('invite', 'WithdrawalMethod', '', 'string', 'withdrawal method', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637');
-- Step 1: refund commission for all users with pending withdrawals.
UPDATE `user` u
JOIN (
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
FROM user_withdrawal
FROM withdrawals
WHERE status = 0
GROUP BY user_id
) p ON u.id = p.user_id
@@ -23,9 +43,9 @@ SET u.commission = u.commission + p.pending_total
WHERE p.pending_total > 0;
-- Step 2: write a migration log entry for each refunded user.
INSERT INTO system_log (type, date, object_id, content, created_at)
INSERT INTO system_logs (type, date, object_id, content, created_at)
SELECT
3 AS type,
33 AS type,
DATE(NOW()) AS date,
p.user_id AS object_id,
JSON_OBJECT(
@@ -38,7 +58,7 @@ SELECT
NOW() AS created_at
FROM (
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
FROM user_withdrawal
FROM withdrawals
WHERE status = 0
GROUP BY user_id
HAVING pending_total > 0
+72
View File
@@ -20,6 +20,14 @@ type schemaColumnPatch struct {
ddl string
}
type schemaColumnDefinitionPatch struct {
table string
column string
dataType string
characterMaxLen *int64
ddl string
}
func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error {
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 {
exists, err := tableExists(ctx.DB, patch.table)
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)
}
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
}
@@ -237,6 +277,38 @@ func indexExists(db *gorm.DB, table, index string) (bool, error) {
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 {
if column == "" {
return table
@@ -0,0 +1,23 @@
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 GetLogMessageRawHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.GetLogMessageRawRequest
_ = c.ShouldBind(&req)
if err := svcCtx.Validate(&req); err != nil {
result.ParamErrorResult(c, err)
return
}
l := log.NewGetLogMessageRawLogic(c.Request.Context(), svcCtx)
resp, err := l.GetLogMessageRaw(&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"
)
// 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 user
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Cancel Withdrawal
func CancelWithdrawalHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.CancelWithdrawalRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := user.NewCancelWithdrawalLogic(c.Request.Context(), svcCtx)
resp, err := l.CancelWithdrawal(&req)
result.HttpResult(c, resp, err)
}
}
+15
View File
@@ -259,6 +259,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get error log message detail
adminLogGroupRouter.GET("/error_message/detail", adminLog.GetErrorLogMessageDetailHandler(serverCtx))
// Get log message raw detail (temporary)
adminLogGroupRouter.GET("/message/detail", adminLog.GetLogMessageRawHandler(serverCtx))
// Get error log message list
adminLogGroupRouter.GET("/error_message/list", adminLog.GetErrorLogMessageListHandler(serverCtx))
@@ -713,6 +716,15 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get admin user invite list
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")
@@ -1047,6 +1059,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Commission Withdraw
publicUserGroupRouter.POST("/commission_withdraw", publicUser.CommissionWithdrawHandler(serverCtx))
// Cancel Withdrawal
publicUserGroupRouter.POST("/withdrawal_cancel", publicUser.CancelWithdrawalHandler(serverCtx))
// Delete Current User Account
publicUserGroupRouter.DELETE("/current_user_account", publicUser.DeleteCurrentUserAccountHandler(serverCtx))
@@ -0,0 +1,66 @@
package log
import (
"context"
"encoding/json"
"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 GetLogMessageRawLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetLogMessageRawLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetLogMessageRawLogic {
return &GetLogMessageRawLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *GetLogMessageRawLogic) GetLogMessageRaw(req *types.GetLogMessageRawRequest) (resp *types.GetLogMessageRawResponse, err error) {
row, err := l.svcCtx.LogMessageModel.FindOne(l.ctx, req.Id)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindOne log_message id=%d: %v", req.Id, err)
}
var contextJSON json.RawMessage
if row.Context != "" {
if json.Valid([]byte(row.Context)) {
contextJSON = json.RawMessage(row.Context)
} else {
b, _ := json.Marshal(row.Context)
contextJSON = b
}
}
var occurredAt int64
if row.OccurredAt != nil {
occurredAt = row.OccurredAt.UnixMilli()
}
return &types.GetLogMessageRawResponse{
Id: row.Id,
Platform: row.Platform,
AppVersion: row.AppVersion,
OsName: row.OsName,
OsVersion: row.OsVersion,
DeviceId: row.DeviceId,
UserId: row.UserId,
SessionId: row.SessionId,
Level: row.Level,
ErrorCode: row.ErrorCode,
Message: row.Message,
Stack: row.Stack,
Context: contextJSON,
ClientIP: row.ClientIP,
UserAgent: row.UserAgent,
Locale: row.Locale,
Digest: row.Digest,
OccurredAt: occurredAt,
CreatedAt: row.CreatedAt.UnixMilli(),
}, 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 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,80 @@
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)
}
if req.Method != nil {
query = query.Where("method = ?", *req.Method)
}
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,
Method: row.Method,
Account: row.Account,
QrCodeUrl: row.QrCodeUrl,
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"
"time"
logicCommon "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
@@ -45,13 +46,13 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
}
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
if userInfo.Balance != req.Balance {
change := req.Balance - userInfo.Balance
if req.Balance != nil && userInfo.Balance != *req.Balance {
change := *req.Balance - userInfo.Balance
balanceLog := log.Balance{
Type: log.BalanceTypeAdjust,
Amount: change,
OrderNo: "",
Balance: req.Balance,
Balance: *req.Balance,
Timestamp: time.Now().UnixMilli(),
}
content, _ := balanceLog.Marshal()
@@ -65,14 +66,14 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
if err != nil {
return err
}
userInfo.Balance = req.Balance
userInfo.Balance = *req.Balance
}
if userInfo.GiftAmount != req.GiftAmount {
change := req.GiftAmount - userInfo.GiftAmount
if req.GiftAmount != nil && userInfo.GiftAmount != *req.GiftAmount {
change := *req.GiftAmount - userInfo.GiftAmount
if change != 0 {
var changeType uint16
if userInfo.GiftAmount < req.GiftAmount {
if userInfo.GiftAmount < *req.GiftAmount {
changeType = log.GiftTypeIncrease
} else {
changeType = log.GiftTypeReduce
@@ -80,7 +81,7 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
giftLog := log.Gift{
Type: changeType,
Amount: change,
Balance: req.GiftAmount,
Balance: *req.GiftAmount,
Remark: "Admin adjustment",
Timestamp: time.Now().UnixMilli(),
}
@@ -95,29 +96,27 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
if err != nil {
return err
}
userInfo.GiftAmount = req.GiftAmount
userInfo.GiftAmount = *req.GiftAmount
}
}
if req.Commission != userInfo.Commission {
commentLog := log.Commission{
Type: log.CommissionTypeAdjust,
Amount: req.Commission - userInfo.Commission,
Timestamp: time.Now().UnixMilli(),
if req.Commission != nil && *req.Commission != userInfo.Commission {
remark := ""
if req.Remark != nil {
remark = *req.Remark
}
content, _ := commentLog.Marshal()
err = tx.Create(&log.SystemLog{
Type: log.TypeCommission.Uint8(),
Date: time.Now().Format(time.DateOnly),
ObjectID: userInfo.Id,
Content: string(content),
}).Error
if err != nil {
if isWithdrawalScene(remark) {
logWithdrawalGuard(l.Logger, userInfo.Id)
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
}
change := *req.Commission - userInfo.Commission
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
return err
}
userInfo.Commission = req.Commission
if err = logicCommon.WriteCommissionLog(tx, userInfo.Id, log.CommissionTypeAdjust, change, ""); err != nil {
return err
}
userInfo.Commission = *req.Commission
}
if req.Avatar != "" {
userInfo.Avatar = req.Avatar
@@ -125,15 +124,17 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
if req.ReferCode != "" {
userInfo.ReferCode = req.ReferCode
}
userInfo.RefererId = req.RefererId
if req.RefererId != nil {
userInfo.RefererId = *req.RefererId
}
if req.Enable != nil {
userInfo.Enable = req.Enable
}
if req.IsAdmin != nil {
userInfo.IsAdmin = req.IsAdmin
}
if req.Remark != "" {
userInfo.Remark = req.Remark
if req.Remark != nil {
userInfo.Remark = *req.Remark
}
if req.OnlyFirstPurchase != nil {
userInfo.OnlyFirstPurchase = req.OnlyFirstPurchase
@@ -37,9 +37,9 @@ func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdraw
}
if err := tx.Model(&usermodel.Withdrawal{}).
Where("id = ? AND status = 0", withdrawalID).
Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending).
Updates(map[string]interface{}{
"status": 1,
"status": usermodel.WithdrawalStatusApproved,
"reason": "",
}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "approve withdrawal failed: %v", err)
@@ -80,9 +80,9 @@ func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawa
// Commission was NOT deducted at application time under the new logic,
// so rejection requires no refund — only a status update.
return tx.Model(&usermodel.Withdrawal{}).
Where("id = ? AND status = 0", withdrawalID).
Where("id = ? AND status = ?", withdrawalID, usermodel.WithdrawalStatusPending).
Updates(map[string]interface{}{
"status": 2,
"status": usermodel.WithdrawalStatusRejected,
"reason": reason,
}).Error
})
+29 -17
View File
@@ -25,7 +25,7 @@ type ReportLogMessageLogic struct {
}
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) {
@@ -35,9 +35,13 @@ func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequ
// 简单限流:设备ID优先,其次IP
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()
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条
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
if req.UserId > 0 { userIdPtr = &req.UserId }
if req.UserId > 0 {
userIdPtr = &req.UserId
}
row := &logmessage.LogMessage{
Platform: req.Platform,
AppVersion: req.AppVersion,
OsName: req.OsName,
OsVersion: req.OsVersion,
DeviceId: req.DeviceId,
Platform: safeTruncate(req.Platform, 32),
AppVersion: safeTruncate(req.AppVersion, 64),
OsName: safeTruncate(req.OsName, 64),
OsVersion: safeTruncate(req.OsVersion, 64),
DeviceId: safeTruncate(req.DeviceId, 255),
UserId: userIdPtr,
SessionId: req.SessionId,
SessionId: safeTruncate(req.SessionId, 255),
Level: req.Level,
ErrorCode: req.ErrorCode,
ErrorCode: safeTruncate(req.ErrorCode, 128),
Message: safeTruncate(req.Message, 1024*64),
Stack: safeTruncate(req.Stack, 1024*1024),
Context: ctxStr,
@@ -84,25 +90,31 @@ func (l *ReportLogMessageLogic) ReportLogMessage(req *types.ReportLogMessageRequ
if err = l.svcCtx.LogMessageModel.Insert(l.ctx, row); err != nil {
// 唯一指纹冲突时尝试查询已有记录返回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 {
return &types.ReportLogMessageResponse{ Id: ex[0].Id }, nil
return &types.ReportLogMessageResponse{Id: ex[0].Id}, nil
}
l.Errorf("[ReportLogMessage] insert error: %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 {
if len(s) <= n { return s }
if len(s) <= n {
return s
}
return s[:n]
}
func clientIP(c *gin.Context) string {
ip := c.ClientIP()
if ip != "" { return ip }
if ip != "" {
return ip
}
host, _, err := net.SplitHostPort(strings.TrimSpace(c.Request.RemoteAddr))
if err == nil && host != "" { return host }
if err == nil && host != "" {
return host
}
return ""
}
+1 -1
View File
@@ -41,7 +41,7 @@ func LoadPendingWithdrawalForUpdate(ctx context.Context, tx *gorm.DB, withdrawal
First(&withdrawal).Error; err != nil {
return nil, err
}
if withdrawal.Status != 0 {
if withdrawal.Status != usermodel.WithdrawalStatusPending {
return nil, errors.New("withdrawal status invalid")
}
return &withdrawal, nil
+4 -4
View File
@@ -109,14 +109,14 @@ func buildObjectKey(prefix string, userID int64, bizType, fileID, fileName strin
if prefix == "" {
prefix = "app-upload"
}
return fmt.Sprintf("%s/%s/%d/%04d/%02d/%s_%s",
return fmt.Sprintf("%s/%04d/%02d/%02d/%d/%s__%s",
prefix,
strings.Trim(safeFileNameRegexp.ReplaceAllString(strings.ToLower(strings.TrimSpace(bizType)), "-"), "-"),
userID,
now.Year(),
int(now.Month()),
fileID,
now.Day(),
userID,
safeFileName(fileName),
fileID,
)
}
@@ -0,0 +1,89 @@
package user
import (
"context"
logicCommon "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/log"
"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"
)
type CancelWithdrawalLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCancelWithdrawalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelWithdrawalLogic {
return &CancelWithdrawalLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CancelWithdrawalLogic) CancelWithdrawal(req *types.CancelWithdrawalRequest) (resp *types.WithdrawalLog, err error) {
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")
}
var withdrawal *user.Withdrawal
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
var txErr error
withdrawal, txErr = logicCommon.LoadPendingWithdrawalForUpdate(l.ctx, tx, req.WithdrawalId)
if txErr != nil {
if txErr.Error() == "withdrawal status invalid" {
return errors.Wrapf(xerr.NewErrCode(xerr.WithdrawalStatusInvalid), "withdrawal %d is not in pending state", req.WithdrawalId)
}
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", txErr)
}
if withdrawal.UserId != u.Id {
return errors.Wrapf(xerr.NewErrCode(xerr.PermissionDenied), "user %d cannot cancel withdrawal belonging to user %d", u.Id, withdrawal.UserId)
}
if txErr = tx.Model(&user.Withdrawal{}).
Where("id = ? AND status = ?", req.WithdrawalId, user.WithdrawalStatusPending).
Update("status", user.WithdrawalStatusCancelled).Error; txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "cancel withdrawal failed: %v", txErr)
}
if txErr = l.svcCtx.UserModel.UpdateCommission(l.ctx, withdrawal.UserId, withdrawal.Amount, tx); txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", txErr)
}
if txErr = logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdrawCancel, withdrawal.Amount, ""); txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", txErr)
}
return nil
})
if err != nil {
return nil, err
}
_ = l.svcCtx.UserModel.ClearUserCache(l.ctx, u)
return &types.WithdrawalLog{
Id: withdrawal.Id,
UserId: withdrawal.UserId,
Amount: withdrawal.Amount,
Content: withdrawal.Content,
Status: user.WithdrawalStatusCancelled,
Reason: "",
Method: withdrawal.Method,
Account: withdrawal.Account,
QrCodeUrl: withdrawal.QrCodeUrl,
CreatedAt: withdrawal.CreatedAt.UnixMilli(),
UpdatedAt: withdrawal.UpdatedAt.UnixMilli(),
}, nil
}
@@ -35,12 +35,28 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// Validate payment method fields
switch req.Method {
case user.WithdrawalMethodAlipay, user.WithdrawalMethodWechat:
if req.QrCodeUrl == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "qr_code_url is required for method %d", req.Method)
}
case user.WithdrawalMethodBank:
if req.Account == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for bank transfer")
}
default: // WithdrawalMethodOther
if req.Account == "" && req.Content == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account or content is required for other methods")
}
}
// Sum all pending (status=0) withdrawals to compute available balance.
// Available = commission - pendingTotal; commission is only deducted on approval.
var pendingTotal int64
if err = l.svcCtx.DB.WithContext(l.ctx).
Model(&user.Withdrawal{}).
Where("user_id = ? AND status = 0", u.Id).
Where("user_id = ? AND status = ?", u.Id, user.WithdrawalStatusPending).
Select("COALESCE(SUM(amount), 0)").
Scan(&pendingTotal).Error; err != nil {
l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err)
@@ -59,8 +75,11 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
UserId: u.Id,
Amount: req.Amount,
Content: req.Content,
Status: 0,
Status: user.WithdrawalStatusPending,
Reason: "",
Method: req.Method,
Account: req.Account,
QrCodeUrl: req.QrCodeUrl,
}
return tx.Create(&w).Error
})
@@ -70,11 +89,15 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
}
return &types.WithdrawalLog{
Id: w.Id,
UserId: u.Id,
Amount: req.Amount,
Content: req.Content,
Status: 0,
Status: user.WithdrawalStatusPending,
Reason: "",
Method: req.Method,
Account: req.Account,
QrCodeUrl: req.QrCodeUrl,
CreatedAt: w.CreatedAt.UnixMilli(),
UpdatedAt: w.UpdatedAt.UnixMilli(),
}, nil
@@ -6,7 +6,9 @@ import (
"sort"
"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/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
@@ -44,6 +46,7 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
tool.DeepCopy(resp, u)
resp.UseStatus = true
// 用家庭范围查设备,而不是只看当前用户自己的 UserDevices
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
@@ -65,6 +68,14 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
}
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 为空时自动生成
if resp.ReferCode == "" {
resp.ReferCode = uuidx.UserInviteCode(u.Id)
@@ -108,6 +119,49 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
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 {
type familyRelation struct {
FamilyId int64
@@ -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 (
"context"
"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"
)
type QueryWithdrawalLogLogic struct {
@@ -24,7 +28,52 @@ func NewQueryWithdrawalLogLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
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,
Method: row.Method,
Account: row.Account,
QrCodeUrl: row.QrCodeUrl,
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"
ctxEncryptedBodyKey = "encrypted_request_body"
ctxDecryptedBodyKey = "decrypted_request_body"
deviceDecryptSkipPathPublicFileUpload = "/v1/public/file/upload"
)
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)
if shouldSkipDeviceRequestDecrypt(c) {
c.Set(ctxDeviceDecryptStatusKey, "skipped")
c.Set(ctxDeviceDecryptReasonKey, "multipart_upload_passthrough")
c.Writer = rw
c.Next()
rw.FlushAbort()
return
}
if !rw.Decrypt() {
c.Set(ctxDeviceDecryptStatusKey, "failed")
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) {
rw = &ResponseWriter{
c: c,
+2
View File
@@ -50,6 +50,8 @@ const (
CommissionTypeWithdraw uint16 = 334 // withdraw
CommissionTypeAdjust uint16 = 335 // Admin Adjust
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund
CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金
GiftTypeIncrease uint16 = 341 // Increase
GiftTypeReduce uint16 = 342 // Reduce
)
+6 -6
View File
@@ -5,14 +5,14 @@ import "time"
type LogMessage struct {
Id int64 `gorm:"primaryKey;AUTO_INCREMENT"`
Platform string `gorm:"type:varchar(32);not null"`
AppVersion string `gorm:"type:varchar(32);default:null"`
OsName string `gorm:"type:varchar(32);default:null"`
OsVersion string `gorm:"type:varchar(32);default:null"`
DeviceId string `gorm:"type:varchar(64);default:null"`
AppVersion string `gorm:"type:varchar(64);default:null"`
OsName string `gorm:"type:varchar(64);default:null"`
OsVersion string `gorm:"type:varchar(64);default:null"`
DeviceId string `gorm:"type:varchar(255);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"`
ErrorCode string `gorm:"type:varchar(64);default:null"`
ErrorCode string `gorm:"type:varchar(128);default:null"`
Message string `gorm:"type:text;not null"`
Stack string `gorm:"type:mediumtext;default:null"`
Context string `gorm:"type:json;default:null"`
+16 -16
View File
@@ -160,8 +160,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").
Select(
"SUM(amount) as amount_total, " +
"SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " +
"SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount",
"SUM(CASE WHEN is_new = 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",
).
Scan(v).Error
})
@@ -177,8 +177,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").
Select(
"SUM(amount) as amount_total, " +
"SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " +
"SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount",
"SUM(CASE WHEN is_new = 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",
).
Scan(v).Error
})
@@ -192,8 +192,8 @@ func (m *customOrderModel) QueryTotalOrders(ctx context.Context) (OrdersTotal, e
return conn.Model(&Order{}).
Select(`
SUM(amount) AS amount_total,
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
SUM(CASE WHEN is_new = 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
`).
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
Scan(&result).Error
@@ -214,8 +214,8 @@ func (m *customOrderModel) QueryMonthlyUserCounts(ctx context.Context, date time
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
return conn.Model(&Order{}).
Select(`
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
`).
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
[]int64{2, 5}, firstDay, nextMonth, "balance").
@@ -232,8 +232,8 @@ func (m *customOrderModel) QueryDateUserCounts(ctx context.Context, date time.Ti
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
return conn.Model(&Order{}).
Select(`
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
`).
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?",
[]int64{2, 5}, dateStr, "balance").
@@ -249,8 +249,8 @@ func (m *customOrderModel) QueryTotalUserCounts(ctx context.Context) (int64, int
return conn.Model(&Order{}).
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
Select(`
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
`).
Scan(&counts).Error
})
@@ -282,8 +282,8 @@ func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.T
Select(`
DATE_FORMAT(created_at, '%Y-%m-%d') AS date,
SUM(amount) AS amount_total,
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
SUM(CASE WHEN is_new = 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
`).
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
[]int64{2, 5}, firstDay, nextDay, "balance").
@@ -326,8 +326,8 @@ func (m *customOrderModel) QueryMonthlyOrdersList(ctx context.Context, date time
Select(`
DATE_FORMAT(created_at, '%Y-%m') AS date,
SUM(amount) AS amount_total,
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
SUM(CASE WHEN is_new = 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
`).
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
[]int64{2, 5}, start, end, "balance").
+17
View File
@@ -26,6 +26,7 @@ type (
Insert(ctx context.Context, data *User, tx ...*gorm.DB) error
FindOne(ctx context.Context, id int64) (*User, 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
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
}
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 {
data, err := m.FindOne(ctx, id)
if err != nil {
+18
View File
@@ -8,6 +8,22 @@ import (
"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) {
deviceIdKey := fmt.Sprintf("%s%v", cacheUserDeviceIdPrefix, id)
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 {
normalizeDeviceForStorage(data)
old, err := m.FindOneDevice(ctx, data.Id)
if err != nil {
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 {
normalizeDeviceForStorage(data)
defer func() {
if clearErr := m.ClearDeviceCache(ctx, data); clearErr != nil {
// log cache clear error
+5 -2
View File
@@ -167,12 +167,15 @@ type Withdrawal struct {
UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"`
Amount int64 `gorm:"not null;comment:Withdrawal Amount"`
Content string `gorm:"type:text;comment:Withdrawal Content"`
Status uint8 `gorm:"type:tinyint(1);default:0;comment:Withdrawal Status: 0: Pending 1: Approved 2: Rejected"`
Status uint8 `gorm:"type:tinyint(1);default:0;comment:Withdrawal Status: 0: Pending 1: Approved 2: Rejected 3: Cancelled"`
Reason string `gorm:"type:varchar(500);default:'';comment:Rejection Reason"`
Method uint8 `gorm:"type:tinyint(1);default:0;comment:收款方式 0:其他 1:支付宝 2:微信 3:银行卡"`
Account string `gorm:"type:varchar(255);default:'';comment:收款账号"`
QrCodeUrl string `gorm:"type:varchar(500);default:'';comment:收款码图片URL"`
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (*Withdrawal) TableName() string {
return "user_withdrawal"
return "withdrawals"
}
+15
View File
@@ -0,0 +1,15 @@
package user
const (
WithdrawalStatusPending uint8 = 0 // 待审核
WithdrawalStatusApproved uint8 = 1 // 已通过
WithdrawalStatusRejected uint8 = 2 // 已拒绝
WithdrawalStatusCancelled uint8 = 3 // 已取消(用户自行撤回)
)
const (
WithdrawalMethodOther uint8 = 0 // 其他
WithdrawalMethodAlipay uint8 = 1 // 支付宝
WithdrawalMethodWechat uint8 = 2 // 微信
WithdrawalMethodBank uint8 = 3 // 银行卡
)
+66 -5
View File
@@ -3,6 +3,8 @@
package types
import "encoding/json"
type ActivateOrderRequest struct {
OrderNo string `json:"order_no" validate:"required"`
}
@@ -296,6 +298,9 @@ type CommissionLog struct {
type CommissionWithdrawRequest struct {
Amount int64 `json:"amount"`
Content string `json:"content"`
Method uint8 `json:"method" validate:"oneof=0 1 2 3"`
Account string `json:"account,omitempty"`
QrCodeUrl string `json:"qr_code_url,omitempty"`
}
type ConnectionRecords struct {
@@ -2313,6 +2318,10 @@ type QueryUserSubscribeNodeListResponse struct {
List []UserSubscribeInfo `json:"list"`
}
type CancelWithdrawalRequest struct {
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
}
type QueryWithdrawalLogListRequest struct {
Page int `form:"page"`
Size int `form:"size"`
@@ -2323,6 +2332,28 @@ type QueryWithdrawalLogListResponse struct {
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"`
Method *uint8 `form:"method,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 {
Id int64 `json:"id"`
Subscribers []int64 `json:"subscribers"`
@@ -3244,17 +3275,17 @@ type UpdateUserBasiceInfoRequest struct {
UserId int64 `json:"user_id" validate:"required"`
Password string `json:"password"`
Avatar string `json:"avatar"`
Balance int64 `json:"balance"`
Commission int64 `json:"commission"`
Balance *int64 `json:"balance"`
Commission *int64 `json:"commission"`
ReferralPercentage uint8 `json:"referral_percentage"`
OnlyFirstPurchase *bool `json:"only_first_purchase"`
GiftAmount int64 `json:"gift_amount"`
GiftAmount *int64 `json:"gift_amount"`
Telegram int64 `json:"telegram"`
ReferCode string `json:"refer_code"`
RefererId int64 `json:"referer_id"`
RefererId *int64 `json:"referer_id"`
Enable *bool `json:"enable"`
IsAdmin *bool `json:"is_admin"`
Remark string `json:"remark"`
Remark *string `json:"remark"`
}
type UpdateUserNotifyRequest struct {
@@ -3317,6 +3348,7 @@ type User struct {
EnableLoginNotify bool `json:"enable_login_notify"`
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
EnableTradeNotify bool `json:"enable_trade_notify"`
UseStatus bool `json:"use_status"`
AuthMethods []UserAuthMethod `json:"auth_methods"`
UserDevices []UserDevice `json:"user_devices"`
Rules []string `json:"rules"`
@@ -3624,6 +3656,9 @@ type WithdrawalLog struct {
Content string `json:"content"`
Status uint8 `json:"status"`
Reason string `json:"reason,omitempty"`
Method uint8 `json:"method"`
Account string `json:"account"`
QrCodeUrl string `json:"qr_code_url"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
@@ -3658,3 +3693,29 @@ type GetAdminUserInviteListResponse struct {
Total int64 `json:"total"`
List []AdminInvitedUser `json:"list"`
}
type GetLogMessageRawRequest struct {
Id int64 `form:"id" validate:"required"`
}
type GetLogMessageRawResponse struct {
Id int64 `json:"id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
OsName string `json:"os_name"`
OsVersion string `json:"os_version"`
DeviceId string `json:"device_id"`
UserId *int64 `json:"user_id"`
SessionId string `json:"session_id"`
Level uint8 `json:"level"`
ErrorCode string `json:"error_code"`
Message string `json:"message"`
Stack string `json:"stack"`
Context json.RawMessage `json:"context"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
Locale string `json:"locale"`
Digest string `json:"digest"`
OccurredAt int64 `json:"occurred_at"`
CreatedAt int64 `json:"created_at"`
}
@@ -0,0 +1,35 @@
package types
import (
"encoding/json"
"testing"
)
func TestUpdateUserBasicInfoRequestRemarkPresence(t *testing.T) {
var omitted UpdateUserBasiceInfoRequest
if err := json.Unmarshal([]byte(`{"user_id":1}`), &omitted); err != nil {
t.Fatalf("unmarshal omitted remark: %v", err)
}
if omitted.Remark != nil {
t.Fatalf("omitted remark = %q, want nil", *omitted.Remark)
}
var cleared UpdateUserBasiceInfoRequest
if err := json.Unmarshal([]byte(`{"user_id":1,"remark":""}`), &cleared); err != nil {
t.Fatalf("unmarshal empty remark: %v", err)
}
if cleared.Remark == nil {
t.Fatal("empty remark was decoded as nil, want explicit empty string")
}
if *cleared.Remark != "" {
t.Fatalf("empty remark = %q, want empty string", *cleared.Remark)
}
var updated UpdateUserBasiceInfoRequest
if err := json.Unmarshal([]byte(`{"user_id":1,"remark":"new note"}`), &updated); err != nil {
t.Fatalf("unmarshal non-empty remark: %v", err)
}
if updated.Remark == nil || *updated.Remark != "new note" {
t.Fatalf("non-empty remark = %#v, want %q", updated.Remark, "new note")
}
}
+4 -4
View File
@@ -22,7 +22,7 @@
- Region: `ap-east-1`
- AWS app EC2:
- Name: `hifast-hk-app-01`
- Public IP: `43.198.248.161`
- Public IP: `18.163.33.75`
- Private IP: `10.0.1.201`
- AWS MySQL:
- Type: `RDS MySQL`
@@ -183,7 +183,7 @@ redis-cli INFO replication
重点看:
- `role:slave`
- `master_host:43.198.248.161`
- `master_host:18.163.33.75`
- `master_port:6379`
- `master_link_status:up`
@@ -416,7 +416,7 @@ SHOW REPLICA STATUS\G
```bash
redis-cli CONFIG SET masterauth '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr'
redis-cli REPLICAOF 43.198.248.161 6379
redis-cli REPLICAOF 18.163.33.75 6379
redis-cli CONFIG SET replica-read-only yes
redis-cli INFO replication
```
@@ -426,7 +426,7 @@ redis-cli INFO replication
检查 `/etc/redis/redis.conf` 至少包含:
```conf
replicaof 43.198.248.161 6379
replicaof 18.163.33.75 6379
masterauth 0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr
replica-read-only yes
```
+33
View File
@@ -0,0 +1,33 @@
# Docker Image Version Pins
This file records the infrastructure image versions pinned in `docker-compose.cloud.yml`.
The versions below match the images observed on the test deployment on 2026-05-26.
| Service | Image | Running version source |
| --- | --- | --- |
| grafana | `grafana/grafana:13.0.1` | `grafana version 13.0.1` |
| prometheus | `prom/prometheus:v3.11.3` | `prometheus, version 3.11.3` |
| nginx-exporter | `nginx/nginx-prometheus-exporter:1.5.0` | image label `org.opencontainers.image.version=1.5.0` |
| node-exporter | `prom/node-exporter:v1.11.1` | `node_exporter, version 1.11.1` |
| cadvisor | `gcr.io/cadvisor/cadvisor:v0.55.1` | `cAdvisor version v0.55.1` |
The test deployment in `/root/bindbox/docker-compose.cloud.yml` also contains
live-only exporter services that are not present in this repository's
`docker-compose.cloud.yml`. They were pinned during staging validation:
| Test-only service | Image | Running version source |
| --- | --- | --- |
| mysql-exporter | `prom/mysqld-exporter:v0.19.0` | `mysqld_exporter, version 0.19.0` |
| redis-exporter | `oliver006/redis_exporter:v1.82.0` | image label `org.opencontainers.image.version=v1.82.0` |
`ppanel-server` intentionally remains variable and requires `PPANEL_SERVER_TAG`
from CI/CD so deployments use an immutable application image tag.
## Rollback
Restore the previous compose file from git and redeploy:
```sh
git checkout HEAD~1 -- docker-compose.cloud.yml .env.example ops/docker-image-version-pins.md
docker compose -f docker-compose.cloud.yml up -d
```
+9 -9
View File
@@ -66,7 +66,7 @@
- Instance ID: `i-079cd9d3ef3748714`
- 角色:当前实际生产入口 / Nginx / 业务服务 / AWS 侧 Redis 主库宿主机
- 私网 IP: `10.0.1.201`
- 公网 IP: `43.198.248.161`
- 公网 IP: `18.163.33.75`
- 业务服务运行方式:`Docker Compose`
- 业务容器:`ppanel-server`
- 部署目录:`/opt/ppanel`
@@ -103,7 +103,7 @@
- 容器名:`hifast-redis`
- 版本:`redis:8.2.1`
- 访问端口:`6379`
- 主库出口地址:`43.198.248.161:6379`
- 主库出口地址:`18.163.33.75:6379`
- 应用当前实际连接:`127.0.0.1:6379`
- 认证方式:已启用密码认证
@@ -130,10 +130,10 @@
```mermaid
flowchart TB
USER["用户 / 客户端"] --> DNS["域名 / DNS / 入口层"]
DNS --> APP["AWS EC2\nhifast-hk-app-01\n43.198.248.161\n10.0.1.201"]
DNS --> APP["AWS EC2\nhifast-hk-app-01\n18.163.33.75\n10.0.1.201"]
APP --> RDS["AWS RDS MySQL\nhifast-mysql-prod-v2\n主库"]
APP --> REDISM["AWS Redis 主库\nDocker redis:8.2.1\n43.198.248.161:6379"]
APP --> REDISM["AWS Redis 主库\nDocker redis:8.2.1\n18.163.33.75:6379"]
RDS -. MySQL 备用 / 同步 .-> MYSQLS["104.238.220.230\nMySQL 备用库"]
REDISM -. Redis 主从复制 .-> REDISS["104.238.220.230\n原生 Redis 8.6.3\n从库"]
@@ -238,7 +238,7 @@ App / Nginx
```mermaid
flowchart LR
SG["hifast-hk-app-core-sg"] --> REDIS["AWS Redis 主库\n43.198.248.161:6379"]
SG["hifast-hk-app-core-sg"] --> REDIS["AWS Redis 主库\n18.163.33.75:6379"]
STANDBY["104.238.220.230/32"] --> SG
```
@@ -311,7 +311,7 @@ RDS 当前状态已经比之前干净很多:
`104.238.220.230` 连接 AWS Redis,不是通过 PEM 证书,也不是通过 SSH 登录 AWS 机器,而是直接作为 Redis 从库去访问 AWS Redis 主库:
- 目标地址:`43.198.248.161:6379`
- 目标地址:`18.163.33.75:6379`
- 连接方式:`TCP`
- 认证方式:`Redis 密码`
- 网络前提:AWS EC2 安全组已放行 `104.238.220.230/32 -> 6379`
@@ -325,7 +325,7 @@ RDS 当前状态已经比之前干净很多:
示意命令:
```bash
redis-cli -h 43.198.248.161 -p 6379 -a '<REDIS_PASSWORD>'
redis-cli -h 18.163.33.75 -p 6379 -a '<REDIS_PASSWORD>'
```
### 4.6.2 104 连接 AWS MySQL RDS 的方式
@@ -380,7 +380,7 @@ mysql -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u admin
- 部署方式:Docker
- 版本:`8.2.1`
- 主库地址:`43.198.248.161:6379`
- 主库地址:`18.163.33.75:6379`
- 运行容器:`hifast-redis`
### 5.2 104 Redis 从库
@@ -417,7 +417,7 @@ mysql -h hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u admin
```mermaid
flowchart LR
REDISMASTER["AWS Redis 主库\n43.198.248.161:6379\nDocker redis:8.2.1"]
REDISMASTER["AWS Redis 主库\n18.163.33.75:6379\nDocker redis:8.2.1"]
REDISSLAVE["104.238.220.230\n原生 Redis 8.6.3\nrole: slave"]
REDISMASTER --> REDISSLAVE
```
+44
View File
@@ -0,0 +1,44 @@
导出数据库:
mysqldump --socket=/var/run/mysqld/mysqld.sock -uroot -p --single-transaction --routines --triggers --events --set-gtid-purged=OFF --source-data=2 --no-tablespaces hifast > /root/data/hifast-final-0515.sql
jpcV41ppanel
gzip -1 /root/hifast-final-0515.sql
导出redis
redis-cli -a '0BVz9XOHf7KUfEuoFJRK-dURdKUGFiZ8QeaHpysHnKeKhLskZb55HPK121lFsKtr' BGSAVE
sleep 5
cp /var/lib/redis/dump.rdb /root/data/redis-backup-0515.rdb
导入数据库:
gunzip -c /root/hifast-final-0515.sql.gz | docker exec -i ppanel-mysql mysql -uroot -p'jpcV41ppanel' hifast
导入redis
docker stop ppanel-redis
docker cp /root/data/redis-backup-0515.rdb ppanel-redis:/data/dump.rdb
docker start ppanel-redis
docker exec ppanel-redis redis-cli -a 'hifast67yj' DBSIZE
docker stop ppanel-redis || true
docker rm -f ppanel-redis
rm -f /root/data/dump.rdb
rm -rf /root/data/appendonlydir
rm -f /root/data/appendonly.aof*
mkdir -p /root/data
docker pull redis:8.6.3
docker run -d \
--name ppanel-redis \
--restart always \
-p 6379:6379 \
redis:8.6.3 \
redis-server --requirepass 'hifast67yj'