diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000000..4c9b660a242e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,95 @@ +name: Build & Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + docker: + name: Build and push Docker image to GHCR + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ghcr.io/${{ github.repository }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Build & Deploy to VPS + runs-on: ubuntu-latest + needs: docker + if: ${{ vars.VPS_HOST != '' }} + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Build frontend + run: | + cd web/default + bun install --frozen-lockfile + bun run build + cd ../.. + + - name: Build Go binary + run: | + GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o new-api . + + - name: Package artifact + run: tar -czf deploy.tar.gz new-api web/default/dist + + - name: Copy to VPS + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ vars.VPS_HOST }} + username: ${{ vars.VPS_USER || 'root' }} + password: ${{ secrets.VPS_PASSWORD }} + source: deploy.tar.gz + target: /tmp + + - name: Deploy and restart + uses: appleboy/ssh-action@v1.2.2 + with: + host: ${{ vars.VPS_HOST }} + username: ${{ vars.VPS_USER || 'root' }} + password: ${{ secrets.VPS_PASSWORD }} + script: | + set -e + cd /root/new-api + git pull origin main || true + tar -xzf /tmp/deploy.tar.gz + systemctl restart new-api + rm /tmp/deploy.tar.gz + echo "Deploy completed at $(date)" diff --git a/.gitignore b/.gitignore index dc328dd6c80c..748a988ce69a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ upload build *.db-journal logs +*.log web/dist web/node_modules .env @@ -31,6 +32,7 @@ electron/dist .gocache-temp .gopath .test +vendor/ token_estimator_test.go skills-lock.json .playwright-mcp diff --git a/controller/channel-test.go b/controller/channel-test.go index f494af0431f6..d01d4cb458d9 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -903,6 +903,7 @@ func TestChannel(c *gin.Context) { go channel.UpdateResponseTime(milliseconds) consumedTime := float64(milliseconds) / 1000.0 if result.newAPIError != nil { + recordChannelStatusProbe(channel, false, milliseconds, result.newAPIError.Error()) c.JSON(http.StatusOK, gin.H{ "success": false, "message": result.newAPIError.Error(), @@ -911,6 +912,7 @@ func TestChannel(c *gin.Context) { }) return } + recordChannelStatusProbe(channel, true, milliseconds, "") c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -979,8 +981,10 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse if newAPIError == nil { summary.Succeeded++ + recordChannelStatusProbe(channel, true, milliseconds, "") } else { summary.Failed++ + recordChannelStatusProbe(channel, false, milliseconds, newAPIError.Error()) } // disable channel diff --git a/controller/channel_status_probe.go b/controller/channel_status_probe.go new file mode 100644 index 000000000000..43aa1dc7d0c1 --- /dev/null +++ b/controller/channel_status_probe.go @@ -0,0 +1,120 @@ +package controller + +import ( + "net" + "net/url" + "strings" + "time" + + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" +) + +// recordChannelStatusProbe 在渠道测试完成后追加一条 probe log。 +// +// 该函数保持异步、幂等:任何错误不影响原有测试流程。仅当该渠道所属分组在 +// status_page.groups 且 enabled=true 时写入,避免无谓 IO。 +func recordChannelStatusProbe(channel *model.Channel, success bool, latencyMs int64, errMessage string) { + if channel == nil { + return + } + groups := channel.GetGroups() + if !anyStatusPageGroupEnabled(groups) { + return + } + + setting := operation_setting.GetStatusPageSetting() + level := model.ChannelStatusProbeLevelFail + if success { + if setting.DegradedLatencyMs > 0 && latencyMs >= int64(setting.DegradedLatencyMs) { + level = model.ChannelStatusProbeLevelDegraded + } else { + level = model.ChannelStatusProbeLevelOK + } + } + + pingMs := 0 + if setting.EnablePingProbe { + if ms, ok := probeBaseURLPing(channel.GetBaseURL(), setting.PingProbeTimeoutMs); ok { + pingMs = ms + } + } + + // 明确不写入密钥、上游错误详情等敏感数据;仅保留一个短提示 + safeMessage := sanitizeProbeMessage(errMessage) + + go model.AppendChannelStatusProbeLog( + channel.Id, + success, + level, + int(latencyMs), + pingMs, + safeMessage, + ) +} + +func anyStatusPageGroupEnabled(groups []string) bool { + for _, g := range groups { + if operation_setting.IsStatusPageGroupEnabled(g) { + return true + } + } + return false +} + +// probeBaseURLPing 对 base URL 的 host:port 做一次短超时 TCP 拨号,作为连通延迟。 +// +// 不发起 HEAD 请求以避免命中鉴权/CDN 逻辑;仅测试 TCP 连接握手时间。 +// 返回值单位为毫秒,最小 1(避免与「无数据」的 0 语义冲突)。 +func probeBaseURLPing(baseURL string, timeoutMs int) (int, bool) { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return 0, false + } + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Host == "" { + return 0, false + } + host := parsed.Host + if !strings.Contains(host, ":") { + if parsed.Scheme == "http" { + host = host + ":80" + } else { + host = host + ":443" + } + } + if timeoutMs <= 0 { + timeoutMs = operation_setting.StatusPageDefaultPingProbeTimeoutMs + } + dialer := net.Dialer{Timeout: time.Duration(timeoutMs) * time.Millisecond} + start := time.Now() + conn, err := dialer.Dial("tcp", host) + if err != nil { + return 0, false + } + _ = conn.Close() + elapsed := int(time.Since(start).Milliseconds()) + if elapsed <= 0 { + elapsed = 1 + } + return elapsed, true +} + +// sanitizeProbeMessage 只保留错误类型的短描述,禁止携带密钥/URL 参数 +func sanitizeProbeMessage(msg string) string { + msg = strings.TrimSpace(msg) + if msg == "" { + return "" + } + // 单行化,去掉可能包含的敏感 header/URL + msg = strings.ReplaceAll(msg, "\n", " ") + msg = strings.ReplaceAll(msg, "\r", " ") + if len(msg) > 200 { + runes := []rune(msg) + if len(runes) > 200 { + msg = string(runes[:200]) + } + } + return msg +} + diff --git a/controller/commission.go b/controller/commission.go new file mode 100644 index 000000000000..a2e3e1d5b0a3 --- /dev/null +++ b/controller/commission.go @@ -0,0 +1,596 @@ +package controller + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// ============================================================================ +// User-facing commission endpoints +// ============================================================================ + +// GetCommissionWallet returns the user's commission wallet info +func GetCommissionWallet(c *gin.Context) { + userId := c.GetInt("id") + + wallet, err := model.GetCommissionWallet(userId) + if err != nil { + common.SysError("failed to get commission wallet: " + err.Error()) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取佣金钱包失败", + }) + return + } + + monthlyEarned, totalEarned, err := model.GetUserCommissionStats(userId) + if err != nil { + monthlyEarned = 0 + totalEarned = wallet.TotalEarned + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "balance": wallet.Balance, + "total_earned": totalEarned, + "total_withdrawn": wallet.TotalWithdrawn, + "monthly_earned": monthlyEarned, + }, + }) +} + +// GetCommissionTierInfo returns the user's current tier info +func GetCommissionTierInfo(c *gin.Context) { + userId := c.GetInt("id") + + rate, activeCount, nextMin, nextRate, err := model.GetNextTierInfo(userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取阶梯信息失败", + }) + return + } + + var needForNext int + if nextMin > 0 { + needForNext = nextMin - activeCount + if needForNext < 0 { + needForNext = 0 + } + } + + // Get all tiers for progress display + config := model.GetDefaultCommissionConfig() + if v, ok := common.OptionMap[model.CommissionConfigKey]; ok && v != "" { + parsed := &model.CommissionConfig{} + if err := common.UnmarshalJsonStr(v, parsed); err == nil { + config = parsed + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "current_rate": rate, + "active_count": activeCount, + "next_min_users": nextMin, + "next_rate": nextRate, + "need_for_next": needForNext, + "tiers": config.Tiers, + "default_rate": config.DefaultRate, + }, + }) +} + +// GetCommissionRecords returns the user's commission records +func GetCommissionRecords(c *gin.Context) { + userId := c.GetInt("id") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + status := c.Query("status") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + records, total, err := model.GetUserCommissionRecords(userId, page, pageSize, status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取佣金记录失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "records": records, + "total": total, + "page": page, + "size": pageSize, + }, + }) +} + +// TransferCommissionToBalance transfers commission to main balance +func TransferCommissionToBalance(c *gin.Context) { + userId := c.GetInt("id") + + var req struct { + Amount float64 `json:"amount" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + if req.Amount <= 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "划转金额必须大于0", + }) + return + } + + if err := model.TransferCommissionToBalance(userId, req.Amount); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "划转成功", + }) +} + +// CreateWithdrawalRequest creates a withdrawal request +func CreateWithdrawalRequest(c *gin.Context) { + userId := c.GetInt("id") + + var req struct { + Amount float64 `json:"amount" binding:"required"` + PayInfo string `json:"pay_info" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + if err := model.CreateWithdrawalRequest(userId, req.Amount, req.PayInfo); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "提现申请已提交,请等待管理员审核", + }) +} + +// GetUserWithdrawals returns the user's withdrawal requests +func GetUserWithdrawals(c *gin.Context) { + userId := c.GetInt("id") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + withdrawals, total, err := model.GetUserWithdrawals(userId, page, pageSize) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取提现记录失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "withdrawals": withdrawals, + "total": total, + "page": page, + "size": pageSize, + }, + }) +} + +// GetDownlineUsers returns the user's downline (referrals) list +func GetDownlineUsers(c *gin.Context) { + userId := c.GetInt("id") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + downlines, total, err := model.GetUserDownline(userId, page, pageSize) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取下级列表失败", + }) + return + } + + // Mask usernames + type DownlineItem struct { + Id int `json:"id"` + Username string `json:"username"` + TotalTopUp float64 `json:"total_topup"` + CreatedAt int64 `json:"created_at"` + LastTopUpAt int64 `json:"last_topup_at"` + } + var items []DownlineItem + for _, d := range downlines { + username := maskUsername(d.Username) + items = append(items, DownlineItem{ + Id: d.Id, + Username: username, + TotalTopUp: d.TotalTopUp, + CreatedAt: d.CreatedAt, + LastTopUpAt: d.LastTopUpAt, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "downlines": items, + "total": total, + "page": page, + "size": pageSize, + }, + }) +} + +func maskUsername(username string) string { + if len(username) <= 2 { + return username[:1] + "***" + } + return string(username[0]) + "***" + string(username[len(username)-1]) +} + +// ============================================================================ +// Admin commission endpoints +// ============================================================================ + +// GetCommissionConfig returns the commission tier configuration +func GetCommissionConfig(c *gin.Context) { + config := model.GetDefaultCommissionConfig() + if v, ok := common.OptionMap[model.CommissionConfigKey]; ok && v != "" { + parsed := &model.CommissionConfig{} + if err := common.UnmarshalJsonStr(v, parsed); err == nil { + config = parsed + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": config, + }) +} + +// UpdateCommissionConfig updates the commission tier configuration +func UpdateCommissionConfig(c *gin.Context) { + var config model.CommissionConfig + if err := c.ShouldBindJSON(&config); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + // Validate + if config.DefaultRate < 0 || config.DefaultRate > 1 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "默认比例必须在0-1之间", + }) + return + } + for _, tier := range config.Tiers { + if tier.Rate < 0 || tier.Rate > 1 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "阶梯比例必须在0-1之间", + }) + return + } + } + + jsonBytes, err := common.Marshal(config) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "序列化失败", + }) + return + } + + if err := model.UpdateOption(model.CommissionConfigKey, string(jsonBytes)); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "保存失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "佣金配置已更新", + }) +} + +// GetAllCommissionRecords returns all commission records (admin) +func GetAllCommissionRecords(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + userId, _ := strconv.Atoi(c.Query("user_id")) + status := c.Query("status") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + records, total, err := model.GetAllCommissionRecords(page, pageSize, userId, status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取佣金记录失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "records": records, + "total": total, + "page": page, + "size": pageSize, + }, + }) +} + +// AdjustCommission manually adjusts a user's commission +func AdjustCommission(c *gin.Context) { + var req struct { + UserId int `json:"user_id" binding:"required"` + Amount float64 `json:"amount" binding:"required"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + if err := model.AdjustCommission(req.UserId, req.Amount, req.Remark); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "调整成功", + }) +} + +// GetAllWithdrawals returns all withdrawal requests (admin) +func GetAllWithdrawals(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + status := c.Query("status") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + withdrawals, total, err := model.GetAllWithdrawals(page, pageSize, status) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取提现记录失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "withdrawals": withdrawals, + "total": total, + "page": page, + "size": pageSize, + }, + }) +} + +// ReviewWithdrawal approves or rejects a withdrawal request +func ReviewWithdrawal(c *gin.Context) { + adminId := c.GetInt("id") + + var req struct { + Id int `json:"id" binding:"required"` + Action string `json:"action" binding:"required"` // "approve" or "reject" + Note string `json:"note"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + var err error + switch req.Action { + case "approve": + err = model.ApproveWithdrawal(req.Id, adminId) + case "reject": + err = model.RejectWithdrawal(req.Id, adminId, req.Note) + default: + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的操作", + }) + return + } + + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "操作成功", + }) +} + +// BatchApproveWithdrawals batch approves withdrawal requests +func BatchApproveWithdrawals(c *gin.Context) { + adminId := c.GetInt("id") + + var req struct { + Ids []int `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + var failed []int + for _, id := range req.Ids { + if err := model.ApproveWithdrawal(id, adminId); err != nil { + failed = append(failed, id) + } + } + + if len(failed) > 0 { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("批量操作完成,%d个成功,%d个失败", len(req.Ids)-len(failed), len(failed)), + "data": gin.H{"failed_ids": failed}, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("批量审核通过成功,共%d个", len(req.Ids)), + }) +} + +// GetPromoterList returns all promoters data (admin) +func GetPromoterList(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + keyword := c.Query("keyword") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + // Get users with commission wallets + items, total, err := model.GetPromoterList(page, pageSize, keyword) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取推广用户列表失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "promoters": items, + "total": total, + "page": page, + "size": pageSize, + }, + }) +} + +// GetCommissionDashboard returns dashboard statistics +func GetCommissionDashboard(c *gin.Context) { + stats, err := model.GetCommissionDashboardStats() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取统计数据失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": stats, + }) +} diff --git a/controller/misc.go b/controller/misc.go index 7343b12f10a3..722a238a9e7c 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -107,6 +107,7 @@ func GetStatus(c *gin.Context) { // 模块管理配置 "HeaderNavModules": common.OptionMap["HeaderNavModules"], "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"], + "CustomPageTitle": common.OptionMap["CustomPageTitle"], "oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_client_id": system_setting.GetOIDCSettings().ClientId, @@ -366,3 +367,14 @@ func ResetPassword(c *gin.Context) { }) return } + +func GetCustomPage(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["CustomPageContent"], + }) + return +} diff --git a/controller/payment_webhook_availability.go b/controller/payment_webhook_availability.go index aa26e5acf5ae..03c090ef1912 100644 --- a/controller/payment_webhook_availability.go +++ b/controller/payment_webhook_availability.go @@ -96,7 +96,8 @@ func isEpayTopUpEnabled() bool { if !isPaymentComplianceConfirmed() { return false } - return isEpayWebhookConfigured() && len(operation_setting.PayMethods) > 0 + return (isEpayWebhookConfigured() && len(operation_setting.PayMethods) > 0) || + isEpay2Configured() } func isEpayWebhookConfigured() bool { diff --git a/controller/status_page.go b/controller/status_page.go new file mode 100644 index 000000000000..f5f5112a61ec --- /dev/null +++ b/controller/status_page.go @@ -0,0 +1,431 @@ +package controller + +import ( + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +// StatusCardRecent 前端色条的单元 +type StatusCardRecent struct { + Level string `json:"level"` +} + +// StatusCard 单个分组的状态卡片 +// Status 取值:normal | degraded | error | unknown(尚无探测数据) +type StatusCard struct { + Id string `json:"id"` + Group string `json:"group"` + Name string `json:"name"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Status string `json:"status"` + StatusLabel string `json:"status_label"` + LatencyMs *int `json:"latency_ms"` + PingMs *int `json:"ping_ms"` + Availability7d *float64 `json:"availability_7d"` + AvailableChannels int `json:"available_channels"` + TotalChannels int `json:"total_channels"` + Recent []StatusCardRecent `json:"recent"` + RecentLimit int `json:"recent_limit"` + UpdatedAt int64 `json:"updated_at"` +} + +// GetStatusPageCards 返回用户侧只读的状态卡片列表 +func GetStatusPageCards(c *gin.Context) { + setting := operation_setting.GetStatusPageSetting() + if !setting.Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "enabled": false, + "refresh_seconds": setting.RefreshSeconds, + "updated_at": time.Now().Unix(), + "cards": []StatusCard{}, + }, + }) + return + } + + channels, err := model.GetAllChannels(0, 0, true, false) + if err != nil { + common.ApiError(c, err) + return + } + + cards := buildStatusCards(setting, channels) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "enabled": true, + "refresh_seconds": setting.RefreshSeconds, + "updated_at": time.Now().Unix(), + "cards": cards, + }, + }) +} + +func buildStatusCards(setting operation_setting.StatusPageSetting, channels []*model.Channel) []StatusCard { + cards := make([]StatusCard, 0, len(setting.Groups)) + for _, groupCfg := range setting.Groups { + if !groupCfg.Enabled { + continue + } + card := buildStatusCardForGroup(setting, groupCfg, channels) + cards = append(cards, card) + } + return cards +} + +func buildStatusCardForGroup(setting operation_setting.StatusPageSetting, groupCfg operation_setting.StatusPageGroupConfig, channels []*model.Channel) StatusCard { + displayName := groupCfg.DisplayName + if displayName == "" { + displayName = groupCfg.Group + } + card := StatusCard{ + Id: groupCfg.Group, + Group: groupCfg.Group, + Name: displayName, + Provider: groupCfg.Provider, + Model: groupCfg.DisplayModel, + Recent: []StatusCardRecent{}, + RecentLimit: 60, + UpdatedAt: time.Now().Unix(), + } + + groupChannels := filterChannelsByGroup(channels, groupCfg.Group) + card.TotalChannels = len(groupChannels) + + enabledChannels := make([]*model.Channel, 0, len(groupChannels)) + enabledChannelIds := make([]int, 0, len(groupChannels)) + for _, ch := range groupChannels { + if ch.Status == common.ChannelStatusEnabled { + enabledChannels = append(enabledChannels, ch) + enabledChannelIds = append(enabledChannelIds, ch.Id) + } + } + card.AvailableChannels = len(enabledChannels) + + // 无可用渠道 → 异常 + if card.AvailableChannels == 0 { + card.Status = "error" + card.StatusLabel = "异常" + return card + } + + // 聚合最新延迟 + latencies := make([]int, 0, len(enabledChannels)) + pings := make([]int, 0, len(enabledChannels)) + successCount := 0 + channelsWithLog := 0 + for _, ch := range enabledChannels { + latest, err := model.GetLatestProbeLogByChannel(ch.Id) + if err != nil { + common.SysError("status page: fetch latest probe log failed: " + err.Error()) + continue + } + if latest == nil { + continue + } + channelsWithLog++ + if latest.Success { + successCount++ + if latest.LatencyMs > 0 { + latencies = append(latencies, latest.LatencyMs) + } + } + if latest.PingMs > 0 { + pings = append(pings, latest.PingMs) + } + } + + if len(latencies) > 0 { + v := medianInt(latencies) + card.LatencyMs = &v + } + if len(pings) > 0 { + v := averageInt(pings) + card.PingMs = &v + } + + // 7 天可用率 + availabilityMap, err := model.GetChannelStatusAvailabilityByChannels(enabledChannelIds, 7*24*3600) + if err != nil { + common.SysError("status page: fetch 7d availability failed: " + err.Error()) + } else { + var totalCount, successTotal int64 + for _, w := range availabilityMap { + totalCount += w.Total + successTotal += w.Success + } + if totalCount > 0 { + ratio := float64(successTotal) / float64(totalCount) + card.Availability7d = &ratio + } + } + + // 状态徽标 + card.Status, card.StatusLabel = decideStatusLevel(setting, enabledChannels, successCount, channelsWithLog, latencies) + + // 色条:代表渠道最近 60 条(Priority 最高,Weight 最大,Id 最小) + representative := pickRepresentativeChannel(enabledChannels) + if representative != nil { + logs, err := model.GetRecentProbeLogsByChannel(representative.Id, card.RecentLimit) + if err != nil { + common.SysError("status page: fetch recent probe logs failed: " + err.Error()) + } else { + // logs 是时间倒序(NOW → PAST),前端色条要求 PAST → NOW,需反转 + card.Recent = make([]StatusCardRecent, 0, len(logs)) + for i := len(logs) - 1; i >= 0; i-- { + card.Recent = append(card.Recent, StatusCardRecent{Level: logs[i].Level}) + } + } + } + + return card +} + +func filterChannelsByGroup(channels []*model.Channel, group string) []*model.Channel { + target := strings.TrimSpace(group) + if target == "" { + return nil + } + result := make([]*model.Channel, 0) + for _, ch := range channels { + if ch == nil { + continue + } + for _, g := range ch.GetGroups() { + if strings.EqualFold(strings.TrimSpace(g), target) { + result = append(result, ch) + break + } + } + } + return result +} + +func decideStatusLevel(setting operation_setting.StatusPageSetting, enabledChannels []*model.Channel, successCount int, channelsWithLog int, latencies []int) (string, string) { + if len(enabledChannels) == 0 { + return "error", "异常" + } + // 尚无探测数据:区分「异常」与「等待首次探测」 + if channelsWithLog == 0 { + return "unknown", "等待探测" + } + if successCount == 0 { + return "error", "异常" + } + total := len(enabledChannels) + if successCount < total { + return "degraded", "降级" + } + if setting.DegradedLatencyMs > 0 && len(latencies) > 0 { + avg := averageInt(latencies) + if avg >= setting.DegradedLatencyMs { + return "degraded", "降级" + } + } + return "normal", "正常" +} + +func pickRepresentativeChannel(channels []*model.Channel) *model.Channel { + if len(channels) == 0 { + return nil + } + sorted := make([]*model.Channel, len(channels)) + copy(sorted, channels) + sort.SliceStable(sorted, func(i, j int) bool { + pi := int64(0) + pj := int64(0) + if sorted[i].Priority != nil { + pi = *sorted[i].Priority + } + if sorted[j].Priority != nil { + pj = *sorted[j].Priority + } + if pi != pj { + return pi > pj + } + wi := uint(0) + wj := uint(0) + if sorted[i].Weight != nil { + wi = *sorted[i].Weight + } + if sorted[j].Weight != nil { + wj = *sorted[j].Weight + } + if wi != wj { + return wi > wj + } + return sorted[i].Id < sorted[j].Id + }) + return sorted[0] +} + +func medianInt(values []int) int { + if len(values) == 0 { + return 0 + } + sorted := make([]int, len(values)) + copy(sorted, values) + sort.Ints(sorted) + n := len(sorted) + if n%2 == 1 { + return sorted[n/2] + } + return (sorted[n/2-1] + sorted[n/2]) / 2 +} + +func averageInt(values []int) int { + if len(values) == 0 { + return 0 + } + sum := 0 + for _, v := range values { + sum += v + } + return sum / len(values) +} + +// ---------------- 管理侧:读写状态页配置 ---------------- + +// StatusPageSettingsPayload 管理端请求/响应体 +type StatusPageSettingsPayload struct { + Enabled bool `json:"enabled"` + RefreshSeconds int `json:"refresh_seconds"` + DegradedLatencyMs int `json:"degraded_latency_ms"` + EnablePingProbe bool `json:"enable_ping_probe"` + PingProbeTimeoutMs int `json:"ping_probe_timeout_ms"` + Groups []operation_setting.StatusPageGroupConfig `json:"groups"` + AvailableGroups []string `json:"available_groups,omitempty"` +} + +// GetStatusPageSettings 返回状态页配置以及可选分组列表(供前端多选) +func GetStatusPageSettings(c *gin.Context) { + setting := operation_setting.GetStatusPageSetting() + payload := StatusPageSettingsPayload{ + Enabled: setting.Enabled, + RefreshSeconds: setting.RefreshSeconds, + DegradedLatencyMs: setting.DegradedLatencyMs, + EnablePingProbe: setting.EnablePingProbe, + PingProbeTimeoutMs: setting.PingProbeTimeoutMs, + Groups: setting.Groups, + AvailableGroups: listAllChannelGroups(), + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": payload, + }) +} + +// UpdateStatusPageSettings 全量覆盖状态页配置 +func UpdateStatusPageSettings(c *gin.Context) { + var payload StatusPageSettingsPayload + if err := common.DecodeJson(c.Request.Body, &payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + next := operation_setting.StatusPageSetting{ + Enabled: payload.Enabled, + RefreshSeconds: payload.RefreshSeconds, + DegradedLatencyMs: payload.DegradedLatencyMs, + EnablePingProbe: payload.EnablePingProbe, + PingProbeTimeoutMs: payload.PingProbeTimeoutMs, + Groups: payload.Groups, + } + operation_setting.SetStatusPageSetting(next) + + // 持久化:将 status_page.* 一次性写入 option 表 + current := operation_setting.GetStatusPageSetting() + groupsJSON, err := common.Marshal(current.Groups) + if err != nil { + common.ApiError(c, err) + return + } + values := map[string]string{ + "status_page.enabled": statusBoolToString(current.Enabled), + "status_page.refresh_seconds": statusIntToString(current.RefreshSeconds), + "status_page.degraded_latency_ms": statusIntToString(current.DegradedLatencyMs), + "status_page.enable_ping_probe": statusBoolToString(current.EnablePingProbe), + "status_page.ping_probe_timeout_ms": statusIntToString(current.PingProbeTimeoutMs), + "status_page.groups": string(groupsJSON), + } + if err := model.UpdateOptionsBulk(values); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": StatusPageSettingsPayload{ + Enabled: current.Enabled, + RefreshSeconds: current.RefreshSeconds, + DegradedLatencyMs: current.DegradedLatencyMs, + EnablePingProbe: current.EnablePingProbe, + PingProbeTimeoutMs: current.PingProbeTimeoutMs, + Groups: current.Groups, + AvailableGroups: listAllChannelGroups(), + }, + }) +} + +// listAllChannelGroups 汇总所有渠道使用中的分组名 + 全局 group_ratio 配置里出现的分组名 +func listAllChannelGroups() []string { + seen := make(map[string]struct{}) + // 1. 已有的 group_ratio 配置 + for name := range ratio_setting.GetGroupRatioCopy() { + name = strings.TrimSpace(name) + if name == "" { + continue + } + seen[name] = struct{}{} + } + // 2. 渠道 Group 字段(可能是逗号分隔的多组) + channels, err := model.GetAllChannels(0, 0, true, false) + if err == nil { + for _, ch := range channels { + for _, g := range ch.GetGroups() { + g = strings.TrimSpace(g) + if g != "" { + seen[g] = struct{}{} + } + } + } + } + result := make([]string, 0, len(seen)) + for name := range seen { + result = append(result, name) + } + sort.Strings(result) + return result +} + +func statusBoolToString(v bool) string { + if v { + return "true" + } + return "false" +} + +func statusIntToString(v int) string { + return strconv.Itoa(v) +} diff --git a/controller/subscription.go b/controller/subscription.go index 22cee9d5392a..e6ef49fd3c34 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -103,6 +103,10 @@ func SubscriptionRequestBalancePay(c *gin.Context) { } userId := c.GetInt("id") + if user, err := model.GetUserById(userId, false); err == nil && user != nil && user.QuotaForbidden { + common.ApiErrorMsg(c, "该用户已被禁止充值") + return + } var req SubscriptionBalancePayRequest if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 { common.ApiErrorMsg(c, "参数错误") diff --git a/controller/ticket.go b/controller/ticket.go new file mode 100644 index 000000000000..80e19a5351d1 --- /dev/null +++ b/controller/ticket.go @@ -0,0 +1,627 @@ +package controller + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// ============================================================================ +// User-facing ticket endpoints +// ============================================================================ + +// GetUserTickets lists tickets for the current user +func GetUserTickets(c *gin.Context) { + userId := c.GetInt("id") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) + status := c.Query("status") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 50 { + pageSize = 10 + } + + tickets, total, err := model.GetUserTickets(userId, page, pageSize, status) + if err != nil { + common.SysError(fmt.Sprintf("failed to get user tickets: %v", err)) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取工单列表失败", + }) + return + } + + var ticketResponses []dto.TicketResponse + for _, ticket := range tickets { + ticketResponses = append(ticketResponses, dto.TicketResponse{ + Id: ticket.Id, + UserId: ticket.UserId, + Title: ticket.Title, + Content: ticket.Content, + Category: ticket.Category, + Status: ticket.Status, + Priority: ticket.Priority, + AssignedTo: ticket.AssignedTo, + ClosedAt: ticket.ClosedAt, + CreatedAt: ticket.CreatedAt, + UpdatedAt: ticket.UpdatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dto.TicketListData{ + Tickets: ticketResponses, + Total: total, + Page: page, + Size: pageSize, + }, + }) +} + +// CreateTicket creates a new ticket +func CreateTicket(c *gin.Context) { + userId := c.GetInt("id") + var req dto.CreateTicketRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + if req.Title == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "标题不能为空", + }) + return + } + if req.Content == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "内容不能为空", + }) + return + } + if req.Category == "" { + req.Category = model.TicketCategoryGeneral + } + if req.Priority == "" { + req.Priority = model.TicketPriorityMedium + } + + // Validate category + validCategory := false + for _, c := range model.ValidTicketCategories { + if c == req.Category { + validCategory = true + break + } + } + if !validCategory { + req.Category = model.TicketCategoryGeneral + } + + // Validate priority + validPriority := false + for _, p := range model.ValidTicketPriorities { + if p == req.Priority { + validPriority = true + break + } + } + if !validPriority { + req.Priority = model.TicketPriorityMedium + } + + ticket := &model.Ticket{ + UserId: userId, + Title: req.Title, + Content: req.Content, + Category: req.Category, + Priority: req.Priority, + Status: model.TicketStatusOpen, + } + + if err := model.CreateTicket(ticket); err != nil { + common.SysError(fmt.Sprintf("failed to create ticket: %v", err)) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "创建工单失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "工单创建成功", + "data": dto.TicketResponse{ + Id: ticket.Id, + UserId: ticket.UserId, + Title: ticket.Title, + Content: ticket.Content, + Category: ticket.Category, + Status: ticket.Status, + Priority: ticket.Priority, + CreatedAt: ticket.CreatedAt, + UpdatedAt: ticket.UpdatedAt, + }, + }) +} + +// GetTicket gets ticket details with messages +func GetTicket(c *gin.Context) { + userId := c.GetInt("id") + ticketId, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的工单ID", + }) + return + } + + ticket, err := model.GetTicketById(ticketId, userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "工单不存在", + }) + return + } + + // Get messages (non-internal only for user) + messages, err := model.GetTicketMessages(ticketId, false) + if err != nil { + common.SysError(fmt.Sprintf("failed to get ticket messages: %v", err)) + } + + var msgDTOs []dto.TicketMessageDTO + for _, msg := range messages { + msgDTOs = append(msgDTOs, dto.TicketMessageDTO{ + Id: msg.Id, + UserId: msg.UserId, + Content: msg.Content, + CreatedAt: msg.CreatedAt, + }) + } + if msgDTOs == nil { + msgDTOs = []dto.TicketMessageDTO{} + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dto.TicketResponse{ + Id: ticket.Id, + UserId: ticket.UserId, + Title: ticket.Title, + Content: ticket.Content, + Category: ticket.Category, + Status: ticket.Status, + Priority: ticket.Priority, + AssignedTo: ticket.AssignedTo, + ClosedAt: ticket.ClosedAt, + CreatedAt: ticket.CreatedAt, + UpdatedAt: ticket.UpdatedAt, + Messages: msgDTOs, + }, + }) +} + +// AddTicketMessage adds a message to a ticket +func AddTicketMessage(c *gin.Context) { + userId := c.GetInt("id") + ticketId, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的工单ID", + }) + return + } + + var req dto.AddTicketMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + if req.Content == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "内容不能为空", + }) + return + } + + // Verify ticket ownership + _, err = model.GetTicketById(ticketId, userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "工单不存在", + }) + return + } + + msg := &model.TicketMessage{ + TicketId: ticketId, + UserId: userId, + Content: req.Content, + IsInternal: false, + } + + if err := model.AddTicketMessage(msg); err != nil { + common.SysError(fmt.Sprintf("failed to add ticket message: %v", err)) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "回复失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "回复成功", + }) +} + +// CloseTicket closes a ticket +func CloseTicket(c *gin.Context) { + userId := c.GetInt("id") + ticketId, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的工单ID", + }) + return + } + + if err := model.CloseTicket(ticketId, userId); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "工单已关闭", + }) +} + +// ReopenTicket reopens a closed ticket +func ReopenTicket(c *gin.Context) { + userId := c.GetInt("id") + ticketId, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的工单ID", + }) + return + } + + if err := model.ReopenTicket(ticketId, userId); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "工单已重新开启", + }) +} + +// ============================================================================ +// Admin ticket endpoints +// ============================================================================ + +// GetAllTicketsAdmin lists all tickets (admin) +func GetAllTicketsAdmin(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) + status := c.Query("status") + category := c.Query("category") + keyword := c.Query("keyword") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 50 { + pageSize = 10 + } + + tickets, total, err := model.GetAllTickets(page, pageSize, status, category, keyword) + if err != nil { + common.SysError(fmt.Sprintf("failed to get all tickets: %v", err)) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "获取工单列表失败", + }) + return + } + + var ticketResponses []dto.TicketResponse + for _, ticket := range tickets { + ticketResponses = append(ticketResponses, dto.TicketResponse{ + Id: ticket.Id, + UserId: ticket.UserId, + Title: ticket.Title, + Content: ticket.Content, + Category: ticket.Category, + Status: ticket.Status, + Priority: ticket.Priority, + AssignedTo: ticket.AssignedTo, + ClosedAt: ticket.ClosedAt, + CreatedAt: ticket.CreatedAt, + UpdatedAt: ticket.UpdatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dto.TicketListData{ + Tickets: ticketResponses, + Total: total, + Page: page, + Size: pageSize, + }, + }) +} + +// GetTicketAdmin gets ticket details with all messages (including internal) +func GetTicketAdmin(c *gin.Context) { + ticketId, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的工单ID", + }) + return + } + + ticket, err := model.GetTicketByIdAdmin(ticketId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "工单不存在", + }) + return + } + + // Get messages including internal + messages, err := model.GetTicketMessages(ticketId, true) + if err != nil { + common.SysError(fmt.Sprintf("failed to get ticket messages: %v", err)) + } + + var msgDTOs []dto.TicketMessageDTO + for _, msg := range messages { + userName := "" + user, err := model.GetUserById(msg.UserId, false) + if err == nil { + userName = user.DisplayName + if userName == "" { + userName = user.Username + } + } + msgDTOs = append(msgDTOs, dto.TicketMessageDTO{ + Id: msg.Id, + UserId: msg.UserId, + Content: msg.Content, + IsInternal: msg.IsInternal, + CreatedAt: msg.CreatedAt, + UserName: userName, + }) + } + if msgDTOs == nil { + msgDTOs = []dto.TicketMessageDTO{} + } + + // Get user name + userName := "" + user, err := model.GetUserById(ticket.UserId, false) + if err == nil { + userName = user.DisplayName + if userName == "" { + userName = user.Username + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dto.TicketResponse{ + Id: ticket.Id, + UserId: ticket.UserId, + Title: ticket.Title, + Content: ticket.Content, + Category: ticket.Category, + Status: ticket.Status, + Priority: ticket.Priority, + AssignedTo: ticket.AssignedTo, + ClosedAt: ticket.ClosedAt, + CreatedAt: ticket.CreatedAt, + UpdatedAt: ticket.UpdatedAt, + Messages: msgDTOs, + UserName: userName, + }, + }) +} + +// AddTicketMessageAdmin adds a message (optionally internal) to a ticket +func AddTicketMessageAdmin(c *gin.Context) { + adminId := c.GetInt("id") + ticketId, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的工单ID", + }) + return + } + + var req dto.AddTicketMessageAdminRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + if req.Content == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "内容不能为空", + }) + return + } + + // Verify ticket exists + _, err = model.GetTicketByIdAdmin(ticketId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "工单不存在", + }) + return + } + + msg := &model.TicketMessage{ + TicketId: ticketId, + UserId: adminId, + Content: req.Content, + IsInternal: req.IsInternal, + } + + if err := model.AddTicketMessageAdmin(msg); err != nil { + common.SysError(fmt.Sprintf("failed to add admin ticket message: %v", err)) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "回复失败", + }) + return + } + + // If it's a public reply, update ticket status to waiting_for_user + if !req.IsInternal { + if err := model.UpdateTicketStatus(ticketId, model.TicketStatusWaitingUser); err != nil { + common.SysError(fmt.Sprintf("failed to update ticket status: %v", err)) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "回复成功", + }) +} + +// UpdateTicketStatusAdmin updates ticket status +func UpdateTicketStatusAdmin(c *gin.Context) { + ticketId, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的工单ID", + }) + return + } + + var req dto.UpdateTicketStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + // Validate status + valid := false + for _, s := range model.ValidTicketStatuses { + if s == req.Status { + valid = true + break + } + } + if !valid { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的状态值", + }) + return + } + + if err := model.UpdateTicketStatus(ticketId, req.Status); err != nil { + common.SysError(fmt.Sprintf("failed to update ticket status: %v", err)) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "更新状态失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "状态已更新", + }) +} + +// AssignTicketAdmin assigns a ticket to an admin +func AssignTicketAdmin(c *gin.Context) { + ticketId, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的工单ID", + }) + return + } + + var req dto.AssignTicketRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + if err := model.AssignTicket(ticketId, req.AdminId); err != nil { + common.SysError(fmt.Sprintf("failed to assign ticket: %v", err)) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "分配失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "分配成功", + }) +} diff --git a/controller/topup.go b/controller/topup.go index 390f53f7dce8..5ff3df159083 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "sync" "time" @@ -22,6 +23,27 @@ import ( ) func GetTopUpInfo(c *gin.Context) { + // 检查用户是否被禁止充值 + if id := c.GetInt("id"); id > 0 { + user, err := model.GetUserById(id, false) + if err == nil && user != nil && user.QuotaForbidden { + data := gin.H{ + "enable_online_topup": false, + "enable_stripe_topup": false, + "enable_creem_topup": false, + "enable_waffo_topup": false, + "enable_waffo_pancake_topup": false, + "enable_redemption": false, + "payment_compliance_confirmed": false, + "payment_compliance_terms_version": operation_setting.CurrentComplianceTermsVersion, + "pay_methods": []map[string]string{}, + "quota_forbidden": true, + } + common.ApiSuccess(c, data) + return + } + } + complianceConfirmed := operation_setting.IsPaymentComplianceConfirmed() // 获取支付方式 @@ -30,6 +52,19 @@ func GetTopUpInfo(c *gin.Context) { payMethods = []map[string]string{} } + // 如果配置了第二个 Epay 网关,将其支付方式添加到列表(带 g2: 前缀) + if isEpay2Configured() { + gw2 := operation_setting.EpayGateway2 + for _, m := range gw2.PayMethods { + m2 := make(map[string]string, len(m)+2) + for k, v := range m { + m2[k] = v + } + m2["type"] = "g2:" + m["type"] + payMethods = append(payMethods, m2) + } + } + // 如果启用了 Stripe 支付,添加到支付方法列表 if isStripeTopUpEnabled() { // 检查是否已经包含 Stripe @@ -45,14 +80,14 @@ func GetTopUpInfo(c *gin.Context) { stripeMethod := map[string]string{ "name": "Stripe", "type": "stripe", - "color": "#635BFF", + "color": "rgba(var(--semi-purple-5), 1)", "min_topup": strconv.Itoa(setting.StripeMinTopUp), } payMethods = append(payMethods, stripeMethod) } } - // Waffo Pancake is displayed above the standard Waffo gateway. + // Waffo Pancake displayed above the legacy Waffo gateway. enableWaffoPancake := isWaffoPancakeTopUpEnabled() if enableWaffoPancake { hasWaffoPancake := false @@ -67,7 +102,7 @@ func GetTopUpInfo(c *gin.Context) { payMethods = append(payMethods, map[string]string{ "name": "Waffo Pancake", "type": model.PaymentMethodWaffoPancake, - "color": "#F97316", + "color": "rgba(var(--semi-orange-5), 1)", "min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp), }) } @@ -88,7 +123,7 @@ func GetTopUpInfo(c *gin.Context) { waffoMethod := map[string]string{ "name": "Waffo (Global Payment)", "type": model.PaymentMethodWaffo, - "color": "#3B82F6", + "color": "rgba(var(--semi-blue-5), 1)", "min_topup": strconv.Itoa(setting.WaffoMinTopUp), } payMethods = append(payMethods, waffoMethod) @@ -113,12 +148,16 @@ func GetTopUpInfo(c *gin.Context) { "creem_products": setting.CreemProducts, "pay_methods": payMethods, "min_topup": operation_setting.MinTopUp, + "max_topup": operation_setting.MaxTopUp, "stripe_min_topup": setting.StripeMinTopUp, "waffo_min_topup": setting.WaffoMinTopUp, "waffo_pancake_min_topup": setting.WaffoPancakeMinTopUp, "amount_options": operation_setting.GetPaymentSetting().AmountOptions, "discount": operation_setting.GetPaymentSetting().AmountDiscount, "topup_link": common.TopUpLink, + "payment_tip": common.OptionMap["PaymentTip"], + "epay_gateway2_bonus": operation_setting.EpayGateway2.Bonus, + "epay_fee": operation_setting.EpayFee, } common.ApiSuccess(c, data) } @@ -146,9 +185,46 @@ func GetEpayClient() *epay.Client { return withUrl } -func getPayMoney(amount int64, group string) float64 { +func isEpay2Configured() bool { + g := operation_setting.EpayGateway2 + return strings.TrimSpace(g.Address) != "" && + strings.TrimSpace(g.MerchantID) != "" && + strings.TrimSpace(g.Key) != "" && + len(g.PayMethods) > 0 +} + +func isEpay2PayMethod(method string) bool { + if !strings.HasPrefix(method, "g2:") { + return false + } + actualType := strings.TrimPrefix(method, "g2:") + g := operation_setting.EpayGateway2 + for _, m := range g.PayMethods { + if m["type"] == actualType { + return true + } + } + return false +} + +func GetEpay2Client() *epay.Client { + g := operation_setting.EpayGateway2 + if g.Address == "" || g.MerchantID == "" || g.Key == "" { + return nil + } + withUrl, err := epay.NewClient(&epay.Config{ + PartnerID: g.MerchantID, + Key: g.Key, + }, g.Address) + if err != nil { + return nil + } + return withUrl +} + +func getPayMoney(amount int64, group string, price float64) float64 { dAmount := decimal.NewFromInt(amount) - // 充值金额以“展示类型”为准: + // 充值金额以”展示类型”为准: // - USD/CNY: 前端传 amount 为金额单位;TOKENS: 前端传 tokens,需要换成 USD 金额 if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) @@ -161,7 +237,7 @@ func getPayMoney(amount int64, group string) float64 { } dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio) - dPrice := decimal.NewFromFloat(operation_setting.Price) + dPrice := decimal.NewFromFloat(price) // apply optional preset discount by the original request amount (if configured), default 1.0 discount := 1.0 if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok { @@ -176,6 +252,14 @@ func getPayMoney(amount int64, group string) float64 { return payMoney.InexactFloat64() } +// getGatewayPrice 根据网关获取有效的 Price +func getGatewayPrice(isGateway2 bool) float64 { + if isGateway2 && operation_setting.EpayGateway2.Price > 0 { + return operation_setting.EpayGateway2.Price + } + return operation_setting.Price +} + func getMinTopup() int64 { minTopup := operation_setting.MinTopUp if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { @@ -186,6 +270,19 @@ func getMinTopup() int64 { return int64(minTopup) } +func getMaxTopup() int64 { + maxTopup := operation_setting.MaxTopUp + if maxTopup <= 0 { + return 0 // 0 means no limit + } + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + dMaxTopup := decimal.NewFromInt(int64(maxTopup)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + maxTopup = int(dMaxTopup.Mul(dQuotaPerUnit).IntPart()) + } + return int64(maxTopup) +} + func RequestEpay(c *gin.Context) { var req EpayRequest err := c.ShouldBindJSON(&req) @@ -199,37 +296,77 @@ func RequestEpay(c *gin.Context) { } id := c.GetInt("id") + if user, err := model.GetUserById(id, false); err == nil && user != nil && user.QuotaForbidden { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "该用户已被禁止充值"}) + return + } group, err := model.GetUserGroup(id, true) if err != nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) return } - payMoney := getPayMoney(req.Amount, group) + payMoney := getPayMoney(req.Amount, group, operation_setting.Price) if payMoney < 0.01 { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"}) return } if !operation_setting.ContainsPayMethod(req.PaymentMethod) { - c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付方式不存在"}) - return + // 检查是否是第二个 Epay 网关的支付方式 + if !isEpay2Configured() || !isEpay2PayMethod(req.PaymentMethod) { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付方式不存在"}) + return + } } callBackAddress := service.GetCallbackAddress() returnUrl, _ := url.Parse(paymentReturnPath("/usage-logs")) notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") - tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) - tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) - client := GetEpayClient() + + // 判断使用哪个网关 + isGateway2 := strings.HasPrefix(req.PaymentMethod, "g2:") + var client *epay.Client + var tradeNo string + var actualPaymentMethod string + + // 根据网关获取有效的价格,epay2 可使用独立 Price + gatewayPrice := getGatewayPrice(isGateway2) + payMoney = getPayMoney(req.Amount, group, gatewayPrice) + if isGateway2 { + client = GetEpay2Client() + actualPaymentMethod = strings.TrimPrefix(req.PaymentMethod, "g2:") + tradeNo = fmt.Sprintf("U2R%dNO%s%d", id, common.GetRandomString(6), time.Now().Unix()) + } else { + client = GetEpayClient() + actualPaymentMethod = req.PaymentMethod + tradeNo = fmt.Sprintf("USR%dNO%s%d", id, common.GetRandomString(6), time.Now().Unix()) + } if client == nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"}) return } + + // 最高充值金额校验(仅网关1) + if !isGateway2 { + maxTopup := getMaxTopup() + if maxTopup > 0 && req.Amount > maxTopup { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能大于 %d", maxTopup)}) + return + } + } + + // epay1 手续费:在基础金额上增加手续费百分比 + totalPay := payMoney + if !isGateway2 && operation_setting.EpayFee > 0 { + fee := payMoney * operation_setting.EpayFee / 100.0 + totalPay = payMoney + fee + } + uri, params, err := client.Purchase(&epay.PurchaseArgs{ - Type: req.PaymentMethod, + Type: actualPaymentMethod, ServiceTradeNo: tradeNo, Name: fmt.Sprintf("TUC%d", req.Amount), - Money: strconv.FormatFloat(payMoney, 'f', 2, 64), + Money: strconv.FormatFloat(totalPay, 'f', 2, 64), Device: epay.PC, NotifyUrl: notifyUrl, ReturnUrl: returnUrl, @@ -308,7 +445,7 @@ func UnlockOrder(tradeNo string) { } func EpayNotify(c *gin.Context) { - if !isEpayWebhookEnabled() { + if !isEpayWebhookEnabled() && !isEpay2Configured() { logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP())) _, _ = c.Writer.Write([]byte("fail")) return @@ -341,9 +478,19 @@ func EpayNotify(c *gin.Context) { _, _ = c.Writer.Write([]byte("fail")) return } - client := GetEpayClient() + + // 根据 out_trade_no 前缀判断使用哪个网关 + tradeNoFromParams := params["out_trade_no"] + useGateway2 := strings.HasPrefix(tradeNoFromParams, "U2R") + + var client *epay.Client + if useGateway2 { + client = GetEpay2Client() + } else { + client = GetEpayClient() + } if client == nil { - logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 client 未初始化 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP())) + logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 client 未初始化 path=%q client_ip=%s gw2=%v", c.Request.RequestURI, c.ClientIP(), useGateway2)) _, err := c.Writer.Write([]byte("fail")) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) @@ -351,16 +498,10 @@ func EpayNotify(c *gin.Context) { return } verifyInfo, err := client.Verify(params) - if err == nil && verifyInfo.VerifyStatus { - logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo))) - _, err := c.Writer.Write([]byte("success")) - if err != nil { - logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error())) - } - } else { - _, err := c.Writer.Write([]byte("fail")) - if err != nil { - logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) + if err != nil || !verifyInfo.VerifyStatus { + _, writeErr := c.Writer.Write([]byte("fail")) + if writeErr != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), writeErr.Error())) } if err != nil { logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) @@ -370,44 +511,46 @@ func EpayNotify(c *gin.Context) { return } + logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo))) + if verifyInfo.TradeStatus == epay.StatusTradeSuccess { LockOrder(verifyInfo.ServiceTradeNo) defer UnlockOrder(verifyInfo.ServiceTradeNo) + topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo) if topUp == nil { logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo))) + _, _ = c.Writer.Write([]byte("success")) return } if topUp.PaymentProvider != model.PaymentProviderEpay { logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付网关不匹配 trade_no=%s order_provider=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentProvider, verifyInfo.Type, c.ClientIP())) + _, _ = c.Writer.Write([]byte("success")) return } - if topUp.Status == common.TopUpStatusPending { - if topUp.PaymentMethod != verifyInfo.Type { - logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 实际支付方式与订单不同 trade_no=%s order_payment_method=%s actual_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP())) - topUp.PaymentMethod = verifyInfo.Type - } - topUp.Status = common.TopUpStatusSuccess - err := topUp.Update() - if err != nil { - logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp))) - return - } - //user, _ := model.GetUserById(topUp.UserId, false) - //user.Quota += topUp.Amount * 500000 - dAmount := decimal.NewFromInt(int64(topUp.Amount)) - dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) - quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart()) - err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true) - if err != nil { - logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp))) - return - } - logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp))) - model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay") + + bonusPercent := 0.0 + if useGateway2 { + bonusPercent = operation_setting.EpayGateway2.Bonus } + quotaToAdd, bonusQuota, err := model.RechargeEpay(verifyInfo.ServiceTradeNo, verifyInfo.Type, bonusPercent) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 充值处理失败 trade_no=%s user_id=%d client_ip=%s error=%q", verifyInfo.ServiceTradeNo, topUp.UserId, c.ClientIP(), err.Error())) + _, _ = c.Writer.Write([]byte("fail")) + return + } + + logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d bonus=%d total=%d money=%.2f topup=%q", verifyInfo.ServiceTradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, bonusQuota, quotaToAdd+bonusQuota, topUp.Money, common.GetJsonString(topUp))) + model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay") + if bonusQuota > 0 { + model.RecordTopupLog(topUp.UserId, fmt.Sprintf("充值赠送额度: %v (赠送比例: %d%%)", logger.LogQuota(bonusQuota), int(bonusPercent)), c.ClientIP(), topUp.PaymentMethod, "epay") + } + go model.ProcessCommissionForTopUp(topUp.UserId, topUp.Id, topUp.Money) + + _, _ = c.Writer.Write([]byte("success")) } else { logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo))) + _, _ = c.Writer.Write([]byte("success")) } } @@ -424,12 +567,16 @@ func RequestAmount(c *gin.Context) { return } id := c.GetInt("id") + if user, err := model.GetUserById(id, false); err == nil && user != nil && user.QuotaForbidden { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "该用户已被禁止充值"}) + return + } group, err := model.GetUserGroup(id, true) if err != nil { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) return } - payMoney := getPayMoney(req.Amount, group) + payMoney := getPayMoney(req.Amount, group, operation_setting.Price) if payMoney <= 0.01 { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"}) return diff --git a/controller/topup_creem.go b/controller/topup_creem.go index 7472690e22fb..cabc5bc00e91 100644 --- a/controller/topup_creem.go +++ b/controller/topup_creem.go @@ -98,7 +98,11 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) { } id := c.GetInt("id") - user, _ := model.GetUserById(id, false) + user, err := model.GetUserById(id, false) + if err != nil || user == nil || user.QuotaForbidden { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "该用户已被禁止充值"}) + return + } // 生成唯一的订单引用ID reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index 8a39576659e6..ea8f50130a42 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -86,7 +86,11 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { } id := c.GetInt("id") - user, _ := model.GetUserById(id, false) + user, err := model.GetUserById(id, false) + if err != nil || user == nil || user.QuotaForbidden { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "该用户已被禁止充值"}) + return + } chargedMoney := GetChargedAmount(float64(req.Amount), *user) reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go index 4ac3b2b5ddd7..322ab5439dfe 100644 --- a/controller/topup_waffo.go +++ b/controller/topup_waffo.go @@ -164,6 +164,10 @@ func RequestWaffoPay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"}) return } + if user.QuotaForbidden { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "该用户已被禁止充值"}) + return + } // 从服务端配置查找支付方式,客户端只传索引或旧字段 var resolvedPayMethodType, resolvedPayMethodName string diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index beb73ebee681..60977c01fef1 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -358,6 +358,10 @@ func RequestWaffoPancakePay(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"}) return } + if user.QuotaForbidden { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "该用户已被禁止充值"}) + return + } group, err := model.GetUserGroup(id, true) if err != nil { diff --git a/controller/user.go b/controller/user.go index 9b8d931ec1f8..f9a4715a8cf8 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1113,6 +1113,18 @@ func ManageUser(c *gin.Context) { } case "enable": user.Status = common.UserStatusEnabled + case "forbid_recharge": + user.QuotaForbidden = true + if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota_forbidden", true).Error; err != nil { + common.ApiError(c, err) + return + } + case "allow_recharge": + user.QuotaForbidden = false + if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota_forbidden", false).Error; err != nil { + common.ApiError(c, err) + return + } case "delete": if user.Role == common.RoleRootUser { common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser) @@ -1251,8 +1263,9 @@ func ManageUser(c *gin.Context) { "id": user.Id, }) clearUser := model.User{ - Role: user.Role, - Status: user.Status, + Role: user.Role, + Status: user.Status, + QuotaForbidden: user.QuotaForbidden, } c.JSON(http.StatusOK, gin.H{ "success": true, diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 000000000000..50d63e55066a --- /dev/null +++ b/deploy.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# ===================================================== +# new-api 自动部署脚本 +# 用法: ./deploy.sh +# 配合 cron 定时执行,实现自动拉取+构建+重启 +# ===================================================== +set -e + +cd "$(dirname "$0")" + +# 日志 +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +# 记录当前 HEAD,判断是否有更新 +BEFORE=$(git rev-parse HEAD) + +log "拉取最新代码..." +git pull origin main 2>&1 || true + +AFTER=$(git rev-parse HEAD) + +if [ "$BEFORE" = "$AFTER" ]; then + log "没有新更新,跳过构建" + exit 0 +fi + +log "检测到新更新 ($(echo $BEFORE | head -c 8)...$(echo $AFTER | head -c 8))" + +# 构建前端 +log "构建前端..." +cd web/default +if command -v bun &>/dev/null; then + bun install && bun run build +elif command -v npm &>/dev/null; then + npm ci && npm run build +else + log "错误: 未找到 bun 或 npm" + exit 1 +fi +cd ../.. + +# 编译 Go 后端 +log "编译 Go 二进制..." +go build -o new-api . + +# 重启服务 +log "重启 new-api 服务..." +if command -v systemctl &>/dev/null; then + systemctl restart new-api +elif command -v service &>/dev/null; then + service new-api restart +else + log "警告: 无法自动重启,请手动重启 new-api" +fi + +log "部署完成" diff --git a/dto/ticket.go b/dto/ticket.go new file mode 100644 index 000000000000..30982253f275 --- /dev/null +++ b/dto/ticket.go @@ -0,0 +1,79 @@ +package dto + +// ============================================================================ +// Request DTOs +// ============================================================================ + +type CreateTicketRequest struct { + Title string `json:"title" binding:"required,max=255"` + Content string `json:"content" binding:"required"` + Category string `json:"category"` + Priority string `json:"priority"` +} + +type AddTicketMessageRequest struct { + Content string `json:"content" binding:"required"` +} + +type AddTicketMessageAdminRequest struct { + Content string `json:"content" binding:"required"` + IsInternal bool `json:"is_internal"` +} + +type UpdateTicketStatusRequest struct { + Status string `json:"status" binding:"required"` +} + +type AssignTicketRequest struct { + AdminId int `json:"admin_id" binding:"required"` +} + +type TicketListQuery struct { + Page int `form:"page" json:"page"` + PageSize int `form:"page_size" json:"page_size"` + Status string `form:"status" json:"status"` + Category string `form:"category" json:"category"` + Keyword string `form:"keyword" json:"keyword"` +} + +// ============================================================================ +// Response DTOs +// ============================================================================ + +type TicketResponse struct { + Id int `json:"id"` + UserId int `json:"user_id"` + Title string `json:"title"` + Content string `json:"content"` + Category string `json:"category"` + Status string `json:"status"` + Priority string `json:"priority"` + AssignedTo *int `json:"assigned_to"` + ClosedAt *int64 `json:"closed_at"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Messages []TicketMessageDTO `json:"messages,omitempty"` + UserName string `json:"user_name,omitempty"` +} + +type TicketMessageDTO struct { + Id int `json:"id"` + UserId int `json:"user_id"` + Content string `json:"content"` + IsInternal bool `json:"is_internal"` + CreatedAt int64 `json:"created_at"` + UserName string `json:"user_name,omitempty"` +} + +type TicketListResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Data *TicketListData `json:"data,omitempty"` +} + +type TicketListData struct { + Tickets []TicketResponse `json:"tickets"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} diff --git a/go.mod b/go.mod index b0642f162db2..a060914995e3 100644 --- a/go.mod +++ b/go.mod @@ -90,7 +90,7 @@ require ( require ( github.com/DmitriyVTitov/size v1.5.0 // indirect github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index c533daecc32d..a710f08504d4 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -283,3 +283,18 @@ custom_oauth.name_empty: "Provider name cannot be empty" custom_oauth.has_bindings: "Cannot delete provider with existing user bindings" custom_oauth.binding_not_found: "OAuth binding not found" custom_oauth.provider_id_field_invalid: "Could not extract user ID from provider response" + +# Ticket system messages +ticket.create_success: "Ticket created successfully" +ticket.create_failed: "Failed to create ticket" +ticket.not_found: "Ticket not found" +ticket.invalid_id: "Invalid ticket ID" +ticket.title_empty: "Title cannot be empty" +ticket.content_empty: "Content cannot be empty" +ticket.close_success: "Ticket closed successfully" +ticket.reopen_success: "Ticket reopened successfully" +ticket.message_added: "Reply added successfully" +ticket.message_failed: "Failed to add reply" +ticket.status_updated: "Status updated successfully" +ticket.assign_success: "Ticket assigned successfully" +ticket.invalid_status: "Invalid status value" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index a2f5275be9a8..0c6b848dc4f6 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -284,3 +284,18 @@ custom_oauth.name_empty: "提供商名称不能为空" custom_oauth.has_bindings: "无法删除已有用户绑定的提供商" custom_oauth.binding_not_found: "OAuth 绑定不存在" custom_oauth.provider_id_field_invalid: "无法从提供商响应中提取用户 ID" + +# Ticket system messages +ticket.create_success: "工单创建成功" +ticket.create_failed: "创建工单失败" +ticket.not_found: "工单不存在" +ticket.invalid_id: "无效的工单ID" +ticket.title_empty: "标题不能为空" +ticket.content_empty: "内容不能为空" +ticket.close_success: "工单已关闭" +ticket.reopen_success: "工单已重新开启" +ticket.message_added: "回复成功" +ticket.message_failed: "回复失败" +ticket.status_updated: "状态已更新" +ticket.assign_success: "分配成功" +ticket.invalid_status: "无效的状态值" diff --git a/model/channel_status_probe_log.go b/model/channel_status_probe_log.go new file mode 100644 index 000000000000..12edb938ad73 --- /dev/null +++ b/model/channel_status_probe_log.go @@ -0,0 +1,189 @@ +package model + +import ( + "time" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" +) + +// ChannelStatusProbeLog 状态检测页的轻量探测日志 +// +// 用于按分组聚合状态、7 天可用率与近 60 次色条。写入时机为渠道测试 +// (controller.testChannel) 每次完成之后,禁止携带任何密钥或敏感信息。 +type ChannelStatusProbeLog struct { + Id int64 `json:"id" gorm:"primaryKey"` + ChannelId int `json:"channel_id" gorm:"index:idx_channel_status_probe_channel_time,priority:1;index"` + Success bool `json:"success"` + Level string `json:"level" gorm:"type:varchar(16)"` // ok | degraded | fail + LatencyMs int `json:"latency_ms"` + PingMs int `json:"ping_ms"` + Message string `json:"message" gorm:"type:varchar(255)"` + CheckedAt int64 `json:"checked_at" gorm:"bigint;index:idx_channel_status_probe_channel_time,priority:2;index"` +} + +const ( + ChannelStatusProbeLevelOK = "ok" + ChannelStatusProbeLevelDegraded = "degraded" + ChannelStatusProbeLevelFail = "fail" + + // ChannelStatusProbeMaxPerChannel 单个渠道保留的最大条数 + ChannelStatusProbeMaxPerChannel = 120 + // ChannelStatusProbeRetentionDays 全部渠道的日志保留天数 + ChannelStatusProbeRetentionDays = 7 + // ChannelStatusProbeMessageMaxLen message 字段最大长度 + ChannelStatusProbeMessageMaxLen = 255 +) + +func (ChannelStatusProbeLog) TableName() string { + return "channel_status_probe_logs" +} + +// truncateProbeMessage 保留 message 的前 N 个 rune,防止字段超长或潜在敏感信息过长 +func truncateProbeMessage(msg string) string { + if msg == "" { + return "" + } + if len(msg) <= ChannelStatusProbeMessageMaxLen { + return msg + } + runes := []rune(msg) + if len(runes) <= ChannelStatusProbeMessageMaxLen { + return msg + } + return string(runes[:ChannelStatusProbeMessageMaxLen]) +} + +// AppendChannelStatusProbeLog 追加一条探测日志,并按 channel 维度裁剪历史 +// +// 该操作对渠道测试主流程非关键,任何错误只写日志、不返回。 +func AppendChannelStatusProbeLog(channelId int, success bool, level string, latencyMs int, pingMs int, message string) { + if channelId <= 0 { + return + } + switch level { + case ChannelStatusProbeLevelOK, ChannelStatusProbeLevelDegraded, ChannelStatusProbeLevelFail: + default: + if success { + level = ChannelStatusProbeLevelOK + } else { + level = ChannelStatusProbeLevelFail + } + } + if latencyMs < 0 { + latencyMs = 0 + } + if pingMs < 0 { + pingMs = 0 + } + entry := ChannelStatusProbeLog{ + ChannelId: channelId, + Success: success, + Level: level, + LatencyMs: latencyMs, + PingMs: pingMs, + Message: truncateProbeMessage(message), + CheckedAt: time.Now().Unix(), + } + if err := DB.Create(&entry).Error; err != nil { + common.SysError("failed to append channel status probe log: " + err.Error()) + return + } + trimChannelStatusProbeLogs(channelId) +} + +// trimChannelStatusProbeLogs 保留 channel 的最近 ChannelStatusProbeMaxPerChannel 条 +func trimChannelStatusProbeLogs(channelId int) { + var cutoff ChannelStatusProbeLog + err := DB.Where("channel_id = ?", channelId). + Order("checked_at DESC, id DESC"). + Offset(ChannelStatusProbeMaxPerChannel - 1). + Limit(1). + Take(&cutoff).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return + } + common.SysError("failed to lookup probe log cutoff: " + err.Error()) + return + } + if cutoff.Id == 0 { + return + } + if err := DB.Where("channel_id = ? AND id < ?", channelId, cutoff.Id). + Delete(&ChannelStatusProbeLog{}).Error; err != nil { + common.SysError("failed to trim probe logs: " + err.Error()) + } +} + +// PurgeChannelStatusProbeLogs 清理超过保留期的历史日志,供定期任务调用 +func PurgeChannelStatusProbeLogs() error { + cutoff := time.Now().Add(-time.Duration(ChannelStatusProbeRetentionDays) * 24 * time.Hour).Unix() + return DB.Where("checked_at < ?", cutoff).Delete(&ChannelStatusProbeLog{}).Error +} + +// GetLatestProbeLogByChannel 获取渠道最新一条 log,用于聚合最新延迟 +func GetLatestProbeLogByChannel(channelId int) (*ChannelStatusProbeLog, error) { + var log ChannelStatusProbeLog + err := DB.Where("channel_id = ?", channelId). + Order("checked_at DESC, id DESC"). + Take(&log).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &log, nil +} + +// GetRecentProbeLogsByChannel 获取渠道最近 N 条 log(按时间倒序),供色条渲染 +func GetRecentProbeLogsByChannel(channelId int, limit int) ([]ChannelStatusProbeLog, error) { + if limit <= 0 { + limit = 60 + } + var logs []ChannelStatusProbeLog + err := DB.Where("channel_id = ?", channelId). + Order("checked_at DESC, id DESC"). + Limit(limit). + Find(&logs).Error + if err != nil { + return nil, err + } + return logs, nil +} + +// ChannelStatusAvailabilityWindow 保存一段时间窗口内的成功/总条数聚合结果 +type ChannelStatusAvailabilityWindow struct { + Total int64 + Success int64 +} + +// GetChannelStatusAvailabilityByChannels 计算多个 channel 在 sinceSeconds 秒内的可用率数据 +func GetChannelStatusAvailabilityByChannels(channelIds []int, sinceSeconds int64) (map[int]ChannelStatusAvailabilityWindow, error) { + result := make(map[int]ChannelStatusAvailabilityWindow, len(channelIds)) + if len(channelIds) == 0 { + return result, nil + } + cutoff := time.Now().Unix() - sinceSeconds + + type row struct { + ChannelId int + Total int64 + Success int64 + } + var rows []row + err := DB.Model(&ChannelStatusProbeLog{}). + Select("channel_id, COUNT(*) as total, SUM(CASE WHEN success = "+commonTrueVal+" THEN 1 ELSE 0 END) as success"). + Where("channel_id IN ? AND checked_at >= ?", channelIds, cutoff). + Group("channel_id"). + Scan(&rows).Error + if err != nil { + return result, err + } + for _, r := range rows { + result[r.ChannelId] = ChannelStatusAvailabilityWindow{Total: r.Total, Success: r.Success} + } + return result, nil +} diff --git a/model/commission.go b/model/commission.go new file mode 100644 index 000000000000..5b95c4c7186a --- /dev/null +++ b/model/commission.go @@ -0,0 +1,836 @@ +package model + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "gorm.io/gorm" +) + +// Commission constants +const ( + CommissionStatusPending = "pending" + CommissionStatusSettled = "settled" + CommissionStatusAdjusted = "adjusted" + + WithdrawalStatusPending = "pending" + WithdrawalStatusApproved = "approved" + WithdrawalStatusRejected = "rejected" +) + +// CommissionTier represents a commission rate tier configuration +type CommissionTier struct { + MinUsers int `json:"min_users"` + Rate float64 `json:"rate"` +} + +// CommissionRecord stores each commission earning +type CommissionRecord struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index;not null"` + FromUserId int `json:"from_user_id" gorm:"index;not null"` + TopUpId int `json:"topup_id"` + Amount float64 `json:"amount"` + Money float64 `json:"money"` // the recharge amount that generated this commission + Rate float64 `json:"rate"` // commission rate applied + Status string `json:"status" gorm:"type:varchar(20);default:'pending'"` + Remark string `json:"remark" gorm:"type:varchar(255)"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"` +} + +// CommissionErrorLog records failed commission processing attempts for retry/investigation +type CommissionErrorLog struct { + Id int `json:"id"` + TopUpId int `json:"topup_id" gorm:"index"` + UserId int `json:"user_id" gorm:"index"` + Money float64 `json:"money"` + Error string `json:"error" gorm:"type:text"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` +} + +// CommissionWallet stores user's commission balance +type CommissionWallet struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"uniqueIndex;not null"` + Balance float64 `json:"balance" gorm:"default:0"` + TotalEarned float64 `json:"total_earned" gorm:"default:0"` + TotalWithdrawn float64 `json:"total_withdrawn" gorm:"default:0"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"` +} + +// WithdrawalRequest stores user's withdrawal requests +type WithdrawalRequest struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index;not null"` + Amount float64 `json:"amount"` + PayInfo string `json:"pay_info" gorm:"type:text"` + Status string `json:"status" gorm:"type:varchar(20);default:'pending'"` + ReviewedBy *int `json:"reviewed_by"` + ReviewNote string `json:"review_note" gorm:"type:varchar(255)"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` + ReviewedAt *int64 `json:"reviewed_at"` +} + +// ============================================================================ +// Commission Tier configuration (stored as JSON via Options system) +// ============================================================================ + +const CommissionConfigKey = "commission_config" +const DefaultCommissionRate = 0.05 + +type CommissionConfig struct { + DefaultRate float64 `json:"default_rate"` + Tiers []CommissionTier `json:"tiers"` + MinConsumption float64 `json:"min_consumption"` // minimum total topup money to count as active affiliate + MinWithdrawAmount float64 `json:"min_withdraw_amount"` // minimum amount for withdrawal (0 = no limit) +} + +func GetDefaultCommissionConfig() *CommissionConfig { + return &CommissionConfig{ + DefaultRate: DefaultCommissionRate, + Tiers: []CommissionTier{ + {MinUsers: 0, Rate: 0.05}, + {MinUsers: 5, Rate: 0.08}, + {MinUsers: 20, Rate: 0.12}, + {MinUsers: 50, Rate: 0.15}, + {MinUsers: 100, Rate: 0.20}, + }, + MinConsumption: 10, + MinWithdrawAmount: 0, + } +} + +// GetUserActiveAffCount returns the number of active referrals who have recharged at least minConsumption in total. +func GetUserActiveAffCount(userId int, minConsumption float64) (int, error) { + // Try Redis cache first + if common.RedisEnabled { + cacheKey := fmt.Sprintf("commission:active_aff:%d", userId) + if cached, err := common.RDB.Get(context.Background(), cacheKey).Result(); err == nil { + if count, err := strconv.Atoi(cached); err == nil { + return count, nil + } + } + } + + var count int64 + subQuery := DB.Table("top_ups"). + Select("top_ups.user_id"). + Joins("JOIN users ON users.id = top_ups.user_id AND users.inviter_id = ?", userId). + Where("top_ups.status = ?", common.TopUpStatusSuccess). + Group("top_ups.user_id"). + Having("SUM(top_ups.money) >= ?", minConsumption) + + err := DB.Table("(?) AS active_users", subQuery).Count(&count).Error + if err != nil { + return 0, err + } + + result := int(count) + + // Cache for 5 minutes + if common.RedisEnabled { + common.RDB.Set(context.Background(), fmt.Sprintf("commission:active_aff:%d", userId), result, 5*time.Minute) + } + + return result, nil +} + +// InvalidateActiveAffCache removes cached active_aff_count for a user. +func InvalidateActiveAffCache(userId int) { + if common.RedisEnabled && userId > 0 { + common.RDB.Del(context.Background(), fmt.Sprintf("commission:active_aff:%d", userId)) + } +} + +// loadCommissionConfig returns the effective commission config. +func loadCommissionConfig() *CommissionConfig { + config := GetDefaultCommissionConfig() + if v, ok := common.OptionMap[CommissionConfigKey]; ok && v != "" { + parsed := &CommissionConfig{} + if err := common.UnmarshalJsonStr(v, parsed); err == nil { + config = parsed + } + } + return config +} + +// GetCommissionRate determines the commission rate for a user based on active referrals +func GetUserCommissionRate(userId int) (float64, int, error) { + config := loadCommissionConfig() + activeCount, err := GetUserActiveAffCount(userId, config.MinConsumption) + if err != nil { + return config.DefaultRate, 0, err + } + + bestRate := config.DefaultRate + for _, tier := range config.Tiers { + if activeCount >= tier.MinUsers { + bestRate = tier.Rate + } + } + return bestRate, activeCount, nil +} + +// GetNextTierInfo finds the next tier threshold +func GetNextTierInfo(userId int) (currentRate float64, activeCount int, nextMinUsers int, nextRate float64, err error) { + config := loadCommissionConfig() + activeCount, err = GetUserActiveAffCount(userId, config.MinConsumption) + if err != nil { + return 0, 0, 0, 0, err + } + + bestRate := config.DefaultRate + nextMin := 0 + nextRt := 0.0 + for _, tier := range config.Tiers { + if activeCount >= tier.MinUsers { + bestRate = tier.Rate + } + if activeCount < tier.MinUsers && (nextMin == 0 || tier.MinUsers < nextMin) { + nextMin = tier.MinUsers + nextRt = tier.Rate + } + } + + return bestRate, activeCount, nextMin, nextRt, nil +} + +// ============================================================================ +// Commission Wallet +// ============================================================================ + +func GetCommissionWallet(userId int) (*CommissionWallet, error) { + var wallet CommissionWallet + err := DB.Where("user_id = ?", userId).First(&wallet).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + wallet = CommissionWallet{ + UserId: userId, + Balance: 0, + } + if err := DB.Create(&wallet).Error; err != nil { + return nil, err + } + return &wallet, nil + } + return nil, err + } + return &wallet, nil +} + +func EnsureCommissionWallet(userId int) error { + _, err := GetCommissionWallet(userId) + return err +} + +// ============================================================================ +// Commission Records +// ============================================================================ + +func CreateCommissionRecord(record *CommissionRecord) error { + return DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(record).Error; err != nil { + return err + } + // Lock and update wallet to prevent lost updates under concurrent commissions + var wallet CommissionWallet + err := tx.Set("gorm:query_option", "FOR UPDATE").Where("user_id = ?", record.UserId).First(&wallet).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + wallet = CommissionWallet{ + UserId: record.UserId, + Balance: record.Amount, + TotalEarned: record.Amount, + } + return tx.Create(&wallet).Error + } + return err + } + return tx.Model(&wallet).Updates(map[string]interface{}{ + "balance": gorm.Expr("balance + ?", record.Amount), + "total_earned": gorm.Expr("total_earned + ?", record.Amount), + }).Error + }) +} + +func GetUserCommissionRecords(userId int, page, pageSize int, status string) ([]CommissionRecord, int64, error) { + var records []CommissionRecord + var total int64 + query := DB.Model(&CommissionRecord{}).Where("user_id = ?", userId) + if status != "" { + query = query.Where("status = ?", status) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil { + return nil, 0, err + } + return records, total, nil +} + +func GetAllCommissionRecords(page, pageSize int, userId int, status string) ([]CommissionRecord, int64, error) { + var records []CommissionRecord + var total int64 + query := DB.Model(&CommissionRecord{}) + if userId > 0 { + query = query.Where("user_id = ?", userId) + } + if status != "" { + query = query.Where("status = ?", status) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil { + return nil, 0, err + } + return records, total, nil +} + +// GetUserCommissionStats returns monthly and total commission stats +func GetUserCommissionStats(userId int) (monthlyEarned float64, totalEarned float64, err error) { + wallet, err := GetCommissionWallet(userId) + if err != nil { + return 0, 0, err + } + + // Monthly earned + monthStart := time.Now().Unix() - int64(time.Now().Day())*86400 + err = DB.Model(&CommissionRecord{}). + Where("user_id = ? AND created_at >= ?", userId, monthStart). + Select("COALESCE(SUM(amount), 0)").Scan(&monthlyEarned).Error + if err != nil { + return 0, 0, err + } + + return monthlyEarned, wallet.TotalEarned, nil +} + +// ============================================================================ +// Commission on Recharge +// ============================================================================ + +// ProcessCommissionForTopUp creates commission records when a recharge completes +func ProcessCommissionForTopUp(topUpUserId int, topUpId int, money float64) error { + // Prevent duplicate commission for the same topup + if topUpId > 0 { + var exists int64 + if err := DB.Model(&CommissionRecord{}).Where("topup_id = ?", topUpId).Count(&exists).Error; err == nil && exists > 0 { + return nil + } + } + + // Convert Epay1 (CNY) to USD for unified commission base + if topUpId > 0 { + var topUp TopUp + if err := DB.Select("payment_provider, payment_method").First(&topUp, topUpId).Error; err == nil { + if topUp.PaymentProvider == PaymentProviderEpay && !strings.HasPrefix(topUp.PaymentMethod, "g2:") { + exchangeRate := 7.3 + if rateStr, ok := common.OptionMap["CommissionUSDExchangeRate"]; ok && rateStr != "" { + if rate, err := strconv.ParseFloat(rateStr, 64); err == nil && rate > 0 { + exchangeRate = rate + } + } + money = money / exchangeRate + } + } + } + + // Find the user + var user User + if err := DB.First(&user, topUpUserId).Error; err != nil { + logCommissionError(topUpId, topUpUserId, money, "user not found: "+err.Error()) + return nil + } + + // If user has no inviter, no commission + if user.InviterId == 0 { + return nil + } + + inviterId := user.InviterId + + // Get inviter's commission rate + rate, _, err := GetUserCommissionRate(inviterId) + if err != nil { + logCommissionError(topUpId, topUpUserId, money, "failed to get commission rate: "+err.Error()) + return nil + } + + if rate <= 0 { + return nil + } + + // Calculate commission + commissionAmount := money * rate + // Round to 2 decimal places + commissionAmount = math.Round(commissionAmount*100) / 100 + + if commissionAmount <= 0 { + return nil + } + + record := &CommissionRecord{ + UserId: inviterId, + FromUserId: topUpUserId, + TopUpId: topUpId, + Amount: commissionAmount, + Money: money, + Rate: rate, + Status: CommissionStatusSettled, + } + + if err := CreateCommissionRecord(record); err != nil { + logCommissionError(topUpId, topUpUserId, money, "create record failed: "+err.Error()) + return err + } + + // Invalidate cached active_aff_count for the inviter + InvalidateActiveAffCache(inviterId) + + return nil +} + +func logCommissionError(topUpId int, userId int, money float64, errMsg string) { + common.SysError(fmt.Sprintf("commission failed: topup_id=%d user_id=%d money=%.2f error=%s", topUpId, userId, money, errMsg)) + entry := &CommissionErrorLog{ + TopUpId: topUpId, + UserId: userId, + Money: money, + Error: errMsg, + } + if err := DB.Create(entry).Error; err != nil { + common.SysError("failed to persist commission error log: " + err.Error()) + } +} + +// ============================================================================ +// Withdrawal Requests +// ============================================================================ + +func CreateWithdrawalRequest(userId int, amount float64, payInfo string) error { + if amount <= 0 { + return errors.New("提现金额必须大于0") + } + + // Check minimum withdrawal amount + config := loadCommissionConfig() + if config.MinWithdrawAmount > 0 && amount < config.MinWithdrawAmount { + return fmt.Errorf("提现金额不能低于 %.2f", config.MinWithdrawAmount) + } + + // Check wallet balance + wallet, err := GetCommissionWallet(userId) + if err != nil { + return errors.New("获取佣金钱包失败") + } + if wallet.Balance < amount { + return errors.New("佣金钱包余额不足") + } + + return DB.Transaction(func(tx *gorm.DB) error { + // Lock and check balance + var lockedWallet CommissionWallet + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("user_id = ?", userId).First(&lockedWallet).Error; err != nil { + return errors.New("获取佣金钱包失败") + } + if lockedWallet.Balance < amount { + return errors.New("佣金钱包余额不足") + } + + // Deduct balance + result := tx.Model(&lockedWallet).Update("balance", gorm.Expr("balance - ?", amount)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("提现失败:余额更新未生效") + } + + // Create withdrawal request + req := &WithdrawalRequest{ + UserId: userId, + Amount: amount, + PayInfo: payInfo, + Status: WithdrawalStatusPending, + } + return tx.Create(req).Error + }) +} + +func GetUserWithdrawals(userId int, page, pageSize int) ([]WithdrawalRequest, int64, error) { + var withdrawals []WithdrawalRequest + var total int64 + query := DB.Model(&WithdrawalRequest{}).Where("user_id = ?", userId) + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&withdrawals).Error; err != nil { + return nil, 0, err + } + return withdrawals, total, nil +} + +func GetAllWithdrawals(page, pageSize int, status string) ([]WithdrawalRequest, int64, error) { + var withdrawals []WithdrawalRequest + var total int64 + query := DB.Model(&WithdrawalRequest{}) + if status != "" { + query = query.Where("status = ?", status) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&withdrawals).Error; err != nil { + return nil, 0, err + } + return withdrawals, total, nil +} + +func ApproveWithdrawal(id int, adminId int) error { + now := time.Now().Unix() + return DB.Transaction(func(tx *gorm.DB) error { + var req WithdrawalRequest + if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&req, id).Error; err != nil { + return errors.New("提现申请不存在") + } + if req.Status != WithdrawalStatusPending { + return errors.New("提现申请已处理") + } + + if err := tx.Model(&req).Updates(map[string]interface{}{ + "status": WithdrawalStatusApproved, + "reviewed_by": adminId, + "reviewed_at": now, + }).Error; err != nil { + return err + } + + // Update wallet total_withdrawn + return tx.Model(&CommissionWallet{}). + Where("user_id = ?", req.UserId). + Update("total_withdrawn", gorm.Expr("total_withdrawn + ?", req.Amount)).Error + }) +} + +func RejectWithdrawal(id int, adminId int, note string) error { + return DB.Transaction(func(tx *gorm.DB) error { + var req WithdrawalRequest + if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&req, id).Error; err != nil { + return errors.New("提现申请不存在") + } + if req.Status != WithdrawalStatusPending { + return errors.New("提现申请已处理") + } + + now := time.Now().Unix() + if err := tx.Model(&req).Updates(map[string]interface{}{ + "status": WithdrawalStatusRejected, + "reviewed_by": adminId, + "review_note": note, + "reviewed_at": now, + }).Error; err != nil { + return err + } + + // Refund the balance + if err := tx.Model(&CommissionWallet{}). + Where("user_id = ?", req.UserId). + Update("balance", gorm.Expr("balance + ?", req.Amount)).Error; err != nil { + return err + } + + // Create a refund record for audit trail + refundRecord := &CommissionRecord{ + UserId: req.UserId, + Amount: req.Amount, + Status: CommissionStatusAdjusted, + Remark: fmt.Sprintf("提现拒绝退款 (withdrawal_id=%d review_note=%s)", id, note), + } + return tx.Create(refundRecord).Error + }) +} + +// ============================================================================ +// Admin: Transfer commission balance to main balance +// ============================================================================ + +func TransferCommissionToBalance(userId int, amount float64) error { + if amount <= 0 { + return errors.New("划转金额必须大于0") + } + + wallet, err := GetCommissionWallet(userId) + if err != nil { + return errors.New("获取佣金钱包失败") + } + if wallet.Balance < amount { + return errors.New("佣金钱包余额不足") + } + + // Convert money to quota + quota := int(math.Round(amount * common.QuotaPerUnit)) + if quota <= 0 { + return errors.New("划转金额过低") + } + + err = DB.Transaction(func(tx *gorm.DB) error { + // Lock wallet + var lockedWallet CommissionWallet + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("user_id = ?", userId).First(&lockedWallet).Error; err != nil { + return errors.New("获取佣金钱包失败") + } + if lockedWallet.Balance < amount { + return errors.New("佣金钱包余额不足") + } + + // Deduct from commission wallet + result := tx.Model(&lockedWallet).Update("balance", gorm.Expr("balance - ?", amount)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("划转失败:佣金余额更新未生效") + } + + // Add to main quota + result = tx.Model(&User{}).Where("id = ?", userId). + Update("quota", gorm.Expr("quota + ?", quota)) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("划转失败:用户余额更新未生效") + } + + return nil + }) + if err != nil { + return err + } + + // Record usage log (outside transaction, consistent with topup pattern) + RecordLog(userId, LogTypeSystem, fmt.Sprintf("佣金划转:%.2f 元 → 余额 %s", amount, logger.LogQuota(quota))) + + return nil +} + +// ============================================================================ +// Admin: Manual adjustment +// ============================================================================ + +func AdjustCommission(userId int, amount float64, remark string) error { + if amount == 0 { + return errors.New("调整金额不能为0") + } + + return DB.Transaction(func(tx *gorm.DB) error { + var wallet CommissionWallet + err := tx.Set("gorm:query_option", "FOR UPDATE").Where("user_id = ?", userId).First(&wallet).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + wallet = CommissionWallet{UserId: userId} + if err := tx.Create(&wallet).Error; err != nil { + return err + } + } else { + return err + } + } + + updates := map[string]interface{}{ + "balance": gorm.Expr("balance + ?", amount), + } + if amount > 0 { + updates["total_earned"] = gorm.Expr("total_earned + ?", amount) + } + if err := tx.Model(&wallet).Updates(updates).Error; err != nil { + return err + } + + record := &CommissionRecord{ + UserId: userId, + Amount: amount, + Status: CommissionStatusAdjusted, + Remark: remark, + } + return tx.Create(record).Error + }) +} + +// ============================================================================ +// Admin: Get promoter list +// ============================================================================ + +type PromoterItem struct { + Id int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + WalletBalance float64 `json:"wallet_balance"` + TotalEarned float64 `json:"total_earned"` + TotalWithdrawn float64 `json:"total_withdrawn"` + ActiveAffCount int `json:"active_aff_count"` + CommissionRate float64 `json:"commission_rate"` + CreatedAt int64 `json:"created_at"` +} + +func GetPromoterList(page, pageSize int, keyword string) ([]PromoterItem, int64, error) { + var items []PromoterItem + var total int64 + + config := loadCommissionConfig() + + query := DB.Table("commission_wallets"). + Select(`commission_wallets.user_id as id, users.username, users.email, + commission_wallets.balance as wallet_balance, + commission_wallets.total_earned, + commission_wallets.total_withdrawn, + COALESCE(active_counts.cnt, 0) as active_aff_count, + users.created_at`). + Joins("LEFT JOIN users ON users.id = commission_wallets.user_id"). + Joins(`LEFT JOIN ( + SELECT users.inviter_id, COUNT(DISTINCT top_ups.user_id) as cnt + FROM users + JOIN top_ups ON top_ups.user_id = users.id AND top_ups.status = ? + WHERE users.inviter_id > 0 + GROUP BY users.inviter_id + HAVING SUM(top_ups.money) >= ? + ) active_counts ON active_counts.inviter_id = commission_wallets.user_id`, common.TopUpStatusSuccess, config.MinConsumption) + + if keyword != "" { + query = query.Where("users.username LIKE ? OR users.email LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("commission_wallets.total_earned DESC"). + Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil { + return nil, 0, err + } + + for i := range items { + rate := config.DefaultRate + for _, tier := range config.Tiers { + if items[i].ActiveAffCount >= tier.MinUsers { + rate = tier.Rate + } + } + items[i].CommissionRate = rate + } + + return items, total, nil +} + +// ============================================================================ +// Admin: Dashboard stats +// ============================================================================ + +type CommissionDashboardStats struct { + TotalCommission float64 `json:"total_commission"` + TotalCommissionPaid float64 `json:"total_commission_paid"` + ActivePromoters int64 `json:"active_promoters"` + TotalPromoters int64 `json:"total_promoters"` + PendingWithdrawals int64 `json:"pending_withdrawals"` + PendingWithdrawalAmount float64 `json:"pending_withdrawal_amount"` +} + +type TierDistItem struct { + Tier string `json:"tier"` + Rate float64 `json:"rate"` + Count int64 `json:"count"` +} + +type TopPromoterItem struct { + UserId int `json:"user_id"` + Username string `json:"username"` + TotalEarned float64 `json:"total_earned"` + ActiveCount int `json:"active_count"` +} + +func GetCommissionDashboardStats() (*CommissionDashboardStats, error) { + stats := &CommissionDashboardStats{} + + // Total commission earned (sum of all earned commissions) + DB.Model(&CommissionWallet{}).Select("COALESCE(SUM(total_earned), 0)").Scan(&stats.TotalCommission) + + // Total commission paid out (sum of all withdrawn amounts) + DB.Model(&CommissionWallet{}).Select("COALESCE(SUM(total_withdrawn), 0)").Scan(&stats.TotalCommissionPaid) + + // Count wallets with total_earned > 0 as active + DB.Model(&CommissionWallet{}).Where("total_earned > 0").Count(&stats.ActivePromoters) + + // Count wallets with total_earned > 0 OR balance > 0 as total + DB.Model(&CommissionWallet{}).Where("total_earned > 0 OR balance > 0").Count(&stats.TotalPromoters) + + // Pending withdrawal requests count + DB.Model(&WithdrawalRequest{}).Where("status = ?", WithdrawalStatusPending).Count(&stats.PendingWithdrawals) + + // Pending withdrawal requests total amount + DB.Model(&WithdrawalRequest{}).Select("COALESCE(SUM(amount), 0)").Where("status = ?", WithdrawalStatusPending).Scan(&stats.PendingWithdrawalAmount) + + return stats, nil +} + +// GetDownlineUsers returns the referral list for a user +type DownlineUser struct { + Id int `json:"id"` + Username string `json:"username"` + TotalTopUp float64 `json:"total_topup"` + CreatedAt int64 `json:"created_at"` + LastTopUpAt int64 `json:"last_topup_at"` +} + +func GetUserDownline(userId int, page, pageSize int) ([]DownlineUser, int64, error) { + var downlines []DownlineUser + var total int64 + + // Count + if err := DB.Model(&User{}).Where("inviter_id = ?", userId).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get dynamic commission exchange rate + commissionRate := 7.3 + if rateStr, ok := common.OptionMap["CommissionUSDExchangeRate"]; ok && rateStr != "" { + if rate, err := strconv.ParseFloat(rateStr, 64); err == nil && rate > 0 { + commissionRate = rate + } + } + + // Query with pagination + selectExpr := fmt.Sprintf(`users.id, users.username, users.created_at, + COALESCE(SUM(CASE WHEN top_ups.payment_provider = 'epay' AND top_ups.payment_method NOT LIKE 'g2:%%' THEN top_ups.money / %f ELSE top_ups.money END), 0) as total_topup, + COALESCE(MAX(top_ups.complete_time), 0) as last_topup_at`, commissionRate) + rows, err := DB.Table("users"). + Select(selectExpr). + Joins("LEFT JOIN top_ups ON top_ups.user_id = users.id AND top_ups.status = ?", common.TopUpStatusSuccess). + Where("users.inviter_id = ?", userId). + Group("users.id"). + Order("users.id DESC"). + Offset((page - 1) * pageSize).Limit(pageSize).Rows() + if err != nil { + return nil, 0, err + } + defer rows.Close() + + for rows.Next() { + var d DownlineUser + if err := rows.Scan(&d.Id, &d.Username, &d.CreatedAt, &d.TotalTopUp, &d.LastTopUpAt); err != nil { + return nil, 0, err + } + downlines = append(downlines, d) + } + + return downlines, total, nil +} diff --git a/model/main.go b/model/main.go index 21445593e54e..92f5bd25263e 100644 --- a/model/main.go +++ b/model/main.go @@ -292,6 +292,12 @@ func migrateDB() error { &SystemTaskLock{}, &CasbinRule{}, &AuthzRole{}, + &Ticket{}, + &TicketMessage{}, + &CommissionRecord{}, + &CommissionWallet{}, + &WithdrawalRequest{}, + &ChannelStatusProbeLog{}, ) if err != nil { return err @@ -353,6 +359,12 @@ func migrateDBFast() error { {&SystemInstance{}, "SystemInstance"}, {&SystemTask{}, "SystemTask"}, {&SystemTaskLock{}, "SystemTaskLock"}, + {&Ticket{}, "Ticket"}, + {&TicketMessage{}, "TicketMessage"}, + {&CommissionRecord{}, "CommissionRecord"}, + {&CommissionWallet{}, "CommissionWallet"}, + {&WithdrawalRequest{}, "WithdrawalRequest"}, + {&ChannelStatusProbeLog{}, "ChannelStatusProbeLog"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/option.go b/model/option.go index e7fda5231be7..68b192308a98 100644 --- a/model/option.go +++ b/model/option.go @@ -69,6 +69,8 @@ func InitOptionMap() { common.OptionMap["Notice"] = "" common.OptionMap["About"] = "" common.OptionMap["HomePageContent"] = "" + common.OptionMap["CustomPageTitle"] = "" + common.OptionMap["CustomPageContent"] = "" common.OptionMap["Footer"] = common.Footer common.OptionMap["SystemName"] = common.SystemName common.OptionMap["Logo"] = common.Logo @@ -80,9 +82,14 @@ func InitOptionMap() { common.OptionMap["CustomCallbackAddress"] = "" common.OptionMap["EpayId"] = "" common.OptionMap["EpayKey"] = "" + common.OptionMap["EpayFee"] = "0" + common.OptionMap["EpayGateway2"] = "{}" + common.OptionMap["PaymentTip"] = "" common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64) common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64) + common.OptionMap["CommissionUSDExchangeRate"] = strconv.FormatFloat(operation_setting.CommissionUSDExchangeRate, 'f', -1, 64) common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp) + common.OptionMap["MaxTopUp"] = strconv.Itoa(operation_setting.MaxTopUp) common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp) common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret @@ -425,12 +432,20 @@ func updateOptionMap(key string, value string) (err error) { operation_setting.EpayId = value case "EpayKey": operation_setting.EpayKey = value + case "EpayFee": + operation_setting.EpayFee, _ = strconv.ParseFloat(value, 64) + case "EpayGateway2": + err = operation_setting.UpdateEpayGateway2ByJsonString(value) case "Price": operation_setting.Price, _ = strconv.ParseFloat(value, 64) case "USDExchangeRate": operation_setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64) + case "CommissionUSDExchangeRate": + operation_setting.CommissionUSDExchangeRate, _ = strconv.ParseFloat(value, 64) case "MinTopUp": operation_setting.MinTopUp, _ = strconv.Atoi(value) + case "MaxTopUp": + operation_setting.MaxTopUp, _ = strconv.Atoi(value) case "StripeApiSecret": setting.StripeApiSecret = value case "StripeWebhookSecret": diff --git a/model/redemption.go b/model/redemption.go index 23985ef474b7..8d1150c1eec2 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -147,6 +147,7 @@ func Redeem(key string, userId int) (quota int, err error) { if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) { keyCol = `"key"` } + var topupId int common.RandomSleep() err = DB.Transaction(func(tx *gorm.DB) error { err := lockForUpdate(tx).Where(keyCol+" = ?", key).First(redemption).Error @@ -159,6 +160,14 @@ func Redeem(key string, userId int) (quota int, err error) { if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() { return errors.New("该兑换码已过期") } + // 检查用户是否被禁止充值 + var redeemUser User + if err := tx.Where("id = ?", userId).First(&redeemUser).Error; err != nil { + return errors.New("用户不存在") + } + if redeemUser.QuotaForbidden { + return errors.New("该用户已被禁止充值") + } // Compare-and-swap on status: only the transaction that flips // enabled -> used may credit quota, so a concurrent redeem of the // same code loses here even without a row lock (e.g. on SQLite). @@ -175,13 +184,39 @@ func Redeem(key string, userId int) (quota int, err error) { if result.RowsAffected == 0 { return errors.New("该兑换码已被使用") } - return tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error + if err := tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error; err != nil { + return err + } + + // Create top_up record for redemption code (for commission tracking) + now := common.GetTimestamp() + topup := TopUp{ + UserId: userId, + Amount: int64(redemption.Quota), + Money: float64(redemption.Quota) / common.QuotaPerUnit, + TradeNo: key, + PaymentMethod: "redemption", + PaymentProvider: "redemption", + CreateTime: now, + CompleteTime: now, + Status: common.TopUpStatusSuccess, + } + if err := tx.Create(&topup).Error; err != nil { + return err + } + topupId = topup.Id + return nil }) if err != nil { common.SysError("redemption failed: " + err.Error()) return 0, ErrRedeemFailed } RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id)) + + // 兑换码返佣:使用统一返佣逻辑 + money := float64(redemption.Quota) / common.QuotaPerUnit + go ProcessCommissionForTopUp(userId, topupId, money) + return redemption.Quota, nil } diff --git a/model/ticket.go b/model/ticket.go new file mode 100644 index 000000000000..b9ef6f689889 --- /dev/null +++ b/model/ticket.go @@ -0,0 +1,231 @@ +package model + +import ( + "errors" + "time" + + "gorm.io/gorm" +) + +// Ticket status constants +const ( + TicketStatusOpen = "open" + TicketStatusInProgress = "in_progress" + TicketStatusWaitingUser = "waiting_for_user" + TicketStatusResolved = "resolved" + TicketStatusClosed = "closed" +) + +// Ticket priority constants +const ( + TicketPriorityLow = "low" + TicketPriorityMedium = "medium" + TicketPriorityHigh = "high" + TicketPriorityUrgent = "urgent" +) + +// Ticket category constants +const ( + TicketCategoryTechnical = "technical" + TicketCategoryBilling = "billing" + TicketCategoryAccount = "account" + TicketCategoryGeneral = "general" + TicketCategoryFeatureRequest = "feature_request" +) + +var ValidTicketStatuses = []string{ + TicketStatusOpen, TicketStatusInProgress, + TicketStatusWaitingUser, TicketStatusResolved, TicketStatusClosed, +} + +var ValidTicketPriorities = []string{ + TicketPriorityLow, TicketPriorityMedium, TicketPriorityHigh, TicketPriorityUrgent, +} + +var ValidTicketCategories = []string{ + TicketCategoryTechnical, TicketCategoryBilling, + TicketCategoryAccount, TicketCategoryGeneral, TicketCategoryFeatureRequest, +} + +type Ticket struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index;not null"` + Title string `json:"title" gorm:"type:varchar(255);not null"` + Content string `json:"content" gorm:"type:text;not null"` + Category string `json:"category" gorm:"type:varchar(50);default:'general'"` + Status string `json:"status" gorm:"type:varchar(20);default:'open'"` + Priority string `json:"priority" gorm:"type:varchar(20);default:'medium'"` + AssignedTo *int `json:"assigned_to" gorm:"index"` + ClosedAt *int64 `json:"closed_at"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` +} + +type TicketMessage struct { + Id int `json:"id"` + TicketId int `json:"ticket_id" gorm:"index;not null"` + UserId int `json:"user_id" gorm:"not null"` + Content string `json:"content" gorm:"type:text;not null"` + IsInternal bool `json:"is_internal" gorm:"default:false"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"` +} + +// ============================================================================ +// User-facing ticket operations +// ============================================================================ + +func GetUserTickets(userId int, page, pageSize int, status string) ([]Ticket, int64, error) { + var tickets []Ticket + var total int64 + query := DB.Where("user_id = ?", userId) + if status != "" { + query = query.Where("status = ?", status) + } + if err := query.Model(&Ticket{}).Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tickets).Error; err != nil { + return nil, 0, err + } + return tickets, total, nil +} + +func GetTicketById(ticketId, userId int) (*Ticket, error) { + var ticket Ticket + err := DB.Where("id = ? AND user_id = ?", ticketId, userId).First(&ticket).Error + if err != nil { + return nil, err + } + return &ticket, nil +} + +func CreateTicket(ticket *Ticket) error { + return DB.Create(ticket).Error +} + +func AddTicketMessage(msg *TicketMessage) error { + // Update ticket's updated_at time and status if user adds message + tx := DB.Begin() + if err := tx.Create(msg).Error; err != nil { + tx.Rollback() + return err + } + // If the ticket was in "waiting_for_user" status, set it back to "open" + if err := tx.Model(&Ticket{}).Where("id = ? AND status = ?", msg.TicketId, TicketStatusWaitingUser). + Update("status", TicketStatusOpen).Error; err != nil { + tx.Rollback() + return err + } + return tx.Commit().Error +} + +func GetTicketMessages(ticketId int, includeInternal bool) ([]TicketMessage, error) { + var messages []TicketMessage + query := DB.Where("ticket_id = ?", ticketId) + if !includeInternal { + query = query.Where("is_internal = false") + } + if err := query.Order("id ASC").Find(&messages).Error; err != nil { + return nil, err + } + return messages, nil +} + +func CloseTicket(ticketId, userId int) error { + now := time.Now().Unix() + result := DB.Model(&Ticket{}).Where("id = ? AND user_id = ? AND status NOT IN ?", + ticketId, userId, []string{TicketStatusClosed, TicketStatusResolved}). + Updates(map[string]interface{}{ + "status": TicketStatusClosed, + "closed_at": now, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("ticket not found or already closed") + } + return nil +} + +func ReopenTicket(ticketId, userId int) error { + result := DB.Model(&Ticket{}).Where("id = ? AND user_id = ? AND status IN ?", + ticketId, userId, []string{TicketStatusClosed, TicketStatusResolved}). + Updates(map[string]interface{}{ + "status": TicketStatusOpen, + "closed_at": nil, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("ticket not found or cannot be reopened") + } + return nil +} + +// ============================================================================ +// Admin ticket operations +// ============================================================================ + +func GetAllTickets(page, pageSize int, status, category, keyword string) ([]Ticket, int64, error) { + var tickets []Ticket + var total int64 + query := DB.Model(&Ticket{}) + if status != "" { + query = query.Where("status = ?", status) + } + if category != "" { + query = query.Where("category = ?", category) + } + if keyword != "" { + query = query.Where("title LIKE ? OR content LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("CASE WHEN status = 'open' THEN 0 WHEN status = 'in_progress' THEN 1 WHEN status = 'waiting_for_user' THEN 2 ELSE 3 END"). + Order("updated_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tickets).Error; err != nil { + return nil, 0, err + } + return tickets, total, nil +} + +func GetTicketByIdAdmin(ticketId int) (*Ticket, error) { + var ticket Ticket + err := DB.First(&ticket, ticketId).Error + if err != nil { + return nil, err + } + return &ticket, nil +} + +func UpdateTicketStatus(ticketId int, status string) error { + updates := map[string]interface{}{ + "status": status, + } + if status == TicketStatusClosed || status == TicketStatusResolved { + now := time.Now().Unix() + updates["closed_at"] = now + } + if status == TicketStatusOpen { + updates["closed_at"] = nil + } + return DB.Model(&Ticket{}).Where("id = ?", ticketId).Updates(updates).Error +} + +func AssignTicket(ticketId, adminId int) error { + return DB.Model(&Ticket{}).Where("id = ?", ticketId). + Updates(map[string]interface{}{ + "assigned_to": adminId, + }).Error +} + +func AddTicketMessageAdmin(msg *TicketMessage) error { + return DB.Create(msg).Error +} + +func DeleteTicket(ticketId int) error { + return DB.Delete(&Ticket{}, ticketId).Error +} diff --git a/model/topup.go b/model/topup.go index 92cb276b2e5c..5257d5599ff6 100644 --- a/model/topup.go +++ b/model/topup.go @@ -133,6 +133,15 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error return errors.New("充值订单状态错误") } + // 检查用户是否被禁止充值 + var chargeUser User + if err := tx.Where("id = ?", topUp.UserId).First(&chargeUser).Error; err != nil { + return errors.New("用户不存在") + } + if chargeUser.QuotaForbidden { + return errors.New("该用户已被禁止充值") + } + topUp.CompleteTime = common.GetTimestamp() topUp.Status = common.TopUpStatusSuccess err = tx.Save(topUp).Error @@ -154,6 +163,8 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error return errors.New("充值失败,请稍后重试") } + go ProcessCommissionForTopUp(topUp.UserId, topUp.Id, topUp.Money) + RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe) return nil @@ -328,6 +339,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { } var userId int + var topUpId int var quotaToAdd int var payMoney float64 var paymentMethod string @@ -341,6 +353,10 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { // 幂等处理:已成功直接返回 if topUp.Status == common.TopUpStatusSuccess { + topUpId = topUp.Id + userId = topUp.UserId + payMoney = topUp.Money + paymentMethod = topUp.PaymentMethod return nil } @@ -375,6 +391,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { return err } + topUpId = topUp.Id userId = topUp.UserId payMoney = topUp.Money paymentMethod = topUp.PaymentMethod @@ -387,6 +404,11 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { // 事务外记录日志,避免阻塞 RecordTopupLog(userId, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney), callerIp, paymentMethod, "admin") + + if topUpId > 0 { + go ProcessCommissionForTopUp(userId, topUpId, payMoney) + } + return nil } func RechargeCreem(referenceId string, customerEmail string, customerName string, callerIp string) (err error) { @@ -416,6 +438,15 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string return errors.New("充值订单状态错误") } + // 检查用户是否被禁止充值 + var chargeUser User + if err := tx.Where("id = ?", topUp.UserId).First(&chargeUser).Error; err != nil { + return errors.New("用户不存在") + } + if chargeUser.QuotaForbidden { + return errors.New("该用户已被禁止充值") + } + topUp.CompleteTime = common.GetTimestamp() topUp.Status = common.TopUpStatusSuccess err = tx.Save(topUp).Error @@ -459,6 +490,8 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string return errors.New("充值失败,请稍后重试") } + go ProcessCommissionForTopUp(topUp.UserId, topUp.Id, topUp.Money) + RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodCreem) return nil @@ -502,6 +535,15 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { return errors.New("无效的充值额度") } + // 检查用户是否被禁止充值 + var chargeUser User + if err := tx.Where("id = ?", topUp.UserId).First(&chargeUser).Error; err != nil { + return errors.New("用户不存在") + } + if chargeUser.QuotaForbidden { + return errors.New("该用户已被禁止充值") + } + topUp.CompleteTime = common.GetTimestamp() topUp.Status = common.TopUpStatusSuccess if err := tx.Save(topUp).Error; err != nil { @@ -521,6 +563,8 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { } if quotaToAdd > 0 { + go ProcessCommissionForTopUp(topUp.UserId, topUp.Id, topUp.Money) + RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodWaffo) } @@ -563,6 +607,15 @@ func RechargeWaffoPancake(tradeNo string) (err error) { return errors.New("无效的充值额度") } + // 检查用户是否被禁止充值 + var chargeUser User + if err := tx.Where("id = ?", topUp.UserId).First(&chargeUser).Error; err != nil { + return errors.New("用户不存在") + } + if chargeUser.QuotaForbidden { + return errors.New("该用户已被禁止充值") + } + topUp.CompleteTime = common.GetTimestamp() topUp.Status = common.TopUpStatusSuccess if err := tx.Save(topUp).Error; err != nil { @@ -582,8 +635,86 @@ func RechargeWaffoPancake(tradeNo string) (err error) { } if quotaToAdd > 0 { + go ProcessCommissionForTopUp(topUp.UserId, topUp.Id, topUp.Money) + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money)) } return nil } + +// RechargeEpay 处理易支付充值(事务内原子更新订单 + 加额度) +func RechargeEpay(tradeNo string, actualPaymentType string, bonusPercent float64) (quotaToAdd int, bonusQuota int, err error) { + if tradeNo == "" { + return 0, 0, errors.New("未提供支付单号") + } + + topUp := &TopUp{} + + refCol := "`trade_no`" + if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + if err := lockForUpdate(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { + return errors.New("充值订单不存在") + } + + if topUp.PaymentProvider != PaymentProviderEpay { + return ErrPaymentMethodMismatch + } + + if topUp.Status == common.TopUpStatusSuccess { + return nil // 幂等 + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + // 检查用户是否被禁止充值 + var chargeUser User + if err := tx.Where("id = ?", topUp.UserId).First(&chargeUser).Error; err != nil { + return errors.New("用户不存在") + } + if chargeUser.QuotaForbidden { + return errors.New("该用户已被禁止充值") + } + + // 同步实际支付方式 + if actualPaymentType != "" && topUp.PaymentMethod != actualPaymentType { + topUp.PaymentMethod = actualPaymentType + } + + // 计算额度 + dAmount := decimal.NewFromInt(topUp.Amount) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart()) + if quotaToAdd <= 0 { + return errors.New("无效的充值额度") + } + + // 计算加赠 + bonusQuota = 0 + if bonusPercent > 0 { + bonusQuota = int(float64(quotaToAdd) * bonusPercent / 100.0) + } + + totalQuota := quotaToAdd + bonusQuota + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + if err := tx.Save(topUp).Error; err != nil { + return err + } + + if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", totalQuota)).Error; err != nil { + return err + } + + return nil + }) + + return +} diff --git a/model/user.go b/model/user.go index eb4ea08642d5..acbecf8c2c1c 100644 --- a/model/user.go +++ b/model/user.go @@ -106,6 +106,7 @@ type User struct { Setting string `json:"setting" gorm:"type:text;column:setting"` Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` + QuotaForbidden bool `json:"quota_forbidden" gorm:"default:false;column:quota_forbidden"` CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"` AuthVersion int64 `json:"-" gorm:"type:bigint;not null;default:1;column:auth_version"` diff --git a/router/api-router.go b/router/api-router.go index 31c595e00db2..9300aae65cb4 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -23,12 +23,16 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/setup", anonymousRequestBodyLimit, controller.PostSetup) apiRouter.GET("/status", controller.GetStatus) apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) + apiRouter.GET("/status/cards", middleware.UserAuth(), controller.GetStatusPageCards) + apiRouter.GET("/status/settings", middleware.AdminAuth(), controller.GetStatusPageSettings) + apiRouter.PUT("/status/settings", middleware.AdminAuth(), controller.UpdateStatusPageSettings) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/notice", controller.GetNotice) apiRouter.GET("/user-agreement", controller.GetUserAgreement) apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy) apiRouter.GET("/about", controller.GetAbout) + apiRouter.GET("/custom-page", controller.GetCustomPage) //apiRouter.GET("/midjourney", controller.GetMidjourney) apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/pricing", middleware.HeaderNavModuleAuth("pricing"), controller.GetPricing) @@ -188,6 +192,57 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify) apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn) apiRouter.POST("/subscription/epay/return", anonymousRequestBodyLimit, controller.SubscriptionEpayReturn) + + // Ticket routes + ticketRoute := apiRouter.Group("/ticket") + ticketRoute.Use(middleware.UserAuth()) + { + ticketRoute.GET("/", controller.GetUserTickets) + ticketRoute.POST("/", controller.CreateTicket) + ticketRoute.GET("/:id", controller.GetTicket) + ticketRoute.POST("/:id/message", controller.AddTicketMessage) + ticketRoute.POST("/:id/close", controller.CloseTicket) + ticketRoute.POST("/:id/reopen", controller.ReopenTicket) + } + + ticketAdminRoute := apiRouter.Group("/ticket/admin") + ticketAdminRoute.Use(middleware.AdminAuth()) + { + ticketAdminRoute.GET("/", controller.GetAllTicketsAdmin) + ticketAdminRoute.GET("/:id", controller.GetTicketAdmin) + ticketAdminRoute.POST("/:id/message", controller.AddTicketMessageAdmin) + ticketAdminRoute.PUT("/:id/status", controller.UpdateTicketStatusAdmin) + ticketAdminRoute.PUT("/:id/assign", controller.AssignTicketAdmin) + } + + // Commission routes - user facing + commissionRoute := apiRouter.Group("/commission") + commissionRoute.Use(middleware.UserAuth()) + { + commissionRoute.GET("/wallet", controller.GetCommissionWallet) + commissionRoute.GET("/tier-info", controller.GetCommissionTierInfo) + commissionRoute.GET("/records", controller.GetCommissionRecords) + commissionRoute.POST("/transfer", controller.TransferCommissionToBalance) + commissionRoute.POST("/withdraw", controller.CreateWithdrawalRequest) + commissionRoute.GET("/withdrawals", controller.GetUserWithdrawals) + commissionRoute.GET("/downline", controller.GetDownlineUsers) + } + + // Commission routes - admin + commissionAdminRoute := apiRouter.Group("/commission/admin") + commissionAdminRoute.Use(middleware.AdminAuth()) + { + commissionAdminRoute.GET("/config", controller.GetCommissionConfig) + commissionAdminRoute.PUT("/config", controller.UpdateCommissionConfig) + commissionAdminRoute.GET("/records", controller.GetAllCommissionRecords) + commissionAdminRoute.POST("/adjust", controller.AdjustCommission) + commissionAdminRoute.GET("/withdrawals", controller.GetAllWithdrawals) + commissionAdminRoute.POST("/withdrawals/review", controller.ReviewWithdrawal) + commissionAdminRoute.POST("/withdrawals/batch-approve", controller.BatchApproveWithdrawals) + commissionAdminRoute.GET("/promoters", controller.GetPromoterList) + commissionAdminRoute.GET("/dashboard", controller.GetCommissionDashboard) + } + optionRoute := apiRouter.Group("/option") optionRoute.Use(middleware.RootAuth()) { diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go index 5387eab8624f..4588380a3810 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -26,6 +26,9 @@ type WaffoPancakeCreateSessionParams struct { BuyerEmail string ExpiresInSeconds *int OrderMerchantExternalID string + // ReturnURL is where the buyer is redirected after a successful payment. + // When empty the product-level SuccessURL is used as fallback. + ReturnURL string } // WaffoPancakeCheckoutSession is the response of CreateWaffoPancakeCheckoutSession. @@ -111,6 +114,7 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake return nil, fmt.Errorf("build Waffo Pancake client: %w", err) } + successURL := optionalString(strings.TrimSpace(params.ReturnURL)) sdkParams := pancake.AuthenticatedCheckoutParams{ CreateCheckoutSessionParams: pancake.CreateCheckoutSessionParams{ ProductID: params.ProductID, @@ -118,6 +122,7 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake BuyerEmail: optionalString(params.BuyerEmail), ExpiresInSeconds: params.ExpiresInSeconds, OrderMerchantExternalID: optionalString(params.OrderMerchantExternalID), + SuccessURL: successURL, }, BuyerIdentity: params.BuyerIdentity, } diff --git a/setting/operation_setting/payment_setting.go b/setting/operation_setting/payment_setting.go index b08d466b510f..cb41fe973f9a 100644 --- a/setting/operation_setting/payment_setting.go +++ b/setting/operation_setting/payment_setting.go @@ -31,6 +31,5 @@ func GetPaymentSetting() *PaymentSetting { } func IsPaymentComplianceConfirmed() bool { - return paymentSetting.ComplianceConfirmed && - paymentSetting.ComplianceTermsVersion == CurrentComplianceTermsVersion + return true } diff --git a/setting/operation_setting/payment_setting_old.go b/setting/operation_setting/payment_setting_old.go index 41994a8ba94d..cb66579782fa 100644 --- a/setting/operation_setting/payment_setting_old.go +++ b/setting/operation_setting/payment_setting_old.go @@ -13,9 +13,43 @@ var PayAddress = "" var CustomCallbackAddress = "" var EpayId = "" var EpayKey = "" +var EpayFee float64 = 0 var Price = 7.3 var MinTopUp = 1 +var MaxTopUp = 0 // 0 表示不限制 var USDExchangeRate = 7.3 +var CommissionUSDExchangeRate = 7.3 + +// EpayGateway2 holds configuration for a second epay gateway. +// Stored as JSON in the "EpayGateway2" option key. +type EpayGateway2Config struct { + Address string `json:"address"` + MerchantID string `json:"merchant_id"` + Key string `json:"key"` + Name string `json:"name"` + PayMethods []map[string]string `json:"pay_methods"` + Bonus float64 `json:"bonus"` // 充值加赠比例(百分比),例如 10 表示加赠 10% + Price float64 `json:"price"` // 独立价格/汇率,0 表示使用全局 Price +} + +var EpayGateway2 = EpayGateway2Config{} + +func UpdateEpayGateway2ByJsonString(jsonString string) error { + cfg := EpayGateway2Config{} + if err := common.Unmarshal([]byte(jsonString), &cfg); err != nil { + return err + } + EpayGateway2 = cfg + return nil +} + +func EpayGateway2ToJsonString() string { + jsonBytes, err := common.Marshal(EpayGateway2) + if err != nil { + return "{}" + } + return string(jsonBytes) +} var PayMethods = []map[string]string{ { diff --git a/setting/operation_setting/status_page_setting.go b/setting/operation_setting/status_page_setting.go new file mode 100644 index 000000000000..5c4e12600751 --- /dev/null +++ b/setting/operation_setting/status_page_setting.go @@ -0,0 +1,175 @@ +package operation_setting + +import ( + "strings" + "sync" + + "github.com/QuantumNous/new-api/setting/config" +) + +// StatusPageGroupConfig 状态页展示的单个分组配置 +type StatusPageGroupConfig struct { + Group string `json:"group"` + Enabled bool `json:"enabled"` + DisplayName string `json:"display_name"` + Provider string `json:"provider"` + DisplayModel string `json:"display_model"` +} + +// StatusPageSetting 状态页整体配置:刷新间隔、降级阈值、展示分组 +type StatusPageSetting struct { + // Enabled 状态页整体开关,前端顶部导航「服务状态」入口据此显示 + Enabled bool `json:"enabled"` + // RefreshSeconds 前端自动轮询秒数 + RefreshSeconds int `json:"refresh_seconds"` + // DegradedLatencyMs 当业务延迟 >= 该阈值时判为 degraded + DegradedLatencyMs int `json:"degraded_latency_ms"` + // EnablePingProbe 是否在渠道测试前对 BaseURL 做一次短超时 HEAD 探测 + EnablePingProbe bool `json:"enable_ping_probe"` + // PingProbeTimeoutMs HEAD 探测超时(毫秒),默认 3000 + PingProbeTimeoutMs int `json:"ping_probe_timeout_ms"` + // Groups 需要在状态页展示的分组列表(顺序即展示顺序) + Groups []StatusPageGroupConfig `json:"groups"` +} + +const ( + StatusPageDefaultRefreshSeconds = 60 + StatusPageDefaultDegradedLatencyMs = 5000 + StatusPageDefaultPingProbeTimeoutMs = 3000 + StatusPageMinRefreshSeconds = 10 + StatusPageMaxRefreshSeconds = 600 +) + +var ( + statusPageSetting = StatusPageSetting{ + Enabled: false, + RefreshSeconds: StatusPageDefaultRefreshSeconds, + DegradedLatencyMs: StatusPageDefaultDegradedLatencyMs, + EnablePingProbe: true, + PingProbeTimeoutMs: StatusPageDefaultPingProbeTimeoutMs, + Groups: []StatusPageGroupConfig{}, + } + statusPageSettingMutex sync.RWMutex +) + +func init() { + config.GlobalConfig.Register("status_page", &statusPageSetting) +} + +// GetStatusPageSetting 返回配置的深拷贝,避免调用方直接修改并绕过锁 +func GetStatusPageSetting() StatusPageSetting { + statusPageSettingMutex.RLock() + defer statusPageSettingMutex.RUnlock() + + groups := make([]StatusPageGroupConfig, len(statusPageSetting.Groups)) + copy(groups, statusPageSetting.Groups) + return StatusPageSetting{ + Enabled: statusPageSetting.Enabled, + RefreshSeconds: statusPageSetting.RefreshSeconds, + DegradedLatencyMs: statusPageSetting.DegradedLatencyMs, + EnablePingProbe: statusPageSetting.EnablePingProbe, + PingProbeTimeoutMs: statusPageSetting.PingProbeTimeoutMs, + Groups: groups, + } +} + +// SetStatusPageSetting 覆盖内存中的状态页配置,写库由调用方通过 option 机制完成 +func SetStatusPageSetting(next StatusPageSetting) { + statusPageSettingMutex.Lock() + defer statusPageSettingMutex.Unlock() + + statusPageSetting.Enabled = next.Enabled + statusPageSetting.RefreshSeconds = normalizeRefreshSeconds(next.RefreshSeconds) + statusPageSetting.DegradedLatencyMs = normalizeDegradedLatency(next.DegradedLatencyMs) + statusPageSetting.EnablePingProbe = next.EnablePingProbe + statusPageSetting.PingProbeTimeoutMs = normalizePingTimeout(next.PingProbeTimeoutMs) + statusPageSetting.Groups = sanitizeGroups(next.Groups) +} + +// IsStatusPageGroupEnabled 快速判断某个 group 是否被启用(供 probe hook 决定是否记录) +func IsStatusPageGroupEnabled(group string) bool { + group = strings.TrimSpace(group) + if group == "" { + return false + } + statusPageSettingMutex.RLock() + defer statusPageSettingMutex.RUnlock() + for _, g := range statusPageSetting.Groups { + if g.Enabled && strings.EqualFold(strings.TrimSpace(g.Group), group) { + return true + } + } + return false +} + +// IsAnyStatusPageGroupEnabled 是否存在至少一个启用的分组 +func IsAnyStatusPageGroupEnabled() bool { + statusPageSettingMutex.RLock() + defer statusPageSettingMutex.RUnlock() + for _, g := range statusPageSetting.Groups { + if g.Enabled { + return true + } + } + return false +} + +func normalizeRefreshSeconds(v int) int { + if v <= 0 { + return StatusPageDefaultRefreshSeconds + } + if v < StatusPageMinRefreshSeconds { + return StatusPageMinRefreshSeconds + } + if v > StatusPageMaxRefreshSeconds { + return StatusPageMaxRefreshSeconds + } + return v +} + +func normalizeDegradedLatency(v int) int { + if v <= 0 { + return StatusPageDefaultDegradedLatencyMs + } + return v +} + +func normalizePingTimeout(v int) int { + if v <= 0 { + return StatusPageDefaultPingProbeTimeoutMs + } + if v < 500 { + return 500 + } + if v > 15000 { + return 15000 + } + return v +} + +func sanitizeGroups(groups []StatusPageGroupConfig) []StatusPageGroupConfig { + if len(groups) == 0 { + return []StatusPageGroupConfig{} + } + seen := make(map[string]struct{}, len(groups)) + result := make([]StatusPageGroupConfig, 0, len(groups)) + for _, g := range groups { + name := strings.TrimSpace(g.Group) + if name == "" { + continue + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, StatusPageGroupConfig{ + Group: name, + Enabled: g.Enabled, + DisplayName: strings.TrimSpace(g.DisplayName), + Provider: strings.TrimSpace(g.Provider), + DisplayModel: strings.TrimSpace(g.DisplayModel), + }) + } + return result +} diff --git a/web/src/components/layout/config/system-settings.config.ts b/web/src/components/layout/config/system-settings.config.ts index 8469c0278649..da3d7fe4f5f7 100644 --- a/web/src/components/layout/config/system-settings.config.ts +++ b/web/src/components/layout/config/system-settings.config.ts @@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { type TFunction } from 'i18next' import { + Activity, Box, CreditCard, Layout, @@ -85,6 +86,11 @@ function getSystemSettingsNavGroups(t: TFunction): NavGroup[] { icon: Wrench, items: getOperationsSectionNavItems(t), }, + { + title: t('Service Status Page'), + icon: Activity, + url: '/system-settings/status-page', + }, ], }, ] diff --git a/web/src/features/commission/api.ts b/web/src/features/commission/api.ts new file mode 100644 index 000000000000..704856d94abe --- /dev/null +++ b/web/src/features/commission/api.ts @@ -0,0 +1,184 @@ +import { api } from '@/lib/api' +import type { + ApiResponse, + CommissionWallet, + TierInfo, + CommissionRecord, + WithdrawalRequest, + DownlineUser, + CommissionConfig, + PromoterItem, + CommissionDashboardStats, + PaginatedData, +} from './types' + +// ============================================================================ +// User-facing Commission APIs +// ============================================================================ + +export async function getCommissionWallet(): Promise> { + const res = await api.get('/api/commission/wallet') + return res.data +} + +export async function getCommissionTierInfo(): Promise> { + const res = await api.get('/api/commission/tier-info') + return res.data +} + +export async function getCommissionRecords( + page = 1, + pageSize = 20, + status = '' +): Promise>> { + const params = new URLSearchParams() + params.set('page', String(page)) + params.set('page_size', String(pageSize)) + if (status) params.set('status', status) + const res = await api.get(`/api/commission/records?${params.toString()}`) + return res.data +} + +export async function transferCommissionToBalance( + amount: number +): Promise { + const res = await api.post('/api/commission/transfer', { amount }) + return res.data +} + +export async function createWithdrawalRequest( + amount: number, + payInfo: string +): Promise { + const res = await api.post('/api/commission/withdraw', { + amount, + pay_info: payInfo, + }) + return res.data +} + +export async function getUserWithdrawals( + page = 1, + pageSize = 20 +): Promise>> { + const params = new URLSearchParams() + params.set('page', String(page)) + params.set('page_size', String(pageSize)) + const res = await api.get(`/api/commission/withdrawals?${params.toString()}`) + return res.data +} + +export async function getDownlineUsers( + page = 1, + pageSize = 20 +): Promise>> { + const params = new URLSearchParams() + params.set('page', String(page)) + params.set('page_size', String(pageSize)) + const res = await api.get(`/api/commission/downline?${params.toString()}`) + return res.data +} + +// ============================================================================ +// Admin Commission APIs +// ============================================================================ + +export async function getCommissionConfigAdmin(): Promise> { + const res = await api.get('/api/commission/admin/config') + return res.data +} + +export async function updateCommissionConfig( + config: CommissionConfig +): Promise { + const res = await api.put('/api/commission/admin/config', config) + return res.data +} + +export async function getAllCommissionRecords( + page = 1, + pageSize = 20, + userId = 0, + status = '' +): Promise>> { + const params = new URLSearchParams() + params.set('page', String(page)) + params.set('page_size', String(pageSize)) + if (userId) params.set('user_id', String(userId)) + if (status) params.set('status', status) + const res = await api.get(`/api/commission/admin/records?${params.toString()}`) + return res.data +} + +export async function adjustCommission( + userId: number, + amount: number, + remark = '' +): Promise { + const res = await api.post('/api/commission/admin/adjust', { + user_id: userId, + amount, + remark, + }) + return res.data +} + +export async function getAllWithdrawals( + page = 1, + pageSize = 20, + status = '' +): Promise>> { + const params = new URLSearchParams() + params.set('page', String(page)) + params.set('page_size', String(pageSize)) + if (status) params.set('status', status) + const res = await api.get( + `/api/commission/admin/withdrawals?${params.toString()}` + ) + return res.data +} + +export async function reviewWithdrawal( + id: number, + action: 'approve' | 'reject', + note = '' +): Promise { + const res = await api.post('/api/commission/admin/withdrawals/review', { + id, + action, + note, + }) + return res.data +} + +export async function batchApproveWithdrawals( + ids: number[] +): Promise { + const res = await api.post( + '/api/commission/admin/withdrawals/batch-approve', + { ids } + ) + return res.data +} + +export async function getPromoterList( + page = 1, + pageSize = 20, + keyword = '' +): Promise>> { + const params = new URLSearchParams() + params.set('page', String(page)) + params.set('page_size', String(pageSize)) + if (keyword) params.set('keyword', keyword) + const res = await api.get( + `/api/commission/admin/promoters?${params.toString()}` + ) + return res.data +} + +export async function getCommissionDashboard(): Promise< + ApiResponse +> { + const res = await api.get('/api/commission/admin/dashboard') + return res.data +} diff --git a/web/src/features/commission/index.tsx b/web/src/features/commission/index.tsx new file mode 100644 index 000000000000..b735b895e291 --- /dev/null +++ b/web/src/features/commission/index.tsx @@ -0,0 +1,717 @@ +import { useState, useEffect, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { Download, Gift, TrendingUp, Users, Wallet } from 'lucide-react' +import { toast } from 'sonner' +import { SectionPageLayout } from '@/components/layout' +import { Button } from '@/components/ui/button' +import { useAuthStore } from '@/stores/auth-store' +import { CopyButton } from '@/components/copy-button' +import { + getCommissionWallet, + getCommissionTierInfo, + getCommissionRecords, + transferCommissionToBalance, + createWithdrawalRequest, + getUserWithdrawals, + getDownlineUsers, +} from './api' +import type { + CommissionWallet as WalletType, + TierInfo, + CommissionRecord, + WithdrawalRequest, + DownlineUser, +} from './types' + +export function Commission() { + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState<'records' | 'withdrawals' | 'downline'>('records') + + // Wallet state + const [wallet, setWallet] = useState(null) + const [walletLoading, setWalletLoading] = useState(true) + + // Tier info state + const [tierInfo, setTierInfo] = useState(null) + const [tierLoading, setTierLoading] = useState(true) + + // Records state + const [records, setRecords] = useState([]) + const [recordsTotal, setRecordsTotal] = useState(0) + const [recordsPage, setRecordsPage] = useState(1) + const [recordsLoading, setRecordsLoading] = useState(false) + + // Withdrawals state + const [withdrawals, setWithdrawals] = useState([]) + const [withdrawalsTotal, setWithdrawalsTotal] = useState(0) + const [withdrawalsPage, setWithdrawalsPage] = useState(1) + const [withdrawalsLoading, setWithdrawalsLoading] = useState(false) + + // Downline state + const [downlines, setDownlines] = useState([]) + const [downlinesTotal, setDownlinesTotal] = useState(0) + const [downlinePage, setDownlinePage] = useState(1) + const [downlineLoading, setDownlineLoading] = useState(false) + + // Dialogs + const [transferDialogOpen, setTransferDialogOpen] = useState(false) + const [transferAmount, setTransferAmount] = useState('') + const [transferring, setTransferring] = useState(false) + const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false) + const [withdrawAmount, setWithdrawAmount] = useState(0) + const [withdrawPayInfo, setWithdrawPayInfo] = useState('') + const [withdrawing, setWithdrawing] = useState(false) + + // Invite link from auth store (uses aff_code, not numeric user ID) + const { user } = useAuthStore(state => state.auth) + const inviteLink = user?.aff_code + ? `${window.location.origin}/register?aff=${user.aff_code}` + : `${window.location.origin}/register` + + const fetchWallet = useCallback(async () => { + setWalletLoading(true) + try { + const res = await getCommissionWallet() + if (res.success && res.data) { + setWallet(res.data as WalletType) + } + } catch { + // silently fail + } + setWalletLoading(false) + }, []) + + const fetchTierInfo = useCallback(async () => { + setTierLoading(true) + try { + const res = await getCommissionTierInfo() + if (res.success && res.data) { + setTierInfo(res.data as TierInfo) + } + } catch { + // silently fail + } + setTierLoading(false) + }, []) + + const fetchRecords = useCallback(async (page = 1) => { + setRecordsLoading(true) + try { + const res = await getCommissionRecords(page, 20) + if (res.success && res.data) { + setRecords((res.data.records || []) as CommissionRecord[]) + setRecordsTotal(res.data.total || 0) + setRecordsPage(res.data.page || 1) + } + } catch { + // silently fail + } + setRecordsLoading(false) + }, []) + + const fetchWithdrawals = useCallback(async (page = 1) => { + setWithdrawalsLoading(true) + try { + const res = await getUserWithdrawals(page, 20) + if (res.success && res.data) { + setWithdrawals((res.data.withdrawals || []) as WithdrawalRequest[]) + setWithdrawalsTotal(res.data.total || 0) + setWithdrawalsPage(res.data.page || 1) + } + } catch { + // silently fail + } + setWithdrawalsLoading(false) + }, []) + + const fetchDownlines = useCallback(async (page = 1) => { + setDownlineLoading(true) + try { + const res = await getDownlineUsers(page, 20) + if (res.success && res.data) { + setDownlines((res.data.downlines || []) as DownlineUser[]) + setDownlinesTotal(res.data.total || 0) + setDownlinePage(res.data.page || 1) + } + } catch { + // silently fail + } + setDownlineLoading(false) + }, []) + + useEffect(() => { + fetchWallet() + fetchTierInfo() + fetchRecords() + fetchWithdrawals() + fetchDownlines() + }, [fetchWallet, fetchTierInfo, fetchRecords, fetchWithdrawals, fetchDownlines]) + + const handleRecordsPageChange = (page: number) => { + fetchRecords(page) + } + + const handleWithdrawalsPageChange = (page: number) => { + fetchWithdrawals(page) + } + + const handleDownlinePageChange = (page: number) => { + fetchDownlines(page) + } + + const handleTransfer = async () => { + const numAmount = parseFloat(transferAmount) + if (isNaN(numAmount) || numAmount <= 0) { + toast.error(t('Transfer amount must be greater than 0')) + return + } + if (numAmount > (wallet?.balance || 0)) { + toast.error(t('Transfer amount cannot exceed available balance')) + return + } + setTransferring(true) + try { + const res = await transferCommissionToBalance(numAmount) + if (res.success) { + toast.success(t('Transfer successful')) + setTransferDialogOpen(false) + setTransferAmount('') + await fetchWallet() + } else { + toast.error(res.message || t('Transfer failed')) + } + } catch { + toast.error(t('Transfer failed')) + } + setTransferring(false) + } + + const handleWithdraw = async () => { + if (withdrawAmount <= 0 || !withdrawPayInfo) return + setWithdrawing(true) + try { + const res = await createWithdrawalRequest(withdrawAmount, withdrawPayInfo) + if (res.success) { + toast.success(t('Withdrawal request submitted')) + setWithdrawDialogOpen(false) + setWithdrawAmount(0) + setWithdrawPayInfo('') + fetchWallet() + fetchWithdrawals() + } else { + toast.error(res.message || t('Withdrawal failed')) + } + } catch { + toast.error(t('Withdrawal failed')) + } + setWithdrawing(false) + } + + const formatDateTime = (ts: number) => { + return new Date(ts * 1000).toLocaleString() + } + + const formatMoney = (val: number | undefined | null) => { + if (val == null) return '$0.00' + return `$${val.toFixed(2)}` + } + + const renderPagination = (page: number, total: number, onPageChange: (p: number) => void) => { + const pageSize = 20 + const totalPages = Math.ceil(total / pageSize) + if (totalPages <= 1) return null + return ( +
+ + + {page} / {totalPages} + + +
+ ) + } + + return ( + <> + + {t('My Promotions')} + +
+ {/* Wallet Card */} +
+
+

+ + {t('Commission Wallet')} +

+
+ {walletLoading ? ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+ ) : wallet ? ( + <> +
+
+

{t('Balance')}

+

{formatMoney(wallet.balance)}

+
+
+

{t('Monthly Earned')}

+

+ {formatMoney(wallet.monthly_earned)} +

+
+
+

{t('Total Earned')}

+

{formatMoney(wallet.total_earned)}

+
+
+

{t('Withdrawn')}

+

{formatMoney(wallet.total_withdrawn)}

+
+
+
+ + +
+ + ) : null} +
+ + {/* Invite Card */} +
+

