Compare commits

..

10 Commits

Author SHA1 Message Date
shanshanzhong147 a615724580 fix: align revenue statistics with order type
持续集成 / 构建/Vet/测试 (pull_request) Has been cancelled
持续集成 / golangci-lint (pull_request) Has been cancelled
测试环境部署 / 构建镜像并部署到测试环境 (push) Has been cancelled
2026-06-23 09:12:42 -07:00
shanshanzhong147 3d1a31a19f 修复: 用户维度限速在过期节点组分支生效 + 统一 speed_limit 单位为 Mbps
- getServerUserListLogic.getExpiredUsers 之前完全忽略 user_subscribe.speed_limit,
  现在带出用户级覆盖并与过期节点组 speed_limit 取更严(mergeSpeedLimit:0 视为无限制)
- node_group.SpeedLimit 注释从 "KB/s" 修正为 "Mbps"(旧注释是笔误,实际下发节点的
  ServerUser.SpeedLimit 字段语义就是 Mbps,节点端 ppanel-node 按 *1e6/8 换算为 Byte/s)
- apis/node/node.api 给 ServerUser.SpeedLimit 加 Mbps 单位注释
- 新增 TestMergeSpeedLimit 表驱动测试覆盖 8 种边界

主链路(活跃用户、套餐 traffic_limit 阶梯)行为不变,已在生产 (server_id=52)
验证 147 个限速用户下发正确,与 DB 完全对应。
2026-06-12 22:39:15 -07:00
shanshanzhong147 08434cfa32 新功能: 佣金回退日志 content 改成中文友好描述
之前接口返回原始 JSON 字符串(前端不好展示):
  "{\"type\":333,\"amount\":-649,\"order_no\":\"xxx\",\"timestamp\":\"...\"}"

改为可读文字:
  333 订单退款回佣 → "订单退款回佣(订单号 xxx)"
  337 提现驳回   → "提现申请被驳回,佣金已退回"
  338 提现取消   → "已取消提现,佣金已退回"

同时影响以下两个接口的 content 字段:
- /v1/public/user/withdrawal_log?biz_type=commission_refund (deprecated)
- /v1/public/user/commission_return_log
2026-06-12 20:35:27 -07:00
shanshanzhong147 be09a115ec 新功能: withdrawal_log 接口加 summary 字段 + admin 禁直接扣减 commission
接口侧:
- /v1/public/user/withdrawal_log 响应新增 summary 字段, 包含:
  - commission_balance (当前余额)
  - locked_by_pending (待审批占用)
  - available_to_withdraw (可提现)
  - total_historical_amount (已通过提现总额)
  - total_refunded_amount (退款回扣总额)
  - total_income_amount (收入总额)
- 前端可据此自洽展示账目对账, 用户能在一个接口里看清整笔账

代码守护:
- updateUserBasicInfoLogic 拒绝任何 change<0 的 commission 修改
- 扣减必须走 approveWithdrawal 写 type=334 日志
- 防止未来 admin 误操作再次造成 user.commission 与 system_logs 失衡

时间戳修复:
- queryWithdrawalLogLogic 和 queryCommissionReturnLogLogic 的时间戳
  从 UnixMilli 改回 Unix (秒级), 符合项目"后端统一秒级"约定

测试:
- 补 buildSummary 的 4 个 mock 查询期望
- 加 summary 字段值正确性断言
2026-06-12 19:49:03 -07:00
shanshanzhong147 077dba3d98 修复: 历史提现迁移到 withdrawals 表的闭环 SQL
- 删除原迁移误将 ticket.status=3 (取消/拒绝) 当作已通过迁入的 5 条脏数据
- 补迁 8 条遗漏的 ticket.status=4 已通过单
- content 改为 '历史提现 #<ticket_id>' 支持反查 ticket 源头
- 修正 13 个用户的 commission 字段使其等于 SUM(type=33 日志)
- 给 6 个前日志时代账号补 type=335 baseline 让账目闭环
- 加 .gitignore 排除审计 CSV/TSV(含真实用户邮箱与收款地址)

执行命令(注意 --default-character-set=utf8mb4 必须):
  docker exec -i ppanel-mysql mysql --default-character-set=utf8mb4 \\
    -uroot -p ppanel < ops/audit/migration_v3.sql

