Compare commits

..

1 Commits

Author SHA1 Message Date
shanshanzhong147 18c667bf21 文档(#99): 补充上传链路自检 runbook
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 19:43:07 -07:00
10 changed files with 218 additions and 237 deletions
+33 -14
View File
@@ -76,6 +76,29 @@ type (
GiftAmount int64 `json:"gift_amount"`
IsAdmin bool `json:"is_admin"`
}
UserSubscribeDetail {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
User User `json:"user"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"`
NodeGroupId int64 `json:"node_group_id"`
GroupLocked bool `json:"group_locked"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
Token string `json:"token"`
Status uint8 `json:"status"`
EffectiveSpeed int64 `json:"effective_speed"`
IsThrottled bool `json:"is_throttled"`
ThrottleRule string `json:"throttle_rule,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
BatchDeleteUserRequest {
Ids []int64 `json:"ids" validate:"required"`
}
@@ -135,22 +158,18 @@ type (
Total int64 `json:"total"`
}
CreateUserSubscribeRequest {
UserId int64 `json:"user_id"`
ExpiredAt int64 `json:"expired_at"`
Traffic int64 `json:"traffic"`
SubscribeId int64 `json:"subscribe_id"`
SpeedLimit int64 `json:"speed_limit,optional"`
TrafficLimit string `json:"traffic_limit,optional"`
UserId int64 `json:"user_id"`
ExpiredAt int64 `json:"expired_at"`
Traffic int64 `json:"traffic"`
SubscribeId int64 `json:"subscribe_id"`
}
UpdateUserSubscribeRequest {
UserSubscribeId int64 `json:"user_subscribe_id"`
SubscribeId int64 `json:"subscribe_id"`
Traffic int64 `json:"traffic"`
ExpiredAt int64 `json:"expired_at"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
TrafficLimit *string `json:"traffic_limit,omitempty"`
UserSubscribeId int64 `json:"user_subscribe_id"`
SubscribeId int64 `json:"subscribe_id"`
Traffic int64 `json:"traffic"`
ExpiredAt int64 `json:"expired_at"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
}
GetUserLoginLogsRequest {
Page int `form:"page"`
+7 -36
View File
@@ -171,13 +171,13 @@ type (
DeviceLimit int64 `json:"device_limit"`
}
VerifyConfig {
CaptchaType string `json:"captcha_type"` // local or turnstile
TurnstileSiteKey string `json:"turnstile_site_key"`
TurnstileSecret string `json:"turnstile_secret"`
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha
CaptchaType string `json:"captcha_type"` // local or turnstile
TurnstileSiteKey string `json:"turnstile_site_key"`
TurnstileSecret string `json:"turnstile_secret"`
EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha
EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha
}
NodeConfig {
NodeSecret string `json:"node_secret"`
@@ -536,35 +536,6 @@ type (
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
UserSubscribeDetail {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
User User `json:"user"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"`
NodeGroupId int64 `json:"node_group_id"`
NodeGroupName string `json:"node_group_name"`
GroupLocked bool `json:"group_locked"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
SpeedLimit int64 `json:"speed_limit"`
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
PlanSpeedLimit int64 `json:"plan_speed_limit"`
Token string `json:"token"`
Status uint8 `json:"status"`
EffectiveSpeed int64 `json:"effective_speed"`
IsThrottled bool `json:"is_throttled"`
ThrottleRule string `json:"throttle_rule,omitempty"`
ThrottleStart int64 `json:"throttle_start,omitempty"`
ThrottleEnd int64 `json:"throttle_end,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
UserAffiliate {
Avatar string `json:"avatar"`
Identifier string `json:"identifier"`
@@ -1,5 +0,0 @@
-- Purpose: Rollback user-level speed limit overrides from user_subscribe
ALTER TABLE `user_subscribe`
DROP COLUMN IF EXISTS `traffic_limit`,
DROP COLUMN IF EXISTS `speed_limit`;
@@ -1,37 +0,0 @@
-- Purpose: Add user-level speed limit overrides to user_subscribe
SET @column_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_subscribe'
AND COLUMN_NAME = 'speed_limit'
);
SET @sql = IF(
@column_exists = 0,
'ALTER TABLE `user_subscribe` ADD COLUMN `speed_limit` int NOT NULL DEFAULT 0 COMMENT ''User-level speed limit override (Mbps, 0=use plan default)'' AFTER `upload`',
'SELECT ''Column speed_limit already exists in user_subscribe table'''
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @column_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_subscribe'
AND COLUMN_NAME = 'traffic_limit'
);
SET @sql = IF(
@column_exists = 0,
'ALTER TABLE `user_subscribe` ADD COLUMN `traffic_limit` text DEFAULT NULL COMMENT ''User-level traffic limit rules override (JSON, NULL=use plan default)'' AFTER `speed_limit`',
'SELECT ''Column traffic_limit already exists in user_subscribe table'''
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -1,9 +1,6 @@
package user
import (
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/user"
"github.com/perfect-panel/server/internal/svc"
@@ -21,25 +18,9 @@ func UpdateUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context)
result.ParamErrorResult(c, validateErr)
return
}
if err := validateUpdateUserSubscribeTrafficLimit(&req); err != nil {
result.ParamErrorResult(c, err)
return
}
l := user.NewUpdateUserSubscribeLogic(c.Request.Context(), svcCtx)
err := l.UpdateUserSubscribe(&req)
result.HttpResult(c, nil, err)
}
}
func validateUpdateUserSubscribeTrafficLimit(req *types.UpdateUserSubscribeRequest) error {
if req.TrafficLimit == nil || *req.TrafficLimit == "" {
return nil
}
var rules []types.TrafficLimit
if err := json.Unmarshal([]byte(*req.TrafficLimit), &rules); err != nil {
return errors.New("traffic_limit must be a valid JSON array")
}
return nil
}
@@ -1,59 +0,0 @@
package user
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/xerr"
)
func TestUpdateUserSubscribeHandlerRejectsInvalidLimits(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
body string
}{
{
name: "negative speed limit",
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"speed_limit":-1}`,
},
{
name: "invalid traffic limit json",
body: `{"user_subscribe_id":1,"subscribe_id":1,"traffic":0,"expired_at":4102444800000,"upload":0,"download":0,"traffic_limit":"not-json"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := gin.New()
router.PUT("/v1/admin/user/subscribe", UpdateUserSubscribeHandler(&svc.ServiceContext{}))
req := httptest.NewRequest(http.MethodPut, "/v1/admin/user/subscribe", bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected HTTP 200, got %d", rec.Code)
}
var resp struct {
Code uint32 `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.Code != xerr.InvalidParams {
t.Fatalf("expected code %d, got %d (%s)", xerr.InvalidParams, resp.Code, resp.Msg)
}
})
}
}
@@ -39,32 +39,22 @@ func (l *UpdateUserSubscribeLogic) UpdateUserSubscribe(req *types.UpdateUserSubs
} else {
userSub.Status = 1
}
speedLimit := userSub.SpeedLimit
if req.SpeedLimit != nil {
speedLimit = *req.SpeedLimit
}
trafficLimit := userSub.TrafficLimit
if req.TrafficLimit != nil {
trafficLimit = *req.TrafficLimit
}
err = l.svcCtx.UserModel.UpdateSubscribe(l.ctx, &user.Subscribe{
Id: userSub.Id,
UserId: userSub.UserId,
OrderId: userSub.OrderId,
SubscribeId: req.SubscribeId,
StartTime: userSub.StartTime,
ExpireTime: time.UnixMilli(req.ExpiredAt),
Traffic: req.Traffic,
Download: req.Download,
Upload: req.Upload,
SpeedLimit: speedLimit,
TrafficLimit: trafficLimit,
Token: userSub.Token,
UUID: userSub.UUID,
Status: userSub.Status,
NodeGroupId: userSub.NodeGroupId,
GroupLocked: userSub.GroupLocked,
Id: userSub.Id,
UserId: userSub.UserId,
OrderId: userSub.OrderId,
SubscribeId: req.SubscribeId,
StartTime: userSub.StartTime,
ExpireTime: time.UnixMilli(req.ExpiredAt),
Traffic: req.Traffic,
Download: req.Download,
Upload: req.Upload,
Token: userSub.Token,
UUID: userSub.UUID,
Status: userSub.Status,
NodeGroupId: userSub.NodeGroupId,
GroupLocked: userSub.GroupLocked,
})
if err != nil {
-2
View File
@@ -101,8 +101,6 @@ type Subscribe struct {
Traffic int64 `gorm:"default:0;comment:Traffic"`
Download int64 `gorm:"default:0;comment:Download Traffic"`
Upload int64 `gorm:"default:0;comment:Upload Traffic"`
SpeedLimit int64 `gorm:"default:0;comment:User-level speed limit override (Mbps, 0=use plan default)"`
TrafficLimit string `gorm:"type:text;default:null;comment:User-level traffic limit rules override (JSON)"`
ExpiredDownload int64 `gorm:"default:0;comment:Expired period download traffic (bytes)"`
ExpiredUpload int64 `gorm:"default:0;comment:Expired period upload traffic (bytes)"`
Token string `gorm:"index:idx_token;unique;type:varchar(255);default:'';comment:Token"`
+34 -41
View File
@@ -541,12 +541,10 @@ type CreateUserRequest struct {
}
type CreateUserSubscribeRequest struct {
UserId int64 `json:"user_id"`
ExpiredAt int64 `json:"expired_at"`
Traffic int64 `json:"traffic"`
SubscribeId int64 `json:"subscribe_id"`
SpeedLimit int64 `json:"speed_limit,optional"`
TrafficLimit string `json:"traffic_limit,optional"`
UserId int64 `json:"user_id"`
ExpiredAt int64 `json:"expired_at"`
Traffic int64 `json:"traffic"`
SubscribeId int64 `json:"subscribe_id"`
}
type CreateUserTicketFollowRequest struct {
@@ -3319,14 +3317,12 @@ type UpdateUserSubscribeNoteRequest struct {
}
type UpdateUserSubscribeRequest struct {
UserSubscribeId int64 `json:"user_subscribe_id"`
SubscribeId int64 `json:"subscribe_id"`
Traffic int64 `json:"traffic"`
ExpiredAt int64 `json:"expired_at"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
TrafficLimit *string `json:"traffic_limit,omitempty"`
UserSubscribeId int64 `json:"user_subscribe_id"`
SubscribeId int64 `json:"subscribe_id"`
Traffic int64 `json:"traffic"`
ExpiredAt int64 `json:"expired_at"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
}
type UpdateUserTicketStatusRequest struct {
@@ -3478,33 +3474,30 @@ type UserSubscribe struct {
}
type UserSubscribeDetail struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
User User `json:"user"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"`
NodeGroupId int64 `json:"node_group_id"`
NodeGroupName string `json:"node_group_name"`
GroupLocked bool `json:"group_locked"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
SpeedLimit int64 `json:"speed_limit"`
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
PlanSpeedLimit int64 `json:"plan_speed_limit"`
Token string `json:"token"`
Status uint8 `json:"status"`
EffectiveSpeed int64 `json:"effective_speed"`
IsThrottled bool `json:"is_throttled"`
ThrottleRule string `json:"throttle_rule,omitempty"`
ThrottleStart int64 `json:"throttle_start,omitempty"`
ThrottleEnd int64 `json:"throttle_end,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
User User `json:"user"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"`
NodeGroupId int64 `json:"node_group_id"`
NodeGroupName string `json:"node_group_name"`
GroupLocked bool `json:"group_locked"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
Token string `json:"token"`
Status uint8 `json:"status"`
EffectiveSpeed int64 `json:"effective_speed"`
IsThrottled bool `json:"is_throttled"`
ThrottleRule string `json:"throttle_rule,omitempty"`
ThrottleStart int64 `json:"throttle_start,omitempty"`
ThrottleEnd int64 `json:"throttle_end,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type UserSubscribeInfo struct {
+130
View File
@@ -0,0 +1,130 @@
# Hifast 上传链路自检 Runbook
本文档用于排查 `POST /v1/public/file/upload``/upload/init``/upload/complete` 相关问题。上传链路依赖:
`客户端 -> Nginx -> ppanel-server -> S3/RustFS endpoint`
## 1. 先确认业务服务健康
在测试机执行:
```bash
curl -k -sS -m 8 -i https://tapi.hifast.biz/v1/common/heartbeat | head -30
docker ps --format '{{.Names}} {{.Image}} {{.Status}} {{.Ports}}'
ss -lntp | grep -E ':(80|443|8080|3306|6379)'
```
预期:
- `/v1/common/heartbeat` 返回 `HTTP 200`,业务 `code=200`
- `ppanel-server``ppanel-mysql``ppanel-redis` 运行中
- Nginx 监听 `80/443`ppanel 监听 `8080`
## 2. 检查上传配置
测试环境当前业务容器使用挂载配置:
```bash
docker inspect ppanel-server --format '{{json .Mounts}}'
docker cp ppanel-server:/app/etc/ppanel.yaml /tmp/ppanel.yaml
awk '/^S3:/{flag=1} flag && /^[A-Za-z0-9_]+:/{if($1!="S3:") exit} flag{print}' /tmp/ppanel.yaml \
| sed -E 's/(AccessKey:).*/\1 ***REDACTED***/; s/(SecretKey:).*/\1 ***REDACTED***/; s/(SessionToken:).*/\1 ***REDACTED***/'
```
重点确认:
- `S3.Enable: true`
- `S3.Endpoint` 是 ppanel-server 所在机器可以访问的地址
- `S3.Bucket` 存在且凭据有 `PutObject` / `HeadObject` 权限
- `S3.UsePathStyle` 与对象存储实现一致
- `S3.PublicBaseURL` 只影响返回 URL,不代表写入 endpoint
## 3. 检查对象存储连通性
先从测试机主机网络检查:
```bash
endpoint='http://107.173.50.22:5017'
host='107.173.50.22'
port='5017'
timeout 5 bash -lc "</dev/tcp/${host}/${port}" && echo tcp_ok || echo tcp_fail
curl -v --connect-timeout 5 --max-time 10 -I "${endpoint}/"
curl -v --connect-timeout 5 --max-time 10 -I "${endpoint}/hifastvpn"
ip route get "${host}"
```
判定:
- TCP 超时:优先查对象存储主机防火墙、安全组、服务监听、源 IP 白名单
- TCP 通但 HTTP 空响应:优先查 endpoint 协议、反向代理、对象存储进程健康
- 返回 S3 XML/鉴权错误:网络已通,再查 AK/SK、bucket、path-style 配置
## 4. 检查应用日志
```bash
docker logs --since 3h ppanel-server 2>&1 \
| grep -Ei 'put object failed|upload|s3|timeout|context canceled|error' \
| tail -120
```
典型根因:
- `put object failed ... context canceled` 且请求耗时约 60 秒:上游对象存储请求没有在 Nginx upstream timeout 前完成,通常是 `S3.Endpoint` 不可达或对象存储服务 hang。
- 立即返回业务错误:通常是 `S3.Enable=false`、Content-Type 不在白名单、bucket/权限错误。
## 5. Nginx 检查
```bash
grep -RIn 'proxy_pass\|proxy_read_timeout\|client_max_body_size\|tapi.hifast.biz' /etc/nginx/sites-enabled /etc/nginx/conf.d
nginx -t
```
注意:单纯放大 `proxy_read_timeout` 只能掩盖现象,不能修复对象存储不可达。
## 6. 修复与回滚
修复优先级:
1. 恢复对象存储 endpoint 的网络访问或服务监听。
2. 如 endpoint 已迁移,更新 `/root/bindbox/configs/ppanel.yaml``S3.Endpoint`,重启 `ppanel-server`
3. 如果凭据或 bucket 变更,同步更新 `S3.AccessKey``S3.SecretKey``S3.Bucket``S3.UsePathStyle`
改配置前先备份:
```bash
cp /root/bindbox/configs/ppanel.yaml /root/bindbox/configs/ppanel.yaml.bak.$(date +%Y%m%d%H%M%S)
docker restart ppanel-server
```
回滚:
```bash
cp /root/bindbox/configs/ppanel.yaml.bak.YYYYMMDDHHMMSS /root/bindbox/configs/ppanel.yaml
docker restart ppanel-server
curl -k -sS -m 8 -i https://tapi.hifast.biz/v1/common/heartbeat | head -30
```
## 7. 验证
使用有效 JWT 和有效签名复测:
```bash
curl -i -X POST 'https://tapi.hifast.biz/v1/public/file/upload' \
-H 'x-app-id: android-client' \
-H 'Authorization: <client-jwt>' \
-H 'x-signature-version: v101' \
-H 'login-type: device' \
-H 'x-signature: <sig>' \
-H 'x-nonce: <nonce>' \
-H 'x-timestamp: <ts>' \
-F 'biz_type=app-package' \
-F 'file=@nconf.zip;type=application/zip' \
--max-time 120
```
预期:
- HTTP 不再出现 `504 Gateway Time-out`
- 业务响应 `code=200`
- `docker logs ppanel-server` 不再出现新的 `put object failed`