package handler import ( "context" "encoding/json" "fmt" "net/http" "strconv" "time" "soul-api/internal/cache" "soul-api/internal/database" "soul-api/internal/model" "soul-api/internal/wechat" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // AdminWithdrawalsAutoApproveGet GET /api/admin/withdrawals/auto-approve 获取自动审批开关状态 func AdminWithdrawalsAutoApproveGet(c *gin.Context) { db := database.DB() enabled := false var refCfg model.SystemConfig if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil { var val map[string]interface{} if err := json.Unmarshal(refCfg.ConfigValue, &val); err == nil { if v, ok := val["enableAutoWithdraw"].(bool); ok { enabled = v } } } c.JSON(http.StatusOK, gin.H{"success": true, "enableAutoApprove": enabled}) } // AdminWithdrawalsAutoApprovePut PUT /api/admin/withdrawals/auto-approve 设置自动审批开关 func AdminWithdrawalsAutoApprovePut(c *gin.Context) { var body struct { EnableAutoApprove bool `json:"enableAutoApprove"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) return } db := database.DB() var refCfg model.SystemConfig val := map[string]interface{}{ "distributorShare": float64(90), "minWithdrawAmount": float64(10), "bindingDays": float64(30), "userDiscount": float64(5), "withdrawFee": float64(5), "enableAutoWithdraw": body.EnableAutoApprove, "vipOrderShareVip": float64(20), "vipOrderShareNonVip": float64(10), } if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil { if err := json.Unmarshal(refCfg.ConfigValue, &val); err == nil { val["enableAutoWithdraw"] = body.EnableAutoApprove } } valBytes, _ := json.Marshal(val) desc := "分销 / 推广规则配置" if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err != nil { refCfg = model.SystemConfig{ConfigKey: "referral_config", ConfigValue: valBytes, Description: &desc} _ = db.Create(&refCfg) } else { refCfg.ConfigValue = valBytes refCfg.Description = &desc _ = db.Save(&refCfg) } cache.InvalidateConfig() c.JSON(http.StatusOK, gin.H{"success": true, "enableAutoApprove": body.EnableAutoApprove, "message": "已更新"}) } // AdminWithdrawalsList GET /api/admin/withdrawals(支持分页 page、pageSize,筛选 status) func AdminWithdrawalsList(c *gin.Context) { statusFilter := c.Query("status") page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 10 } db := database.DB() q := db.Model(&model.Withdrawal{}) if statusFilter != "" && statusFilter != "all" { q = q.Where("status = ?", statusFilter) } var total int64 q.Count(&total) var list []model.Withdrawal query := db.Order("created_at DESC") if statusFilter != "" && statusFilter != "all" { query = query.Where("status = ?", statusFilter) } if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "withdrawals": []interface{}{}, "stats": gin.H{"total": 0}}) return } userIds := make([]string, 0, len(list)) seen := make(map[string]bool) for _, w := range list { if !seen[w.UserID] { seen[w.UserID] = true userIds = append(userIds, w.UserID) } } var users []model.User if len(userIds) > 0 { database.DB().Where("id IN ?", userIds).Find(&users) } userMap := make(map[string]*model.User) for i := range users { userMap[users[i].ID] = &users[i] } withdrawals := make([]gin.H, 0, len(list)) for _, w := range list { u := userMap[w.UserID] userName := "未知用户" var userAvatar *string account := "未绑定微信号" if w.WechatID != nil && *w.WechatID != "" { account = *w.WechatID } if u != nil { if u.Nickname != nil { userName = *u.Nickname } userAvatar = u.Avatar if u.WechatID != nil && *u.WechatID != "" { account = *u.WechatID } } st := "pending" if w.Status != nil { st = *w.Status if st == "success" { st = "completed" } else if st == "failed" { st = "rejected" } else if st == "pending_confirm" { st = "pending_confirm" } } userConfirmedAt := interface{}(nil) if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() { userConfirmedAt = w.UserConfirmedAt.Format("2006-01-02 15:04:05") } avStr := "" if userAvatar != nil { avStr = resolveAvatarURL(*userAvatar) } // 备注:失败时显示 failReason/errorMessage,否则显示用户 remark remark := "" if st == "rejected" || st == "failed" { if w.FailReason != nil && *w.FailReason != "" { remark = *w.FailReason } else if w.ErrorMessage != nil && *w.ErrorMessage != "" { remark = *w.ErrorMessage } } if remark == "" && w.Remark != nil && *w.Remark != "" { remark = *w.Remark } withdrawals = append(withdrawals, gin.H{ "id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": avStr, "amount": w.Amount, "status": st, "createdAt": w.CreatedAt, "method": "wechat", "account": account, "userConfirmedAt": userConfirmedAt, "remark": remark, }) } totalPages := int(total) / pageSize if int(total)%pageSize > 0 { totalPages++ } var pendingCount, successCount, failedCount int64 var pendingAmount, successAmount float64 db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"pending", "pending_confirm", "processing"}).Count(&pendingCount) db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"pending", "pending_confirm", "processing"}).Select("COALESCE(SUM(amount), 0)").Scan(&pendingAmount) db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"success", "completed"}).Count(&successCount) db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"success", "completed"}).Select("COALESCE(SUM(amount), 0)").Scan(&successAmount) db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"failed", "rejected"}).Count(&failedCount) c.JSON(http.StatusOK, gin.H{ "success": true, "withdrawals": withdrawals, "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages, "stats": gin.H{ "total": total, "pendingCount": pendingCount, "pendingAmount": pendingAmount, "successCount": successCount, "successAmount": successAmount, "failedCount": failedCount, }, }) } // doApproveWithdrawal 执行提现审批逻辑(打款),供 AdminWithdrawalsAction 与自动审批共用 // 返回 (successMessage, error),成功时 err 为 nil func doApproveWithdrawal(db *gorm.DB, id string) (string, error) { now := time.Now() var w model.Withdrawal if err := db.Where("id = ?", id).First(&w).Error; err != nil { return "", fmt.Errorf("提现记录不存在") } st := "" if w.Status != nil { st = *w.Status } if st != "pending" && st != "processing" && st != "pending_confirm" { return "", fmt.Errorf("当前状态不允许批准") } openID := "" if w.WechatOpenid != nil && *w.WechatOpenid != "" { openID = *w.WechatOpenid } if openID == "" { var u model.User if err := db.Where("id = ?", w.UserID).First(&u).Error; err == nil && u.OpenID != nil { openID = *u.OpenID } } if openID == "" { return "", fmt.Errorf("用户未绑定微信 openid,无法打款") } _, totalCommission, withdrawn, pending, _ := computeAvailableWithdraw(db, w.UserID) availableRaw := totalCommission - withdrawn - pending if availableRaw < -0.01 { return "", fmt.Errorf("用户当前可提现不足,无法批准") } remark := "提现" if w.Remark != nil && *w.Remark != "" { remark = *w.Remark } withdrawFee := 0.0 var refCfg model.SystemConfig if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil { var refVal map[string]interface{} if err := json.Unmarshal(refCfg.ConfigValue, &refVal); err == nil { if v, ok := refVal["withdrawFee"].(float64); ok { withdrawFee = v / 100 } } } actualAmount := w.Amount * (1 - withdrawFee) if actualAmount < 0.01 { actualAmount = 0.01 } amountFen := int(actualAmount * 100) if amountFen < 1 { return "", fmt.Errorf("提现金额异常") } params := wechat.FundAppTransferParams{ OutBillNo: w.ID, OpenID: openID, Amount: amountFen, Remark: remark, NotifyURL: "", TransferSceneId: "1005", } result, err := wechat.InitiateTransferByFundApp(params) if err != nil { errMsg := err.Error() if errMsg == "支付/转账未初始化,请先调用 wechat.Init" || errMsg == "转账客户端未初始化" { _ = db.Model(&w).Updates(map[string]interface{}{"status": "success", "processed_at": now}).Error return "已标记为已打款。当前未接入微信转账,请线下打款。", nil } _ = db.Model(&w).Updates(map[string]interface{}{ "status": "failed", "fail_reason": errMsg, "error_message": errMsg, "processed_at": now, }).Error return "", fmt.Errorf("%s", errMsg) } if result.OutBillNo == "" { failMsg := "微信未返回商户单号,请检查商户平台(如 IP 白名单)或查看服务端日志" _ = db.Model(&w).Updates(map[string]interface{}{ "status": "failed", "fail_reason": failMsg, "error_message": failMsg, "processed_at": now, }).Error return "", fmt.Errorf("%s", failMsg) } rowStatus := "processing" if result.State == "WAIT_USER_CONFIRM" { rowStatus = "pending_confirm" } upd := map[string]interface{}{ "status": rowStatus, "detail_no": result.OutBillNo, "batch_no": result.OutBillNo, "batch_id": result.TransferBillNo, "processed_at": now, } if result.PackageInfo != "" { upd["package_info"] = result.PackageInfo } if err := db.Model(&w).Updates(upd).Error; err != nil { return "", fmt.Errorf("更新状态失败: %w", err) } if openID != "" { go func() { ctx := context.Background() if e := wechat.SendWithdrawSubscribeMessage(ctx, openID, w.Amount, true); e != nil { fmt.Printf("[AdminWithdrawals] 订阅消息发送失败 id=%s: %v\n", id, e) } }() } return "已发起打款,微信处理中", nil } // AdminWithdrawalsAction PUT /api/admin/withdrawals 审核/打款 // approve:先调微信转账接口打款,成功则标为 processing,失败则标为 failed 并返回错误。 // 若未初始化微信转账客户端,则仅将状态标为 success(线下打款后批准)。 // reject:直接标为 failed。 func AdminWithdrawalsAction(c *gin.Context) { var body struct { ID string `json:"id"` Action string `json:"action"` ErrorMessage string `json:"errorMessage"` Reason string `json:"reason"` } if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"}) return } reason := body.ErrorMessage if reason == "" { reason = body.Reason } if reason == "" && body.Action == "reject" { reason = "管理员拒绝" } db := database.DB() now := time.Now() switch body.Action { case "reject": err := db.Model(&model.Withdrawal{}).Where("id = ?", body.ID).Updates(map[string]interface{}{ "status": "failed", "error_message": reason, "fail_reason": reason, "processed_at": now, }).Error if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "message": "已拒绝"}) return case "approve": msg, err := doApproveWithdrawal(db, body.ID) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "message": msg}) return default: c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 approve 或 reject"}) } } // AdminWithdrawalsSync POST /api/admin/withdrawals/sync 主动向微信查询转账结果并更新状态(无回调时的备选) // body: { "id": "提现记录id" } 同步单条;不传 id 或 id 为空则同步所有 processing/pending_confirm func AdminWithdrawalsSync(c *gin.Context) { var body struct { ID string `json:"id"` } _ = c.ShouldBindJSON(&body) db := database.DB() var list []model.Withdrawal if body.ID != "" { var w model.Withdrawal if err := db.Where("id = ?", body.ID).First(&w).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现记录不存在"}) return } list = []model.Withdrawal{w} } else { if err := db.Where("status IN ?", []string{"processing", "pending_confirm"}). Find(&list).Error; err != nil || len(list) == 0 { c.JSON(http.StatusOK, gin.H{"success": true, "message": "暂无待同步记录", "synced": 0}) return } } now := time.Now() synced := 0 for _, w := range list { batchNo := "" detailNo := "" if w.BatchNo != nil { batchNo = *w.BatchNo } if w.DetailNo != nil { detailNo = *w.DetailNo } if detailNo == "" { continue } var status, failReason string // FundApp 单笔:batch_no == detail_no 时用商户单号查询 if batchNo == detailNo { state, _, fail, err := wechat.QueryTransferByOutBill(detailNo) if err != nil { continue } status = state failReason = fail } else { res, err := wechat.QueryTransfer(batchNo, detailNo) if err != nil { continue } if s, ok := res["detail_status"].(string); ok { status = s } if s, ok := res["fail_reason"].(string); ok { failReason = s } } up := map[string]interface{}{"processed_at": now} switch status { case "SUCCESS": up["status"] = "success" case "FAIL": up["status"] = "failed" if failReason != "" { up["fail_reason"] = failReason } default: continue } if err := db.Model(&model.Withdrawal{}).Where("id = ?", w.ID).Updates(up).Error; err != nil { continue } synced++ fmt.Printf("[AdminWithdrawals] 同步状态 id=%s -> %s\n", w.ID, up["status"]) } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "已向微信查询并更新", "synced": synced, "total": len(list), }) }