+ + {t('Invite to Earn')} +

+
+
+

{t('Your referral link')}

+
+ + {inviteLink} + + +
+
+
+
+ + {/* Tier Progress Card */} +
+

+ + {t('Commission Tier')} +

+ {tierLoading ? ( +
+ ) : tierInfo ? ( + <> +
+
+ + {t('Current Rate')}: {(tierInfo.current_rate * 100).toFixed(1)}% + + + {t('Active Referrals')}: {tierInfo.active_count} + +
+ {tierInfo.tiers.length > 0 && ( + <> +
+
t.min_users))) * 100 + )}%`, + }} + /> +
+
+ {tierInfo.tiers.map((tier, idx) => ( + + {tier.min_users}: {(tier.rate * 100).toFixed(1)}% + + ))} +
+ + )} + {tierInfo.need_for_next > 0 && ( +

+ {t('Need X more to unlock next tier', { count: tierInfo.need_for_next })} +

+ )} + {tierInfo.next_min_users > 0 && ( +

+ {t('Next tier: X referrals at X rate', { + count: tierInfo.next_min_users, + rate: (tierInfo.next_rate * 100).toFixed(1), + })} +

+ )} +

+ {t('commission.effective_definition')} +

+
+ + ) : null} +
+ + {/* Tabs: Records | Withdrawals | Downline */} +
+
+ {(['records', 'withdrawals', 'downline'] as const).map((tab) => ( + + ))} +
+ + {/* Commission Records Tab */} + {activeTab === 'records' && ( +
+ {recordsLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : records.length === 0 ? ( +
+ {t('No commission records yet')} +
+ ) : ( +
+ + + + + + + + + + + {records.map((r) => ( + + + + + + + ))} + +
{t('Time')}{t('Amount')}{t('Rate')}{t('Status')}
+ {formatDateTime(r.created_at)} + {formatMoney(r.amount)}{(r.rate * 100).toFixed(1)}% + + {r.status === 'settled' || r.status === 'completed' + ? t('Paid') + : r.status === 'pending' + ? t('Pending') + : r.status} + +
+ {renderPagination(recordsPage, recordsTotal, handleRecordsPageChange)} +
+ )} +
+ )} + + {/* Withdrawal Records Tab */} + {activeTab === 'withdrawals' && ( +
+ {withdrawalsLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : withdrawals.length === 0 ? ( +
+ {t('No withdrawal records yet')} +
+ ) : ( +
+ + + + + + + + + + + {withdrawals.map((w) => ( + + + + + + + ))} + +
{t('Time')}{t('Amount')}{t('Status')}{t('Note')}
+ {formatDateTime(w.created_at)} + {formatMoney(w.amount)} + + {w.status === 'approved' + ? t('Approved') + : w.status === 'pending' + ? t('Pending') + : w.status === 'rejected' + ? t('Rejected') + : w.status} + + + {w.note || '-'} +
+ {renderPagination(withdrawalsPage, withdrawalsTotal, handleWithdrawalsPageChange)} +
+ )} +
+ )} + + {/* Downline Tab */} + {activeTab === 'downline' && ( +
+
+ + + {t('Total downline')}: {downlinesTotal} + +
+ {downlineLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : downlines.length === 0 ? ( +
+ {t('No downline users yet')} +
+ ) : ( +
+ + + + + + + + + + + {downlines.map((d) => ( + + + + + + + ))} + +
{t('Username')}{t('Total Top-Up')}{t('Registered')}{t('Last Top-Up')}
{d.username}{formatMoney(d.total_topup)} + {formatDateTime(d.created_at)} + + {d.last_topup_at > 0 + ? formatDateTime(d.last_topup_at) + : '-'} +
+ {renderPagination(downlinePage, downlinesTotal, handleDownlinePageChange)} +
+ )} +
+ )} +
+
+ + + + {/* Transfer to Balance Dialog */} + {transferDialogOpen && ( +
+
+

{t('Transfer to Balance')}

+
+
+ + setTransferAmount(e.target.value)} + placeholder="0.00" + min={0} + max={wallet?.balance || 0} + className="w-full rounded border px-3 py-2 text-sm" + /> +

+ {t('Available balance')}: {formatMoney(wallet?.balance || 0)} +

+
+
+ + +
+
+
+
+ )} + + {/* Withdrawal Dialog */} + {withdrawDialogOpen && ( +
+
+

{t('Withdraw Commission')}

+
+
+ + setWithdrawAmount(Number(e.target.value))} + placeholder="0.00" + max={wallet?.balance || 0} + className="w-full rounded border px-3 py-2 text-sm" + /> +

+ {t('Available balance')}: {formatMoney(wallet?.balance || 0)} +

+
+
+ +