新增用户确认收款功能,更新提现记录模型以支持用户确认时间,并在提现管理页面展示确认状态。同时,优化提现处理逻辑,确保在用户确认后记录相关信息,提升系统稳定性和用户体验。

This commit is contained in:
乘风
2026-02-11 09:56:57 +08:00
parent ff92ec0e3e
commit a174d8e16d
16 changed files with 289 additions and 54 deletions

View File

@@ -186,46 +186,68 @@ Page({
return `${m}-${day}`
},
// 确认收款(调起微信收款页)
confirmReceive(e) {
// 确认收款:有 package 时调起微信收款页,成功后记录;无 package 时仅调用后端记录「已确认收款」
async confirmReceive(e) {
const index = e.currentTarget.dataset.index
const id = e.currentTarget.dataset.id
const list = this.data.pendingConfirmList || []
let item = (typeof index === 'number' || (index !== undefined && index !== '')) ? list[index] : null
if (!item && id) item = list.find(x => x.id === id) || null
if (!item || !item.package) {
if (!item) {
wx.showToast({ title: '请稍后刷新再试', icon: 'none' })
return
}
const mchId = this.data.withdrawMchId
const appId = this.data.withdrawAppId
if (!mchId || !appId) {
wx.showToast({ title: '参数缺失,请刷新重试', icon: 'none' })
const hasPackage = item.package && mchId && appId && wx.canIUse('requestMerchantTransfer')
const recordConfirmReceived = async () => {
const userInfo = app.globalData.userInfo
if (userInfo && userInfo.id) {
try {
await app.request({
url: '/api/miniprogram/withdraw/confirm-received',
method: 'POST',
data: { withdrawalId: item.id, userId: userInfo.id }
})
} catch (e) { /* 仅记录,不影响前端展示 */ }
}
const newList = list.filter(x => x.id !== item.id)
this.setData({ pendingConfirmList: newList })
this.loadPendingConfirm()
}
if (hasPackage) {
wx.showLoading({ title: '调起收款...', mask: true })
wx.requestMerchantTransfer({
mchId,
appId,
package: item.package,
success: async () => {
wx.hideLoading()
wx.showToast({ title: '收款成功', icon: 'success' })
await recordConfirmReceived()
},
fail: (err) => {
wx.hideLoading()
const msg = (err.errMsg || '').includes('cancel') ? '已取消' : (err.errMsg || '收款失败')
wx.showToast({ title: msg, icon: 'none' })
},
complete: () => { wx.hideLoading() }
})
return
}
if (!wx.canIUse('requestMerchantTransfer')) {
wx.showToast({ title: '当前微信版本不支持,请升级后重试', icon: 'none' })
return
// 无 package 时仅记录「确认已收款」(当前直接打款无 package用户点按钮即记录
wx.showLoading({ title: '提交中...', mask: true })
try {
await recordConfirmReceived()
wx.hideLoading()
wx.showToast({ title: '已记录确认收款', icon: 'success' })
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '操作失败', icon: 'none' })
}
wx.showLoading({ title: '调起收款...', mask: true })
wx.requestMerchantTransfer({
mchId,
appId,
package: item.package,
success: () => {
wx.hideLoading()
wx.showToast({ title: '收款成功', icon: 'success' })
const newList = list.filter(x => x.id !== item.id)
this.setData({ pendingConfirmList: newList })
this.loadPendingConfirm()
},
fail: (err) => {
wx.hideLoading()
const msg = (err.errMsg || '').includes('cancel') ? '已取消' : (err.errMsg || '收款失败')
wx.showToast({ title: msg, icon: 'none' })
},
complete: () => { wx.hideLoading() }
})
},
// 从与推广中心相同的接口拉取收益数据并更新展示(累计收益 = totalCommission可提现 = 累计-已提现-待审核)

View File

@@ -23,12 +23,19 @@
"condition": {
"miniprogram": {
"list": [
{
"name": "pages/match/match",
"pathName": "pages/match/match",
"query": "",
"scene": null,
"launchMode": "default"
},
{
"name": "看书",
"pathName": "pages/read/read",
"query": "id=1.4",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "分销中心",

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-Y_ZBqQPE.js"></script>
<script type="module" crossorigin src="/assets/index-BKk79j3K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-2chBMZjx.css">
</head>
<body>

View File

@@ -22,6 +22,7 @@ interface Withdrawal {
method?: 'wechat' | 'alipay'
account?: string
name?: string
userConfirmedAt?: string | null
userCommissionInfo?: {
totalCommission: number
withdrawnEarnings: number
@@ -145,7 +146,7 @@ export function WithdrawalsPage() {
case 'processing':
return (
<Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0">
</Badge>
)
case 'success':
@@ -295,6 +296,7 @@ export function WithdrawalsPage() {
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-right font-medium"></th>
</tr>
</thead>
@@ -379,6 +381,15 @@ export function WithdrawalsPage() {
<td className="p-4 text-gray-400">
{w.processedAt ? new Date(w.processedAt).toLocaleString() : '-'}
</td>
<td className="p-4 text-gray-400">
{w.userConfirmedAt ? (
<span className="text-green-400" title={w.userConfirmedAt}>
{new Date(w.userConfirmedAt).toLocaleString()}
</span>
) : (
'-'
)}
</td>
<td className="p-4 text-right">
{(w.status === 'pending' || w.status === 'pending_confirm') && (
<div className="flex items-center justify-end gap-2">

View File

@@ -118,6 +118,29 @@ def run_build(root):
# ==================== 打包 ====================
DEPLOY_PORT = 8081
def set_env_port(env_path, port=DEPLOY_PORT):
"""将 .env 文件中的 PORT 设为指定值(用于部署包)"""
if not os.path.isfile(env_path):
return
with open(env_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
found = False
new_lines = []
for line in lines:
s = line.strip()
if "=" in s and s.split("=", 1)[0].strip() == "PORT":
new_lines.append("PORT=%s\n" % port)
found = True
else:
new_lines.append(line)
if not found:
new_lines.append("PORT=%s\n" % port)
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
f.writelines(new_lines)
def pack_deploy(root, binary_path, include_env=True):
"""打包二进制和 .env 为 tar.gz"""
@@ -126,14 +149,18 @@ def pack_deploy(root, binary_path, include_env=True):
try:
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
env_src = os.path.join(root, ".env")
staging_env = os.path.join(staging, ".env")
if include_env and os.path.isfile(env_src):
shutil.copy2(env_src, os.path.join(staging, ".env"))
shutil.copy2(env_src, staging_env)
print(" [已包含] .env")
else:
env_example = os.path.join(root, ".env.example")
if os.path.isfile(env_example):
shutil.copy2(env_example, os.path.join(staging, ".env"))
shutil.copy2(env_example, staging_env)
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
if os.path.isfile(staging_env):
set_env_port(staging_env, DEPLOY_PORT)
print(" [已设置] PORT=%s(部署用)" % DEPLOY_PORT)
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
with tarfile.open(tarball, "w:gz") as tf:
for name in os.listdir(staging):

View File

@@ -118,6 +118,29 @@ def run_build(root):
# ==================== 打包 ====================
DEPLOY_PORT = 8080
def set_env_port(env_path, port=DEPLOY_PORT):
"""将 .env 文件中的 PORT 设为指定值(用于部署包)"""
if not os.path.isfile(env_path):
return
with open(env_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
found = False
new_lines = []
for line in lines:
s = line.strip()
if "=" in s and s.split("=", 1)[0].strip() == "PORT":
new_lines.append("PORT=%s\n" % port)
found = True
else:
new_lines.append(line)
if not found:
new_lines.append("PORT=%s\n" % port)
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
f.writelines(new_lines)
def pack_deploy(root, binary_path, include_env=True):
"""打包二进制和 .env 为 tar.gz"""
@@ -126,14 +149,18 @@ def pack_deploy(root, binary_path, include_env=True):
try:
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
env_src = os.path.join(root, ".env")
staging_env = os.path.join(staging, ".env")
if include_env and os.path.isfile(env_src):
shutil.copy2(env_src, os.path.join(staging, ".env"))
shutil.copy2(env_src, staging_env)
print(" [已包含] .env")
else:
env_example = os.path.join(root, ".env.example")
if os.path.isfile(env_example):
shutil.copy2(env_example, os.path.join(staging, ".env"))
shutil.copy2(env_example, staging_env)
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
if os.path.isfile(staging_env):
set_env_port(staging_env, DEPLOY_PORT)
print(" [已设置] PORT=%s(部署用)" % DEPLOY_PORT)
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
with tarfile.open(tarball, "w:gz") as tf:
for name in os.listdir(staging):

View File

@@ -21,6 +21,9 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.WechatCallbackLog{}); err != nil {
log.Printf("database: wechat_callback_logs migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Withdrawal{}); err != nil {
log.Printf("database: withdrawals migrate warning: %v", err)
}
log.Println("database: connected")
return nil
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"fmt"
"net/http"
"time"
@@ -69,10 +70,15 @@ func AdminWithdrawalsList(c *gin.Context) {
st = "pending_confirm"
}
}
userConfirmedAt := interface{}(nil)
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
userConfirmedAt = w.UserConfirmedAt.Format("2006-01-02 15:04:05")
}
withdrawals = append(withdrawals, gin.H{
"id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": userAvatar,
"amount": w.Amount, "status": st, "createdAt": w.CreatedAt,
"method": "wechat", "account": account,
"userConfirmedAt": userConfirmedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "withdrawals": withdrawals, "stats": gin.H{"total": len(withdrawals)}})
@@ -218,20 +224,36 @@ func AdminWithdrawalsAction(c *gin.Context) {
return
}
// 打款已受理(FundApp 单笔),更新为处理中并保存商户单号、微信转账单号
fmt.Printf("[AdminWithdrawals] 微信已受理 id=%s out_bill_no=%s transfer_bill_no=%s\n", body.ID, result.OutBillNo, result.TransferBillNo)
processingStatus := "processing"
if err := db.Model(&w).Updates(map[string]interface{}{
"status": processingStatus,
"detail_no": result.OutBillNo, // 回调用 out_bill_no 匹配此字段
"batch_no": result.OutBillNo, // 单笔无批次,存同一单号便于查询
// 打款已受理(微信同步返回立即落库商户单号、微信单号、package_info、按 state 设 status不依赖回调
fmt.Printf("[AdminWithdrawals] 微信已受理 id=%s out_bill_no=%s transfer_bill_no=%s state=%s\n", body.ID, result.OutBillNo, result.TransferBillNo, result.State)
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,
}).Error; err != nil {
}
if result.PackageInfo != "" {
upd["package_info"] = result.PackageInfo
}
if err := db.Model(&w).Updates(upd).Error; err != nil {
fmt.Printf("[AdminWithdrawals] 更新提现状态失败 id=%s: %v\n", body.ID, err)
c.JSON(http.StatusOK, gin.H{"success": false, "error": "更新状态失败: " + err.Error()})
return
}
// 发起转账成功后发订阅消息(异步,失败不影响接口返回)
if openID != "" {
go func() {
ctx := context.Background()
if err := wechat.SendWithdrawSubscribeMessage(ctx, openID, w.Amount, true); err != nil {
fmt.Printf("[AdminWithdrawals] 订阅消息发送失败 id=%s: %v\n", body.ID, err)
}
}()
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已发起打款,微信处理中",

View File

@@ -258,8 +258,10 @@ func WithdrawConfirmInfo(c *gin.Context) {
if appId == "" {
appId = "wxb8bbb2b10dec74aa"
}
// package 需由「用户确认模式」转账接口返回并落库,当前批量转账无 package返回空有值时可调 wx.requestMerchantTransfer
packageInfo := ""
if w.PackageInfo != nil && *w.PackageInfo != "" {
packageInfo = *w.PackageInfo
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
@@ -293,8 +295,16 @@ func WithdrawPendingConfirm(c *gin.Context) {
"amount": w.Amount,
"createdAt": w.CreatedAt,
}
// 若有 package 信息requestMerchantTransfer 用),一并返回;当前直接打款无 package给空字符串
item["package"] = ""
if w.PackageInfo != nil && *w.PackageInfo != "" {
item["package"] = *w.PackageInfo
} else {
item["package"] = ""
}
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
item["userConfirmedAt"] = w.UserConfirmedAt.Format("2006-01-02 15:04:05")
} else {
item["userConfirmedAt"] = nil
}
out = append(out, item)
}
mchId := os.Getenv("WECHAT_MCH_ID")
@@ -314,3 +324,41 @@ func WithdrawPendingConfirm(c *gin.Context) {
},
})
}
// WithdrawConfirmReceived POST /api/miniprogram/withdraw/confirm-received 用户确认收款(记录已点击确认)
// body: { "withdrawalId": "xxx", "userId": "xxx" },仅本人可操作,更新 user_confirmed_at
func WithdrawConfirmReceived(c *gin.Context) {
var req struct {
WithdrawalID string `json:"withdrawalId" binding:"required"`
UserID string `json:"userId" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawalId 或 userId"})
return
}
db := database.DB()
var w model.Withdrawal
if err := db.Where("id = ? AND user_id = ?", req.WithdrawalID, req.UserID).First(&w).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "提现记录不存在或无权操作"})
return
}
st := ""
if w.Status != nil {
st = *w.Status
}
// 仅处理中或已成功的可标记「用户已确认收款」
if st != "processing" && st != "pending_confirm" && st != "success" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前状态不可确认收款"})
return
}
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已确认过"})
return
}
now := time.Now()
if err := db.Model(&w).Update("user_confirmed_at", now).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "更新失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已记录确认收款"})
}

View File

@@ -13,10 +13,12 @@ type Withdrawal struct {
BatchNo *string `gorm:"column:batch_no;size:100" json:"batchNo,omitempty"` // 商家批次单号
DetailNo *string `gorm:"column:detail_no;size:100" json:"detailNo,omitempty"` // 商家明细单号
BatchID *string `gorm:"column:batch_id;size:100" json:"batchId,omitempty"` // 微信批次单号
PackageInfo *string `gorm:"column:package_info;size:500" json:"packageInfo,omitempty"` // 微信返回的 package_info供小程序 wx.requestMerchantTransfer
Remark *string `gorm:"column:remark;size:200" json:"remark,omitempty"` // 提现备注
FailReason *string `gorm:"column:fail_reason;size:500" json:"failReason,omitempty"` // 失败原因
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"`
UserConfirmedAt *time.Time `gorm:"column:user_confirmed_at" json:"userConfirmedAt,omitempty"` // 用户点击「确认收款」时间
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"`
}
func (Withdrawal) TableName() string { return "withdrawals" }

View File

@@ -244,6 +244,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.POST("/withdraw", handler.WithdrawPost)
miniprogram.GET("/withdraw/records", handler.WithdrawRecords)
miniprogram.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
miniprogram.POST("/withdraw/confirm-received", handler.WithdrawConfirmReceived)
miniprogram.GET("/withdraw/confirm-info", handler.WithdrawConfirmInfo)
}

View File

@@ -15,11 +15,13 @@ import (
"soul-api/internal/config"
"github.com/ArtisanCloud/PowerLibs/v3/object"
"github.com/ArtisanCloud/PowerWeChat/v3/src/miniProgram"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/models"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/power"
"github.com/ArtisanCloud/PowerWeChat/v3/src/miniProgram"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
notifyrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/notify/request"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment/order/request"
subrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/basicService/subscribeMessage/request"
)
var (
@@ -394,3 +396,39 @@ func GenerateOrderSn() string {
random := now.UnixNano() % 1000000
return fmt.Sprintf("MP%s%06d", timestamp, random)
}
// WithdrawSubscribeTemplateID 提现结果订阅消息模板 ID与小程序 app.js withdrawSubscribeTmplId 一致)
const WithdrawSubscribeTemplateID = "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE"
// SendWithdrawSubscribeMessage 发起转账成功后发订阅消息(提现成功/待确认收款)
// openID 为接收人 openidamount 为提现金额success 为 true 表示打款已受理
func SendWithdrawSubscribeMessage(ctx context.Context, openID string, amount float64, success bool) error {
if miniProgramApp == nil {
return fmt.Errorf("小程序未初始化")
}
phrase := "提现成功"
thing8 := "微信打款成功,请点击查收"
if !success {
phrase = "提现失败"
thing8 = "请联系官方客服"
}
amountStr := fmt.Sprintf("¥%.2f", amount)
data := &power.HashMap{
"phrase4": object.HashMap{"value": phrase},
"amount5": object.HashMap{"value": amountStr},
"thing8": object.HashMap{"value": thing8},
}
state := "formal"
if cfg != nil && cfg.Mode == "debug" {
state = "developer"
}
_, err := miniProgramApp.SubscribeMessage.Send(ctx, &subrequest.RequestSubscribeMessageSend{
ToUser: openID,
TemplateID: WithdrawSubscribeTemplateID,
Page: "/pages/my/my",
MiniProgramState: state,
Lang: "zh_CN",
Data: data,
})
return err
}

View File

@@ -149,11 +149,13 @@ type FundAppTransferParams struct {
TransferSceneId string // 可选,如 "1005"
}
// FundAppTransferResult 单笔转账结果
// FundAppTransferResult 单笔转账结果(微信同步返回,无需等回调即可落库)
type FundAppTransferResult struct {
OutBillNo string
TransferBillNo string
State string
OutBillNo string // 商户单号
TransferBillNo string // 微信转账单号
State string // 如 WAIT_USER_CONFIRM 表示待用户确认收款
PackageInfo string // 供小程序 wx.requestMerchantTransfer 使用
CreateTime string // 微信返回的 create_time
}
// InitiateTransferByFundApp 发起商家转账到零钱PowerWeChat FundApp.TransferBills 单笔接口)
@@ -205,6 +207,8 @@ func InitiateTransferByFundApp(params FundAppTransferParams) (*FundAppTransferRe
OutBillNo: resp.OutBillNo,
TransferBillNo: resp.TransferBillNo,
State: resp.State,
PackageInfo: resp.PackageInfo,
CreateTime: resp.CreateTime,
}
return result, nil
}

Binary file not shown.

23
soul-api/订阅消息.md Normal file
View File

@@ -0,0 +1,23 @@
data := &power.HashMap{
"phrase4": power.StringMap{
"value": "提现成功",//提现结果:提现成功、提现失败
},
"amount5": pwer.StringMap{
"value": "¥8.6",//提现金额
},
"thing8": power.StringMap{
"value": "微信打款成功,请点击查收",//备注,如果打款失败就提示请联系官方客服
},
}
MiniProgramApp.SubscribeMessage.Send(ctx, &request.RequestSubscribeMessageSend{
ToUser: "OPENID",//需要根据订单号联表查询提现表的user_id就是opend_id
TemplateID: "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",//这串是正确的
Page: "/pages/my/my",
// developer为开发版trial为体验版formal为正式版 这块最好根据我的域名区分,
// 开发环境是souldev.quwanzhi.com 正式环境是 soulapi.quwanzhi.com
MiniProgramState: "formal",
Lang: "zh_CN",
Data: data,
})
{"create_time":"2026-02-10T18:02:54+08:00","out_bill_no":"WD1770691555206100","package_info":"ABBQO+oYAAABAAAAAAAk+yPZGrq+hyjETwKLaRAAAADnGpepZahT9IkJjn90+1qg6ZgBGi0Qjs+Pff8cmSa31vfwaewAXCM6F4nJ9wEZRdwDm4QridPWurNI1lWD7iSS7oX/YzP5XOnpeAlYX3tjHLTDdDQ=","state":"WAIT_USER_CONFIRM","transfer_bill_no":"1330000114850082602100071440076263"}