跑完后 14 项体检全 PASS, 0 个用户余额失衡。
2026-06-12 19:48:37 -07:00
shanshanzhong147 39bd36b2f8 配置(#35): 修复验收报告路径 (#26)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 02:03:43 -07:00
shanshanzhong147 cfb253d96f 修复(#38): commission_refund 收窄到只查 type=333(移除 337/338 提现退佣混入)
Closes HIF-38

owner 业务定义:commission_refund 这个分类只应返回「下级退单导致用户拿到的佣金被扣回」记录。

改动:
- queryWithdrawalLogLogic.go: ?biz_type=commission_refund 分支 SQL 过滤从 IN(333,337,338) 改为 = 333
- 同步更新 logic/handler 单测

不在范围:
- /commission_return_log(新推荐接口)继续返回 333/337/338 — 前端暂未切,owner 决定不动
- 337/338 不另外开 UI 入口 — 提现记录 status 字段(rejected/cancelled)已表达
2026-06-12 01:51:48 -07:00
shanshanzhong147 f11097ab83 新功能(#26): 拆分退款日志查询接口
拆分用户中心退款日志查询:

- 新增 GET /v1/public/user/commission_return_log(333/337/338)
- 旧 GET /v1/public/user/withdrawal_log?biz_type=commission_refund 复用新逻辑做兼容
- 默认 withdrawal_log 行为不变(仍查 withdrawals 表)
- 单测覆盖 333/337/338 happy path、坏 JSON 跳过、object_id 隔离、handler 级 HTTP 响应

父 issue: HIF-25
子 issue: HIF-26
2026-06-11 22:02:22 -07:00
shanshanzhong147 3e6318dcdf 配置(#24): 放宽发布验收配置校验 (#22)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 02:48:03 -07:00
shanshanzhong147 c39bfd39dd 修复(#21): 禁止通用订单状态写入claimed (#20)
* 修复(#21): 禁止通用订单状态写入claimed

Co-authored-by: multica-agent <github@multica.ai>

* 文档(#21): 补充PR说明

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-11 02:47:48 -07:00
22 changed files with 1055 additions and 124 deletions
+40 -18
View File
@@ -21,10 +21,10 @@ concurrency:
env:
ACCEPTANCE_ARTIFACT_NAME: release-acceptance-${{ github.run_id }}-${{ github.run_attempt }}
ACCEPTANCE_REPORT_DIR: acceptance-artifacts
ACCEPTANCE_REPORT_PATH: acceptance-artifacts/acceptance-report.json
ACCEPTANCE_TEST_JSON: acceptance-artifacts/go-test.json
ACCEPTANCE_FAILURE_LOG: acceptance-artifacts/failure.log
ACCEPTANCE_REPORT_DIR: ${{ github.workspace }}/acceptance-artifacts
ACCEPTANCE_REPORT_PATH: ${{ github.workspace }}/acceptance-artifacts/acceptance-report.json
ACCEPTANCE_TEST_JSON: ${{ github.workspace }}/acceptance-artifacts/go-test.json
ACCEPTANCE_FAILURE_LOG: ${{ github.workspace }}/acceptance-artifacts/failure.log
ACCEPTANCE_NODE_SERVER_ID: ${{ vars.ACCEPTANCE_NODE_SERVER_ID || '31' }}
ACCEPTANCE_NODE_PROTOCOL: ${{ vars.ACCEPTANCE_NODE_PROTOCOL || 'trojan' }}
@@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL }}
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }}
if: >-
${{
github.event_name == 'workflow_dispatch' ||
@@ -65,7 +65,7 @@ jobs:
ACCEPTANCE_ADMIN_PASSWORD: ${{ secrets.ACCEPTANCE_ADMIN_PASSWORD }}
ACCEPTANCE_USER_EMAIL: ${{ secrets.ACCEPTANCE_USER_EMAIL }}
ACCEPTANCE_USER_PASSWORD: ${{ secrets.ACCEPTANCE_USER_PASSWORD }}
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL }}
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL || vars.STAGING_BASE_URL || 'https://tapi.hifast.biz' }}
STAGING_DB_HOST: ${{ secrets.STAGING_DB_HOST }}
STAGING_DB_USER: ${{ secrets.STAGING_DB_USER }}
STAGING_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
@@ -75,13 +75,24 @@ jobs:
run: |
set -euo pipefail
missing=()
required=(
if [ -z "${STAGING_BASE_URL:-}" ]; then
echo "STAGING_BASE_URL 未配置且默认值不可用" | tee "$ACCEPTANCE_FAILURE_LOG"
{
echo "## 发布验收测试"
echo
echo "状态:配置错误"
echo
echo "- `STAGING_BASE_URL` 不能为空。"
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
missing_optional=()
optional=(
ACCEPTANCE_ADMIN_EMAIL
ACCEPTANCE_ADMIN_PASSWORD
ACCEPTANCE_USER_EMAIL
ACCEPTANCE_USER_PASSWORD
STAGING_BASE_URL
STAGING_DB_HOST
STAGING_DB_USER
STAGING_DB_PASSWORD
@@ -90,24 +101,35 @@ jobs:
STAGING_REDIS_PASSWORD
)
for key in "${required[@]}"; do
for key in "${optional[@]}"; do
if [ -z "${!key:-}" ]; then
missing+=("$key")
missing_optional+=("$key")
fi
done
if [ "${#missing[@]}" -gt 0 ]; then
printf '缺少必需 GitHub Actions secrets/vars\n' | tee "$ACCEPTANCE_FAILURE_LOG"
printf -- '- %s\n' "${missing[@]}" | tee -a "$ACCEPTANCE_FAILURE_LOG"
: > "$ACCEPTANCE_FAILURE_LOG"
if [ "${#missing_optional[@]}" -gt 0 ]; then
printf '缺少可选 GitHub Actions secrets/vars,部分验收用例将被跳过:\n' | tee -a "$ACCEPTANCE_FAILURE_LOG"
printf -- '- %s\n' "${missing_optional[@]}" | tee -a "$ACCEPTANCE_FAILURE_LOG"
{
echo "## 发布验收测试"
echo
echo "状态:配置缺失"
echo "状态:部分配置缺失"
echo
echo "缺少以下 secrets/vars"
printf -- '- `%s`\n' "${missing[@]}"
echo "缺少以下可选 secrets/vars,对应验收用例会在测试阶段自动跳过"
printf -- '- `%s`\n' "${missing_optional[@]}"
echo
echo "- `STAGING_BASE_URL`${STAGING_BASE_URL}"
} >> "$GITHUB_STEP_SUMMARY"
else
{
echo "## 发布验收测试"
echo
echo "状态:配置检查通过"
echo
echo "- `STAGING_BASE_URL`${STAGING_BASE_URL}"
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
- name: 下载依赖模块
+2
View File
@@ -64,6 +64,8 @@ type (
ServerUser {
Id int64 `json:"id"`
UUID string `json:"uuid"`
// SpeedLimit 单位为 Mbps0 表示不限速。
// 节点端 (V2bX/XrayR 等) 按 Mbps 解释该值,服务端透传不做单位换算。
SpeedLimit int64 `json:"speed_limit"`
DeviceLimit int64 `json:"device_limit"`
}
+34 -3
View File
@@ -137,9 +137,35 @@ type (
Size int `form:"size"`
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
}
WithdrawalLogSummary {
CommissionBalance int64 `json:"commission_balance"`
LockedByPending int64 `json:"locked_by_pending"`
AvailableToWithdraw int64 `json:"available_to_withdraw"`
TotalHistoricalAmount int64 `json:"total_historical_amount"`
TotalRefundedAmount int64 `json:"total_refunded_amount"`
TotalIncomeAmount int64 `json:"total_income_amount"`
}
QueryWithdrawalLogListResponse {
List []WithdrawalLog `json:"list"`
Total int64 `json:"total"`
List []WithdrawalLog `json:"list"`
Total int64 `json:"total"`
Summary *WithdrawalLogSummary `json:"summary,omitempty"`
}
QueryCommissionReturnLogRequest {
Page int `form:"page"`
Size int `form:"size"`
}
CommissionReturnLog {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
Amount int64 `json:"amount"`
EventType uint16 `json:"event_type"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
QueryCommissionReturnLogResponse {
List []CommissionReturnLog `json:"list"`
Total int64 `json:"total"`
}
GetDeviceOnlineStatsResponse {
WeeklyStats []WeeklyStat `json:"weekly_stats"`
@@ -384,10 +410,14 @@ service ppanel {
@handler CancelWithdrawal
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
@doc "Query Withdrawal Log"
@doc "Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log)"
@handler QueryWithdrawalLog
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
@doc "Query Commission Return Log"
@handler QueryCommissionReturnLog
get /commission_return_log (QueryCommissionReturnLogRequest) returns (QueryCommissionReturnLogResponse)
@doc "Device Online Statistics"
@handler DeviceOnlineStatistics
get /device_online_statistics returns (GetDeviceOnlineStatsResponse)
@@ -447,3 +477,4 @@ service ppanel {
@handler DeviceWsConnect
get /device_ws_connect
}
@@ -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"
)
// Query Commission Return Log
func QueryCommissionReturnLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.QueryCommissionReturnLogRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := user.NewQueryCommissionReturnLogLogic(c.Request.Context(), svcCtx)
resp, err := l.QueryCommissionReturnLog(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,141 @@
package user
import (
"context"
"database/sql/driver"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
logmodel "github.com/perfect-panel/server/internal/model/log"
usermodel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/constant"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestCommissionReturnLogHandler_HTTPResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
db, mock, cleanup := newCommissionReturnHandlerTestDB(t)
defer cleanup()
expectCommissionReturnHTTPQueries(mock, 42, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, time.Unix(1700000000, 0)).
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, time.Unix(1700000000, 0)).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0)))
router := gin.New()
svcCtx := &svc.ServiceContext{DB: db}
router.Use(injectTestUser(42))
router.GET("/v1/public/user/commission_return_log", QueryCommissionReturnLogHandler(svcCtx))
req := httptest.NewRequest(http.MethodGet, "/v1/public/user/commission_return_log?page=1&size=10", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := strings.TrimSpace(rec.Body.String())
t.Logf("commission_return_log response: %s", body)
if !strings.Contains(body, `"event_type":338`) || !strings.Contains(body, `"event_type":337`) || !strings.Contains(body, `"event_type":333`) {
t.Fatalf("response body = %s", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func TestWithdrawalLogHandler_CommissionRefundHTTPResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
db, mock, cleanup := newCommissionReturnHandlerTestDB(t)
defer cleanup()
expectCommissionReturnHTTPQueries(mock, 42, 1, logmodel.CommissionTypeRefund)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), int64(42), "%\"type\":333%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", int64(42), `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, time.Unix(1700000000, 0)))
router := gin.New()
svcCtx := &svc.ServiceContext{DB: db}
router.Use(injectTestUser(42))
router.GET("/v1/public/user/withdrawal_log", QueryWithdrawalLogHandler(svcCtx))
req := httptest.NewRequest(http.MethodGet, "/v1/public/user/withdrawal_log?page=1&size=10&biz_type=commission_refund", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := strings.TrimSpace(rec.Body.String())
t.Logf("withdrawal_log commission_refund response: %s", body)
if !strings.Contains(body, `"biz_type":"commission_refund"`) || !strings.Contains(body, `"amount":2500`) || strings.Contains(body, `"amount":1500`) || strings.Contains(body, `"amount":2000`) {
t.Fatalf("response body = %s", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func newCommissionReturnHandlerTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if strings.Contains(actualSQL, expectedSQL) {
return nil
}
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
func injectTestUser(userID int64) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), constant.CtxKeyUser, &usermodel.User{Id: userID})
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func expectCommissionReturnHTTPQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) {
query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?"
args := []driver.Value{logmodel.TypeCommission.Uint8(), userID}
if len(eventTypes) > 0 {
clauses := make([]string, 0, len(eventTypes))
for _, eventType := range eventTypes {
clauses = append(clauses, "`content` LIKE ?")
args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType))
}
query += " AND (" + strings.Join(clauses, " OR ") + ")"
}
mock.ExpectQuery(query).
WithArgs(args...).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total))
}
@@ -8,7 +8,7 @@ import (
"github.com/perfect-panel/server/pkg/result"
)
// Query Withdrawal Log
// Query Withdrawal Log (biz_type=commission_refund deprecated, use /commission_return_log)
func QueryWithdrawalLogHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.QueryWithdrawalLogListRequest
+3
View File
@@ -1180,6 +1180,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Verify Email
publicUserGroupRouter.POST("/verify_email", publicUser.VerifyEmailHandler(serverCtx))
// Query Commission Return Log
publicUserGroupRouter.GET("/commission_return_log", publicUser.QueryCommissionReturnLogHandler(serverCtx))
// Query Withdrawal Log
publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx))
}
@@ -15,6 +15,10 @@ import (
queue "github.com/perfect-panel/server/queue/types"
)
const (
orderStatusClaimed = 6
)
type UpdateOrderStatusLogic struct {
logger.Logger
ctx context.Context
@@ -31,8 +35,8 @@ func NewUpdateOrderStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext)
}
func (l *UpdateOrderStatusLogic) UpdateOrderStatus(req *types.UpdateOrderStatusRequest) error {
if req.Status == orderStatusRefunded {
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "refund status must use refund order endpoint")
if req.Status == orderStatusClaimed || req.Status == orderStatusRefunded {
return errors.Wrapf(xerr.NewErrCode(xerr.OrderStatusError), "claimed/refund statuses are reserved for internal refund and activation flows")
}
info, err := l.svcCtx.OrderModel.FindOne(l.ctx, req.Id)
@@ -17,11 +17,13 @@ func TestUpdateOrderStatus_RejectsRefundStatus(t *testing.T) {
svcCtx: &svc.ServiceContext{},
}
err := logic.UpdateOrderStatus(&types.UpdateOrderStatusRequest{
Id: 1001,
Status: orderStatusRefunded,
})
if !isErrCode(err, xerr.OrderStatusError) {
t.Fatalf("UpdateOrderStatus error code = %v, want OrderStatusError; raw=%v", errCodeOf(err), err)
for _, status := range []uint8{orderStatusClaimed, orderStatusRefunded} {
err := logic.UpdateOrderStatus(&types.UpdateOrderStatusRequest{
Id: 1001,
Status: status,
})
if !isErrCode(err, xerr.OrderStatusError) {
t.Fatalf("status %d: UpdateOrderStatus error code = %v, want OrderStatusError; raw=%v", status, errCodeOf(err), err)
}
}
}
@@ -110,6 +110,16 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
}
change := *req.Commission - userInfo.Commission
if change < 0 {
// 禁止直接扣减佣金:扣减必须走 approveWithdrawal 写 type=334 日志,
// 否则 user.commission 和 system_logs 会再次失衡,破坏账目闭环。
l.Logger.Errorw("blocked direct commission deduction via admin update",
logger.Field("user_id", userInfo.Id),
logger.Field("change", change),
)
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess),
"commission deduction must go through withdrawal approval, not direct edit")
}
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
return err
}
@@ -0,0 +1,183 @@
package user
import (
"context"
"strconv"
"strings"
"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"
)
type commissionReturnLogRecord struct {
LogID int64
UserID int64
Amount int64
EventType uint16
Content string
CreatedAt int64
UpdatedAt int64
}
type QueryCommissionReturnLogLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewQueryCommissionReturnLogLogic Query Commission Return Log
func NewQueryCommissionReturnLogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryCommissionReturnLogLogic {
return &QueryCommissionReturnLogLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *QueryCommissionReturnLogLogic) QueryCommissionReturnLog(req *types.QueryCommissionReturnLogRequest) (*types.QueryCommissionReturnLogResponse, 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")
}
page, size := normalizePagination(req.Page, req.Size)
list, total, err := l.queryCommissionReturnLogRecords(u.Id, page, size)
if err != nil {
return nil, err
}
respList := make([]types.CommissionReturnLog, 0, len(list))
for _, item := range list {
respList = append(respList, types.CommissionReturnLog{
Id: item.LogID,
UserId: item.UserID,
Amount: item.Amount,
EventType: item.EventType,
Content: item.Content,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
})
}
return &types.QueryCommissionReturnLogResponse{
List: respList,
Total: total,
}, nil
}
func normalizePagination(page, size int) (int, int) {
if page <= 0 {
page = 1
}
if size <= 0 {
size = 10
}
return page, size
}
func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecords(userID int64, page, size int) ([]commissionReturnLogRecord, int64, error) {
return l.queryCommissionReturnLogRecordsByEventTypes(userID, page, size,
log.CommissionTypeRefund,
log.CommissionTypeWithdrawReject,
log.CommissionTypeWithdrawCancel,
)
}
func (l *QueryCommissionReturnLogLogic) queryCommissionReturnLogRecordsByEventTypes(userID int64, page, size int, eventTypes ...uint16) ([]commissionReturnLogRecord, int64, error) {
if len(eventTypes) == 0 {
return []commissionReturnLogRecord{}, 0, nil
}
likeClauses := make([]string, 0, len(eventTypes))
args := make([]interface{}, 0, len(eventTypes)+2)
args = append(args, log.TypeCommission.Uint8(), userID)
for _, eventType := range eventTypes {
likeClauses = append(likeClauses, "`content` LIKE ?")
args = append(args, commissionReturnLogEventTypePattern(eventType))
}
query := l.svcCtx.DB.WithContext(l.ctx).
Model(&log.SystemLog{}).
Where("`type` = ? AND object_id = ? AND ("+strings.Join(likeClauses, " OR ")+")", args...)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count commission return logs failed: %v", err)
}
var rows []log.SystemLog
if err := query.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
return nil, 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query commission return logs failed: %v", err)
}
list := make([]commissionReturnLogRecord, 0, len(rows))
for _, row := range rows {
var content log.Commission
if err := content.Unmarshal([]byte(row.Content)); err != nil {
l.Errorw("unmarshal commission return log content failed",
logger.Field("log_id", row.Id),
logger.Field("error", err.Error()),
)
continue
}
if !isCommissionReturnEventType(content.Type) {
continue
}
// content.Timestamp 历史数据为毫秒,按项目约定(统一秒级)转换;为 0 时回退到 row.CreatedAt 秒值
var timestamp int64
if content.Timestamp > 0 {
timestamp = content.Timestamp / 1000
} else {
timestamp = row.CreatedAt.Unix()
}
list = append(list, commissionReturnLogRecord{
LogID: row.Id,
UserID: row.ObjectID,
Amount: content.Amount,
EventType: content.Type,
Content: commissionReturnLogContentText(content.Type, content.OrderNo),
CreatedAt: timestamp,
UpdatedAt: timestamp,
})
}
return list, total, nil
}
func commissionReturnLogEventTypePattern(eventType uint16) string {
return "%\"type\":" + strconv.FormatUint(uint64(eventType), 10) + "%"
}
func isCommissionReturnEventType(eventType uint16) bool {
switch eventType {
case log.CommissionTypeRefund, log.CommissionTypeWithdrawReject, log.CommissionTypeWithdrawCancel:
return true
default:
return false
}
}
// commissionReturnLogContentText 把佣金回退事件的原始 JSON 转成前端友好文字。
// 333 订单退款回佣 - 附订单号便于追溯;337/338 提现相关 - 跟订单无关。
func commissionReturnLogContentText(eventType uint16, orderNo string) string {
switch eventType {
case log.CommissionTypeRefund:
if strings.TrimSpace(orderNo) != "" {
return "订单退款回佣(订单号 " + orderNo + ")"
}
return "订单退款回佣"
case log.CommissionTypeWithdrawReject:
return "提现申请被驳回,佣金已退回"
case log.CommissionTypeWithdrawCancel:
return "已取消提现,佣金已退回"
default:
return "佣金调整"
}
}
@@ -40,14 +40,7 @@ func (l *QueryWithdrawalLogLogic) QueryWithdrawalLog(req *types.QueryWithdrawalL
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
page := req.Page
size := req.Size
if page <= 0 {
page = 1
}
if size <= 0 {
size = 10
}
page, size := normalizePagination(req.Page, req.Size)
switch req.BizType {
case "", withdrawalLogBizTypeWithdrawal:
@@ -85,58 +78,89 @@ func (l *QueryWithdrawalLogLogic) queryWithdrawalLogs(userID int64, page, size i
Method: row.Method,
Account: row.Account,
QrCodeUrl: row.QrCodeUrl,
CreatedAt: row.CreatedAt.UnixMilli(),
UpdatedAt: row.UpdatedAt.UnixMilli(),
CreatedAt: row.CreatedAt.Unix(),
UpdatedAt: row.UpdatedAt.Unix(),
})
}
summary, err := l.buildSummary(userID)
if err != nil {
return nil, err
}
return &types.QueryWithdrawalLogListResponse{
List: list,
Total: total,
List: list,
Total: total,
Summary: summary,
}, nil
}
func (l *QueryWithdrawalLogLogic) queryCommissionRefundLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
query := l.svcCtx.DB.WithContext(l.ctx).
Model(&log.SystemLog{}).
Where("`type` = ? AND object_id = ? AND `content` LIKE ?", log.TypeCommission.Uint8(), userID, "%\"type\":333%")
// buildSummary 聚合用户佣金账目快照,供前端展示闭环对账
//
// commission_balance = user.commission(当前余额,由 system_logs 累计而来)
// locked_by_pending = 待审批提现占用
// available_to_withdraw = balance - locked
// total_historical_amount = 已通过提现累计(status=1
// total_refunded_amount = 佣金回扣累计(type=333/337/338
// total_income_amount = 佣金收入累计(type=331/332
func (l *QueryWithdrawalLogLogic) buildSummary(userID int64) (*types.WithdrawalLogSummary, error) {
db := l.svcCtx.DB.WithContext(l.ctx)
var summary types.WithdrawalLogSummary
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count commission refund logs failed: %v", err)
if err := db.Model(&user.User{}).Where("id = ?", userID).
Select("COALESCE(commission, 0)").Scan(&summary.CommissionBalance).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load commission balance failed: %v", err)
}
var rows []log.SystemLog
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 commission refund logs failed: %v", err)
if err := db.Model(&user.Withdrawal{}).
Where("user_id = ? AND status = ?", userID, user.WithdrawalStatusPending).
Select("COALESCE(SUM(amount), 0)").Scan(&summary.LockedByPending).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "sum pending withdrawals failed: %v", err)
}
if err := db.Model(&user.Withdrawal{}).
Where("user_id = ? AND status = ?", userID, user.WithdrawalStatusApproved).
Select("COALESCE(SUM(amount), 0)").Scan(&summary.TotalHistoricalAmount).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "sum approved withdrawals failed: %v", err)
}
row := db.Raw(`
SELECT
COALESCE(SUM(CASE WHEN CAST(JSON_EXTRACT(content,'$.type') AS UNSIGNED) IN (?,?)
THEN CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED) ELSE 0 END), 0) AS income,
COALESCE(SUM(CASE WHEN CAST(JSON_EXTRACT(content,'$.type') AS UNSIGNED) IN (?,?,?)
THEN CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED) ELSE 0 END), 0) AS refund
FROM system_logs
WHERE type = ? AND object_id = ?`,
log.CommissionTypePurchase, log.CommissionTypeRenewal,
log.CommissionTypeRefund, log.CommissionTypeWithdrawReject, log.CommissionTypeWithdrawCancel,
log.TypeCommission.Uint8(), userID,
).Row()
if err := row.Scan(&summary.TotalIncomeAmount, &summary.TotalRefundedAmount); err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "aggregate commission logs failed: %v", err)
}
summary.AvailableToWithdraw = summary.CommissionBalance - summary.LockedByPending
return &summary, nil
}
func (l *QueryWithdrawalLogLogic) queryCommissionRefundLogs(userID int64, page, size int) (*types.QueryWithdrawalLogListResponse, error) {
queryLogic := NewQueryCommissionReturnLogLogic(l.ctx, l.svcCtx)
rows, total, err := queryLogic.queryCommissionReturnLogRecordsByEventTypes(userID, page, size, log.CommissionTypeRefund)
if err != nil {
return nil, err
}
list := make([]types.WithdrawalLog, 0, len(rows))
for _, row := range rows {
var content log.Commission
if err := content.Unmarshal([]byte(row.Content)); err != nil {
l.Errorw("unmarshal commission refund log content failed",
logger.Field("log_id", row.Id),
logger.Field("error", err.Error()),
)
continue
}
if content.Type != log.CommissionTypeRefund {
continue
}
timestamp := content.Timestamp
if timestamp == 0 {
timestamp = row.CreatedAt.UnixMilli()
}
list = append(list, types.WithdrawalLog{
Id: row.Id,
Id: row.LogID,
BizType: withdrawalLogBizTypeCommissionRefund,
UserId: row.ObjectID,
Amount: content.Amount,
UserId: row.UserID,
Amount: row.Amount,
Content: row.Content,
CreatedAt: timestamp,
UpdatedAt: timestamp,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
@@ -1,7 +1,9 @@
package user
import (
"bytes"
"context"
"database/sql/driver"
"fmt"
"strings"
"testing"
@@ -38,6 +40,23 @@ func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) {
}).AddRow(
int64(1001), userID, int64(3000), "bank withdrawal", usermodel.WithdrawalStatusPending, "", uint8(3), "acct-001", "", createdAt, updatedAt,
))
// buildSummary 触发的 4 个聚合查询
mock.ExpectQuery("SELECT COALESCE(commission, 0) FROM `user`").
WithArgs(userID).
WillReturnRows(sqlmock.NewRows([]string{"commission"}).AddRow(int64(5000)))
mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`").
WithArgs(userID, usermodel.WithdrawalStatusPending).
WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(3000)))
mock.ExpectQuery("SELECT COALESCE(SUM(amount), 0) FROM `withdrawals`").
WithArgs(userID, usermodel.WithdrawalStatusApproved).
WillReturnRows(sqlmock.NewRows([]string{"sum"}).AddRow(int64(0)))
mock.ExpectQuery("FROM system_logs").
WithArgs(
logmodel.CommissionTypePurchase, logmodel.CommissionTypeRenewal,
logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel,
logmodel.TypeCommission.Uint8(), userID,
).
WillReturnRows(sqlmock.NewRows([]string{"income", "refund"}).AddRow(int64(8000), int64(0)))
logic := newTestQueryWithdrawalLogLogic(t, db, userID)
resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{
@@ -55,29 +74,64 @@ func TestQueryWithdrawalLog_WithWithdrawalBizType(t *testing.T) {
if got.BizType != withdrawalLogBizTypeWithdrawal {
t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeWithdrawal)
}
if got.Id != 1001 || got.UserId != userID || got.Amount != 3000 || got.CreatedAt != createdAt.UnixMilli() || got.UpdatedAt != updatedAt.UnixMilli() {
if got.Id != 1001 || got.UserId != userID || got.Amount != 3000 || got.CreatedAt != createdAt.Unix() || got.UpdatedAt != updatedAt.Unix() {
t.Fatalf("withdrawal item = %+v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
if resp.Summary == nil {
t.Fatalf("expected summary, got nil")
}
if resp.Summary.CommissionBalance != 5000 || resp.Summary.LockedByPending != 3000 ||
resp.Summary.AvailableToWithdraw != 2000 || resp.Summary.TotalIncomeAmount != 8000 {
t.Fatalf("summary = %+v, want balance=5000 locked=3000 avail=2000 income=8000", resp.Summary)
}
assertQueryWithdrawalLogExpectations(t, mock)
}
func TestQueryWithdrawalLog_WithCommissionRefundBizType(t *testing.T) {
func TestQueryCommissionReturnLog_HappyPathIncludes333337338(t *testing.T) {
const userID = int64(42)
content := `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000000123}`
createdAt := time.Unix(1700000000, 0)
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
defer cleanup()
mock.ExpectQuery("SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ? AND `content` LIKE ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND `content` LIKE ? ORDER BY id DESC LIMIT ?").
expectCommissionReturnQueries(mock, userID, 3, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
AddRow(int64(2003), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":338,"amount":1500,"order_no":"ORDER-3","timestamp":1700000003123}`, createdAt).
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt))
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
if err != nil {
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
}
if resp.Total != 3 || len(resp.List) != 3 {
t.Fatalf("QueryCommissionReturnLog response = %+v, want three logs", resp)
}
eventTypes := []uint16{resp.List[0].EventType, resp.List[1].EventType, resp.List[2].EventType}
wantTypes := []uint16{logmodel.CommissionTypeWithdrawCancel, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeRefund}
if fmt.Sprint(eventTypes) != fmt.Sprint(wantTypes) {
t.Fatalf("event types = %v, want %v", eventTypes, wantTypes)
}
assertQueryWithdrawalLogExpectations(t, mock)
}
func TestQueryWithdrawalLog_WithCommissionRefundBizTypeOnlyIncludes333(t *testing.T) {
const userID = int64(42)
createdAt := time.Unix(1700000000, 0)
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
defer cleanup()
expectCommissionReturnQueries(mock, userID, 1, logmodel.CommissionTypeRefund)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, content, createdAt))
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333,"amount":2500,"order_no":"ORDER-1","timestamp":1700000001123}`, createdAt))
logic := newTestQueryWithdrawalLogLogic(t, db, userID)
resp, err := logic.QueryWithdrawalLog(&types.QueryWithdrawalLogListRequest{
@@ -91,19 +145,72 @@ func TestQueryWithdrawalLog_WithCommissionRefundBizType(t *testing.T) {
if resp.Total != 1 || len(resp.List) != 1 {
t.Fatalf("QueryWithdrawalLog response = %+v, want one commission refund", resp)
}
got := resp.List[0]
if got.BizType != withdrawalLogBizTypeCommissionRefund {
t.Fatalf("BizType = %q, want %q", got.BizType, withdrawalLogBizTypeCommissionRefund)
for _, item := range resp.List {
if item.BizType != withdrawalLogBizTypeCommissionRefund {
t.Fatalf("BizType = %q, want %q", item.BizType, withdrawalLogBizTypeCommissionRefund)
}
if item.Status != 0 || item.Reason != "" || item.Method != 0 || item.Account != "" || item.QrCodeUrl != "" {
t.Fatalf("withdrawal-only fields should keep zero values, got %+v", item)
}
}
if got.Id != 2001 || got.UserId != userID || got.Amount != 2500 || got.Content != content || got.CreatedAt != 1700000000123 || got.UpdatedAt != 1700000000123 {
t.Fatalf("commission refund item = %+v", got)
if resp.List[0].Id != 2001 {
t.Fatalf("commission refund item id = %d, want 2001", resp.List[0].Id)
}
if got.Status != 0 || got.Method != 0 || got.Account != "" || got.QrCodeUrl != "" {
t.Fatalf("commission refund withdrawal-only fields = %+v, want zero values", got)
assertQueryWithdrawalLogExpectations(t, mock)
}
func TestQueryCommissionReturnLog_SkipsInvalidJSONAndLogsWarn(t *testing.T) {
const userID = int64(42)
createdAt := time.Unix(1700000000, 0)
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
defer cleanup()
expectCommissionReturnQueries(mock, userID, 2, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}).
AddRow(int64(2002), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":337,"amount":2000,"order_no":"ORDER-2","timestamp":1700000002123}`, createdAt).
AddRow(int64(2001), logmodel.TypeCommission.Uint8(), "2023-11-14", userID, `{"type":333`, createdAt))
var buf bytes.Buffer
restoreLogger := captureTestLogs(&buf)
defer restoreLogger()
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
if err != nil {
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
if resp.Total != 2 || len(resp.List) != 1 {
t.Fatalf("QueryCommissionReturnLog response = %+v, want total=2 and one valid row", resp)
}
if !strings.Contains(buf.String(), "unmarshal commission return log content failed") {
t.Fatalf("expected warn log, got %q", buf.String())
}
assertQueryWithdrawalLogExpectations(t, mock)
}
func TestQueryCommissionReturnLog_FiltersOtherUsersByObjectID(t *testing.T) {
const userID = int64(42)
db, mock, cleanup := newQueryWithdrawalLogTestDB(t)
defer cleanup()
expectCommissionReturnQueries(mock, userID, 0, logmodel.CommissionTypeRefund, logmodel.CommissionTypeWithdrawReject, logmodel.CommissionTypeWithdrawCancel)
mock.ExpectQuery("SELECT * FROM `system_logs` WHERE `type` = ? AND object_id = ? AND (`content` LIKE ? OR `content` LIKE ? OR `content` LIKE ?) ORDER BY id DESC LIMIT ?").
WithArgs(logmodel.TypeCommission.Uint8(), userID, "%\"type\":333%", "%\"type\":337%", "%\"type\":338%", 10).
WillReturnRows(sqlmock.NewRows([]string{"id", "type", "date", "object_id", "content", "created_at"}))
logic := NewQueryCommissionReturnLogLogic(newTestQueryCtx(userID), &svc.ServiceContext{DB: db})
resp, err := logic.QueryCommissionReturnLog(&types.QueryCommissionReturnLogRequest{Page: 1, Size: 10})
if err != nil {
t.Fatalf("QueryCommissionReturnLog unexpected error: %v", err)
}
if resp.Total != 0 || len(resp.List) != 0 {
t.Fatalf("QueryCommissionReturnLog response = %+v, want no rows for filtered user", resp)
}
assertQueryWithdrawalLogExpectations(t, mock)
}
func TestQueryWithdrawalLog_RejectsInvalidBizType(t *testing.T) {
@@ -119,9 +226,7 @@ func TestQueryWithdrawalLog_RejectsInvalidBizType(t *testing.T) {
if !isQueryWithdrawalLogErrCode(err, xerr.InvalidParams) {
t.Fatalf("QueryWithdrawalLog err = %v, want InvalidParams", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
assertQueryWithdrawalLogExpectations(t, mock)
}
func newQueryWithdrawalLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
@@ -150,7 +255,7 @@ func newQueryWithdrawalLogTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func(
func newTestQueryWithdrawalLogLogic(t *testing.T, db *gorm.DB, userID int64) *QueryWithdrawalLogLogic {
t.Helper()
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
ctx := newTestQueryCtx(userID)
return &QueryWithdrawalLogLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
@@ -160,6 +265,47 @@ func newTestQueryWithdrawalLogLogic(t *testing.T, db *gorm.DB, userID int64) *Qu
}
}
func newTestQueryCtx(userID int64) context.Context {
return context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
}
func expectCommissionReturnQueries(mock sqlmock.Sqlmock, userID int64, total int64, eventTypes ...uint16) {
query := "SELECT count(*) FROM `system_logs` WHERE `type` = ? AND object_id = ?"
args := []driver.Value{logmodel.TypeCommission.Uint8(), userID}
if len(eventTypes) > 0 {
clauses := make([]string, 0, len(eventTypes))
for _, eventType := range eventTypes {
clauses = append(clauses, "`content` LIKE ?")
args = append(args, fmt.Sprintf("%%\"type\":%d%%", eventType))
}
query += " AND (" + strings.Join(clauses, " OR ") + ")"
}
mock.ExpectQuery(query).
WithArgs(args...).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(total))
}
func assertQueryWithdrawalLogExpectations(t *testing.T, mock sqlmock.Sqlmock) {
t.Helper()
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func captureTestLogs(buf *bytes.Buffer) func() {
prevWriter := logger.Reset()
prevLevel := logger.InfoLevel
logger.SetLevel(logger.DebugLevel)
logger.SetWriter(logger.NewWriter(buf))
return func() {
logger.Reset()
logger.SetLevel(prevLevel)
if prevWriter != nil {
logger.SetWriter(prevWriter)
}
}
}
func queryWithdrawalLogErrCodeOf(err error) uint32 {
if err == nil {
return 0
@@ -258,13 +258,13 @@ func (l *GetServerUserListLogic) GetServerUserList(req *types.GetServerUserListR
}
}
// 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户
// 处理过期订阅用户:如果当前节点属于过期节点组,添加符合条件的过期用户
// 用户级 speed_limit (user_subscribe.speed_limit) 与过期节点组 speed_limit
// 取更严格的一个 — 0 视为"无限制",正值优先于 0。
if len(nodeGroupIds) > 0 {
expiredUsers, expiredSpeedLimit := l.getExpiredUsers(nodeGroupIds)
for i := range expiredUsers {
if expiredSpeedLimit > 0 {
expiredUsers[i].SpeedLimit = expiredSpeedLimit
}
expiredUsers[i].SpeedLimit = mergeSpeedLimit(expiredUsers[i].SpeedLimit, expiredSpeedLimit)
}
users = append(users, expiredUsers...)
}
@@ -369,14 +369,34 @@ func (l *GetServerUserListLogic) getExpiredUsers(serverNodeGroupIds []int64) ([]
}
seen[userSub.Id] = true
users = append(users, types.ServerUser{
Id: userSub.Id,
UUID: userSub.UUID,
Id: userSub.Id,
UUID: userSub.UUID,
SpeedLimit: userSub.SpeedLimit,
})
}
return users, int64(expiredGroup.SpeedLimit)
}
// mergeSpeedLimit 返回两个速度限制(Mbps)中更严格的一个。
// 0 视为"无限制",因此会被任意正值覆盖;都为 0 时返回 0。
// 用于用户级 speed_limit 与节点组级 speed_limit 的合并:
// - both 0 → 0 (不限速)
// - 仅一个 > 0 → 取该值
// - both > 0 → 取较小者(更严格)
func mergeSpeedLimit(a, b int64) int64 {
if a <= 0 {
return b
}
if b <= 0 {
return a
}
if a < b {
return a
}
return b
}
func (l *GetServerUserListLogic) checkExpiredUserEligibility(userSub *user.Subscribe, expiredGroup *group.NodeGroup) bool {
expiredDays := int(time.Since(userSub.ExpireTime).Hours() / 24)
if expiredDays > expiredGroup.ExpiredDaysLimit {
@@ -338,3 +338,32 @@ func httptestNewRequest() *http.Request {
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/v1/server/user", nil)
return req
}
// TestMergeSpeedLimit 验证用户级 speed_limit 与过期节点组 speed_limit 合并规则:
// 0 = 不限制 (loses),正值优先;都为正取较小者(更严格)。
// 这是 user-dimension 限速在过期节点组分支下能生效的关键。
func TestMergeSpeedLimit(t *testing.T) {
cases := []struct {
name string
a int64
b int64
want int64
}{
{"both zero stays zero", 0, 0, 0},
{"a positive b zero takes a", 30, 0, 30},
{"a zero b positive takes b", 0, 50, 50},
{"a negative treated as zero takes b", -1, 50, 50},
{"b negative treated as zero takes a", 50, -1, 50},
{"both positive takes smaller (a<b)", 20, 50, 20},
{"both positive takes smaller (b<a)", 80, 30, 30},
{"equal positive returns same value", 25, 25, 25},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := mergeSpeedLimit(tc.a, tc.b); got != tc.want {
t.Fatalf("mergeSpeedLimit(%d, %d) = %d, want %d", tc.a, tc.b, got, tc.want)
}
})
}
}
+4 -1
View File
@@ -16,7 +16,10 @@ type NodeGroup struct {
IsExpiredGroup *bool `gorm:"default:false;not null;index:idx_is_expired_group;comment:Is Expired Group"`
ExpiredDaysLimit int `gorm:"default:7;not null;comment:Expired days limit (days)"`
MaxTrafficGBExpired *int64 `gorm:"default:0;comment:Max traffic for expired users (GB)"`
SpeedLimit int `gorm:"default:0;not null;comment:Speed limit (KB/s)"`
// SpeedLimit: 过期节点组对其内用户施加的速度上限。
// 实际下发节点的字段为 ServerUser.SpeedLimit (Mbps),二者直接透传,无单位换算。
// 注:早期 schema 注释写作 KB/s 系笔误,真实语义与 user_subscribe.speed_limit / subscribe.speed_limit 一致,均为 Mbps。
SpeedLimit int `gorm:"default:0;not null;comment:Speed limit (Mbps); 0 means no limit"`
MinTrafficGB *int64 `gorm:"default:0;comment:Minimum Traffic (GB) for this node group"`
MaxTrafficGB *int64 `gorm:"default:0;comment:Maximum Traffic (GB) for this node group"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
+16 -16
View File
@@ -162,8 +162,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 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",
"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",
).
Scan(v).Error
})
@@ -179,8 +179,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 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",
"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",
).
Scan(v).Error
})
@@ -194,8 +194,8 @@ func (m *customOrderModel) QueryTotalOrders(ctx context.Context) (OrdersTotal, e
return conn.Model(&Order{}).
Select(`
SUM(amount) AS amount_total,
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
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
`).
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
Scan(&result).Error
@@ -216,8 +216,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 is_new = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
`).
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
[]int64{2, 5}, firstDay, nextMonth, "balance").
@@ -234,8 +234,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 is_new = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
`).
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?",
[]int64{2, 5}, dateStr, "balance").
@@ -251,8 +251,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 is_new = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
`).
Scan(&counts).Error
})
@@ -284,8 +284,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 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
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
`).
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
[]int64{2, 5}, firstDay, nextDay, "balance").
@@ -328,8 +328,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 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
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
`).
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
[]int64{2, 5}, start, end, "balance").
+32 -2
View File
@@ -309,6 +309,16 @@ type CommissionLog struct {
Timestamp int64 `json:"timestamp"`
}
type CommissionReturnLog struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
Amount int64 `json:"amount"`
EventType uint16 `json:"event_type"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type CommissionWithdrawRequest struct {
Amount int64 `json:"amount"`
Content string `json:"content"`
@@ -2376,6 +2386,16 @@ type QueryAnnouncementResponse struct {
List []Announcement `json:"announcements"`
}
type QueryCommissionReturnLogRequest struct {
Page int `form:"page"`
Size int `form:"size"`
}
type QueryCommissionReturnLogResponse struct {
List []CommissionReturnLog `json:"list"`
Total int64 `json:"total"`
}
type QueryDocumentDetailRequest struct {
Id int64 `form:"id" validate:"required"`
}
@@ -2545,9 +2565,19 @@ type QueryWithdrawalLogListRequest struct {
BizType string `form:"biz_type" validate:"omitempty,oneof=withdrawal commission_refund"`
}
type WithdrawalLogSummary struct {
CommissionBalance int64 `json:"commission_balance"`
LockedByPending int64 `json:"locked_by_pending"`
AvailableToWithdraw int64 `json:"available_to_withdraw"`
TotalHistoricalAmount int64 `json:"total_historical_amount"`
TotalRefundedAmount int64 `json:"total_refunded_amount"`
TotalIncomeAmount int64 `json:"total_income_amount"`
}
type QueryWithdrawalLogListResponse struct {
List []WithdrawalLog `json:"list"`
Total int64 `json:"total"`
List []WithdrawalLog `json:"list"`
Total int64 `json:"total"`
Summary *WithdrawalLogSummary `json:"summary,omitempty"`
}
type QuotaTask struct {
+2
View File
@@ -0,0 +1,2 @@
*.csv
*.tsv
+195
View File
@@ -0,0 +1,195 @@
-- ============================================================================
-- migration_v3.sql
-- 提现迁移 + 佣金余额闭环修复
-- ============================================================================
--
-- 目的:
-- 1. 把 ticket(提现工单) 表里 status=4 的 185 条记录全量重迁到 withdrawals 表
-- - 删除 5 条历史错迁的 status=3 (取消/拒绝) 单
-- - 补迁 8 条历史漏迁的 status=4 (已通过) 单
-- 2. 修正 13 个 user.commission 字段,使其等于 SUM(全部 type=33 日志)
-- 3. content 写入 '历史提现 #<ticket_id>',支持反查 ticket 源头
--
-- 执行方式(注意 --default-character-set 必须显式声明):
-- docker exec -i ppanel-mysql mysql --default-character-set=utf8mb4 \
-- -uroot -pppanel_dev ppanel < migration_v3.sql
--
-- 回滚方式(假设 COMMIT 之后想恢复):
-- TRUNCATE TABLE withdrawals;
-- INSERT INTO withdrawals SELECT * FROM withdrawals_backup_v3;
-- UPDATE user u JOIN user_commission_backup_v3 b ON b.id=u.id SET u.commission = b.commission;
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 阶段 0: 备份(在事务外执行,即使后续 ROLLBACK 备份也保留)
-- ----------------------------------------------------------------------------
DROP TABLE IF EXISTS withdrawals_backup_v3;
CREATE TABLE withdrawals_backup_v3 AS SELECT * FROM withdrawals;
DROP TABLE IF EXISTS user_commission_backup_v3;
CREATE TABLE user_commission_backup_v3 AS
SELECT id, commission, NOW() AS backup_at FROM user WHERE commission <> 0;
SELECT 'backup_done' AS step,
(SELECT COUNT(*) FROM withdrawals_backup_v3) AS withdrawals_rows,
(SELECT COUNT(*) FROM user_commission_backup_v3) AS user_commission_rows;
-- ----------------------------------------------------------------------------
-- 阶段 1: 事务开始
-- ----------------------------------------------------------------------------
START TRANSACTION;
-- ----------------------------------------------------------------------------
-- 阶段 2: 清空 withdrawals 表
-- ----------------------------------------------------------------------------
TRUNCATE TABLE withdrawals;
-- ----------------------------------------------------------------------------
-- 阶段 3: 从 ticket 表全量重迁(严格只迁 status=4)
-- 中文字面量统一 COLLATE utf8mb4_general_ci 跟 ticket 表一致
-- ----------------------------------------------------------------------------
INSERT INTO withdrawals (
user_id, amount, content, status, reason,
method, account, qr_code_url, created_at, updated_at
)
SELECT
t.user_id,
ROUND(CAST(TRIM(REPLACE(REPLACE(t.title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''),
_utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) AS DECIMAL(18,2)) * 100) AS amount_cents,
CONCAT(_utf8mb4'历史提现 #' COLLATE utf8mb4_0900_ai_ci, t.id) AS content,
1 AS status,
'' AS reason,
CASE
WHEN t.description LIKE _utf8mb4'支付宝%' COLLATE utf8mb4_general_ci THEN 1
WHEN t.description LIKE _utf8mb4'微信%' COLLATE utf8mb4_general_ci THEN 2
WHEN UPPER(t.description) LIKE 'USDT%' OR UPPER(t.description) LIKE 'TRC20%' THEN 3
ELSE 0
END AS method,
CASE
WHEN UPPER(t.description) LIKE 'USDT(TRC20)-%'
THEN TRIM(SUBSTRING(t.description, LOCATE('-', t.description) + 1))
WHEN UPPER(t.description) LIKE 'USDT%' AND LOCATE('-', t.description) > 0
THEN TRIM(SUBSTRING(t.description, LOCATE('-', t.description) + 1))
WHEN t.description LIKE _utf8mb4'支付宝-data:image%' COLLATE utf8mb4_general_ci THEN _utf8mb4'支付宝收款码' COLLATE utf8mb4_0900_ai_ci
WHEN t.description LIKE _utf8mb4'微信-data:image%' COLLATE utf8mb4_general_ci THEN _utf8mb4'微信收款码' COLLATE utf8mb4_0900_ai_ci
WHEN CHAR_LENGTH(t.description) > 255 THEN LEFT(t.description, 255)
ELSE t.description
END AS account,
'' AS qr_code_url,
t.created_at,
t.updated_at
FROM ticket t
WHERE t.title LIKE _utf8mb4'提现%' COLLATE utf8mb4_general_ci
AND t.status = 4
AND TRIM(REPLACE(REPLACE(t.title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''),
_utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) REGEXP '^[0-9]+([.][0-9]+)?$'
AND ROUND(CAST(TRIM(REPLACE(REPLACE(t.title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''),
_utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) AS DECIMAL(18,2)) * 100) > 0
ORDER BY t.id;
-- ----------------------------------------------------------------------------
-- 阶段 4: 修正用户的 commission 字段,使其等于 SUM(type=33 日志)
-- ----------------------------------------------------------------------------
UPDATE user u
JOIN (
SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) AS log_sum
FROM system_logs
WHERE type = 33
GROUP BY object_id
) s ON s.object_id = u.id
SET u.commission = s.log_sum
WHERE u.commission <> s.log_sum;
-- ----------------------------------------------------------------------------
-- 阶段 4b: 处理"前日志时代"账号 — 有 commission 余额但 system_logs 完全没记录
-- 补一条 type=335 admin_adjust 日志记录历史基准余额,让账目闭环
-- content 标记 source=pre_log_baseline 便于追溯
-- ----------------------------------------------------------------------------
INSERT INTO system_logs (type, date, object_id, content, created_at)
SELECT 33,
DATE_FORMAT(u.created_at, '%Y-%m-%d'),
u.id,
JSON_OBJECT(
'type', 335,
'amount', u.commission,
'order_no', '',
'timestamp', UNIX_TIMESTAMP(u.created_at)*1000,
'source', 'pre_log_baseline'
),
u.created_at
FROM user u
WHERE u.commission <> 0
AND NOT EXISTS (
SELECT 1 FROM system_logs s WHERE s.type=33 AND s.object_id=u.id
);
-- ----------------------------------------------------------------------------
-- 阶段 5: 验证(任何一项 verdict=FAIL 都应 ROLLBACK)
-- ----------------------------------------------------------------------------
-- 5a. withdrawals 总数 = ticket status=4 数,且都 > 0
SELECT
'5a_count_match' AS check_name,
(SELECT COUNT(*) FROM ticket WHERE title LIKE _utf8mb4'提现%' COLLATE utf8mb4_general_ci AND status=4
AND TRIM(REPLACE(REPLACE(title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''),
_utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) REGEXP '^[0-9]+([.][0-9]+)?$') AS expected,
(SELECT COUNT(*) FROM withdrawals) AS actual,
IF(
(SELECT COUNT(*) FROM withdrawals) > 0
AND (SELECT COUNT(*) FROM ticket WHERE title LIKE _utf8mb4'提现%' COLLATE utf8mb4_general_ci AND status=4
AND TRIM(REPLACE(REPLACE(title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''),
_utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) REGEXP '^[0-9]+([.][0-9]+)?$')
= (SELECT COUNT(*) FROM withdrawals),
'PASS', 'FAIL'
) AS verdict;
-- 5b. 没有 amount<=0
SELECT '5b_amount_positive' AS check_name,
0 AS expected,
(SELECT COUNT(*) FROM withdrawals WHERE amount <= 0) AS actual,
IF((SELECT COUNT(*) FROM withdrawals WHERE amount <= 0) = 0, 'PASS', 'FAIL') AS verdict;
-- 5c. content 必须形如 '历史提现 #<digits>'
SELECT '5c_content_format' AS check_name,
0 AS expected,
(SELECT COUNT(*) FROM withdrawals
WHERE content NOT REGEXP CONCAT('^', _utf8mb4'历史提现 #' COLLATE utf8mb4_0900_ai_ci, '[0-9]+$')) AS actual,
IF((SELECT COUNT(*) FROM withdrawals
WHERE content NOT REGEXP CONCAT('^', _utf8mb4'历史提现 #' COLLATE utf8mb4_0900_ai_ci, '[0-9]+$')) = 0,
'PASS', 'FAIL') AS verdict;
-- 5d. user.commission == SUM(type=33 logs) 全用户闭环
SELECT '5d_balance_closure' AS check_name,
0 AS expected,
(SELECT COUNT(*) FROM user u
LEFT JOIN (SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) amt
FROM system_logs WHERE type=33 GROUP BY object_id) s ON s.object_id=u.id
WHERE u.commission <> COALESCE(s.amt, 0)) AS actual,
IF((SELECT COUNT(*) FROM user u
LEFT JOIN (SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) amt
FROM system_logs WHERE type=33 GROUP BY object_id) s ON s.object_id=u.id
WHERE u.commission <> COALESCE(s.amt, 0)) = 0, 'PASS', 'FAIL') AS verdict;
-- 5e. 31742 闭环具体值核对
SELECT '5e_user_31742' AS check_name,
(SELECT COALESCE(SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)),0)
FROM system_logs WHERE type=33 AND object_id=31742) AS expected_from_logs,
(SELECT commission FROM user WHERE id=31742) AS actual_balance,
(SELECT COUNT(*) FROM withdrawals WHERE user_id=31742) AS withdrawals_count;
-- ----------------------------------------------------------------------------
-- 阶段 6: 提交事务
-- 检查上面所有 verdict 是 PASS 后才 COMMIT,否则 ROLLBACK
-- ----------------------------------------------------------------------------
COMMIT;
-- ----------------------------------------------------------------------------
-- 阶段 7: 事后摘要
-- ----------------------------------------------------------------------------
SELECT 'final_summary' AS section,
(SELECT COUNT(*) FROM withdrawals) AS withdrawals_count,
(SELECT SUM(amount) FROM withdrawals WHERE status=1) AS total_amount_cents,
(SELECT COUNT(*) FROM user u
LEFT JOIN (SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) amt
FROM system_logs WHERE type=33 GROUP BY object_id) s ON s.object_id=u.id
WHERE u.commission <> COALESCE(s.amt, 0)) AS imbalanced_users_after_fix;
+57
View File
@@ -0,0 +1,57 @@
## 关联 Issue
Closes HIF-21
## 改动摘要
禁止 `PUT /v1/admin/order/status` 通过通用改状态入口把订单写成 `status=6`claimed)或 `status=7`refunded)。
这两个状态分别保留给激活 worker 临时 claim 流程和专用退款流程,避免后台或旧前端绕过退款副作用只改订单状态。
## 改动细节
- `internal/logic/admin/order/updateOrderStatusLogic.go`:在入口校验中同时拒绝 `status=6``status=7`,统一返回 `OrderStatusError`
- `internal/logic/admin/order/updateOrderStatusLogic_test.go`:扩充单测,覆盖通用改状态接口对 `claimed` / `refunded` 两个保留状态的拒绝行为。
## 测试计划
- [x] `go build ./...` 通过
- [x] `go vet ./...` 通过
- [ ] `go test -race ./... -count=1` 通过
- [ ] golangci-lint 通过
- [x] 新增/修改的逻辑有对应单测覆盖
- [ ] (如涉及 DB 变更)migration up/down 双向验证
- [ ] (如涉及 APIcurl / Postman 验证命令贴在下面
<!-- 贴 curl 或测试输出 -->
```bash
go test ./internal/logic/admin/order/...
ok github.com/perfect-panel/server/internal/logic/admin/order 4.224s
go test ./...
# 业务相关包通过;tests/acceptance 因 staging https://tapi.hifast.biz 请求超时失败
# FAIL: TestPublicSmoke/error_path_missing_auth
# GET https://tapi.hifast.biz/v1/public/user/info: context deadline exceeded
golangci-lint run
# 当前基线存在与本次改动无关的历史问题,例如:
# pkg/updater/updater.go:301 errcheck
# internal/logic/admin/group/exportGroupResultLogic.go:110 errcheck
# pkg/proc/shutdown.go:19 unused
```
## 风险 / 回滚
- 风险较低:仅收紧 admin 通用改状态接口的允许输入,不改退款逻辑、不改队列状态流转。
- 若需回滚,直接回退本 PR 即可;不会涉及数据迁移或额外清理。
## Reviewer 自检清单
- [x] PR 标题符合 commitlint 规范(`修复/新功能/重构/文档/配置(#<num>): ...`
- [x] 分支命名 `fix/<num>-…` / `feat/<num>-…` / `chore/…`
- [x] 目标分支 = `internal`
- [x] 改动 scope 与 Issue 描述一致,无 scope creep
- [x] **无无关代码改动**(架构师红线)
- [x] 无密钥/凭证泄露
- [ ] CI 全绿
- [ ] 测试工程师已验收(如涉及业务逻辑)
+4 -3
View File
@@ -10,9 +10,11 @@ go test ./tests/acceptance/... -staging-url=https://tapi.hifast.biz
Without credentials, tests that require admin/user login or NodeSecret are skipped and unauthenticated error-path checks still run.
## Required secrets
## Workflow configuration
The staging workflow should provide these values through GitHub Actions secrets or environment variables:
`STAGING_BASE_URL` should be provided through GitHub Actions secrets or variables. When omitted, the workflow falls back to `https://tapi.hifast.biz`.
The following secrets are optional. When any of them are missing, the workflow still runs and the corresponding acceptance cases are skipped automatically:
- `ACCEPTANCE_ADMIN_EMAIL`
- `ACCEPTANCE_ADMIN_PASSWORD`
@@ -24,7 +26,6 @@ The staging workflow should provide these values through GitHub Actions secrets
- `STAGING_DB_NAME`
- `STAGING_REDIS_ADDR`
- `STAGING_REDIS_PASSWORD`
- `STAGING_BASE_URL`
`ACCEPTANCE_NODE_SECRET` is optional. When DB credentials are present, the loader reads NodeSecret from the staging `system` table instead of storing it as a long-lived secret.