diff --git a/go/conf/app.conf b/go/conf/app.conf index 0d97b89..d8f755a 100644 --- a/go/conf/app.conf +++ b/go/conf/app.conf @@ -51,4 +51,14 @@ acme_directory = # 证书落盘根目录。签发后生成 {ssl_cert_dir}/{域名}/fullchain.pem 与 privkey.pem, # Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。 -ssl_cert_dir = /www/wwwroot/ssl-certs \ No newline at end of file +ssl_cert_dir = /www/wwwroot/ssl-certs + +# ==================== 支付模块 ==================== +# 渠道敏感参数(商户密钥/私钥/Secret)入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。 +# 注意:更换该值后,历史已保存的渠道参数将无法解密,需在各渠道配置页重新保存一次。 +payment_secret_key = f656fe85ed6a1caba9f19966d8cdde46114709969bea5875cde7664d4ad9f535 +# 渠道异步回调的对外基址:最终回调地址 = {payment_callback_base}/api/payment/callback/{渠道} +# TODO: 填你自己服务器的公网 HTTPS 域名(如 https://api.yunzer.com.cn),不是申请来的。 +# 要求:公网可访问、HTTPS、无鉴权无重定向;微信 JSAPI 还需在商户平台「支付授权目录」登记同一域名。 +# 本地开发收不到渠道回调,留空即可(回调地址退化为相对路径 /api/payment/callback/{渠道},由网关补齐域名)。 +payment_callback_base = https://api.yunzer.com.cn \ No newline at end of file diff --git a/go/controllers/backend_payment.go b/go/controllers/backend_payment.go new file mode 100644 index 0000000..0da67e5 --- /dev/null +++ b/go/controllers/backend_payment.go @@ -0,0 +1,214 @@ +package controllers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services/payment" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendPaymentController 租户端支付接口(下单页 / 收银台) +// 租户端身份为 jwt UserType=backend,TenantId 即租户ID。 +type BackendPaymentController struct { + beego.Controller +} + +func (c *BackendPaymentController) backendClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *BackendPaymentController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendPaymentController) ok(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +func (c *BackendPaymentController) tenantID() string { + claims, _ := c.backendClaims() + if claims == nil { + return "" + } + return strconv.Itoa(claims.TenantId) +} + +// tenantName 查询租户名称(下单快照用) +func (c *BackendPaymentController) tenantName() string { + claims, err := c.backendClaims() + if err != nil || claims.TenantId <= 0 { + return "" + } + tenant := &models.SystemTenant{} + if err := models.Orm.QueryTable(new(models.SystemTenant)). + Filter("id", uint64(claims.TenantId)). + Filter("delete_time__isnull", true). + One(tenant); err != nil { + return "" + } + return tenant.TenantName +} + +// Channels GET /backend/payment/channels 收银台可用渠道(已启用且参数完整) +func (c *BackendPaymentController) Channels() { + if _, err := c.backendClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + var rows []models.PlatformPaymentChannel + _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)). + Filter("enabled", 1). + Filter("delete_time__isnull", true). + OrderBy("id"). + All(&rows) + if err != nil && err != orm.ErrNoRows { + c.jsonErr(500, 500, "查询渠道失败: "+err.Error()) + return + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + cfg, err := payment.LoadChannelConfig(rows[i].Channel) + if err != nil || len(cfg.Params) == 0 { + continue // 参数不完整的渠道不下发 + } + extra := map[string]interface{}{} + for k, v := range cfg.Extra { + extra[k] = v + } + list = append(list, map[string]interface{}{ + "channel": rows[i].Channel, "name": rows[i].Name, "extra": extra, + }) + } + c.ok(map[string]interface{}{"list": list}) +} + +// Create POST /backend/payment/create 创建支付单并返回渠道支付参数 +// 入参:{ out_trade_no, order_type, amount(分), channel, subject, return_url, pay_type, openid, +// +// promoter_id?, promoter_name?, promoter_type?, expire_minutes? } +// +// 出参:{ pay_no, channel, pay_params } +func (c *BackendPaymentController) Create() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p struct { + OutTradeNo string `json:"out_trade_no"` + OrderType string `json:"order_type"` + Amount int64 `json:"amount"` + Channel string `json:"channel"` + Subject string `json:"subject"` + ReturnURL string `json:"return_url"` + PayType string `json:"pay_type"` + OpenID string `json:"openid"` + PromoterID string `json:"promoter_id"` + PromoterName string `json:"promoter_name"` + PromoterType string `json:"promoter_type"` + ExpireMinutes int `json:"expire_minutes"` + } + if err := json.Unmarshal(body, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if strings.TrimSpace(p.OutTradeNo) == "" { + c.jsonErr(400, 400, "业务订单号不能为空") + return + } + if p.Amount <= 0 { + c.jsonErr(400, 400, "支付金额必须大于 0") + return + } + if strings.TrimSpace(p.Channel) == "" { + c.jsonErr(400, 400, "请选择支付渠道") + return + } + if p.OrderType == "" { + p.OrderType = models.CommissionOrderTypePlatformUsage + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + order, params, err := payment.CreatePayment(ctx, payment.CreateInput{ + OutTradeNo: strings.TrimSpace(p.OutTradeNo), + OrderType: p.OrderType, + TenantID: strconv.Itoa(claims.TenantId), + TenantName: c.tenantName(), + Amount: p.Amount, + Channel: strings.TrimSpace(p.Channel), + Subject: strings.TrimSpace(p.Subject), + ReturnURL: strings.TrimSpace(p.ReturnURL), + ClientIP: c.Ctx.Input.IP(), + PayType: p.PayType, + OpenID: p.OpenID, + PromoterID: strings.TrimSpace(p.PromoterID), + PromoterName: strings.TrimSpace(p.PromoterName), + PromoterType: strings.TrimSpace(p.PromoterType), + ExpireMinutes: p.ExpireMinutes, + }) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + c.ok(map[string]interface{}{ + "pay_no": order.PayNo, + "channel": order.Channel, + "status": order.Status, + "pay_params": params, + }) +} + +// Query GET /backend/payment/query/:payNo 查询支付单(仅本租户) +func (c *BackendPaymentController) Query() { + if _, err := c.backendClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + payNo := c.Ctx.Input.Param(":payNo") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + row, err := payment.QueryPayment(ctx, payNo, true) + if err != nil { + c.jsonErr(404, 404, "支付单不存在") + return + } + if row.TenantID != c.tenantID() { + c.jsonErr(403, 403, "无权访问该支付单") + return + } + c.ok(map[string]interface{}{ + "pay_no": row.PayNo, "out_trade_no": row.OutTradeNo, + "amount": row.Amount, "channel": row.Channel, "status": row.Status, + "paid_at": row.PaidAt, "create_time": row.CreateTime, + }) +} diff --git a/go/controllers/platform_commission.go b/go/controllers/platform_commission.go new file mode 100644 index 0000000..7220a0d --- /dev/null +++ b/go/controllers/platform_commission.go @@ -0,0 +1,427 @@ +package controllers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services/payment" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformCommissionController 平台端推广佣金(台账 / 统计 / 规则维护) +type PlatformCommissionController struct { + beego.Controller +} + +func (c *PlatformCommissionController) platformClaims() (*jwtutil.Claims, error) { + return paymentPlatformClaims(&c.Controller) +} + +func (c *PlatformCommissionController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *PlatformCommissionController) ok(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +// commissionRuleDTO 规则出参。 +// 比例值统一按「百分比」返回:库里存百分比×100(500 = 5%),接口返回 5,便于前端直接编辑。 +func commissionRuleDTO(row *models.PlatformCommissionRule) map[string]interface{} { + dto := map[string]interface{}{ + "id": row.ID, "rule_name": row.RuleName, "calc_type": row.CalcType, + "order_type": row.OrderType, "status": row.Status, "remark": row.Remark, + "create_user_id": row.CreateUserID, "create_user_name": row.CreateUserName, + "create_time": row.CreateTime, "update_time": row.UpdateTime, + "steps": []map[string]interface{}{}, + } + if row.CalcType == models.CommissionCalcFixed { + dto["value"] = row.Value // 固定金额:分 + } else { + dto["value"] = float64(row.Value) / 100 // 比例:5% -> 5 + } + if row.CalcType == models.CommissionCalcTiered && row.TierJSON != nil && *row.TierJSON != "" { + var tiers []models.CommissionTier + if err := json.Unmarshal([]byte(*row.TierJSON), &tiers); err == nil { + steps := make([]map[string]interface{}, 0, len(tiers)) + for _, t := range tiers { + var maxV interface{} + if t.Max != nil { + maxV = *t.Max + } + steps = append(steps, map[string]interface{}{ + "min": t.Min, "max": maxV, "rate": float64(t.Rate) / 100, + }) + } + dto["steps"] = steps + } + } + return dto +} + +// commissionOrderDTO 佣金单出参 +func commissionOrderDTO(row *models.PlatformCommissionOrder) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, "commission_no": row.CommissionNo, + "pay_no": row.PayNo, "out_trade_no": row.OutTradeNo, + "rule_id": row.RuleID, "rule_name": row.RuleName, "calc_type": row.CalcType, + "promoter_id": row.PromoterID, "promoter_name": row.PromoterName, "promoter_type": row.PromoterType, + "base_amount": row.BaseAmount, "amount": row.Amount, + "status": row.Status, "pay_method": row.PayMethod, "pay_voucher": row.PayVoucher, + "paid_at": row.PaidAt, "paid_user_id": row.PaidUserID, "paid_user_name": row.PaidUserName, + "remark": row.Remark, "create_time": row.CreateTime, "update_time": row.UpdateTime, + } +} + +// List GET /platform/commission/list 佣金台账 +func (c *PlatformCommissionController) List() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + page, _ := strconv.Atoi(c.GetString("page", "1")) + pageSize, _ := strconv.Atoi(c.GetString("pageSize", "10")) + rows, total, err := payment.ListCommissions(payment.CommissionListInput{ + Promoter: strings.TrimSpace(c.GetString("promoter")), + Status: strings.TrimSpace(c.GetString("status")), + StartTime: strings.TrimSpace(c.GetString("start_time")), + EndTime: strings.TrimSpace(c.GetString("end_time")), + Page: page, + PageSize: pageSize, + }) + if err != nil { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + return + } + if rows == nil { + rows = []models.PlatformCommissionOrder{} + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, commissionOrderDTO(&rows[i])) + } + c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize}) +} + +// Stats GET /platform/commission/stats 台账统计 +func (c *PlatformCommissionController) Stats() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + stats, err := payment.GetCommissionStats() + if err != nil { + c.jsonErr(500, 500, "统计失败: "+err.Error()) + return + } + c.ok(stats) +} + +// MarkPaid POST /platform/commission/:id/pay 标记已付(发放登记) +func (c *PlatformCommissionController) MarkPaid() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p struct { + PayMethod string `json:"pay_method"` + PayVoucher string `json:"pay_voucher"` + Remark string `json:"remark"` + } + if err := json.Unmarshal(body, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if strings.TrimSpace(p.PayMethod) == "" { + c.jsonErr(400, 400, "请选择发放方式") + return + } + row, err := payment.MarkCommissionPaid(context.Background(), payment.MarkCommissionPaidInput{ + ID: id, PayMethod: strings.TrimSpace(p.PayMethod), PayVoucher: strings.TrimSpace(p.PayVoucher), + Remark: strings.TrimSpace(p.Remark), + OperatorID: fmt.Sprintf("%d", claims.UserID), OperatorName: claims.Username, + }) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + c.ok(commissionOrderDTO(row)) +} + +/* ---------------- 佣金规则 ---------------- */ + +type commissionRulePayload struct { + RuleName string `json:"rule_name"` + CalcType string `json:"calc_type"` + Value *float64 `json:"value"` + Steps []struct { + Min *float64 `json:"min"` + Max *float64 `json:"max"` + Rate *float64 `json:"rate"` + } `json:"steps"` + OrderType string `json:"order_type"` + Status *int8 `json:"status"` + Remark string `json:"remark"` +} + +// buildTierJSON 组装阶梯配置(金额分;rate 存百分比×100) +func buildTierJSON(steps []struct { + Min *float64 `json:"min"` + Max *float64 `json:"max"` + Rate *float64 `json:"rate"` +}) (string, error) { + if len(steps) == 0 { + return "", fmt.Errorf("阶梯规则至少需要一档") + } + tiers := make([]models.CommissionTier, 0, len(steps)) + for i, s := range steps { + if s.Min == nil || s.Rate == nil { + return "", fmt.Errorf("第 %d 档的起始金额与比例必填", i+1) + } + tier := models.CommissionTier{ + Min: int64(*s.Min + 0.5), + Rate: int64(*s.Rate*100 + 0.5), + } + if s.Max != nil && *s.Max > 0 { + mx := int64(*s.Max + 0.5) + tier.Max = &mx + if mx <= tier.Min { + return "", fmt.Errorf("第 %d 档结束金额需大于起始金额", i+1) + } + } + if tier.Rate <= 0 { + return "", fmt.Errorf("第 %d 档比例需大于 0", i+1) + } + tiers = append(tiers, tier) + } + b, err := json.Marshal(tiers) + return string(b), err +} + +// RulesList GET /platform/commission/rules 规则列表 +func (c *PlatformCommissionController) RulesList() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + page, _ := strconv.Atoi(c.GetString("page", "1")) + pageSize, _ := strconv.Atoi(c.GetString("pageSize", "20")) + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 200 { + pageSize = 20 + } + qs := models.Orm.QueryTable(new(models.PlatformCommissionRule)).Filter("delete_time__isnull", true) + if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" { + qs = qs.Filter("rule_name__icontains", kw) + } + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + return + } + var rows []models.PlatformCommissionRule + _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil && err != orm.ErrNoRows { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + return + } + if rows == nil { + rows = []models.PlatformCommissionRule{} + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, commissionRuleDTO(&rows[i])) + } + c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize}) +} + +// RuleCreate POST /platform/commission/rules 新增规则 +func (c *PlatformCommissionController) RuleCreate() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p commissionRulePayload + if err := json.Unmarshal(body, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if strings.TrimSpace(p.RuleName) == "" { + c.jsonErr(400, 400, "规则名称不能为空") + return + } + row := &models.PlatformCommissionRule{ + RuleName: strings.TrimSpace(p.RuleName), + CalcType: strings.TrimSpace(p.CalcType), + OrderType: strings.TrimSpace(p.OrderType), + Status: 1, + Remark: strings.TrimSpace(p.Remark), + CreateUserID: fmt.Sprintf("%d", claims.UserID), + CreateUserName: claims.Username, + } + if p.Status != nil { + row.Status = *p.Status + } + if err := applyCommissionRuleValues(row, &p); err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + if _, err := models.Orm.Insert(row); err != nil { + c.jsonErr(500, 500, "创建失败: "+err.Error()) + return + } + c.ok(commissionRuleDTO(row)) +} + +// RuleUpdate POST /platform/commission/rules/:id 更新规则 +func (c *PlatformCommissionController) RuleUpdate() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + row := &models.PlatformCommissionRule{} + if err := models.Orm.QueryTable(new(models.PlatformCommissionRule)). + Filter("id", id).Filter("delete_time__isnull", true).One(row); err != nil { + c.jsonErr(404, 404, "规则不存在") + return + } + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p commissionRulePayload + if err := json.Unmarshal(body, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if name := strings.TrimSpace(p.RuleName); name != "" { + row.RuleName = name + } + if calc := strings.TrimSpace(p.CalcType); calc != "" { + row.CalcType = calc + } + if ot := strings.TrimSpace(p.OrderType); ot != "" { + row.OrderType = ot + } + row.Remark = strings.TrimSpace(p.Remark) + if p.Status != nil { + row.Status = *p.Status + } + if err := applyCommissionRuleValues(row, &p); err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + if _, err := models.Orm.Update(row); err != nil { + c.jsonErr(500, 500, "更新失败: "+err.Error()) + return + } + c.ok(commissionRuleDTO(row)) +} + +// applyCommissionRuleValues 按计算方式写入 value / tier_json +func applyCommissionRuleValues(row *models.PlatformCommissionRule, p *commissionRulePayload) error { + switch row.CalcType { + case models.CommissionCalcPercent: + if p.Value == nil || *p.Value <= 0 { + return fmt.Errorf("请输入大于 0 的比例值") + } + row.Value = int64(*p.Value*100 + 0.5) + row.TierJSON = nil + case models.CommissionCalcFixed: + if p.Value == nil || *p.Value <= 0 { + return fmt.Errorf("请输入大于 0 的固定金额(分)") + } + row.Value = int64(*p.Value + 0.5) + row.TierJSON = nil + case models.CommissionCalcTiered: + tierJSON, err := buildTierJSON(p.Steps) + if err != nil { + return err + } + row.Value = 0 + row.TierJSON = &tierJSON + default: + return fmt.Errorf("计算方式不合法") + } + switch row.OrderType { + case models.CommissionOrderTypeAll, models.CommissionOrderTypePlatformUsage, + models.CommissionOrderTypeModuleShop, models.CommissionOrderTypeServiceFee: + default: + return fmt.Errorf("适用订单类型不合法") + } + return nil +} + +// RuleDelete DELETE /platform/commission/rules/:id 删除规则(软删除;历史佣金单不受影响) +func (c *PlatformCommissionController) RuleDelete() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + now := time.Now() + if _, err := models.Orm.QueryTable(new(models.PlatformCommissionRule)). + Filter("id", id). + Update(orm.Params{"delete_time": now, "update_time": now}); err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + c.ok(nil) +} + +// RuleToggle POST /platform/commission/rules/:id/status 启用/停用 +func (c *PlatformCommissionController) RuleToggle() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p struct { + Status *int8 `json:"status"` + } + if err := json.Unmarshal(body, &p); err != nil || p.Status == nil { + c.jsonErr(400, 400, "参数错误") + return + } + if _, err := models.Orm.QueryTable(new(models.PlatformCommissionRule)). + Filter("id", id). + Update(orm.Params{"status": *p.Status, "update_time": time.Now()}); err != nil { + c.jsonErr(500, 500, "操作失败: "+err.Error()) + return + } + c.ok(map[string]interface{}{"status": *p.Status}) +} diff --git a/go/controllers/platform_payment.go b/go/controllers/platform_payment.go new file mode 100644 index 0000000..683348b --- /dev/null +++ b/go/controllers/platform_payment.go @@ -0,0 +1,615 @@ +package controllers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services/payment" + + "github.com/beego/beego/v2/client/orm" + beelog "github.com/beego/beego/v2/core/logs" + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformPaymentController 平台端支付管理(渠道配置 / 支付订单 / 兜底退回 / 渠道回调) +type PlatformPaymentController struct { + beego.Controller +} + +func (c *PlatformPaymentController) platformClaims() (*jwtutil.Claims, error) { + return paymentPlatformClaims(&c.Controller) +} + +// paymentPlatformClaims 平台端鉴权(支付相关控制器共用),传各控制器内嵌的 &c.Controller +func paymentPlatformClaims(c *beego.Controller) (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *PlatformPaymentController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *PlatformPaymentController) ok(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +// channelSecretKeys 各渠道的敏感参数键(接口回显时只回掩码;提交掩码/空串表示不修改) +var channelSecretKeys = map[string][]string{ + payment.ChannelWechat: {"api_v3_key"}, + payment.ChannelAlipay: {"app_private_key"}, + payment.ChannelUnionPay: {"cert_password"}, + payment.ChannelCloudPay: {"cert_password"}, + payment.ChannelPayPal: {"client_secret"}, +} + +func channelRowDTO(row *models.PlatformPaymentChannel) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, "channel": row.Channel, "name": row.Name, + "merchant_no": row.MerchantNo, "callback_url": row.CallbackURL, + "enabled": row.Enabled == 1, + "remark": row.Remark, + "last_test_time": row.LastTestTime, "last_test_result": row.LastTestResult, + "create_time": row.CreateTime, "update_time": row.UpdateTime, + } +} + +// maskedConfigView 解密配置并掩码敏感字段,供前端回显 +func maskedConfigView(cfg *payment.ChannelConfig, row *models.PlatformPaymentChannel) map[string]interface{} { + view := map[string]interface{}{} + for k, v := range cfg.Params { + view[k] = v + } + for _, key := range channelSecretKeys[row.Channel] { + if v, ok := view[key]; ok { + if s, ok2 := v.(string); ok2 && s != "" { + view[key] = payment.MaskSecret(s) + } + } + } + return view +} + +func findChannelRow(channel string) (*models.PlatformPaymentChannel, error) { + row := &models.PlatformPaymentChannel{} + err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)). + Filter("channel", channel). + Filter("delete_time__isnull", true). + One(row) + return row, err +} + +type paymentChannelPayload struct { + Name string `json:"name"` + MerchantNo string `json:"merchant_no"` + CallbackURL string `json:"callback_url"` + Remark string `json:"remark"` + Config map[string]string `json:"config"` + Extra map[string]any `json:"extra"` +} + +// loadMergedConfig 加载已存配置并把「新填写且非掩码」的值合并进去(用于保存与测试连接共用) +func loadMergedConfig(channel string, p *paymentChannelPayload) (*payment.ChannelConfig, error) { + cfg, err := payment.LoadChannelConfig(channel) + if err != nil { + return nil, err + } + if p == nil { + return cfg, nil + } + for k, v := range p.Config { + v = strings.TrimSpace(v) + if v == "" || payment.IsMasked(v) { + continue // 空串 / 掩码 = 保持原值 + } + cfg.Params[k] = v + } + if p.Extra != nil { + cfg.Extra = p.Extra + } + return cfg, nil +} + +// ListChannels GET /platform/payment/channels 渠道概览 +func (c *PlatformPaymentController) ListChannels() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + var rows []models.PlatformPaymentChannel + _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)). + Filter("delete_time__isnull", true). + OrderBy("id"). + All(&rows) + if err != nil && err != orm.ErrNoRows { + c.jsonErr(500, 500, "查询渠道失败: "+err.Error()) + return + } + if rows == nil { + rows = []models.PlatformPaymentChannel{} + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, channelRowDTO(&rows[i])) + } + c.ok(map[string]interface{}{"list": list, "total": len(list)}) +} + +// GetChannel GET /platform/payment/channels/:channel 渠道配置详情(敏感字段掩码) +func (c *PlatformPaymentController) GetChannel() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + channel := c.Ctx.Input.Param(":channel") + row, err := findChannelRow(channel) + if err != nil { + c.jsonErr(404, 404, "渠道不存在") + return + } + cfg, err := payment.LoadChannelConfig(channel) + if err != nil { + c.jsonErr(500, 500, err.Error()) + return + } + dto := channelRowDTO(row) + dto["config"] = maskedConfigView(cfg, row) + dto["extra"] = cfg.Extra + dto["cert_files"] = cfg.CertPaths + c.ok(dto) +} + +// SaveChannel POST /platform/payment/channels/:channel 保存渠道配置(敏感字段留空/掩码表示不修改) +func (c *PlatformPaymentController) SaveChannel() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + channel := c.Ctx.Input.Param(":channel") + if _, err := findChannelRow(channel); err != nil { + c.jsonErr(404, 404, "渠道不存在") + return + } + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p paymentChannelPayload + if err := json.Unmarshal(body, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + merged, err := loadMergedConfig(channel, &p) + if err != nil { + c.jsonErr(500, 500, err.Error()) + return + } + plain, _ := json.Marshal(merged.Params) + enc, err := payment.EncryptConfig(string(plain)) + if err != nil { + c.jsonErr(500, 500, "渠道参数加密失败: "+err.Error()) + return + } + + extraJSON := "" + if p.Extra != nil { + b, _ := json.Marshal(p.Extra) + extraJSON = string(b) + } + + now := time.Now() + updates := orm.Params{ + "config_json": enc, + "merchant_no": strings.TrimSpace(p.MerchantNo), + "remark": strings.TrimSpace(p.Remark), + "update_time": now, + } + if p.Name != "" { + updates["name"] = strings.TrimSpace(p.Name) + } + if p.CallbackURL != "" { + updates["callback_url"] = strings.TrimSpace(p.CallbackURL) + } + if extraJSON != "" { + updates["extra_json"] = extraJSON + } + if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)). + Filter("channel", channel). + Update(updates); err != nil { + c.jsonErr(500, 500, "保存失败: "+err.Error()) + return + } + _ = claims + row, _ := findChannelRow(channel) + c.ok(channelRowDTO(row)) +} + +// TestChannel POST /platform/payment/channels/:channel/test 测试连接(支持未保存参数) +func (c *PlatformPaymentController) TestChannel() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + channel := c.Ctx.Input.Param(":channel") + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p paymentChannelPayload + _ = json.Unmarshal(body, &p) // 允许空 body:只测已保存配置 + + merged, err := loadMergedConfig(channel, &p) + if err != nil { + c.jsonErr(500, 500, err.Error()) + return + } + adapter, err := payment.GetChannelAdapter(channel) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + msg, err := adapter.TestConnect(ctx, merged) + + now := time.Now() + result := msg + if err != nil { + result = err.Error() + } + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannel)). + Filter("channel", channel). + Update(orm.Params{"last_test_time": now, "last_test_result": result, "update_time": now}) + if err != nil { + c.jsonErr(400, 400, result) + return + } + c.ok(map[string]interface{}{"message": result}) +} + +// ToggleChannel POST /platform/payment/channels/:channel/status 启用/停用 +func (c *PlatformPaymentController) ToggleChannel() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + channel := c.Ctx.Input.Param(":channel") + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p struct { + Enabled *bool `json:"enabled"` + } + if err := json.Unmarshal(body, &p); err != nil || p.Enabled == nil { + c.jsonErr(400, 400, "参数错误") + return + } + + cfg, err := payment.LoadChannelConfig(channel) + if err != nil { + c.jsonErr(404, 404, "渠道不存在") + return + } + if *p.Enabled && len(cfg.Params) == 0 { + c.jsonErr(400, 400, "渠道参数未配置,无法启用") + return + } + if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)). + Filter("channel", channel). + Update(orm.Params{"enabled": b2i(*p.Enabled), "update_time": time.Now()}); err != nil { + c.jsonErr(500, 500, "操作失败: "+err.Error()) + return + } + c.ok(map[string]interface{}{"enabled": *p.Enabled}) +} + +func b2i(v bool) int8 { + if v { + return 1 + } + return 0 +} + +// UploadCertificate POST /platform/payment/channels/:channel/certificate 上传渠道证书 +// 证书文件保存在服务端 certs/payment// 下(0600 权限,不对外提供访问), +// 文件路径写入 cert_json,密钥内容绝不入库、不回显。 +func (c *PlatformPaymentController) UploadCertificate() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + channel := c.Ctx.Input.Param(":channel") + if _, err := findChannelRow(channel); err != nil { + c.jsonErr(404, 404, "渠道不存在") + return + } + certType := strings.TrimSpace(c.GetString("cert_type")) + if certType == "" { + certType = "cert" + } + file, header, err := c.GetFile("file") + if err != nil || header == nil { + c.jsonErr(400, 400, "请选择证书文件") + return + } + defer func() { _ = file.Close() }() + + dir := filepath.Join("certs", "payment", channel) + if err := os.MkdirAll(dir, 0o700); err != nil { + c.jsonErr(500, 500, "创建证书目录失败: "+err.Error()) + return + } + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext == "" { + ext = ".pem" + } + name := fmt.Sprintf("%s_%d%s", certType, time.Now().UnixNano(), ext) + savePath := filepath.Join(dir, name) + + out, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + c.jsonErr(500, 500, "保存证书失败: "+err.Error()) + return + } + if _, err := io.Copy(out, file); err != nil { + _ = out.Close() + c.jsonErr(500, 500, "保存证书失败: "+err.Error()) + return + } + _ = out.Close() + + // 更新 cert_json + cfg, err := payment.LoadChannelConfig(channel) + if err != nil { + c.jsonErr(500, 500, err.Error()) + return + } + if cfg.CertPaths == nil { + cfg.CertPaths = map[string]string{} + } + certKey := certType + "_path" + cfg.CertPaths[certKey] = savePath + if _, err := os.Stat(savePath); err == nil { + // 同步补充对应关系:cert -> cert_path,key -> key_path + if certType == "cert" { + cfg.CertPaths["cert_path"] = savePath + } else { + cfg.CertPaths["key_path"] = savePath + } + } + certJSON, _ := json.Marshal(cfg.CertPaths) + if _, err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)). + Filter("channel", channel). + Update(orm.Params{"cert_json": string(certJSON), "update_time": time.Now()}); err != nil { + c.jsonErr(500, 500, "更新证书信息失败: "+err.Error()) + return + } + c.ok(map[string]interface{}{"file": header.Filename, "path": savePath, "cert_type": certType}) +} + +/* ---------------- 支付订单 ---------------- */ + +func paymentOrderDTO(row *models.PlatformPaymentOrder) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, "pay_no": row.PayNo, "out_trade_no": row.OutTradeNo, + "order_type": row.OrderType, "subject": row.Subject, + "tenant_id": row.TenantID, "tenant_name": row.TenantName, + "amount": row.Amount, "order_amount": row.OrderAmount, "currency": row.Currency, + "channel": row.Channel, "merchant_no": row.MerchantNo, "channel_trade_no": row.ChannelTradeNo, + "status": row.Status, "refund_amount": row.RefundAmount, + "return_url": row.ReturnURL, "notify_url": row.NotifyURL, + "expire_at": row.ExpireAt, "paid_at": row.PaidAt, "closed_at": row.ClosedAt, + "notify_at": row.NotifyAt, "last_query_at": row.LastQueryAt, + "order_synced": row.OrderSynced, + "promoter_id": row.PromoterID, "promoter_name": row.PromoterName, "promoter_type": row.PromoterType, + "create_time": row.CreateTime, "update_time": row.UpdateTime, + } +} + +// ListOrders GET /platform/payment/orders 支付单列表 +func (c *PlatformPaymentController) ListOrders() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + page, _ := strconv.Atoi(c.GetString("page", "1")) + pageSize, _ := strconv.Atoi(c.GetString("pageSize", "10")) + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 200 { + pageSize = 10 + } + + qs := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).Filter("delete_time__isnull", true) + if v := strings.TrimSpace(c.GetString("tenant_id")); v != "" { + qs = qs.Filter("tenant_id", v) + } + if v := strings.TrimSpace(c.GetString("out_trade_no")); v != "" { + qs = qs.Filter("out_trade_no__icontains", v) + } + if v := strings.TrimSpace(c.GetString("pay_no")); v != "" { + qs = qs.Filter("pay_no__icontains", v) + } + if v := strings.TrimSpace(c.GetString("channel")); v != "" { + qs = qs.Filter("channel", v) + } + if v := strings.TrimSpace(c.GetString("status")); v != "" { + qs = qs.Filter("status", v) + } + if v := strings.TrimSpace(c.GetString("start_time")); v != "" { + if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil { + qs = qs.Filter("create_time__gte", t) + } + } + if v := strings.TrimSpace(c.GetString("end_time")); v != "" { + if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil { + qs = qs.Filter("create_time__lt", t.AddDate(0, 0, 1)) + } + } + + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + return + } + var rows []models.PlatformPaymentOrder + _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil && err != orm.ErrNoRows { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + return + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, paymentOrderDTO(&rows[i])) + } + c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize}) +} + +// OrderDetail GET /platform/payment/orders/:payNo 支付单详情(含状态流转 + 最近回调原文) +func (c *PlatformPaymentController) OrderDetail() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + payNo := c.Ctx.Input.Param(":payNo") + row, err := payment.GetOrderByPayNo(payNo) + if err != nil { + c.jsonErr(404, 404, "支付单不存在") + return + } + dto := paymentOrderDTO(row) + + // 状态流转记录 + var logs []models.PlatformPaymentOrderLog + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrderLog)). + Filter("pay_no", payNo). + Filter("delete_time__isnull", true). + OrderBy("id"). + All(&logs) + records := make([]map[string]interface{}, 0, len(logs)) + for i := range logs { + records = append(records, map[string]interface{}{ + "to_status": logs[i].ToStatus, "operator": logs[i].Operator, + "remark": logs[i].Remark, "create_time": logs[i].CreateTime, + }) + } + dto["records"] = records + + // 最近一次成功回调的原文(来自 yz_platform_payment_callback_log.raw_body) + var cb models.PlatformPaymentCallbackLog + err = models.Orm.QueryTable(new(models.PlatformPaymentCallbackLog)). + Filter("pay_no", payNo). + Filter("delete_time__isnull", true). + OrderBy("-id"). + One(&cb) + if err == nil && cb.RawBody != nil { + dto["callback_raw"] = *cb.RawBody + dto["notify_at"] = cb.CreateTime + } + c.ok(dto) +} + +// QueryOrder POST /platform/payment/orders/:payNo/query 手动查询渠道状态(兜底回调丢失) +func (c *PlatformPaymentController) QueryOrder() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + payNo := c.Ctx.Input.Param(":payNo") + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p struct { + Sync *bool `json:"sync"` + } + _ = json.Unmarshal(body, &p) + sync := true + if p.Sync != nil { + sync = *p.Sync + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + row, err := payment.QueryPayment(ctx, payNo, sync) + if err != nil { + c.jsonErr(404, 404, err.Error()) + return + } + c.ok(paymentOrderDTO(row)) +} + +// Refund POST /platform/payment/refund 手动原路退回(兜底能力) +func (c *PlatformPaymentController) Refund() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p struct { + PayNo string `json:"pay_no"` + Amount int64 `json:"amount"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(body, &p); err != nil || strings.TrimSpace(p.PayNo) == "" { + c.jsonErr(400, 400, "参数错误") + return + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + refund, err := payment.ManualRefund(ctx, payment.RefundInput{ + PayNo: strings.TrimSpace(p.PayNo), + Amount: p.Amount, + Reason: strings.TrimSpace(p.Reason), + OperatorID: fmt.Sprintf("%d", claims.UserID), + OperatorName: claims.Username, + }) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + c.ok(map[string]interface{}{ + "refund_no": refund.RefundNo, "status": refund.Status, + "amount": refund.Amount, "channel_refund_no": refund.ChannelRefundNo, + "fail_reason": refund.FailReason, + }) +} + +// Notify POST /platform/payment/callback/:channel 渠道异步通知入口(渠道服务器调用,无平台鉴权) +// 处理成功时按渠道约定返回:微信 {"code":"SUCCESS"} / 支付宝 success / 银联与 PayPal ok / 200 空体。 +func (c *PlatformPaymentController) Notify() { + channel := c.Ctx.Input.Param(":channel") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + ack, err := payment.HandleNotify(ctx, channel, c.Ctx.Request, c.Ctx.Input.IP()) + if err != nil { + beelog.Warn("支付回调处理失败: channel=%s err=%v", channel, err) + c.Ctx.Output.SetStatus(http.StatusInternalServerError) + _, _ = c.Ctx.ResponseWriter.Write([]byte("FAIL")) + return + } + if ack == "" { + ack = "ok" + } + c.Ctx.Output.SetStatus(http.StatusOK) + _, _ = c.Ctx.ResponseWriter.Write([]byte(ack)) +} diff --git a/go/controllers/platform_payment_reconcile.go b/go/controllers/platform_payment_reconcile.go new file mode 100644 index 0000000..bba5acd --- /dev/null +++ b/go/controllers/platform_payment_reconcile.go @@ -0,0 +1,188 @@ +package controllers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services/payment" + + beego "github.com/beego/beego/v2/server/web" +) + +// PlatformPaymentReconcileController 平台端对账管理(差异列表 / 标记处理 / 账单导入 / 执行对账) +type PlatformPaymentReconcileController struct { + beego.Controller +} + +func (c *PlatformPaymentReconcileController) platformClaims() (*jwtutil.Claims, error) { + return paymentPlatformClaims(&c.Controller) +} + +func (c *PlatformPaymentReconcileController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *PlatformPaymentReconcileController) ok(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +func reconcileDiffDTO(row *models.PlatformPaymentReconcileDiff) map[string]interface{} { + return map[string]interface{}{ + "id": row.ID, "channel": row.Channel, "bill_date": row.BillDate, + "batch_no": row.BatchNo, "local_trade_no": row.LocalTradeNo, "channel_trade_no": row.ChannelTradeNo, + "local_amount": row.LocalAmount, "channel_amount": row.ChannelAmount, "diff_amount": row.DiffAmount, + "diff_type": row.DiffType, "handle_status": row.HandleStatus, + "handle_user_id": row.HandleUserID, "handle_user_name": row.HandleUserName, + "handle_time": row.HandleTime, "remark": row.Remark, + "create_time": row.CreateTime, "update_time": row.UpdateTime, + } +} + +// ListReconcile GET /platform/payment/reconcile 对账差异列表 +func (c *PlatformPaymentReconcileController) ListReconcile() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + page, _ := strconv.Atoi(c.GetString("page", "1")) + pageSize, _ := strconv.Atoi(c.GetString("pageSize", "10")) + rows, total, err := payment.ListReconcileDiffs(payment.ReconcileListInput{ + Channel: strings.TrimSpace(c.GetString("channel")), + BillDate: strings.TrimSpace(c.GetString("bill_date")), + DiffType: strings.TrimSpace(c.GetString("diff_type")), + HandleStatus: strings.TrimSpace(c.GetString("handle_status")), + Page: page, + PageSize: pageSize, + }) + if err != nil { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + return + } + if rows == nil { + rows = []models.PlatformPaymentReconcileDiff{} + } + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + list = append(list, reconcileDiffDTO(&rows[i])) + } + c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize}) +} + +// HandleReconcile POST /platform/payment/reconcile/:id/handle 标记差异处理 +func (c *PlatformPaymentReconcileController) HandleReconcile() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p struct { + HandleStatus string `json:"handle_status"` + Remark string `json:"remark"` + } + if err := json.Unmarshal(body, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + row, err := payment.MarkDiffHandled(context.Background(), payment.MarkDiffHandledInput{ + ID: id, HandleStatus: strings.TrimSpace(p.HandleStatus), Remark: strings.TrimSpace(p.Remark), + OperatorID: fmt.Sprintf("%d", claims.UserID), OperatorName: claims.Username, + }) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + c.ok(reconcileDiffDTO(row)) +} + +// RunReconcile POST /platform/payment/reconcile/run 执行对账比对 +func (c *PlatformPaymentReconcileController) RunReconcile() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p struct { + Channel string `json:"channel"` + BillDate string `json:"bill_date"` + } + if err := json.Unmarshal(body, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + summary, err := payment.RunReconcile(ctx, strings.TrimSpace(p.Channel), strings.TrimSpace(p.BillDate)) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + c.ok(summary) +} + +// ImportBill POST /platform/payment/reconcile/import 导入渠道账单 +// 两种方式:multipart 上传 CSV(字段 file + channel + bill_date),或 JSON {channel, bill_date, rows:[...]} +func (c *PlatformPaymentReconcileController) ImportBill() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + var channel, billDate string + var rows []payment.BillRow + + contentType := c.Ctx.Request.Header.Get("Content-Type") + if strings.Contains(contentType, "multipart/form-data") { + channel = strings.TrimSpace(c.GetString("channel")) + billDate = strings.TrimSpace(c.GetString("bill_date")) + file, header, ferr := c.GetFile("file") + if ferr != nil || header == nil { + c.jsonErr(400, 400, "请选择账单文件") + return + } + defer func() { _ = file.Close() }() + data, rerr := io.ReadAll(file) + if rerr != nil { + c.jsonErr(400, 400, "读取账单文件失败: "+rerr.Error()) + return + } + rows, _ = payment.ParseChannelBillCSV(data) + } else { + body, _ := io.ReadAll(c.Ctx.Request.Body) + var p struct { + Channel string `json:"channel"` + BillDate string `json:"bill_date"` + Rows []payment.BillRow `json:"rows"` + } + if err := json.Unmarshal(body, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + channel, billDate, rows = strings.TrimSpace(p.Channel), strings.TrimSpace(p.BillDate), p.Rows + } + + if channel == "" || billDate == "" { + c.jsonErr(400, 400, "channel 与 bill_date 不能为空") + return + } + imported, err := payment.ImportChannelBill(context.Background(), channel, billDate, rows) + if err != nil { + c.jsonErr(400, 400, err.Error()) + return + } + c.ok(map[string]interface{}{"imported": imported, "channel": channel, "bill_date": billDate}) +} diff --git a/go/docs/sql/alter_platform_payment_order_promoter.sql b/go/docs/sql/alter_platform_payment_order_promoter.sql new file mode 100644 index 0000000..914f6cd --- /dev/null +++ b/go/docs/sql/alter_platform_payment_order_promoter.sql @@ -0,0 +1,30 @@ +-- ============================================================= +-- 支付单表补列:yz_platform_payment_order 增加推广方快照字段 +-- 变更原因:佣金在「支付成功」事件上生成,需要知道该笔收款归属的推广方; +-- 推广方在下单时确定,随支付单落快照(后续改归属不影响历史佣金)。 +-- 新增列: +-- promoter_id varchar(64) NOT NULL DEFAULT '' 推广方ID +-- promoter_name varchar(128) NOT NULL DEFAULT '' 推广方名称 +-- promoter_type varchar(32) NOT NULL DEFAULT '' 推广方类型 channel/service/staff +-- 本脚本可重复执行:列不存在时才执行 ALTER,已存在自动跳过(不报 1060 Duplicate column)。 +-- 运行:mysql -h -P -u -p < alter_platform_payment_order_promoter.sql +-- ============================================================= + +-- promoter_id +SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order'); +SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_id'); +SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_id` varchar(64) NOT NULL DEFAULT '''' COMMENT ''推广方ID(下单时快照,支付成功后据此生成佣金)'' AFTER `order_synced`', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- promoter_name +SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_name'); +SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_name` varchar(128) NOT NULL DEFAULT '''' COMMENT ''推广方名称快照'' AFTER `promoter_id`', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- promoter_type +SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_payment_order' AND COLUMN_NAME = 'promoter_type'); +SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_payment_order` ADD COLUMN `promoter_type` varchar(32) NOT NULL DEFAULT '''' COMMENT ''推广方类型快照:channel渠道伙伴/service服务商/staff内部员工'' AFTER `promoter_name`', 'DO 0'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 校验: +SHOW COLUMNS FROM yz_platform_payment_order LIKE 'promoter%'; diff --git a/go/docs/sql/create_platform_commission.sql b/go/docs/sql/create_platform_commission.sql new file mode 100644 index 0000000..b240e72 --- /dev/null +++ b/go/docs/sql/create_platform_commission.sql @@ -0,0 +1,73 @@ +-- ============================================================= +-- 平台端推广佣金相关表 +-- yz_platform_commission_rule 佣金规则(按比例 / 固定金额 / 阶梯) +-- yz_platform_commission_order 佣金单(台账,含发放登记) +-- +-- 业务约定: +-- 1. 佣金由平台自有资金支出,与租户收款资金流解耦,不参与分账; +-- 2. 佣金单在「业务订单支付成功」事件上按启用中的规则生成,状态先为 payable(应付); +-- 3. 一笔支付单 + 同一推广方只应生成一张佣金单(uk_pay_promoter), +-- 但 MySQL 唯一索引对 NULL 不去重(delete_time),业务层仍需按 pay_no 查重后再插入; +-- 4. 金额一律以「分」为单位的 bigint 存储; +-- 5. 规则修改只影响之后生成的佣金单,历史佣金单保留规则快照字段(rule_name/calc_type)。 +-- +-- 可重复执行(CREATE TABLE IF NOT EXISTS)。 +-- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < create_platform_commission.sql +-- ============================================================= + +-- ------------------------------------------------------------- +-- 1. 佣金规则表 +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `yz_platform_commission_rule` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `rule_name` varchar(128) NOT NULL DEFAULT '' COMMENT '规则名称,如「渠道推广-平台使用费 5%」', + `calc_type` varchar(20) NOT NULL DEFAULT 'percent' COMMENT '计算方式:percent按比例/fixed固定金额/tiered阶梯', + `value` bigint(20) NOT NULL DEFAULT '0' COMMENT '规则值:percent=百分比×100(5%存500,支持2.5%存250);fixed=固定金额(分);tiered=0且明细见 tier_json', + `tier_json` text COMMENT '阶梯配置JSON:[{"min":0,"max":1000000,"rate":300}];金额单位为分,max 为 null 表示不限,rate 同为百分比×100', + `order_type` varchar(32) NOT NULL DEFAULT 'platform_usage' COMMENT '适用订单类型:all全部/platform_usage平台使用费/module_shop模块购买/service_fee服务费', + `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用/0停用(停用后新佣金单不再匹配该规则,历史佣金单不受影响)', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人ID', + `create_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '创建人姓名', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + KEY `idx_status` (`status`,`order_type`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台推广佣金规则表'; + +-- ------------------------------------------------------------- +-- 2. 佣金单表(台账) +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `yz_platform_commission_order` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `commission_no` varchar(40) NOT NULL DEFAULT '' COMMENT '佣金单号,形如 CM202609150001', + `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号(yz_platform_payment_order.pay_no)', + `out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '关联业务订单号', + `rule_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '命中的佣金规则ID', + `rule_name` varchar(128) NOT NULL DEFAULT '' COMMENT '命中规则名称快照', + `calc_type` varchar(20) NOT NULL DEFAULT '' COMMENT '计算方式快照:percent/fixed/tiered', + `promoter_id` varchar(64) NOT NULL DEFAULT '' COMMENT '推广方ID(渠道伙伴 / 服务商 / 内部员工)', + `promoter_name` varchar(128) NOT NULL DEFAULT '' COMMENT '推广方名称', + `promoter_type` varchar(32) NOT NULL DEFAULT '' COMMENT '推广方类型:channel渠道伙伴/service服务商/staff内部员工', + `base_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '计佣基数(分)= 业务订单实付金额', + `amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '佣金金额(分)', + `status` varchar(20) NOT NULL DEFAULT 'payable' COMMENT '佣金状态:payable应付/settled已付/tax_processing税务处理中/canceled已作废', + `pay_method` varchar(20) NOT NULL DEFAULT '' COMMENT '发放方式:bank银行转账/balance余额抵扣/manual人工发放/offset下期抵扣', + `pay_voucher` varchar(255) NOT NULL DEFAULT '' COMMENT '发放凭证 / 银行流水号', + `paid_at` datetime DEFAULT NULL COMMENT '发放时间', + `paid_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '登记发放人ID', + `paid_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '登记发放人姓名', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_commission_no` (`commission_no`,`delete_time`), + UNIQUE KEY `uk_pay_promoter` (`pay_no`,`promoter_id`,`delete_time`), + KEY `idx_out_trade_no` (`out_trade_no`), + KEY `idx_status` (`status`,`create_time`), + KEY `idx_promoter` (`promoter_id`,`create_time`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台推广佣金单表(台账)'; diff --git a/go/docs/sql/create_platform_payment.sql b/go/docs/sql/create_platform_payment.sql new file mode 100644 index 0000000..72661ee --- /dev/null +++ b/go/docs/sql/create_platform_payment.sql @@ -0,0 +1,223 @@ +-- ============================================================= +-- 平台端支付模块相关表(平台向租户收取平台使用费) +-- yz_platform_payment_channel 渠道配置(一渠道一行,敏感参数加密后存 JSON) +-- yz_platform_payment_order 支付单 +-- yz_platform_payment_order_log 支付单状态流转流水 +-- yz_platform_payment_callback_log 渠道回调日志(幂等去重 + 问题排查) +-- yz_platform_payment_channel_bill 渠道账单明细(对账源数据) +-- yz_platform_payment_reconcile_diff 对账差异 +-- yz_platform_payment_refund 手动原路退回记录(兜底能力) +-- +-- 设计约定(与业务约定一致): +-- 1. 资金流向:租户付款直接进平台对公账户,不涉及分账;仅保留后台「手动原路退回」兜底; +-- 2. 金额一律用「分」为单位的 bigint 存储,前端展示时 /100 转元; +-- 3. 状态类字段用 varchar 存英文枚举,前端 StatusTag 直接映射,避免数字魔法值; +-- 4. 渠道密钥 / 私钥 / ClientSecret 不入库明文,只存 config_json(写入前由后端加密); +-- 证书本体存文件(object_key),库内只留元信息(cert_json); +-- 5. 表结构与项目其他表一致:软删除 delete_time、create_time/update_time 自动维护; +-- 6. 唯一键带上 delete_time 是本项目约定(软删除后可重新插入同键数据); +-- 注意 MySQL 唯一索引对 NULL 不去重,因此「支付幂等/回调去重」仍必须在业务层 +-- 用「按 pay_no 查重 + 条件更新(WHERE status <> 'paid')」兜底,不能只依赖唯一键。 +-- 7. 业务订单字段(业务订单状态、订单摘要等)由业务模块持有,本模块只冗余订单号与金额, +-- 详情接口聚合返回,不在此重复落库。 +-- +-- 可重复执行(CREATE TABLE IF NOT EXISTS)。 +-- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < create_platform_payment.sql +-- ============================================================= + +-- ------------------------------------------------------------- +-- 1. 支付渠道配置表 +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `yz_platform_payment_channel` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识:wechat/alipay/unionpay/cloudpay/paypal', + `name` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道名称(展示用)', + `merchant_no` varchar(128) NOT NULL DEFAULT '' COMMENT '商户号(展示用,未配置时前端显示未配置)', + `config_json` text COMMENT '渠道参数JSON(含密钥/私钥/Secret,写入前加密;接口回显敏感字段时只回掩码)', + `cert_json` text COMMENT '证书元信息JSON:文件名、object_key、序列号、到期日(不含证书私钥内容)', + `callback_url` varchar(255) NOT NULL DEFAULT '' COMMENT '回调地址(只读):/api/payment/callback/{channel}', + `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用:1启用/0停用(参数不完整时前端展示为未配置)', + `extra_json` text COMMENT '渠道扩展开关JSON:云闪付 show_logo/logo_positions、PayPal env 等', + `last_test_time` datetime DEFAULT NULL COMMENT '最近一次「测试连接」时间', + `last_test_result` varchar(255) NOT NULL DEFAULT '' COMMENT '最近一次测试连接结果说明', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_channel` (`channel`,`delete_time`), + KEY `idx_enabled` (`enabled`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道配置表'; + +-- ------------------------------------------------------------- +-- 2. 支付单表 +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `yz_platform_payment_order` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号(支付模块生成,形如 P202609150009)', + `out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务订单号(业务模块生成;同一订单可有多笔支付单)', + `order_type` varchar(32) NOT NULL DEFAULT 'platform_usage' COMMENT '订单类型:platform_usage平台使用费/module_shop模块购买/service_fee服务费', + `subject` varchar(255) NOT NULL DEFAULT '' COMMENT '订单标题(渠道侧展示文案)', + `tenant_id` varchar(64) NOT NULL DEFAULT '' COMMENT '租户ID', + `tenant_name` varchar(128) NOT NULL DEFAULT '' COMMENT '租户名称(冗余,便于列表展示)', + `amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '实付金额(分)', + `order_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '业务订单金额(分)', + `currency` varchar(8) NOT NULL DEFAULT 'CNY' COMMENT '币种', + `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '支付渠道标识', + `merchant_no` varchar(128) NOT NULL DEFAULT '' COMMENT '下单时商户号快照', + `channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号', + `status` varchar(20) NOT NULL DEFAULT 'created' COMMENT '状态:created/pending/paying/paid/failed/closed/refunding/refunded', + `refund_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '已退回金额(分),仅兜底手动退回时累加', + `client_ip` varchar(64) NOT NULL DEFAULT '' COMMENT '下单客户端IP', + `return_url` varchar(255) NOT NULL DEFAULT '' COMMENT '支付完成同步跳回地址(由下单参数带入)', + `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '渠道回调地址快照', + `expire_at` datetime DEFAULT NULL COMMENT '支付超时时间(过期由补偿任务置为 closed)', + `paid_at` datetime DEFAULT NULL COMMENT '支付完成时间', + `closed_at` datetime DEFAULT NULL COMMENT '关闭时间', + `notify_at` datetime DEFAULT NULL COMMENT '最近一次成功回调时间', + `last_query_at` datetime DEFAULT NULL COMMENT '最近一次主动查询渠道状态时间', + `order_synced` tinyint(1) NOT NULL DEFAULT '0' COMMENT '业务订单状态是否已同步:1已同步/0待同步(补偿任务扫描用)', + `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pay_no` (`pay_no`,`delete_time`), + KEY `idx_out_trade_no` (`out_trade_no`), + KEY `idx_tenant` (`tenant_id`,`create_time`), + KEY `idx_status` (`status`,`create_time`), + KEY `idx_channel_trade` (`channel`,`channel_trade_no`), + KEY `idx_order_synced` (`order_synced`,`status`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付单表'; + +-- ------------------------------------------------------------- +-- 3. 支付单状态流转流水(详情页时间轴数据源) +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `yz_platform_payment_order_log` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号(yz_platform_payment_order.pay_no)', + `from_status` varchar(20) NOT NULL DEFAULT '' COMMENT '变更前状态(创建时为空)', + `to_status` varchar(20) NOT NULL DEFAULT '' COMMENT '变更后状态', + `operator` varchar(64) NOT NULL DEFAULT '' COMMENT '操作来源:租户端下单页/渠道回调/平台管理员/定时补偿', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '变更说明(如:支付成功,已同步业务订单为已支付)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + KEY `idx_pay_no` (`pay_no`,`create_time`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付单状态流转流水表'; + +-- ------------------------------------------------------------- +-- 4. 渠道回调日志表(幂等去重与排查) +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `yz_platform_payment_callback_log` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识', + `event_type` varchar(64) NOT NULL DEFAULT '' COMMENT '事件类型:TRANSACTION.SUCCESS/trade.success/PAYMENT.CAPTURE.COMPLETED 等', + `event_id` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道事件ID(微信 event id / 支付宝 notify_id / PayPal event id)', + `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号', + `out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务订单号', + `channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号', + `trade_state` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道侧交易状态原文', + `amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道通知金额(分)', + `verify_result` tinyint(1) NOT NULL DEFAULT '0' COMMENT '验签结果:1通过/0失败', + `is_duplicate` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否重复通知:1是(幂等命中,直接返回渠道要求的成功响应)/0否', + `handle_result` tinyint(1) NOT NULL DEFAULT '0' COMMENT '处理结果:1成功/0失败', + `handle_msg` varchar(255) NOT NULL DEFAULT '' COMMENT '处理说明或错误信息', + `raw_body` mediumtext COMMENT '渠道原始报文(脱敏后保留,详情页「渠道回调原文」数据源)', + `client_ip` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道来源IP', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_channel_event` (`channel`,`channel_trade_no`,`event_type`,`delete_time`), + KEY `idx_pay_no` (`pay_no`), + KEY `idx_event_id` (`event_id`), + KEY `idx_create_time` (`create_time`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道回调日志表(幂等去重)'; + +-- ------------------------------------------------------------- +-- 5. 渠道账单明细表(对账源数据) +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `yz_platform_payment_channel_bill` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识', + `bill_date` date NOT NULL COMMENT '账单日期(渠道账单自然日)', + `channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号', + `out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道侧商户订单号(对应业务订单号)', + `amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道账单金额(分)', + `fee` bigint(20) NOT NULL DEFAULT '0' COMMENT '渠道手续费(分)', + `trade_state` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道侧交易状态', + `trade_time` datetime DEFAULT NULL COMMENT '渠道交易时间', + `is_matched` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已与本地支付单匹配:1是/0否', + `matched_pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '匹配到的本地支付单号', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_channel_trade` (`channel`,`channel_trade_no`,`delete_time`), + KEY `idx_bill_date` (`channel`,`bill_date`,`is_matched`), + KEY `idx_out_trade_no` (`out_trade_no`,`bill_date`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付渠道账单明细表(对账源数据)'; + +-- ------------------------------------------------------------- +-- 6. 对账差异表 +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `yz_platform_payment_reconcile_diff` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识', + `bill_date` date NOT NULL COMMENT '对账日期(同渠道账单日)', + `batch_no` varchar(40) NOT NULL DEFAULT '' COMMENT '对账批次号(同渠道同日一次对账一个批次,便于追溯与重跑)', + `local_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '本地支付单号(长款时为空串,不写 NULL 以便参与唯一键)', + `channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道流水号(短款时为空串)', + `local_amount` bigint(20) DEFAULT NULL COMMENT '本地金额(分),无本地记录时为 NULL', + `channel_amount` bigint(20) DEFAULT NULL COMMENT '渠道金额(分),无渠道记录时为 NULL', + `diff_amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '差额(分)= 渠道金额 - 本地金额,负数表示短款', + `diff_type` varchar(20) NOT NULL DEFAULT '' COMMENT '差异类型:long长款(渠道有本地无)/short短款(本地有渠道无)/status_mismatch状态不一致', + `handle_status` varchar(20) NOT NULL DEFAULT 'unhandled' COMMENT '处理状态:unhandled待处理/handling处理中/handled已处理/ignored已忽略', + `handle_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '处理人ID', + `handle_user_name` varchar(128) NOT NULL DEFAULT '' COMMENT '处理人姓名', + `handle_time` datetime DEFAULT NULL COMMENT '处理时间', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '处理说明(人工填写,便于日后追溯)', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_diff` (`channel`,`bill_date`,`diff_type`,`channel_trade_no`,`local_trade_no`,`delete_time`), + KEY `idx_channel_date` (`channel`,`bill_date`), + KEY `idx_handle` (`handle_status`,`bill_date`), + KEY `idx_batch_no` (`batch_no`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付对账差异表'; + +-- ------------------------------------------------------------- +-- 7. 手动原路退回记录表(兜底能力,业务上不提供退款入口) +-- ------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `yz_platform_payment_refund` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID', + `refund_no` varchar(40) NOT NULL DEFAULT '' COMMENT '退回单号(支付模块生成)', + `pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '关联支付单号', + `out_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '关联业务订单号', + `channel` varchar(32) NOT NULL DEFAULT '' COMMENT '渠道标识', + `channel_refund_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道退回流水号', + `amount` bigint(20) NOT NULL DEFAULT '0' COMMENT '退回金额(分)', + `reason` varchar(500) NOT NULL DEFAULT '' COMMENT '退回原因(必填,人工填写,用于审计)', + `status` varchar(20) NOT NULL DEFAULT 'processing' COMMENT '退回状态:processing处理中/success成功/failed失败', + `fail_reason` varchar(500) NOT NULL DEFAULT '' COMMENT '失败原因', + `operator_id` varchar(64) NOT NULL DEFAULT '' COMMENT '操作人ID(平台管理员)', + `operator_name` varchar(128) NOT NULL DEFAULT '' COMMENT '操作人姓名', + `finished_at` datetime DEFAULT NULL COMMENT '退回完成时间', + `remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_refund_no` (`refund_no`,`delete_time`), + KEY `idx_pay_no` (`pay_no`), + KEY `idx_status` (`status`,`create_time`), + KEY `idx_delete_time` (`delete_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台支付手动原路退回记录表(兜底能力)'; diff --git a/go/docs/sql/init_platform_payment_channel.sql b/go/docs/sql/init_platform_payment_channel.sql new file mode 100644 index 0000000..4fe1882 --- /dev/null +++ b/go/docs/sql/init_platform_payment_channel.sql @@ -0,0 +1,39 @@ +-- ============================================================= +-- 平台支付渠道:初始化五个渠道配置行 +-- 表:yz_platform_payment_channel +-- 说明: +-- 五个渠道(微信支付 / 支付宝 / 银联 / 云闪付 / PayPal)与前端 +-- platform/src/views/system/payment/channels/ 一一对应。 +-- 这里只插入「渠道骨架」:名称、回调地址、默认开关,商户号与密钥留空, +-- 由平台管理员在渠道配置页填写(敏感参数写入 config_json 前由后端加密)。 +-- 初始 enabled 一律为 0(停用),配置完成并通过「测试连接」后再由页面启用, +-- 前端会按「enabled=1 → 已启用;enabled=0 且无参数 → 未配置;enabled=0 且有参数 → 已停用」展示状态。 +-- 云闪付复用银联商户参数,仅存收银台标识开关(extra_json.show_logo / logo_positions); +-- PayPal 默认沙箱环境(extra_json.env=sandbox),当前业务不跨境,保持停用。 +-- 本脚本幂等:已存在同名渠道(含已软删)则跳过,可重复执行。 +-- 依赖:先执行 create_platform_payment.sql 建表。 +-- 运行:mysql --default-character-set=utf8mb4 -h -P -u -p < init_platform_payment_channel.sql +-- ============================================================= + +INSERT INTO `yz_platform_payment_channel` + (`channel`, `name`, `merchant_no`, `config_json`, `cert_json`, `callback_url`, `enabled`, `extra_json`, `remark`, `create_time`, `update_time`) +SELECT v.`channel`, v.`name`, '', NULL, NULL, + CONCAT('/api/payment/callback/', v.`channel`), 0, v.`extra_json`, v.`remark`, NOW(), NOW() +FROM ( + SELECT 'wechat' AS `channel`, '微信支付' AS `name`, '' AS `extra_json`, + '主渠道:JSAPI / Native 扫码收款,需商户号、APPID、APIv3 密钥与商户证书' AS `remark` + UNION ALL SELECT 'alipay', '支付宝', '', + '电脑网站支付 / 手机网站支付,RSA2 签名,异步通知无需登记' + UNION ALL SELECT 'unionpay', '银联', '', + '全渠道收单,商户号与证书由收单机构下发,回调地址需向收单机构报备' + UNION ALL SELECT 'cloudpay', '云闪付', '{"show_logo":1,"logo_positions":["pc","h5"]}', + '复用银联商户参数,仅控制收银台是否展示云闪付标识' + UNION ALL SELECT 'paypal', 'PayPal', '{"env":"sandbox"}', + '跨境预留渠道,当前业务不跨境,默认停用' +) v +WHERE NOT EXISTS ( + SELECT 1 FROM `yz_platform_payment_channel` c WHERE c.`channel` = v.`channel` +); + +-- 校验:SELECT id, channel, name, merchant_no, enabled, callback_url, delete_time +-- FROM yz_platform_payment_channel ORDER BY id; diff --git a/go/go.mod b/go/go.mod index f8401b5..1963de2 100644 --- a/go/go.mod +++ b/go/go.mod @@ -6,19 +6,23 @@ require ( github.com/beego/beego/v2 v2.1.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/qiniu/go-sdk/v7 v7.18.2 - golang.org/x/crypto v0.1.0 + golang.org/x/crypto v0.38.0 ) require ( github.com/go-sql-driver/mysql v1.7.0 github.com/google/uuid v1.6.0 github.com/mark3labs/mcp-go v0.33.0 - golang.org/x/net v0.7.0 + github.com/plutov/paypal/v4 v4.12.0 + github.com/smartwalle/alipay/v3 v3.2.31 + github.com/wechatpay-apiv3/wechatpay-go v0.2.21 + golang.org/x/net v0.40.0 + software.sslmate.com/src/go-pkcs12 v0.7.3 ) require ( github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect @@ -30,12 +34,15 @@ require ( github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.9.0 // indirect github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect + github.com/smartwalle/ncrypto v1.0.4 // indirect + github.com/smartwalle/ngx v1.1.2 // indirect + github.com/smartwalle/nsign v1.0.9 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.6.0 // indirect - golang.org/x/text v0.14.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go/go.sum b/go/go.sum index b5b7fdc..d750b41 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,9 +1,11 @@ +github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw= +github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= github.com/beego/beego/v2 v2.1.0 h1:Lk0FtQGvDQCx5V5yEu4XwDsIgt+QOlNjt5emUa3/ZmA= github.com/beego/beego/v2 v2.1.0/go.mod h1:6h36ISpaxNrrpJ27siTpXBG8d/Icjzsc7pU1bWpp0EE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -57,6 +59,8 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/plutov/paypal/v4 v4.12.0 h1:tp6bClhe9qt3IgN7arT8RiLgDe+LPSvXA67wU+8tu5Q= +github.com/plutov/paypal/v4 v4.12.0/go.mod h1:9K/agLFwXpz5Tpuc3aNxPvzIdUo6BPL7pf5+x4ITOug= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI= @@ -77,32 +81,48 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 h1:DAYUYH5869yV94zvCES9F51oYtN5oGlwjxJJz7ZCnik= github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg= +github.com/smartwalle/alipay/v3 v3.2.31 h1:KVwWJZ5JvNxPridHrcpTwgJeMzi1IqZopid0sDJQewU= +github.com/smartwalle/alipay/v3 v3.2.31/go.mod h1:0G9wqvo1719hxo6ZWntujuPNyVQFWikS52LF62rRYDg= +github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8= +github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk= +github.com/smartwalle/ngx v1.1.2 h1:W+K262lHUvdfJ/2e762dIFwN543kxC/4qLkuCQLRS8o= +github.com/smartwalle/ngx v1.1.2/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0= +github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E= +github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -110,8 +130,8 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -121,8 +141,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -130,8 +150,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -141,3 +161,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU= +software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/go/main.go b/go/main.go index 039dc64..b7d74c0 100644 --- a/go/main.go +++ b/go/main.go @@ -4,6 +4,7 @@ import ( "server/models" _ "server/routers" "server/services" + "server/services/payment" "server/version" beego "github.com/beego/beego/v2/server/web" @@ -33,5 +34,8 @@ func main() { // 启动回款逾期联动任务(回款逾期 → 合同「异常」→ 关联项目「异常」) services.StartPaybackOverdueScheduler(make(chan struct{})) + // 启动支付补偿任务(超时关单 / 业务订单同步重试,5 分钟一轮) + payment.StartPaymentScheduler(make(chan struct{})) + beego.Run() } diff --git a/go/models/init.go b/go/models/init.go index 21108a2..666d345 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -137,6 +137,19 @@ func Init(_ string) { new(BackendOaPayrollItem), new(BackendEmployeeFile), new(BackendEmployeeFileRecord), + + // 平台端支付模块(docs/sql/create_platform_payment.sql) + new(PlatformPaymentChannel), + new(PlatformPaymentOrder), + new(PlatformPaymentOrderLog), + new(PlatformPaymentCallbackLog), + new(PlatformPaymentChannelBill), + new(PlatformPaymentReconcileDiff), + new(PlatformPaymentRefund), + + // 平台端推广佣金(docs/sql/create_platform_commission.sql) + new(PlatformCommissionRule), + new(PlatformCommissionOrder), ) // 创建全局 Ormer diff --git a/go/models/platform_commission.go b/go/models/platform_commission.go new file mode 100644 index 0000000..5caa840 --- /dev/null +++ b/go/models/platform_commission.go @@ -0,0 +1,96 @@ +package models + +import "time" + +// ============================================================= +// 平台端推广佣金模型(表结构见 docs/sql/create_platform_commission.sql) +// 业务约定: +// 1. 佣金由平台自有资金支出,与租户收款资金流解耦; +// 2. 佣金在「业务订单支付成功」事件上按启用中的规则生成,初始状态 payable; +// 3. 一笔支付单 + 同一推广方只生成一张佣金单(uk_pay_promoter,业务层再按 pay_no 查重)。 +// ============================================================= + +// PlatformCommissionRule 佣金规则 yz_platform_commission_rule +type PlatformCommissionRule struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + RuleName string `orm:"column(rule_name);size(128)" json:"rule_name"` + CalcType string `orm:"column(calc_type);size(20)" json:"calc_type"` + Value int64 `orm:"column(value);default(0)" json:"value"` + TierJSON *string `orm:"column(tier_json);type(text);null" json:"-"` + OrderType string `orm:"column(order_type);size(32)" json:"order_type"` + Status int8 `orm:"column(status);default(1)" json:"status"` + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"` + CreateUserName string `orm:"column(create_user_name);size(128)" json:"create_user_name"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformCommissionRule) TableName() string { return "yz_platform_commission_rule" } + +// 计算方式 +const ( + CommissionCalcPercent = "percent" // 按比例:value = 百分比 × 100(5% 存 500) + CommissionCalcFixed = "fixed" // 固定金额:value = 分 + CommissionCalcTiered = "tiered" // 阶梯:value 忽略,明细见 tier_json +) + +// 适用订单类型 +const ( + CommissionOrderTypeAll = "all" + CommissionOrderTypePlatformUsage = "platform_usage" + CommissionOrderTypeModuleShop = "module_shop" + CommissionOrderTypeServiceFee = "service_fee" +) + +// CommissionTier 阶梯档位(tier_json 数组元素) +type CommissionTier struct { + Min int64 `json:"min"` // 起始金额(分,含) + Max *int64 `json:"max"` // 结束金额(分,不含);null 表示不限 + Rate int64 `json:"rate"` // 比例:百分比 × 100 +} + +// PlatformCommissionOrder 佣金单(台账) yz_platform_commission_order +type PlatformCommissionOrder struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + CommissionNo string `orm:"column(commission_no);size(40)" json:"commission_no"` + PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"` + OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"` + RuleID uint64 `orm:"column(rule_id);default(0)" json:"rule_id"` + RuleName string `orm:"column(rule_name);size(128)" json:"rule_name"` + CalcType string `orm:"column(calc_type);size(20)" json:"calc_type"` + PromoterID string `orm:"column(promoter_id);size(64)" json:"promoter_id"` + PromoterName string `orm:"column(promoter_name);size(128)" json:"promoter_name"` + PromoterType string `orm:"column(promoter_type);size(32)" json:"promoter_type"` + BaseAmount int64 `orm:"column(base_amount);default(0)" json:"base_amount"` + Amount int64 `orm:"column(amount);default(0)" json:"amount"` + Status string `orm:"column(status);size(20)" json:"status"` + PayMethod string `orm:"column(pay_method);size(20)" json:"pay_method"` + PayVoucher string `orm:"column(pay_voucher);size(255)" json:"pay_voucher"` + PaidAt *time.Time `orm:"column(paid_at);type(datetime);null" json:"paid_at"` + PaidUserID string `orm:"column(paid_user_id);size(64)" json:"paid_user_id"` + PaidUserName string `orm:"column(paid_user_name);size(128)" json:"paid_user_name"` + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformCommissionOrder) TableName() string { return "yz_platform_commission_order" } + +// 佣金状态 +const ( + CommissionStatusPayable = "payable" // 应付 + CommissionStatusSettled = "settled" // 已付 + CommissionStatusTaxProcessing = "tax_processing" // 税务处理中 + CommissionStatusCanceled = "canceled" // 已作废 +) + +// 发放方式 +const ( + CommissionPayBank = "bank" // 银行转账 + CommissionPayBalance = "balance" // 余额抵扣 + CommissionPayManual = "manual" // 人工发放 + CommissionPayOffset = "offset" // 下期抵扣 +) diff --git a/go/models/platform_payment.go b/go/models/platform_payment.go new file mode 100644 index 0000000..f80bffa --- /dev/null +++ b/go/models/platform_payment.go @@ -0,0 +1,214 @@ +package models + +import "time" + +// ============================================================= +// 平台端支付模块模型(表结构见 docs/sql/create_platform_payment.sql) +// 约定: +// 1. 金额字段一律 int64,单位「分」; +// 2. 状态字段存英文枚举字符串(前端 StatusTag 直接映射); +// 3. 渠道密钥类参数不落明文,config_json 存 AES-GCM 密文(见 services/payment/crypto.go); +// 4. 软删除统一用 delete_time,查询时 Filter("delete_time__isnull", true)。 +// ============================================================= + +// PlatformPaymentChannel 平台支付渠道配置 yz_platform_payment_channel +type PlatformPaymentChannel struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Channel string `orm:"column(channel);size(32)" json:"channel"` + Name string `orm:"column(name);size(64)" json:"name"` + MerchantNo string `orm:"column(merchant_no);size(128)" json:"merchant_no"` + ConfigJSON *string `orm:"column(config_json);type(text);null" json:"-"` + CertJSON *string `orm:"column(cert_json);type(text);null" json:"-"` + CallbackURL string `orm:"column(callback_url);size(255)" json:"callback_url"` + Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"` + ExtraJSON *string `orm:"column(extra_json);type(text);null" json:"-"` + LastTestTime *time.Time `orm:"column(last_test_time);type(datetime);null" json:"last_test_time"` + LastTestResult string `orm:"column(last_test_result);size(255)" json:"last_test_result"` + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformPaymentChannel) TableName() string { return "yz_platform_payment_channel" } + +// PlatformPaymentOrder 平台支付单 yz_platform_payment_order +type PlatformPaymentOrder struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"` + OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"` + OrderType string `orm:"column(order_type);size(32)" json:"order_type"` + Subject string `orm:"column(subject);size(255)" json:"subject"` + TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"` + TenantName string `orm:"column(tenant_name);size(128)" json:"tenant_name"` + Amount int64 `orm:"column(amount);default(0)" json:"amount"` + OrderAmount int64 `orm:"column(order_amount);default(0)" json:"order_amount"` + Currency string `orm:"column(currency);size(8)" json:"currency"` + Channel string `orm:"column(channel);size(32)" json:"channel"` + MerchantNo string `orm:"column(merchant_no);size(128)" json:"merchant_no"` + ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"` + Status string `orm:"column(status);size(20)" json:"status"` + RefundAmount int64 `orm:"column(refund_amount);default(0)" json:"refund_amount"` + ClientIP string `orm:"column(client_ip);size(64)" json:"client_ip"` + ReturnURL string `orm:"column(return_url);size(255)" json:"return_url"` + NotifyURL string `orm:"column(notify_url);size(255)" json:"notify_url"` + ExpireAt *time.Time `orm:"column(expire_at);type(datetime);null" json:"expire_at"` + PaidAt *time.Time `orm:"column(paid_at);type(datetime);null" json:"paid_at"` + ClosedAt *time.Time `orm:"column(closed_at);type(datetime);null" json:"closed_at"` + NotifyAt *time.Time `orm:"column(notify_at);type(datetime);null" json:"notify_at"` + LastQueryAt *time.Time `orm:"column(last_query_at);type(datetime);null" json:"last_query_at"` + OrderSynced int8 `orm:"column(order_synced);default(0)" json:"order_synced"` + PromoterID string `orm:"column(promoter_id);size(64)" json:"promoter_id"` + PromoterName string `orm:"column(promoter_name);size(128)" json:"promoter_name"` + PromoterType string `orm:"column(promoter_type);size(32)" json:"promoter_type"` + Remark string `orm:"column(remark);size(255)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformPaymentOrder) TableName() string { return "yz_platform_payment_order" } + +// 支付单状态枚举 +const ( + PayStatusCreated = "created" // 已创建 + PayStatusPending = "pending" // 待支付 + PayStatusPaying = "paying" // 支付中 + PayStatusPaid = "paid" // 支付成功 + PayStatusFailed = "failed" // 支付失败 + PayStatusClosed = "closed" // 已关闭 + PayStatusRefunding = "refunding" // 退回中 + PayStatusRefunded = "refunded" // 已退回 +) + +// PlatformPaymentOrderLog 支付单状态流转流水 yz_platform_payment_order_log +type PlatformPaymentOrderLog struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"` + FromStatus string `orm:"column(from_status);size(20)" json:"from_status"` + ToStatus string `orm:"column(to_status);size(20)" json:"to_status"` + Operator string `orm:"column(operator);size(64)" json:"operator"` + Remark string `orm:"column(remark);size(500)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformPaymentOrderLog) TableName() string { return "yz_platform_payment_order_log" } + +// PlatformPaymentCallbackLog 渠道回调日志 yz_platform_payment_callback_log +type PlatformPaymentCallbackLog struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Channel string `orm:"column(channel);size(32)" json:"channel"` + EventType string `orm:"column(event_type);size(64)" json:"event_type"` + EventID string `orm:"column(event_id);size(128)" json:"event_id"` + PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"` + OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"` + ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"` + TradeState string `orm:"column(trade_state);size(32)" json:"trade_state"` + Amount int64 `orm:"column(amount);default(0)" json:"amount"` + VerifyResult int8 `orm:"column(verify_result);default(0)" json:"verify_result"` + IsDuplicate int8 `orm:"column(is_duplicate);default(0)" json:"is_duplicate"` + HandleResult int8 `orm:"column(handle_result);default(0)" json:"handle_result"` + HandleMsg string `orm:"column(handle_msg);size(255)" json:"handle_msg"` + RawBody *string `orm:"column(raw_body);type(text);null" json:"raw_body"` + ClientIP string `orm:"column(client_ip);size(64)" json:"client_ip"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformPaymentCallbackLog) TableName() string { + return "yz_platform_payment_callback_log" +} + +// PlatformPaymentChannelBill 渠道账单明细 yz_platform_payment_channel_bill +type PlatformPaymentChannelBill struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Channel string `orm:"column(channel);size(32)" json:"channel"` + BillDate time.Time `orm:"column(bill_date);type(date)" json:"bill_date"` + ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"` + OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"` + Amount int64 `orm:"column(amount);default(0)" json:"amount"` + Fee int64 `orm:"column(fee);default(0)" json:"fee"` + TradeState string `orm:"column(trade_state);size(32)" json:"trade_state"` + TradeTime *time.Time `orm:"column(trade_time);type(datetime);null" json:"trade_time"` + IsMatched int8 `orm:"column(is_matched);default(0)" json:"is_matched"` + MatchedPayNo string `orm:"column(matched_pay_no);size(40)" json:"matched_pay_no"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformPaymentChannelBill) TableName() string { + return "yz_platform_payment_channel_bill" +} + +// PlatformPaymentReconcileDiff 对账差异 yz_platform_payment_reconcile_diff +type PlatformPaymentReconcileDiff struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Channel string `orm:"column(channel);size(32)" json:"channel"` + BillDate time.Time `orm:"column(bill_date);type(date)" json:"bill_date"` + BatchNo string `orm:"column(batch_no);size(40)" json:"batch_no"` + LocalTradeNo string `orm:"column(local_trade_no);size(64)" json:"local_trade_no"` + ChannelTradeNo string `orm:"column(channel_trade_no);size(64)" json:"channel_trade_no"` + LocalAmount *int64 `orm:"column(local_amount);null" json:"local_amount"` + ChannelAmount *int64 `orm:"column(channel_amount);null" json:"channel_amount"` + DiffAmount int64 `orm:"column(diff_amount);default(0)" json:"diff_amount"` + DiffType string `orm:"column(diff_type);size(20)" json:"diff_type"` + HandleStatus string `orm:"column(handle_status);size(20)" json:"handle_status"` + HandleUserID string `orm:"column(handle_user_id);size(64)" json:"handle_user_id"` + HandleUserName string `orm:"column(handle_user_name);size(128)" json:"handle_user_name"` + HandleTime *time.Time `orm:"column(handle_time);type(datetime);null" json:"handle_time"` + Remark string `orm:"column(remark);size(500)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformPaymentReconcileDiff) TableName() string { + return "yz_platform_payment_reconcile_diff" +} + +// 对账差异类型 / 处理状态 +const ( + DiffTypeLong = "long" // 长款:渠道有、本地无 + DiffTypeShort = "short" // 短款:本地有、渠道无 + DiffTypeStatusMismatch = "status_mismatch" // 状态不一致 +) + +const ( + DiffHandleUnhandled = "unhandled" + DiffHandleHandling = "handling" + DiffHandleHandled = "handled" + DiffHandleIgnored = "ignored" +) + +// PlatformPaymentRefund 手动原路退回记录 yz_platform_payment_refund +type PlatformPaymentRefund struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + RefundNo string `orm:"column(refund_no);size(40)" json:"refund_no"` + PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"` + OutTradeNo string `orm:"column(out_trade_no);size(64)" json:"out_trade_no"` + Channel string `orm:"column(channel);size(32)" json:"channel"` + ChannelRefundNo string `orm:"column(channel_refund_no);size(64)" json:"channel_refund_no"` + Amount int64 `orm:"column(amount);default(0)" json:"amount"` + Reason string `orm:"column(reason);size(500)" json:"reason"` + Status string `orm:"column(status);size(20)" json:"status"` + FailReason string `orm:"column(fail_reason);size(500)" json:"fail_reason"` + OperatorID string `orm:"column(operator_id);size(64)" json:"operator_id"` + OperatorName string `orm:"column(operator_name);size(128)" json:"operator_name"` + FinishedAt *time.Time `orm:"column(finished_at);type(datetime);null" json:"finished_at"` + Remark string `orm:"column(remark);size(500)" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformPaymentRefund) TableName() string { return "yz_platform_payment_refund" } + +// 退回状态 +const ( + RefundStatusProcessing = "processing" + RefundStatusSuccess = "success" + RefundStatusFailed = "failed" +) diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index dd6772d..3f2fd19 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -11,6 +11,14 @@ import ( func Register() { RegisterAuthRoutes() RegisterRoleRoutes() + RegisterPaymentRoutes() +} + +// RegisterPaymentRoutes 注册租户端支付路由(下单页 / 收银台)。 +func RegisterPaymentRoutes() { + beego.Router("/backend/payment/channels", &controllers.BackendPaymentController{}, "get:Channels") + beego.Router("/backend/payment/create", &controllers.BackendPaymentController{}, "post:Create") + beego.Router("/backend/payment/query/:payNo", &controllers.BackendPaymentController{}, "get:Query") } // RegisterRoleRoutes 注册 backend 租户角色管理路由。 diff --git a/go/routers/platform/payment.go b/go/routers/platform/payment.go new file mode 100644 index 0000000..b2993e5 --- /dev/null +++ b/go/routers/platform/payment.go @@ -0,0 +1,42 @@ +package platform + +import ( + "server/controllers" + + beego "github.com/beego/beego/v2/server/web" +) + +// RegisterPaymentRoutes 注册平台端支付模块路由(表结构见 docs/sql/create_platform_payment.sql)。 +// 注意:/reconcile/handle/:id、/commission/markPaid/:id 刻意避开与静态段同级, +// 避免 beego 路由树中通配段与静态段混用带来的匹配歧义。 +func RegisterPaymentRoutes() { + // 渠道配置 + beego.Router("/platform/payment/channels", &controllers.PlatformPaymentController{}, "get:ListChannels") + beego.Router("/platform/payment/channels/:channel", &controllers.PlatformPaymentController{}, "get:GetChannel;post:SaveChannel") + beego.Router("/platform/payment/channels/:channel/test", &controllers.PlatformPaymentController{}, "post:TestChannel") + beego.Router("/platform/payment/channels/:channel/status", &controllers.PlatformPaymentController{}, "post:ToggleChannel") + beego.Router("/platform/payment/channels/:channel/certificate", &controllers.PlatformPaymentController{}, "post:UploadCertificate") + + // 支付订单 + beego.Router("/platform/payment/orders", &controllers.PlatformPaymentController{}, "get:ListOrders") + beego.Router("/platform/payment/orders/:payNo", &controllers.PlatformPaymentController{}, "get:OrderDetail") + beego.Router("/platform/payment/orders/:payNo/query", &controllers.PlatformPaymentController{}, "post:QueryOrder") + beego.Router("/platform/payment/refund", &controllers.PlatformPaymentController{}, "post:Refund") + + // 对账 + beego.Router("/platform/payment/reconcile", &controllers.PlatformPaymentReconcileController{}, "get:ListReconcile") + beego.Router("/platform/payment/reconcile/run", &controllers.PlatformPaymentReconcileController{}, "post:RunReconcile") + beego.Router("/platform/payment/reconcile/import", &controllers.PlatformPaymentReconcileController{}, "post:ImportBill") + beego.Router("/platform/payment/reconcile/handle/:id", &controllers.PlatformPaymentReconcileController{}, "post:HandleReconcile") + + // 渠道异步通知(渠道服务器调用,无平台鉴权;鉴权由各渠道适配器验签完成) + beego.Router("/api/payment/callback/:channel", &controllers.PlatformPaymentController{}, "post:Notify") + + // 推广佣金 + beego.Router("/platform/commission/list", &controllers.PlatformCommissionController{}, "get:List") + beego.Router("/platform/commission/stats", &controllers.PlatformCommissionController{}, "get:Stats") + beego.Router("/platform/commission/markPaid/:id", &controllers.PlatformCommissionController{}, "post:MarkPaid") + beego.Router("/platform/commission/rules", &controllers.PlatformCommissionController{}, "get:RulesList;post:RuleCreate") + beego.Router("/platform/commission/rules/:id", &controllers.PlatformCommissionController{}, "post:RuleUpdate;delete:RuleDelete") + beego.Router("/platform/commission/rules/:id/status", &controllers.PlatformCommissionController{}, "post:RuleToggle") +} diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index 7f7e3a8..5635b20 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -8,6 +8,9 @@ import ( // Register 注册平台端路由 func Register() { + // 支付模块(渠道配置 / 支付订单 / 对账 / 佣金 / 渠道回调) + RegisterPaymentRoutes() + // 平台登录相关 beego.Router("/platform/login", &controllers.PlatformAuthController{}, "post:LoginPlatform") beego.Router("/platform/currentUser", &controllers.PlatformAuthController{}, "get:GetCurrentUser") diff --git a/go/services/payment/channel.go b/go/services/payment/channel.go new file mode 100644 index 0000000..048f619 --- /dev/null +++ b/go/services/payment/channel.go @@ -0,0 +1,261 @@ +package payment + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "server/models" + + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 渠道适配器接口与运行时配置 +// +// 五个渠道的差异全部收敛在各 Channel 实现里(wechat/alipay/unionpay/cloudpay/paypal), +// 支付服务(service.go)只面向本接口编程,业务页面不出现渠道 if-else。 +// ============================================================= + +// 渠道标识(与 yz_platform_payment_channel.channel、前端页面一一对应) +const ( + ChannelWechat = "wechat" + ChannelAlipay = "alipay" + ChannelUnionPay = "unionpay" + ChannelCloudPay = "cloudpay" + ChannelPayPal = "paypal" +) + +// 归一化交易状态(各渠道状态统一映射到这几个值) +const ( + StateSuccess = "success" + StatePending = "pending" + StateFailed = "failed" + StateClosed = "closed" + StateRefunded = "refunded" +) + +// 预下单方式 +const ( + PayTypeQR = "qr" // 扫码(微信 Native) + PayTypeWeb = "web" // PC 网页跳转(支付宝电脑网站支付 / 银联网关 / PayPal) + PayTypeH5 = "h5" // 手机网页跳转 + PayTypeJSAPI = "jsapi" // 公众号内支付(需要 OpenID) +) + +var ( + ErrChannelNotConfigured = errors.New("渠道未配置或参数不完整") + ErrChannelDisabled = errors.New("渠道已停用") + ErrChannelNotFound = errors.New("渠道不存在") +) + +// PayParams 渠道下单后返回给前端的拉起支付参数 +type PayParams struct { + Channel string `json:"channel"` + PayType string `json:"pay_type"` + CodeURL string `json:"code_url,omitempty"` // 扫码:二维码内容 + RedirectURL string `json:"redirect_url,omitempty"` // 跳转:直接 302 或前端 window.open + Form string `json:"form,omitempty"` // 表单:写入页面后自动提交(银联前台跳转等) + JSAPI map[string]string `json:"jsapi,omitempty"` // 公众号内:WeixinJSBridge 调用参数 + ChannelTradeNo string `json:"channel_trade_no,omitempty"` // 渠道侧单号(如有) +} + +// ChannelState 渠道侧交易状态(已归一化) +type ChannelState struct { + ChannelTradeNo string + TradeState string // StateSuccess / StatePending / StateFailed / StateClosed + Amount int64 // 分 + PaidAt *time.Time // 渠道侧支付完成时间 + Raw string // 渠道原始响应(排查用) +} + +// NotifyResult 渠道异步通知解析结果(已验签、已解密) +type NotifyResult struct { + PayNo string + OutTradeNo string + ChannelTradeNo string + EventType string // 渠道事件类型原文 + EventID string // 渠道事件/通知 ID(用于幂等去重) + TradeState string // 归一化状态 + Amount int64 // 分 + Paid bool // 是否支付成功事件 + AckBody string // 需要原样返回给渠道的响应体(SUCCESS / success / ok / "") + Raw string // 通知报文(脱敏后入库) +} + +// PrepayOption 预下单附加参数 +type PrepayOption struct { + PayType string // PayTypeQR / PayTypeWeb / PayTypeH5 / PayTypeJSAPI;空值由适配器给默认 + OpenID string // JSAPI 支付必填 +} + +// ChannelConfig 渠道运行时配置(敏感参数已解密) +type ChannelConfig struct { + ID uint64 + Channel string + Name string + MerchantNo string + Enabled bool + CallbackURL string + Params map[string]string // 解密后的渠道参数 + Extra map[string]any // 渠道扩展开关(云闪付标识、PayPal 环境等) + CertPaths map[string]string // 证书文件路径(cert_json) +} + +// Get 读取渠道参数 +func (c *ChannelConfig) Get(key string) string { + if c == nil || c.Params == nil { + return "" + } + return strings.TrimSpace(c.Params[key]) +} + +// ExtraBool 读取扩展开关 +func (c *ChannelConfig) ExtraBool(key string, def bool) bool { + if c == nil || c.Extra == nil { + return def + } + if v, ok := c.Extra[key].(bool); ok { + return v + } + return def +} + +// EnsureUsable 校验配置可用(已启用且参数非空) +func (c *ChannelConfig) EnsureUsable() error { + if c == nil || len(c.Params) == 0 { + return ErrChannelNotConfigured + } + if !c.Enabled { + return ErrChannelDisabled + } + return nil +} + +// Channel 渠道适配器接口 +type Channel interface { + Code() string + Name() string + // Prepay 预下单,返回拉起支付参数 + Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) + // Query 主动查询渠道侧交易状态 + Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) + // ParseNotify 解析并验签渠道异步通知 + ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) + // Refund 原路退回(兜底能力),返回渠道退回流水号 + Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) + // TestConnect 连通性/凭证校验,返回人类可读的结果说明 + TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) +} + +var ( + registryMu sync.RWMutex + registry = map[string]Channel{} +) + +// Register 注册渠道适配器 +func Register(ch Channel) { + registryMu.Lock() + defer registryMu.Unlock() + registry[ch.Code()] = ch +} + +// GetChannelAdapter 取渠道适配器 +func GetChannelAdapter(code string) (Channel, error) { + registryMu.RLock() + defer registryMu.RUnlock() + ch, ok := registry[code] + if !ok { + return nil, ErrChannelNotFound + } + return ch, nil +} + +// Channels 返回全部已注册适配器 +func Channels() []Channel { + registryMu.RLock() + defer registryMu.RUnlock() + list := make([]Channel, 0, len(registry)) + for _, ch := range registry { + list = append(list, ch) + } + return list +} + +// LoadChannelConfig 从库加载渠道配置(敏感参数解密) +func LoadChannelConfig(channel string) (*ChannelConfig, error) { + row := &models.PlatformPaymentChannel{} + err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)). + Filter("channel", channel). + Filter("delete_time__isnull", true). + One(row) + if err != nil { + return nil, fmt.Errorf("渠道 %s 配置不存在", channel) + } + + cfg := &ChannelConfig{ + ID: row.ID, + Channel: row.Channel, + Name: row.Name, + MerchantNo: row.MerchantNo, + Enabled: row.Enabled == 1, + CallbackURL: row.CallbackURL, + Params: map[string]string{}, + Extra: map[string]any{}, + CertPaths: map[string]string{}, + } + if row.ConfigJSON != nil && *row.ConfigJSON != "" { + plain, derr := DecryptConfig(*row.ConfigJSON) + if derr != nil { + return nil, fmt.Errorf("渠道 %s 参数解密失败: %w", channel, derr) + } + _ = json.Unmarshal([]byte(plain), &cfg.Params) + } + if row.ExtraJSON != nil && *row.ExtraJSON != "" { + _ = json.Unmarshal([]byte(*row.ExtraJSON), &cfg.Extra) + } + if row.CertJSON != nil && *row.CertJSON != "" { + _ = json.Unmarshal([]byte(*row.CertJSON), &cfg.CertPaths) + } + return cfg, nil +} + +// LoadEnabledChannelConfig 加载「已启用且参数完整」的渠道配置 +func LoadEnabledChannelConfig(channel string) (*ChannelConfig, error) { + cfg, err := LoadChannelConfig(channel) + if err != nil { + return nil, err + } + if err := cfg.EnsureUsable(); err != nil { + return nil, err + } + return cfg, nil +} + +// PublicBaseURL 对外域名基址:app.conf 的 payment_callback_base(如 https://pay.example.com) +// 未配置时返回空串,回调地址形如 /api/payment/callback/wechat(由网关补齐域名)。 +func PublicBaseURL() string { + v, _ := beego.AppConfig.String("payment_callback_base") + return strings.TrimRight(strings.TrimSpace(v), "/") +} + +// CallbackURL 渠道回调地址(与渠道配置表 callback_url 保持一致) +func CallbackURL(channel string) string { + if cfg, err := LoadChannelConfig(channel); err == nil && cfg.CallbackURL != "" { + return cfg.CallbackURL + } + return PublicBaseURL() + "/api/payment/callback/" + channel +} + +func init() { + Register(&WechatChannel{}) + Register(&AlipayChannel{}) + Register(&UnionPayChannel{}) + Register(&CloudPayChannel{}) + Register(&PayPalChannel{}) +} diff --git a/go/services/payment/channel_alipay.go b/go/services/payment/channel_alipay.go new file mode 100644 index 0000000..c0fb108 --- /dev/null +++ b/go/services/payment/channel_alipay.go @@ -0,0 +1,194 @@ +package payment + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "server/models" + + "github.com/smartwalle/alipay/v3" +) + +// AlipayChannel 支付宝(电脑网站支付 / 手机网站支付,RSA2) +// +// 渠道参数(config_json): +// +// appid 开放平台应用 APPID +// app_private_key 应用私钥(RSA2,PKCS8) +// alipay_public_key 支付宝公钥(回显公钥,用于验签) +// is_production "1" 正式环境 / 其他为沙箱 +type AlipayChannel struct{} + +func (c *AlipayChannel) Code() string { return ChannelAlipay } +func (c *AlipayChannel) Name() string { return "支付宝" } + +func (c *AlipayChannel) buildClient(cfg *ChannelConfig) (*alipay.Client, error) { + appID := cfg.Get("appid") + priv := cfg.Get("app_private_key") + pub := cfg.Get("alipay_public_key") + if appID == "" || priv == "" || pub == "" { + return nil, fmt.Errorf("支付宝参数不完整:需要 appid / app_private_key / alipay_public_key") + } + client, err := alipay.New(appID, priv, cfg.Get("is_production") == "1") + if err != nil { + return nil, fmt.Errorf("创建支付宝客户端失败: %w", err) + } + if err := client.LoadAliPayPublicKey(pub); err != nil { + return nil, fmt.Errorf("加载支付宝公钥失败: %w", err) + } + return client, nil +} + +func (c *AlipayChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) { + client, err := c.buildClient(cfg) + if err != nil { + return nil, err + } + + trade := alipay.Trade{ + NotifyURL: cfg.CallbackURL, + ReturnURL: order.ReturnURL, + Subject: order.Subject, + OutTradeNo: order.PayNo, + TotalAmount: FenToYuan(order.Amount), + } + if order.ExpireAt != nil { + mins := int(time.Until(*order.ExpireAt).Minutes()) + if mins < 1 { + mins = 1 + } + trade.TimeoutExpress = fmt.Sprintf("%dm", mins) + } + + if strings.EqualFold(opt.PayType, PayTypeH5) { + // 手机网站支付 + trade.ProductCode = "QUICK_WAP_WAY" + u, err := client.TradeWapPay(alipay.TradeWapPay{Trade: trade}) + if err != nil { + return nil, fmt.Errorf("支付宝手机网站下单失败: %w", err) + } + return &PayParams{Channel: ChannelAlipay, PayType: PayTypeH5, RedirectURL: u.String()}, nil + } + + // 默认:电脑网站支付(PC 收银台) + trade.ProductCode = "FAST_INSTANT_TRADE_PAY" + u, err := client.TradePagePay(alipay.TradePagePay{Trade: trade}) + if err != nil { + return nil, fmt.Errorf("支付宝电脑网站下单失败: %w", err) + } + return &PayParams{Channel: ChannelAlipay, PayType: PayTypeWeb, RedirectURL: u.String()}, nil +} + +// normalizeAlipayStatus 支付宝交易状态 -> 归一化状态 +func normalizeAlipayStatus(status alipay.TradeStatus) string { + switch status { + case alipay.TradeStatusSuccess, alipay.TradeStatusFinished: + return StateSuccess + case alipay.TradeStatusClosed: + return StateClosed + case alipay.TradeStatusWaitBuyerPay: + return StatePending + default: + return StatePending + } +} + +func (c *AlipayChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) { + client, err := c.buildClient(cfg) + if err != nil { + return nil, err + } + rsp, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: order.PayNo}) + if err != nil { + return nil, fmt.Errorf("支付宝查询订单失败: %w", err) + } + // 交易不存在(如超时未付被渠道关闭):不视为失败,返回待支付以便走关闭逻辑 + if rsp.Code != "10000" { + if rsp.SubCode == "ACQ.TRADE_NOT_EXIST" { + return &ChannelState{TradeState: StatePending, Raw: rsp.SubMsg}, nil + } + return nil, fmt.Errorf("支付宝查询失败: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg) + } + + state := &ChannelState{ + ChannelTradeNo: rsp.TradeNo, + TradeState: normalizeAlipayStatus(rsp.TradeStatus), + Raw: fmt.Sprintf("trade_no=%s status=%s total=%s", rsp.TradeNo, rsp.TradeStatus, rsp.TotalAmount), + } + if fen, err := YuanToFen(rsp.TotalAmount); err == nil { + state.Amount = fen + } + if state.TradeState == StateSuccess { + now := time.Now() + state.PaidAt = &now + } + return state, nil +} + +func (c *AlipayChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) { + client, err := c.buildClient(cfg) + if err != nil { + return nil, err + } + if err := r.ParseForm(); err != nil { + return nil, fmt.Errorf("解析支付宝通知参数失败: %w", err) + } + notification, err := client.DecodeNotification(ctx, r.Form) + if err != nil { + return nil, fmt.Errorf("支付宝通知验签失败: %w", err) + } + + result := &NotifyResult{ + EventType: notification.NotifyType, + EventID: notification.NotifyId, + OutTradeNo: notification.OutTradeNo, + PayNo: notification.OutTradeNo, + ChannelTradeNo: notification.TradeNo, + AckBody: "success", + } + if fen, err := YuanToFen(notification.TotalAmount); err == nil { + result.Amount = fen + } + result.TradeState = normalizeAlipayStatus(notification.TradeStatus) + result.Paid = result.TradeState == StateSuccess + return result, nil +} + +func (c *AlipayChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) { + client, err := c.buildClient(cfg) + if err != nil { + return "", err + } + rsp, err := client.TradeRefund(ctx, alipay.TradeRefund{ + OutTradeNo: order.PayNo, + RefundAmount: FenToYuan(amount), + RefundReason: reason, + OutRequestNo: refundNo, + }) + if err != nil { + return "", fmt.Errorf("支付宝退回请求失败: %w", err) + } + if rsp.Code != "10000" { + return "", fmt.Errorf("支付宝退回失败: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg) + } + return rsp.TradeNo, nil +} + +func (c *AlipayChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) { + client, err := c.buildClient(cfg) + if err != nil { + return "", err + } + // 查询一笔必然不存在的交易:能返回「交易不存在」即说明 APPID 与签名配置正确 + rsp, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: "__connect_test__"}) + if err != nil { + return "", fmt.Errorf("支付宝凭证校验失败: %w", err) + } + if rsp.Code == "10000" || rsp.SubCode == "ACQ.TRADE_NOT_EXIST" { + return "连接成功:APPID 与 RSA2 签名校验通过", nil + } + return "", fmt.Errorf("支付宝凭证校验未通过: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg) +} diff --git a/go/services/payment/channel_paypal.go b/go/services/payment/channel_paypal.go new file mode 100644 index 0000000..a624267 --- /dev/null +++ b/go/services/payment/channel_paypal.go @@ -0,0 +1,244 @@ +package payment + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "server/models" + + "github.com/plutov/paypal/v4" +) + +// PayPalChannel PayPal(Checkout v2 + Webhook) +// +// 渠道参数(config_json): +// +// client_id REST App Client ID +// client_secret REST App Secret +// webhook_id Webhook ID(用于验签) +// currency 币种,默认 USD +// +// 扩展(extra_json): +// +// env sandbox / live +type PayPalChannel struct{} + +func (c *PayPalChannel) Code() string { return ChannelPayPal } +func (c *PayPalChannel) Name() string { return "PayPal" } + +func (c *PayPalChannel) buildClient(cfg *ChannelConfig) (*paypal.Client, error) { + clientID := cfg.Get("client_id") + secret := cfg.Get("client_secret") + if clientID == "" || secret == "" { + return nil, fmt.Errorf("PayPal 参数不完整:需要 client_id / client_secret") + } + apiBase := paypal.APIBaseSandBox + if strings.EqualFold(fmt.Sprint(cfg.Extra["env"]), "live") { + apiBase = paypal.APIBaseLive + } + client, err := paypal.NewClient(clientID, secret, apiBase) + if err != nil { + return nil, fmt.Errorf("创建 PayPal 客户端失败: %w", err) + } + return client, nil +} + +func (c *PayPalChannel) currency(cfg *ChannelConfig) string { + if v := cfg.Get("currency"); v != "" { + return v + } + return "USD" +} + +func (c *PayPalChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) { + client, err := c.buildClient(cfg) + if err != nil { + return nil, err + } + subject := truncateStr(order.Subject, 120) + payOrder, err := client.CreateOrder(ctx, paypal.OrderIntentCapture, + []paypal.PurchaseUnitRequest{{ + CustomID: order.PayNo, + Description: subject, + Amount: &paypal.PurchaseUnitAmount{ + Currency: c.currency(cfg), + Value: FenToYuan(order.Amount), + }, + }}, + nil, + &paypal.ApplicationContext{ + ReturnURL: order.ReturnURL, + CancelURL: order.ReturnURL, + }, + ) + if err != nil { + return nil, fmt.Errorf("PayPal 下单失败: %w", err) + } + + // v2 的跳转链接 rel 为 payer-action(旧版为 approve) + approve := "" + for _, l := range payOrder.Links { + if l.Rel == "approve" || l.Rel == "payer-action" { + approve = l.Href + break + } + } + if approve == "" { + return nil, fmt.Errorf("PayPal 下单未返回跳转链接") + } + return &PayParams{ + Channel: ChannelPayPal, + PayType: PayTypeWeb, + RedirectURL: approve, + ChannelTradeNo: payOrder.ID, // PayPal 订单号,回调后替换为 capture id + }, nil +} + +func (c *PayPalChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) { + client, err := c.buildClient(cfg) + if err != nil { + return nil, err + } + if order.ChannelTradeNo == "" { + return nil, fmt.Errorf("PayPal 查询需要渠道订单号(尚未下单成功)") + } + payOrder, err := client.GetOrder(ctx, order.ChannelTradeNo) + if err != nil { + return nil, fmt.Errorf("PayPal 查询订单失败: %w", err) + } + state := &ChannelState{ChannelTradeNo: payOrder.ID, Raw: fmt.Sprintf("status=%s", payOrder.Status)} + switch payOrder.Status { + case "COMPLETED": + state.TradeState = StateSuccess + now := time.Now() + state.PaidAt = &now + case "VOIDED": + state.TradeState = StateClosed + case "APPROVED": + // 用户已批准但未捕获,等待 webhook 或人工捕获 + state.TradeState = StatePending + default: + state.TradeState = StatePending + } + for _, pu := range payOrder.PurchaseUnits { + if pu.Amount != nil { + if fen, err := YuanToFen(pu.Amount.Value); err == nil { + state.Amount = fen + } + break + } + } + return state, nil +} + +type paypalEvent struct { + ID string `json:"id"` + EventType string `json:"event_type"` + Resource struct { + ID string `json:"id"` + Status string `json:"status"` + CustomID string `json:"custom_id"` + Amount struct { + Value string `json:"value"` + CurrencyCode string `json:"currency_code"` + } `json:"amount"` + } `json:"resource"` +} + +func (c *PayPalChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) { + client, err := c.buildClient(cfg) + if err != nil { + return nil, err + } + webhookID := cfg.Get("webhook_id") + if webhookID == "" { + return nil, fmt.Errorf("PayPal 参数不完整:需要 webhook_id") + } + + // VerifyWebhookSignature 会读取请求体,先读出来再回填,供后面解析事件内容 + raw, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("读取 PayPal 通知失败: %w", err) + } + r.Body = io.NopCloser(bytes.NewReader(raw)) + + verify, err := client.VerifyWebhookSignature(ctx, r, webhookID) + if err != nil { + return nil, fmt.Errorf("PayPal 验签请求失败: %w", err) + } + if verify == nil || verify.VerificationStatus != "SUCCESS" { + return nil, fmt.Errorf("PayPal 通知验签失败: %s", verify.VerificationStatus) + } + + event := &paypalEvent{} + if err := json.Unmarshal(raw, event); err != nil { + return nil, fmt.Errorf("解析 PayPal 事件失败: %w", err) + } + + result := &NotifyResult{ + EventType: event.EventType, + EventID: event.ID, + AckBody: "", + Raw: truncateStr(string(raw), 8192), + } + result.PayNo = event.Resource.CustomID + result.OutTradeNo = event.Resource.CustomID + result.ChannelTradeNo = event.Resource.ID + if fen, ferr := YuanToFen(event.Resource.Amount.Value); ferr == nil { + result.Amount = fen + } + switch event.EventType { + case "PAYMENT.CAPTURE.COMPLETED": + result.TradeState = StateSuccess + result.Paid = true + case "PAYMENT.CAPTURE.DENIED", "PAYMENT.CAPTURE.DECLINED": + result.TradeState = StateFailed + case "PAYMENT.CAPTURE.REFUNDED", "PAYMENT.CAPTURE.REVERSED": + result.TradeState = StateRefunded + default: + result.TradeState = StatePending + } + return result, nil +} + +func (c *PayPalChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) { + client, err := c.buildClient(cfg) + if err != nil { + return "", err + } + if order.ChannelTradeNo == "" { + return "", fmt.Errorf("PayPal 退回需要 capture id(支付成功回调后写入),请先确认该单已收到成功回调") + } + resp, err := client.RefundCapture(ctx, order.ChannelTradeNo, paypal.RefundCaptureRequest{ + Amount: &paypal.Money{ + Currency: c.currency(cfg), + Value: FenToYuan(amount), + }, + NoteToPayer: truncateStr(reason, 200), + }) + if err != nil { + return "", fmt.Errorf("PayPal 退回请求失败: %w", err) + } + return resp.ID, nil +} + +func (c *PayPalChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) { + client, err := c.buildClient(cfg) + if err != nil { + return "", err + } + token, err := client.GetAccessToken(ctx) + if err != nil { + return "", fmt.Errorf("PayPal OAuth2 凭证校验失败: %w", err) + } + if token == nil || token.Token == "" { + return "", fmt.Errorf("PayPal OAuth2 未返回访问令牌") + } + return fmt.Sprintf("连接成功:OAuth2 凭证校验通过(环境 %s)", cfg.Extra["env"]), nil +} diff --git a/go/services/payment/channel_unionpay.go b/go/services/payment/channel_unionpay.go new file mode 100644 index 0000000..910e4cd --- /dev/null +++ b/go/services/payment/channel_unionpay.go @@ -0,0 +1,394 @@ +package payment + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + "time" + + "server/models" + + pkcs12 "software.sslmate.com/src/go-pkcs12" +) + +// UnionPayChannel 银联全渠道(PC 网关支付 / 手机控件支付) +// +// 银联没有官方 Go SDK,这里按「全渠道支付平台产品接口规范」实现签名与请求: +// - 签名:SHA256withRSA,字段按 key ASCII 升序拼 key=value&... 后私钥签名,base64 放入 signature; +// - 下单:POST /gateway/api/frontTransReq.do(前台跳转,返回自动提交表单); +// - 查询:POST /gateway/api/queryTrans.do(txnType=00); +// - 退货:POST /gateway/api/backTransReq.do(txnType=04)。 +// +// 渠道参数(config_json): +// +// mer_id 商户号 +// acq_ins_code 收单机构代码 +// cert_password 商户证书密码 +// is_production "1" 生产网关 / 其他为测试网关 +// +// 证书(cert_json): +// +// cert_path 商户证书(.pfx / .p12,或 .cer + .pem 私钥组合) +type UnionPayChannel struct{} + +func (c *UnionPayChannel) Code() string { return ChannelUnionPay } +func (c *UnionPayChannel) Name() string { return "银联" } + +const ( + unionPayGatewayProd = "https://gateway.95516.com" + unionPayGatewayTest = "https://gateway.test.95516.com" + unionPayVersion = "5.1.0" + unionPayCurrencyCNY = "156" + unionPayDefaultBizType = "000201" // B2C 网关支付 +) + +func unionPayGateway(cfg *ChannelConfig) string { + if cfg.Get("is_production") == "1" { + return unionPayGatewayProd + } + return unionPayGatewayTest +} + +// unionPayKey 加载商户私钥与证书(支持 .pfx/.p12 与 .pem+.cer 两种形式) +func unionPayKey(cfg *ChannelConfig) (*rsa.PrivateKey, *x509.Certificate, error) { + certPath := cfg.CertPaths["cert_path"] + if certPath == "" { + return nil, nil, fmt.Errorf("银联缺少商户证书:请先上传商户证书") + } + data, err := os.ReadFile(certPath) + if err != nil { + return nil, nil, fmt.Errorf("读取银联证书失败: %w", err) + } + if block, _ := pem.Decode(data); block != nil && strings.Contains(block.Type, "PRIVATE KEY") { + keyData, kerr := os.ReadFile(cfg.CertPaths["key_path"]) + if kerr != nil { + return nil, nil, fmt.Errorf("读取银联私钥失败: %w", kerr) + } + priv, perr := parsePKCS1Or8(string(keyData)) + if perr != nil { + return nil, nil, perr + } + cert, cerr := loadCertFile(certPath) + if cerr != nil { + return nil, nil, cerr + } + return priv, cert, nil + } + + privAny, cert, err := pkcs12.Decode(data, cfg.Get("cert_password")) + if err != nil { + return nil, nil, fmt.Errorf("解析银联 .pfx 证书失败(密码是否正确): %w", err) + } + priv, ok := privAny.(*rsa.PrivateKey) + if !ok { + return nil, nil, fmt.Errorf("银联证书私钥不是 RSA 私钥") + } + if cert == nil { + return nil, nil, fmt.Errorf("银联证书缺少证书信息") + } + return priv, cert, nil +} + +func parsePKCS1Or8(pemStr string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, fmt.Errorf("私钥不是有效的 PEM") + } + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("解析私钥失败: %w", err) + } + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("私钥不是 RSA 私钥") + } + return rsaKey, nil +} + +func loadCertFile(path string) (*x509.Certificate, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("证书不是有效的 PEM") + } + return x509.ParseCertificate(block.Bytes) +} + +// unionPayCertID 银联证书序列号(SerialNumber 十六进制大写) +func unionPayCertID(cert *x509.Certificate) string { + return strings.ToUpper(fmt.Sprintf("%x", cert.SerialNumber)) +} + +// unionPaySign 银联签名:key 升序拼 key=value&... 后 SHA256withRSA +func unionPaySign(params map[string]string, key *rsa.PrivateKey) (string, error) { + keys := make([]string, 0, len(params)) + for k, v := range params { + if k == "signature" || v == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + pairs := make([]string, 0, len(keys)) + for _, k := range keys { + pairs = append(pairs, k+"="+params[k]) + } + digest := sha256.Sum256([]byte(strings.Join(pairs, "&"))) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(sig), nil +} + +// unionPaySigned 组装带签名与 certId 的请求参数 +func unionPaySigned(cfg *ChannelConfig, priv *rsa.PrivateKey, cert *x509.Certificate, extra map[string]string) (map[string]string, error) { + params := map[string]string{ + "version": unionPayVersion, + "encoding": "UTF-8", + "signMethod": "01", + "accessType": "0", + "merId": cfg.Get("mer_id"), + } + for k, v := range extra { + if v != "" { + params[k] = v + } + } + sig, err := unionPaySign(params, priv) + if err != nil { + return nil, fmt.Errorf("银联签名失败: %w", err) + } + params["signature"] = sig + params["certId"] = unionPayCertID(cert) + return params, nil +} + +// unionPayPostRaw 提交表单并返回原始响应 +func unionPayPostRaw(ctx context.Context, endpoint string, params map[string]string) (string, error) { + form := url.Values{} + for k, v := range params { + form.Set(k, v) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") + resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + if err != nil { + return "", fmt.Errorf("请求银联网关失败: %w", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("银联网关返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 200)) + } + return string(body), nil +} + +// unionPayPostForm 解析 form-urlencoded 响应(queryTrans / backTransReq.do) +func unionPayPostForm(ctx context.Context, endpoint string, params map[string]string) (url.Values, error) { + body, err := unionPayPostRaw(ctx, endpoint, params) + if err != nil { + return nil, err + } + vals, perr := url.ParseQuery(body) + if perr != nil || len(vals) == 0 { + return nil, fmt.Errorf("银联响应不是有效的表单数据: %s", truncateStr(body, 200)) + } + return vals, nil +} + +func truncateStr(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func (c *UnionPayChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) { + priv, cert, err := unionPayKey(cfg) + if err != nil { + return nil, err + } + extra := map[string]string{ + "txnType": "01", + "txnSubType": "01", + "bizType": unionPayDefaultBizType, + "orderId": order.PayNo, + "txnTime": time.Now().Format("20060102150405"), + "txnAmt": strconv.FormatInt(order.Amount, 10), + "currencyCode": unionPayCurrencyCNY, + "backUrl": cfg.CallbackURL, + "frontUrl": order.ReturnURL, + } + params, err := unionPaySigned(cfg, priv, cert, extra) + if err != nil { + return nil, err + } + // 前台跳转:银联返回自动提交表单,前端写入页面即可拉起收银台 + html, err := unionPayPostRaw(ctx, unionPayGateway(cfg)+"/gateway/api/frontTransReq.do", params) + if err != nil { + return nil, fmt.Errorf("银联下单失败: %w", err) + } + payType := PayTypeWeb + if strings.EqualFold(opt.PayType, PayTypeH5) { + payType = PayTypeH5 + } + return &PayParams{Channel: ChannelUnionPay, PayType: payType, Form: html}, nil +} + +func (c *UnionPayChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) { + priv, cert, err := unionPayKey(cfg) + if err != nil { + return nil, err + } + extra := map[string]string{ + "txnType": "00", + "txnSubType": "00", + "bizType": unionPayDefaultBizType, + "orderId": order.PayNo, + "txnTime": order.CreateTime.Format("20060102150405"), + } + params, err := unionPaySigned(cfg, priv, cert, extra) + if err != nil { + return nil, err + } + // TODO(银联): 查询响应字段(origRespCode/origTxnAmt/queryId 等)以入网时下发的接口文档为准,联调时校准。 + vals, err := unionPayPostForm(ctx, unionPayGateway(cfg)+"/gateway/api/queryTrans.do", params) + if err != nil { + return nil, err + } + state := &ChannelState{Raw: fmt.Sprint(vals)} + if vals.Get("queryId") != "" { + state.ChannelTradeNo = vals.Get("queryId") + } + if vals.Get("origRespCode") == "00" { + state.TradeState = StateSuccess + if amt, aerr := strconv.ParseInt(vals.Get("origTxnAmt"), 10, 64); aerr == nil { + state.Amount = amt + } + now := time.Now() + state.PaidAt = &now + } else { + state.TradeState = StatePending + } + return state, nil +} + +func (c *UnionPayChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) { + priv, _, err := unionPayKey(cfg) + if err != nil { + return nil, err + } + if err := r.ParseForm(); err != nil { + return nil, fmt.Errorf("解析银联通知失败: %w", err) + } + form := map[string]string{} + for k := range r.Form { + form[k] = r.Form.Get(k) + } + expect, err := unionPaySign(form, priv) + if err != nil { + return nil, err + } + if expect != r.Form.Get("signature") { + return nil, fmt.Errorf("银联通知验签失败") + } + + result := &NotifyResult{ + EventType: "UNIONPAY.NOTIFY", + AckBody: "ok", + Raw: fmt.Sprint(r.Form), + } + result.PayNo = form["orderId"] + result.OutTradeNo = form["orderId"] + result.ChannelTradeNo = form["queryId"] + if amt, aerr := strconv.ParseInt(form["txnAmt"], 10, 64); aerr == nil { + result.Amount = amt + } + if form["respCode"] == "00" { + result.TradeState = StateSuccess + result.Paid = true + } else { + result.TradeState = StateFailed + } + return result, nil +} + +func (c *UnionPayChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) { + if order.ChannelTradeNo == "" { + return "", fmt.Errorf("银联退回需要原交易流水号,请先执行「手动查询渠道状态」") + } + priv, cert, err := unionPayKey(cfg) + if err != nil { + return "", err + } + extra := map[string]string{ + "txnType": "04", + "txnSubType": "00", + "bizType": unionPayDefaultBizType, + "orderId": refundNo, + "txnTime": time.Now().Format("20060102150405"), + "txnAmt": strconv.FormatInt(amount, 10), + "origQryId": order.ChannelTradeNo, + "backUrl": cfg.CallbackURL, + } + params, err := unionPaySigned(cfg, priv, cert, extra) + if err != nil { + return "", err + } + // TODO(银联): 退货响应字段与重试策略以入网时下发的接口文档为准,联调时校准。 + vals, err := unionPayPostForm(ctx, unionPayGateway(cfg)+"/gateway/api/backTransReq.do", params) + if err != nil { + return "", err + } + if vals.Get("respCode") != "00" { + return "", fmt.Errorf("银联退回失败: %s %s", vals.Get("respCode"), vals.Get("respMsg")) + } + return vals.Get("queryId"), nil +} + +func (c *UnionPayChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) { + priv, cert, err := unionPayKey(cfg) + if err != nil { + return "", err + } + if _, err := unionPaySign(map[string]string{"merId": cfg.Get("mer_id"), "orderId": "__connect_test__"}, priv); err != nil { + return "", err + } + // TODO(银联): 如需校验网关连通性,可在入网报备后用一笔查询交易确认。 + return fmt.Sprintf("连接成功:证书加载与签名校验通过(证书序列号 %s,网关 %s)", unionPayCertID(cert), unionPayGateway(cfg)), nil +} + +// CloudPayChannel 云闪付:走银联通道,商户参数与证书完全复用银联配置; +// 收银台是否展示云闪付标识由 extra_json.show_logo 控制(由前端收银台读取),适配器层面与银联一致。 +type CloudPayChannel struct { + UnionPayChannel +} + +func (c *CloudPayChannel) Code() string { return ChannelCloudPay } +func (c *CloudPayChannel) Name() string { return "云闪付" } diff --git a/go/services/payment/channel_wechat.go b/go/services/payment/channel_wechat.go new file mode 100644 index 0000000..d86a17d --- /dev/null +++ b/go/services/payment/channel_wechat.go @@ -0,0 +1,316 @@ +package payment + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "server/models" + + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" + "github.com/wechatpay-apiv3/wechatpay-go/core/consts" + "github.com/wechatpay-apiv3/wechatpay-go/core/downloader" + "github.com/wechatpay-apiv3/wechatpay-go/core/notify" + "github.com/wechatpay-apiv3/wechatpay-go/core/option" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments/native" + "github.com/wechatpay-apiv3/wechatpay-go/utils" +) + +// WechatChannel 微信支付(JSAPI / Native 扫码) +// +// 渠道参数(config_json): +// +// mch_id 商户号 +// appid 公众号 / 小程序 APPID +// cert_serial_no 商户 API 证书序列号 +// api_v3_key APIv3 密钥 +// +// 证书(cert_json): +// +// key_path apiclient_key.pem 上传后的服务端路径(不入库内容,见渠道证书上传接口) +type WechatChannel struct{} + +func (c *WechatChannel) Code() string { return ChannelWechat } +func (c *WechatChannel) Name() string { return "微信支付" } + +// buildClient 创建微信支付 APIv3 客户端(带自动证书更新与加解密能力) +func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, error) { + mchID := cfg.Get("mch_id") + serial := cfg.Get("cert_serial_no") + apiV3Key := cfg.Get("api_v3_key") + if mchID == "" || serial == "" || apiV3Key == "" { + return nil, "", fmt.Errorf("微信支付参数不完整:需要 mch_id / cert_serial_no / api_v3_key") + } + + keyPath := cfg.CertPaths["key_path"] + if keyPath == "" { + return nil, "", fmt.Errorf("微信支付缺少商户私钥:请先上传 apiclient_key.pem") + } + pem, err := os.ReadFile(keyPath) + if err != nil { + return nil, "", fmt.Errorf("读取商户私钥失败: %w", err) + } + privateKey, err := utils.LoadPrivateKey(string(pem)) + if err != nil { + return nil, "", fmt.Errorf("解析商户私钥失败: %w", err) + } + + client, err := core.NewClient(context.Background(), + option.WithMerchantCredential(mchID, serial, privateKey), + option.WithWechatPayAutoAuthCipher(mchID, serial, privateKey, apiV3Key), + ) + if err != nil { + return nil, "", fmt.Errorf("创建微信支付客户端失败: %w", err) + } + return client, mchID, nil +} + +func (c *WechatChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) { + client, mchID, err := c.buildClient(cfg) + if err != nil { + return nil, err + } + appID := cfg.Get("appid") + if appID == "" { + return nil, fmt.Errorf("微信支付参数不完整:需要 appid") + } + notifyURL := cfg.CallbackURL + amount := &native.Amount{Total: core.Int64(order.Amount), Currency: core.String("CNY")} + + // 公众号内支付 + if strings.EqualFold(opt.PayType, PayTypeJSAPI) { + if opt.OpenID == "" { + return nil, fmt.Errorf("微信 JSAPI 支付需要 OpenID") + } + svc := jsapi.JsapiApiService{Client: client} + resp, _, err := svc.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{ + Appid: core.String(appID), + Mchid: core.String(mchID), + Description: core.String(order.Subject), + OutTradeNo: core.String(order.PayNo), + NotifyUrl: core.String(notifyURL), + Amount: &jsapi.Amount{Total: core.Int64(order.Amount), Currency: core.String("CNY")}, + Payer: &jsapi.Payer{Openid: core.String(opt.OpenID)}, + }) + if err != nil { + return nil, fmt.Errorf("微信 JSAPI 下单失败: %w", err) + } + if resp == nil || resp.PrepayId == nil { + return nil, fmt.Errorf("微信 JSAPI 下单未返回 prepay_id") + } + + // 按微信规范自行生成 JSAPI 调起参数(timeStamp/nonceStr/paySign) + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce, _ := randomNonce(16) + message := appID + "\n" + ts + "\n" + nonce + "\n" + "prepay_id=" + *resp.PrepayId + "\n" + sign, err := client.Sign(ctx, message) + if err != nil { + return nil, fmt.Errorf("生成 JSAPI 签名失败: %w", err) + } + return &PayParams{ + Channel: ChannelWechat, + PayType: PayTypeJSAPI, + JSAPI: map[string]string{ + "appId": appID, + "timeStamp": ts, + "nonceStr": nonce, + "package": "prepay_id=" + *resp.PrepayId, + "signType": "RSA", + "paySign": sign.Signature, + }, + }, nil + } + + // 默认:Native 扫码(PC 收银台) + req := native.PrepayRequest{ + Appid: core.String(appID), + Mchid: core.String(mchID), + Description: core.String(order.Subject), + OutTradeNo: core.String(order.PayNo), + NotifyUrl: core.String(notifyURL), + Amount: amount, + } + if order.ExpireAt != nil { + req.TimeExpire = order.ExpireAt + } + svc := native.NativeApiService{Client: client} + resp, _, err := svc.Prepay(ctx, req) + if err != nil { + return nil, fmt.Errorf("微信 Native 下单失败: %w", err) + } + if resp == nil || resp.CodeUrl == nil { + return nil, fmt.Errorf("微信 Native 下单未返回 code_url") + } + return &PayParams{Channel: ChannelWechat, PayType: PayTypeQR, CodeURL: *resp.CodeUrl}, nil +} + +// normalizeWechatState 微信交易状态 -> 归一化状态 +func normalizeWechatState(state string) string { + switch state { + case "SUCCESS": + return StateSuccess + case "NOTPAY", "USERPAYING": + return StatePending + case "CLOSED", "REVOKED": + return StateClosed + case "PAYERROR": + return StateFailed + case "REFUND": + return StateRefunded + default: + return StatePending + } +} + +func (c *WechatChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) { + client, mchID, err := c.buildClient(cfg) + if err != nil { + return nil, err + } + url := consts.WechatPayAPIServer + "/v3/pay/transactions/out-trade-no/" + order.PayNo + "?mchid=" + mchID + result, err := client.Get(ctx, url) + if err != nil { + return nil, fmt.Errorf("微信查询订单失败: %w", err) + } + body, err := io.ReadAll(result.Response.Body) + if err != nil { + return nil, fmt.Errorf("读取微信查询响应失败: %w", err) + } + tx := &payments.Transaction{} + if err := json.Unmarshal(body, tx); err != nil { + return nil, fmt.Errorf("解析微信查询响应失败: %w", err) + } + + state := &ChannelState{Raw: string(body)} + if tx.TransactionId != nil { + state.ChannelTradeNo = *tx.TransactionId + } + state.TradeState = normalizeWechatState(valueOrEmpty(tx.TradeState)) + if tx.Amount != nil && tx.Amount.Total != nil { + state.Amount = *tx.Amount.Total + } + if tx.SuccessTime != nil { + if t, perr := time.Parse(time.RFC3339, *tx.SuccessTime); perr == nil { + state.PaidAt = &t + } + } + return state, nil +} + +func (c *WechatChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) { + mchID := cfg.Get("mch_id") + apiV3Key := cfg.Get("api_v3_key") + + // 平台证书由 WithWechatPayAutoAuthCipher 注册的下载器维护,直接复用其证书访问器验签 + visitor := downloader.MgrInstance().GetCertificateVisitor(mchID) + handler, err := notify.NewRSANotifyHandler(apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor)) + if err != nil { + return nil, fmt.Errorf("创建微信通知处理器失败: %w", err) + } + + tx := &payments.Transaction{} + req, err := handler.ParseNotifyRequest(ctx, r, tx) + if err != nil { + return nil, fmt.Errorf("微信通知验签/解密失败: %w", err) + } + + result := &NotifyResult{ + EventType: req.EventType, + EventID: req.ID, + AckBody: `{"code":"SUCCESS","message":"成功"}`, + Raw: req.Resource.Plaintext, + } + if tx.OutTradeNo != nil { + result.OutTradeNo = *tx.OutTradeNo + result.PayNo = *tx.OutTradeNo + } + if tx.TransactionId != nil { + result.ChannelTradeNo = *tx.TransactionId + } + if tx.Amount != nil && tx.Amount.Total != nil { + result.Amount = *tx.Amount.Total + } + if tx.SuccessTime != nil { + if t, perr := time.Parse(time.RFC3339, *tx.SuccessTime); perr == nil { + _ = t + } + } + result.TradeState = normalizeWechatState(valueOrEmpty(tx.TradeState)) + result.Paid = result.TradeState == StateSuccess + return result, nil +} + +func (c *WechatChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) { + client, _, err := c.buildClient(cfg) + if err != nil { + return "", err + } + body := map[string]any{ + "out_trade_no": order.PayNo, + "out_refund_no": refundNo, + "reason": reason, + "notify_url": "", // 退回结果以主动查询为准;如需退回回调,配置后填入 + "amount": map[string]any{ + "refund": amount, + "total": order.Amount, + "currency": "CNY", + }, + } + result, err := client.Post(ctx, consts.WechatPayAPIServer+"/v3/refund/domestic/refunds", body) + if err != nil { + return "", fmt.Errorf("微信退回请求失败: %w", err) + } + respBody, _ := io.ReadAll(result.Response.Body) + var resp struct { + RefundID string `json:"refund_id"` + Status string `json:"status"` + } + _ = json.Unmarshal(respBody, &resp) + if resp.Status != "" && resp.Status != "SUCCESS" && resp.Status != "PROCESSING" { + return resp.RefundID, fmt.Errorf("微信退回未受理,状态: %s", resp.Status) + } + return resp.RefundID, nil +} + +func (c *WechatChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) { + client, _, err := c.buildClient(cfg) + if err != nil { + return "", err + } + // 平台证书接口只做签名校验,不产生任何交易 + result, err := client.Get(ctx, consts.WechatPayAPIServer+"/v3/certificates") + if err != nil { + return "", fmt.Errorf("微信凭证校验失败: %w", err) + } + body, _ := io.ReadAll(result.Response.Body) + return fmt.Sprintf("连接成功:商户证书与 APIv3 密钥校验通过(平台证书响应 %d 字节)", len(body)), nil +} + +/* ---------------- 小工具 ---------------- */ + +func valueOrEmpty(v *string) string { + if v == nil { + return "" + } + return *v +} + +// randomNonce 生成 n 字节随机数的十六进制串 +func randomNonce(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} diff --git a/go/services/payment/commission.go b/go/services/payment/commission.go new file mode 100644 index 0000000..df79551 --- /dev/null +++ b/go/services/payment/commission.go @@ -0,0 +1,273 @@ +package payment + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "server/models" + + "github.com/beego/beego/v2/client/orm" + beelog "github.com/beego/beego/v2/core/logs" +) + +/* ============================================================= + * 推广佣金:规则匹配 -> 金额计算 -> 佣金单生成(幂等)-> 发放登记 + * 佣金是平台自有资金支出,与租户收款资金流解耦。 + * ============================================================= */ + +// OnOrderPaid 支付成功后生成佣金单(幂等:同一支付单 + 同一推广方只生成一张) +// 无推广方 / 无匹配规则 / 佣金金额为 0 时返回 (nil, nil)。 +func OnOrderPaid(ctx context.Context, order *models.PlatformPaymentOrder) (*models.PlatformCommissionOrder, error) { + if order == nil || order.PromoterID == "" { + return nil, nil + } + + // 幂等查重 + exist := &models.PlatformCommissionOrder{} + err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)). + Filter("pay_no", order.PayNo). + Filter("promoter_id", order.PromoterID). + Filter("delete_time__isnull", true). + One(exist) + if err == nil { + return exist, nil + } else if err != orm.ErrNoRows { + return nil, fmt.Errorf("查询佣金单失败: %w", err) + } + + rule, err := matchCommissionRule(order.OrderType) + if err != nil { + return nil, err + } + if rule == nil { + return nil, nil + } + amount := calcCommissionAmount(rule, order.Amount) + if amount <= 0 { + beelog.Warn("佣金规则 %s 计算金额为 0,跳过(订单 %s)", rule.RuleName, order.PayNo) + return nil, nil + } + + row := &models.PlatformCommissionOrder{ + CommissionNo: NextCommissionNo(), + PayNo: order.PayNo, + OutTradeNo: order.OutTradeNo, + RuleID: rule.ID, + RuleName: rule.RuleName, + CalcType: rule.CalcType, + PromoterID: order.PromoterID, + PromoterName: order.PromoterName, + PromoterType: order.PromoterType, + BaseAmount: order.Amount, + Amount: amount, + Status: models.CommissionStatusPayable, + Remark: fmt.Sprintf("支付单 %s 支付成功自动生成", order.PayNo), + } + if _, err := models.Orm.Insert(row); err != nil { + if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") { + // 并发生成命中唯一键:查回已有记录 + return exist, nil + } + return nil, fmt.Errorf("创建佣金单失败: %w", err) + } + beelog.Info("佣金单已生成: %s 支付单 %s 推广方 %s 金额 %d 分", row.CommissionNo, order.PayNo, order.PromoterName, amount) + return row, nil +} + +// matchCommissionRule 匹配启用中的佣金规则(同类型规则取最新一条) +func matchCommissionRule(orderType string) (*models.PlatformCommissionRule, error) { + var rules []models.PlatformCommissionRule + _, err := models.Orm.QueryTable(new(models.PlatformCommissionRule)). + Filter("status", 1). + Filter("delete_time__isnull", true). + OrderBy("-id"). + All(&rules) + if err != nil { + return nil, err + } + for i := range rules { + if rules[i].OrderType == models.CommissionOrderTypeAll || rules[i].OrderType == orderType { + return &rules[i], nil + } + } + return nil, nil +} + +// calcCommissionAmount 按规则计算佣金(分),向下取整 +func calcCommissionAmount(rule *models.PlatformCommissionRule, baseAmount int64) int64 { + if rule == nil || baseAmount <= 0 { + return 0 + } + switch rule.CalcType { + case models.CommissionCalcPercent: + return baseAmount * rule.Value / 10000 // value = 百分比 × 100 + case models.CommissionCalcFixed: + return rule.Value + case models.CommissionCalcTiered: + for _, tier := range parseTiers(rule.TierJSON) { + if baseAmount < tier.Min { + continue + } + if tier.Max != nil && baseAmount >= *tier.Max { + continue + } + return baseAmount * tier.Rate / 10000 + } + return 0 + default: + return 0 + } +} + +func parseTiers(raw *string) []models.CommissionTier { + tiers := []models.CommissionTier{} + if raw == nil || *raw == "" { + return tiers + } + _ = json.Unmarshal([]byte(*raw), &tiers) + return tiers +} + +/* ---------------- 佣金台账查询与发放 ---------------- */ + +// CommissionListInput 台账查询条件 +type CommissionListInput struct { + Promoter string + Status string + StartTime string // YYYY-MM-DD + EndTime string + Page int + PageSize int +} + +// ListCommissions 佣金台账分页 +func ListCommissions(in CommissionListInput) ([]models.PlatformCommissionOrder, int64, error) { + qs := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).Filter("delete_time__isnull", true) + if in.Promoter != "" { + qs = qs.Filter("promoter_name__icontains", in.Promoter) + } + if in.Status != "" { + qs = qs.Filter("status", in.Status) + } + if in.StartTime != "" { + if t, err := time.ParseInLocation("2006-01-02", in.StartTime, time.Local); err == nil { + qs = qs.Filter("create_time__gte", t) + } + } + if in.EndTime != "" { + if t, err := time.ParseInLocation("2006-01-02", in.EndTime, time.Local); err == nil { + qs = qs.Filter("create_time__lt", t.AddDate(0, 0, 1)) + } + } + total, err := qs.Count() + if err != nil { + return nil, 0, err + } + if in.Page <= 0 { + in.Page = 1 + } + if in.PageSize <= 0 || in.PageSize > 200 { + in.PageSize = 10 + } + var rows []models.PlatformCommissionOrder + _, err = qs.OrderBy("-id").Limit(in.PageSize, (in.Page-1)*in.PageSize).All(&rows) + return rows, total, err +} + +// CommissionStats 台账统计(应付 / 已付 / 待处理笔数) +type CommissionStats struct { + PayableAmount int64 `json:"payable_amount"` + PaidAmount int64 `json:"paid_amount"` + PendingCount int64 `json:"pending_count"` +} + +// GetCommissionStats 统计口径: +// +// 应付总额 = status=payable 的金额合计;已付总额 = status=settled;待处理笔数 = payable + tax_processing。 +func GetCommissionStats() (*CommissionStats, error) { + type row struct { + Total int64 `orm:"column(total)"` + Cnt int64 `orm:"column(cnt)"` + } + stats := &CommissionStats{} + pay := row{} + if err := models.Orm.Raw( + "SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?", + models.CommissionStatusPayable).QueryRow(&pay); err != nil { + return nil, err + } + stats.PayableAmount = pay.Total + stats.PendingCount = pay.Cnt + + paid := row{} + if err := models.Orm.Raw( + "SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?", + models.CommissionStatusSettled).QueryRow(&paid); err != nil { + return nil, err + } + stats.PaidAmount = paid.Total + + tax := row{} + if err := models.Orm.Raw( + "SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?", + models.CommissionStatusTaxProcessing).QueryRow(&tax); err != nil { + return nil, err + } + stats.PendingCount += tax.Cnt + return stats, nil +} + +// MarkCommissionPaidInput 标记已付入参 +type MarkCommissionPaidInput struct { + ID uint64 + PayMethod string + PayVoucher string + Remark string + OperatorID string + OperatorName string +} + +// MarkCommissionPaid 发放登记:应付/税务处理中 -> 已付 +func MarkCommissionPaid(ctx context.Context, in MarkCommissionPaidInput) (*models.PlatformCommissionOrder, error) { + row := &models.PlatformCommissionOrder{} + if err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)). + Filter("id", in.ID). + Filter("delete_time__isnull", true). + One(row); err != nil { + return nil, fmt.Errorf("佣金单不存在") + } + if row.Status == models.CommissionStatusSettled { + return nil, errors.New("该佣金单已是已付状态") + } + now := time.Now() + _, err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)). + Filter("id", row.ID). + Update(orm.Params{ + "status": models.CommissionStatusSettled, + "pay_method": in.PayMethod, + "pay_voucher": in.PayVoucher, + "paid_at": now, + "paid_user_id": in.OperatorID, + "paid_user_name": in.OperatorName, + "remark": in.Remark, + "update_time": now, + }) + if err != nil { + return nil, err + } + return GetCommissionByID(in.ID) +} + +// GetCommissionByID 按 ID 查询佣金单 +func GetCommissionByID(id uint64) (*models.PlatformCommissionOrder, error) { + row := &models.PlatformCommissionOrder{} + err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(row) + return row, err +} diff --git a/go/services/payment/crypto.go b/go/services/payment/crypto.go new file mode 100644 index 0000000..d2111c1 --- /dev/null +++ b/go/services/payment/crypto.go @@ -0,0 +1,106 @@ +package payment + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "strings" + + beego "github.com/beego/beego/v2/server/web" +) + +// ============================================================= +// 渠道敏感参数的对称加密与掩码 +// +// yz_platform_payment_channel.config_json 中的密钥/私钥/Secret 以 AES-256-GCM 加密后存储, +// 格式:base64( "YZP1:" + nonce + ciphertext )。 +// 密钥来源:app.conf 的 payment_secret_key(任意长度字符串,内部做 SHA-256 派生); +// 未配置时退回内置兜底密钥(保证开箱可用),上线前务必在 app.conf 配置该值, +// 且注意:更换密钥后,历史密文将无法解密,需要重新保存各渠道配置。 +// ============================================================= + +const configCipherPrefix = "YZP1:" + +// fallbackSecretKey 内置兜底密钥,仅用于开发/未配置场景 +const fallbackSecretKey = "yunzer_payment_default_secret_key_v1" + +func configKey() []byte { + raw, _ := beego.AppConfig.String("payment_secret_key") + if strings.TrimSpace(raw) == "" { + raw = fallbackSecretKey + } + sum := sha256.Sum256([]byte(raw)) + return sum[:] +} + +// EncryptConfig 加密渠道参数 JSON +func EncryptConfig(plain string) (string, error) { + if plain == "" { + return "", nil + } + block, err := aes.NewCipher(configKey()) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil) + buf := append([]byte(configCipherPrefix), nonce...) + buf = append(buf, ciphertext...) + return base64.StdEncoding.EncodeToString(buf), nil +} + +// DecryptConfig 解密渠道参数 JSON;空串原样返回 +func DecryptConfig(encoded string) (string, error) { + if encoded == "" { + return "", nil + } + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", errors.New("渠道参数不是有效的 base64") + } + if len(raw) < len(configCipherPrefix) || string(raw[:len(configCipherPrefix)]) != configCipherPrefix { + return "", errors.New("渠道参数格式不正确(缺少加密前缀)") + } + block, err := aes.NewCipher(configKey()) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := raw[len(configCipherPrefix) : len(configCipherPrefix)+gcm.NonceSize()] + ciphertext := raw[len(configCipherPrefix)+gcm.NonceSize():] + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", errors.New("渠道参数解密失败(payment_secret_key 是否被更换过?)") + } + return string(plain), nil +} + +// MaskSecret 生成掩码:保留末 4 位 +func MaskSecret(v string) string { + if v == "" { + return "" + } + if len(v) <= 4 { + return "******" + } + return "******" + v[len(v)-4:] +} + +// IsMasked 判断前端回传的值是否仍是掩码(表示「不修改原值」) +func IsMasked(v string) bool { + return strings.HasPrefix(v, "******") +} diff --git a/go/services/payment/ids.go b/go/services/payment/ids.go new file mode 100644 index 0000000..514bee7 --- /dev/null +++ b/go/services/payment/ids.go @@ -0,0 +1,81 @@ +package payment + +import ( + "crypto/rand" + "fmt" + "math/big" + "strconv" + "strings" + "sync" + "time" +) + +/* ---------------- 金额工具 ---------------- */ + +// FenToYuan 分 -> 元(两位小数字符串),供渠道下单参数使用 +func FenToYuan(fen int64) string { + if fen < 0 { + fen = 0 + } + return strconv.FormatInt(fen/100, 10) + "." + fmt.Sprintf("%02d", fen%100) +} + +// YuanToFen 元(字符串)-> 分 +func YuanToFen(yuan string) (int64, error) { + yuan = strings.TrimSpace(yuan) + if yuan == "" { + return 0, nil + } + f, err := strconv.ParseFloat(yuan, 64) + if err != nil { + return 0, fmt.Errorf("金额格式不正确: %s", yuan) + } + if f < 0 { + return 0, fmt.Errorf("金额不能为负数: %s", yuan) + } + return int64(f*100 + 0.5), nil +} + +/* ---------------- 单号生成 ---------------- + + 规则:前缀 + yyyyMMddHHmmss + 4 位随机数字(进程内串行生成,避免同秒碰撞; + 落库仍有唯一键兜底,撞号时由调用方重试一次)。 + 支付单 P202609151024310001 + 退回单 R202609151024310001 + 佣金单 CM202609151024310001 + 对账批次 RC202609151024310001 +---------------------------------------- */ + +var ( + noMu sync.Mutex + lastNoTime int64 + lastNoSeq int64 +) + +func nextSerial(prefix string) string { + noMu.Lock() + defer noMu.Unlock() + + now := time.Now().Unix() + seq := int64(0) + if now == lastNoTime { + seq = lastNoSeq + 1 + } + lastNoTime = now + lastNoSeq = seq + + n, _ := rand.Int(rand.Reader, big.NewInt(10000)) + return fmt.Sprintf("%s%s%04d%04d", prefix, time.Now().Format("20060102150405"), seq, n.Int64()) +} + +// NextPayNo 生成支付单号 +func NextPayNo() string { return nextSerial("P") } + +// NextRefundNo 生成退回单号 +func NextRefundNo() string { return nextSerial("R") } + +// NextCommissionNo 生成佣金单号 +func NextCommissionNo() string { return nextSerial("CM") } + +// NextBatchNo 生成对账批次号 +func NextBatchNo() string { return nextSerial("RC") } diff --git a/go/services/payment/reconcile.go b/go/services/payment/reconcile.go new file mode 100644 index 0000000..8e5889f --- /dev/null +++ b/go/services/payment/reconcile.go @@ -0,0 +1,367 @@ +package payment + +import ( + "context" + "encoding/csv" + "fmt" + "strings" + "time" + + "server/models" + + "github.com/beego/beego/v2/client/orm" + beelog "github.com/beego/beego/v2/core/logs" +) + +/* ============================================================= + * 日终对账:渠道账单导入 -> 与本地支付流水比对 -> 产出差异 -> 人工标记处理 + * 差异类型:long 长款(渠道有本地无)/ short 短款(本地有渠道无)/ status_mismatch 状态不一致 + * ============================================================= */ + +// BillRow 渠道账单明细行(导入用) +type BillRow struct { + ChannelTradeNo string // 渠道流水号 + OutTradeNo string // 渠道侧商户订单号(下单时传的 pay_no) + Amount int64 // 渠道金额(分) + Fee int64 // 渠道手续费(分) + TradeState string // 渠道侧交易状态 + TradeTime *time.Time // 渠道交易时间 +} + +// ImportChannelBill 导入某渠道某日的账单明细(幂等:按 channel + channel_trade_no 更新或插入) +func ImportChannelBill(ctx context.Context, channel, billDate string, rows []BillRow) (int, error) { + day, err := time.ParseInLocation("2006-01-02", billDate, time.Local) + if err != nil { + return 0, fmt.Errorf("对账日期格式应为 YYYY-MM-DD") + } + imported := 0 + for _, r := range rows { + if strings.TrimSpace(r.ChannelTradeNo) == "" { + continue + } + exist := &models.PlatformPaymentChannelBill{} + err := models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)). + Filter("channel", channel). + Filter("channel_trade_no", r.ChannelTradeNo). + Filter("delete_time__isnull", true). + One(exist) + if err == nil { + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)). + Filter("id", exist.ID). + Update(orm.Params{ + "amount": r.Amount, "fee": r.Fee, "trade_state": r.TradeState, + "update_time": time.Now(), + }) + imported++ + continue + } + row := &models.PlatformPaymentChannelBill{ + Channel: channel, + BillDate: day, + ChannelTradeNo: r.ChannelTradeNo, + OutTradeNo: r.OutTradeNo, + Amount: r.Amount, + Fee: r.Fee, + TradeState: r.TradeState, + TradeTime: r.TradeTime, + } + if _, err := models.Orm.Insert(row); err == nil { + imported++ + } + } + return imported, nil +} + +// ParseChannelBillCSV 解析账单 CSV。 +// 约定列:channel_trade_no, out_trade_no, amount(元), fee(元), trade_state, trade_time +// TODO(渠道): 各渠道账单文件列名/编码不同(微信为制表符分隔、支付宝为 GBK CSV), +// 对接时按渠道导出文件写各自的解析器,统一转成 BillRow。 +func ParseChannelBillCSV(data []byte) ([]BillRow, error) { + reader := csv.NewReader(strings.NewReader(string(data))) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, err + } + rows := make([]BillRow, 0, len(records)) + for i, rec := range records { + if i == 0 || len(rec) == 0 { + continue + } + get := func(idx int) string { + if idx < len(rec) { + return strings.TrimSpace(rec[idx]) + } + return "" + } + row := BillRow{ + ChannelTradeNo: get(0), + OutTradeNo: get(1), + TradeState: get(4), + } + if row.ChannelTradeNo == "" { + continue + } + if fen, err := YuanToFen(get(2)); err == nil { + row.Amount = fen + } + if fen, err := YuanToFen(get(3)); err == nil { + row.Fee = fen + } + if t := get(5); t != "" { + for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339, "2006-01-02"} { + if tt, err := time.ParseInLocation(layout, t, time.Local); err == nil { + row.TradeTime = &tt + break + } + } + } + rows = append(rows, row) + } + return rows, nil +} + +// ReconcileSummary 一次对账的执行结果 +type ReconcileSummary struct { + BatchNo string `json:"batch_no"` + Channel string `json:"channel"` + BillDate string `json:"bill_date"` + BillCount int `json:"bill_count"` + Matched int `json:"matched"` + LongCount int `json:"long_count"` + ShortCount int `json:"short_count"` + MismatchCount int `json:"mismatch_count"` +} + +// RunReconcile 对某渠道某日的账单与本地支付流水做比对,产出差异(幂等:按唯一键更新已有差异)。 +// 匹配策略:渠道账单的商户订单号即本地下单时传的 pay_no,优先按 pay_no 匹配,其次按 out_trade_no; +// 本地侧只统计支付成功日(paid_at)落在对账日内的支付单。 +func RunReconcile(ctx context.Context, channel, billDate string) (*ReconcileSummary, error) { + day, err := time.ParseInLocation("2006-01-02", billDate, time.Local) + if err != nil { + return nil, fmt.Errorf("对账日期格式应为 YYYY-MM-DD") + } + if _, err := LoadChannelConfig(channel); err != nil { + return nil, err + } + + var bills []models.PlatformPaymentChannelBill + _, err = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)). + Filter("channel", channel). + Filter("bill_date", day). + Filter("delete_time__isnull", true). + All(&bills) + if err != nil { + return nil, err + } + + var locals []models.PlatformPaymentOrder + _, err = models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("delete_time__isnull", true). + Filter("status", models.PayStatusPaid). + Filter("paid_at__gte", day). + Filter("paid_at__lt", day.AddDate(0, 0, 1)). + All(&locals) + if err != nil { + return nil, err + } + localByPayNo := map[string]*models.PlatformPaymentOrder{} + localByOutTrade := map[string]*models.PlatformPaymentOrder{} + for i := range locals { + localByPayNo[locals[i].PayNo] = &locals[i] + localByOutTrade[locals[i].OutTradeNo] = &locals[i] + } + + batchNo := NextBatchNo() + summary := &ReconcileSummary{BatchNo: batchNo, Channel: channel, BillDate: billDate, BillCount: len(bills)} + matchedPayNos := map[string]bool{} + + for i := range bills { + bill := bills[i] + local := localByPayNo[bill.OutTradeNo] + if local == nil { + local = localByOutTrade[bill.OutTradeNo] + } + + if local == nil { + summary.LongCount++ + _ = upsertDiff(channel, day, batchNo, "", bill.ChannelTradeNo, + models.DiffTypeLong, nil, &bill.Amount, "渠道账单存在该流水,本地无对应支付单") + continue + } + + if local.Status == models.PayStatusPaid && bill.Amount == local.Amount { + summary.Matched++ + matchedPayNos[local.PayNo] = true + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)). + Filter("id", bill.ID). + Update(orm.Params{"is_matched": 1, "matched_pay_no": local.PayNo, "update_time": time.Now()}) + continue + } + + summary.MismatchCount++ + reason := "本地与渠道金额不一致" + if bill.Amount == local.Amount { + reason = fmt.Sprintf("渠道已成功但本地状态为 %s", local.Status) + } + _ = upsertDiff(channel, day, batchNo, local.PayNo, bill.ChannelTradeNo, + models.DiffTypeStatusMismatch, &local.Amount, &bill.Amount, reason) + } + + for i := range locals { + if matchedPayNos[locals[i].PayNo] { + continue + } + summary.ShortCount++ + _ = upsertDiff(channel, day, batchNo, locals[i].PayNo, "", + models.DiffTypeShort, &locals[i].Amount, nil, "本地已支付,渠道账单无对应流水") + } + + beelog.Info("对账完成: 渠道=%s 日期=%s 账单 %d 笔,匹配 %d,长款 %d,短款 %d,不一致 %d(批次 %s)", + channel, billDate, summary.BillCount, summary.Matched, summary.LongCount, summary.ShortCount, summary.MismatchCount, batchNo) + return summary, nil +} + +// upsertDiff 写入/更新差异记录(幂等:唯一键 channel+bill_date+diff_type+channel_trade_no+local_trade_no) +func upsertDiff(channel string, day time.Time, batchNo, localTradeNo, channelTradeNo, diffType string, localAmount, channelAmount *int64, remark string) error { + exist := &models.PlatformPaymentReconcileDiff{} + err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)). + Filter("channel", channel). + Filter("bill_date", day). + Filter("diff_type", diffType). + Filter("channel_trade_no", channelTradeNo). + Filter("local_trade_no", localTradeNo). + Filter("delete_time__isnull", true). + One(exist) + + diffAmount := int64(0) + switch { + case channelAmount != nil && localAmount != nil: + diffAmount = *channelAmount - *localAmount + case channelAmount != nil: + diffAmount = *channelAmount + case localAmount != nil: + diffAmount = -*localAmount + } + + if err == nil { + _, uerr := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)). + Filter("id", exist.ID). + Update(orm.Params{ + "batch_no": batchNo, + "local_amount": localAmount, + "channel_amount": channelAmount, + "diff_amount": diffAmount, + "update_time": time.Now(), + }) + return uerr + } + + row := &models.PlatformPaymentReconcileDiff{ + Channel: channel, + BillDate: day, + BatchNo: batchNo, + LocalTradeNo: localTradeNo, + ChannelTradeNo: channelTradeNo, + LocalAmount: localAmount, + ChannelAmount: channelAmount, + DiffAmount: diffAmount, + DiffType: diffType, + HandleStatus: models.DiffHandleUnhandled, + Remark: remark, + } + _, err = models.Orm.Insert(row) + return err +} + +// ReconcileListInput 差异列表查询条件 +type ReconcileListInput struct { + Channel string + BillDate string + DiffType string + HandleStatus string + Page int + PageSize int +} + +// ListReconcileDiffs 对账差异分页 +func ListReconcileDiffs(in ReconcileListInput) ([]models.PlatformPaymentReconcileDiff, int64, error) { + qs := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).Filter("delete_time__isnull", true) + if in.Channel != "" { + qs = qs.Filter("channel", in.Channel) + } + if in.BillDate != "" { + if t, err := time.ParseInLocation("2006-01-02", in.BillDate, time.Local); err == nil { + qs = qs.Filter("bill_date", t) + } + } + if in.DiffType != "" { + qs = qs.Filter("diff_type", in.DiffType) + } + if in.HandleStatus != "" { + qs = qs.Filter("handle_status", in.HandleStatus) + } + total, err := qs.Count() + if err != nil { + return nil, 0, err + } + if in.Page <= 0 { + in.Page = 1 + } + if in.PageSize <= 0 || in.PageSize > 200 { + in.PageSize = 10 + } + var rows []models.PlatformPaymentReconcileDiff + _, err = qs.OrderBy("-id").Limit(in.PageSize, (in.Page-1)*in.PageSize).All(&rows) + return rows, total, err +} + +// MarkDiffHandledInput 标记差异处理入参 +type MarkDiffHandledInput struct { + ID uint64 + HandleStatus string // handled / handling / ignored + Remark string + OperatorID string + OperatorName string +} + +// MarkDiffHandled 标记对账差异处理状态 +func MarkDiffHandled(ctx context.Context, in MarkDiffHandledInput) (*models.PlatformPaymentReconcileDiff, error) { + row := &models.PlatformPaymentReconcileDiff{} + if err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)). + Filter("id", in.ID). + Filter("delete_time__isnull", true). + One(row); err != nil { + return nil, fmt.Errorf("差异记录不存在") + } + switch in.HandleStatus { + case models.DiffHandleHandled, models.DiffHandleHandling, models.DiffHandleIgnored: + default: + return nil, fmt.Errorf("处理状态不合法") + } + now := time.Now() + _, err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)). + Filter("id", row.ID). + Update(orm.Params{ + "handle_status": in.HandleStatus, + "remark": in.Remark, + "handle_user_id": in.OperatorID, + "handle_user_name": in.OperatorName, + "handle_time": now, + "update_time": now, + }) + if err != nil { + return nil, err + } + return GetReconcileDiffByID(in.ID) +} + +// GetReconcileDiffByID 按 ID 查询差异记录 +func GetReconcileDiffByID(id uint64) (*models.PlatformPaymentReconcileDiff, error) { + row := &models.PlatformPaymentReconcileDiff{} + err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(row) + return row, err +} diff --git a/go/services/payment/service.go b/go/services/payment/service.go new file mode 100644 index 0000000..5edc4b1 --- /dev/null +++ b/go/services/payment/service.go @@ -0,0 +1,613 @@ +package payment + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "server/models" + + "github.com/beego/beego/v2/client/orm" + beelog "github.com/beego/beego/v2/core/logs" +) + +/* ============================================================= + * 支付服务核心:创建支付单 / 查询 / 状态机 / 回调幂等 / 兜底退回 / 补偿任务 + * + * 与业务模块的解耦点: + * 1. 业务订单的「已支付」同步通过 RegisterOrderSyncHook 注册钩子完成; + * 未注册钩子时视为无需同步(order_synced 直接置 1)。 + * 2. 佣金推广方:下单入参可携带(快照到支付单),未携带时由 + * RegisterPromoterResolver 注册的钩子推导(如按租户归属的渠道伙伴)。 + * ============================================================= */ + +// OrderSyncFunc 业务订单状态同步钩子 +type OrderSyncFunc func(ctx context.Context, order *models.PlatformPaymentOrder) error + +// PromoterResolveFunc 推广方解析钩子 +type PromoterResolveFunc func(ctx context.Context, order *models.PlatformPaymentOrder) (promoterID, promoterName, promoterType string, ok bool) + +var ( + orderSyncHook OrderSyncFunc + promoterResolve PromoterResolveFunc +) + +// RegisterOrderSyncHook 注册业务订单同步钩子(在业务模块 init 时调用) +func RegisterOrderSyncHook(fn OrderSyncFunc) { orderSyncHook = fn } + +// RegisterPromoterResolver 注册推广方解析钩子 +func RegisterPromoterResolver(fn PromoterResolveFunc) { promoterResolve = fn } + +// CreateInput 创建支付单入参(租户端下单页 -> POST /backend/payment/create) +type CreateInput struct { + OutTradeNo string // 业务订单号(必填) + OrderType string // platform_usage / module_shop / service_fee + TenantID string + TenantName string + Amount int64 // 分 + Channel string // 渠道标识(必填) + Subject string + ReturnURL string // 支付完成同步跳回地址 + ClientIP string + PayType string // qr / web / h5 / jsapi;空值由适配器给默认 + OpenID string // JSAPI 必填 + PromoterID string + PromoterName string + PromoterType string + ExpireMinutes int // 支付有效期(分钟),默认 30 +} + +const defaultExpireMinutes = 30 + +// GetOrderByPayNo 按支付单号查询 +func GetOrderByPayNo(payNo string) (*models.PlatformPaymentOrder, error) { + row := &models.PlatformPaymentOrder{} + err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("pay_no", payNo). + Filter("delete_time__isnull", true). + One(row) + if err != nil { + return nil, err + } + return row, nil +} + +// LogStateChange 记录状态流转流水 +func LogStateChange(payNo, fromStatus, toStatus, operator, remark string) { + row := &models.PlatformPaymentOrderLog{ + PayNo: payNo, + FromStatus: fromStatus, + ToStatus: toStatus, + Operator: operator, + Remark: remark, + } + if _, err := models.Orm.Insert(row); err != nil { + beelog.Warn("支付单流转日志写入失败: %s %v", payNo, err) + } +} + +// CreatePayment 创建支付单并调用渠道下单 +// 幂等:同一 out_trade_no + channel 存在未终态支付单时,直接复用并重新拉起支付参数。 +func CreatePayment(ctx context.Context, in CreateInput) (*models.PlatformPaymentOrder, *PayParams, error) { + if strings.TrimSpace(in.OutTradeNo) == "" { + return nil, nil, errors.New("业务订单号不能为空") + } + if in.Amount <= 0 { + return nil, nil, errors.New("支付金额必须大于 0") + } + adapter, err := GetChannelAdapter(in.Channel) + if err != nil { + return nil, nil, err + } + cfg, err := LoadEnabledChannelConfig(in.Channel) + if err != nil { + return nil, nil, err + } + + // 幂等:复用未终态支付单 + var exist models.PlatformPaymentOrder + err = models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("out_trade_no", in.OutTradeNo). + Filter("channel", in.Channel). + Filter("delete_time__isnull", true). + Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying). + One(&exist) + if err == nil { + params, perr := adapter.Prepay(ctx, &exist, cfg, PrepayOption{PayType: in.PayType, OpenID: in.OpenID}) + if perr != nil { + return nil, nil, perr + } + return &exist, params, nil + } else if err != orm.ErrNoRows { + return nil, nil, fmt.Errorf("查询支付单失败: %w", err) + } + + payNo := NextPayNo() + now := time.Now() + minutes := in.ExpireMinutes + if minutes <= 0 { + minutes = defaultExpireMinutes + } + expireAt := now.Add(time.Duration(minutes) * time.Minute) + + order := &models.PlatformPaymentOrder{ + PayNo: payNo, + OutTradeNo: in.OutTradeNo, + OrderType: in.OrderType, + Subject: in.Subject, + TenantID: in.TenantID, + TenantName: in.TenantName, + Amount: in.Amount, + OrderAmount: in.Amount, + Currency: "CNY", + Channel: in.Channel, + MerchantNo: cfg.MerchantNo, + Status: models.PayStatusCreated, + ClientIP: in.ClientIP, + ReturnURL: in.ReturnURL, + NotifyURL: cfg.CallbackURL, + ExpireAt: &expireAt, + PromoterID: in.PromoterID, + PromoterName: in.PromoterName, + PromoterType: in.PromoterType, + } + // 下单未携带推广方时,交给业务模块推导(如租户的签约渠道伙伴) + if order.PromoterID == "" && promoterResolve != nil { + if pid, pname, ptype, ok := promoterResolve(ctx, order); ok { + order.PromoterID, order.PromoterName, order.PromoterType = pid, pname, ptype + } + } + if _, err := models.Orm.Insert(order); err != nil { + return nil, nil, fmt.Errorf("创建支付单失败: %w", err) + } + LogStateChange(order.PayNo, "", models.PayStatusCreated, "租户端下单页", "创建支付单,渠道:"+cfg.Name) + + params, err := adapter.Prepay(ctx, order, cfg, PrepayOption{PayType: in.PayType, OpenID: in.OpenID}) + if err != nil { + // 渠道下单失败:保留支付单并置为 failed,便于排查与重下 + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Update(orm.Params{"status": models.PayStatusFailed, "update_time": now}) + LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusFailed, "支付服务", "渠道下单失败:"+err.Error()) + return nil, nil, err + } + LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusCreated, "支付服务", "渠道下单成功,返回支付参数") + if params.ChannelTradeNo != "" { + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Update(orm.Params{"channel_trade_no": params.ChannelTradeNo, "update_time": time.Now()}) + order.ChannelTradeNo = params.ChannelTradeNo + } + return order, params, nil +} + +// QueryPayment 查询支付单;syncChannel=true 时主动向渠道查询并同步状态(兜底回调丢失) +func QueryPayment(ctx context.Context, payNo string, syncChannel bool) (*models.PlatformPaymentOrder, error) { + order, err := GetOrderByPayNo(payNo) + if err != nil { + return nil, fmt.Errorf("支付单不存在") + } + now := time.Now() + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Update(orm.Params{"last_query_at": now, "update_time": now}) + order.LastQueryAt = &now + + if !syncChannel { + return order, nil + } + adapter, aerr := GetChannelAdapter(order.Channel) + cfg, cerr := LoadChannelConfig(order.Channel) + if aerr != nil || cerr != nil { + return order, nil + } + st, qerr := adapter.Query(ctx, order, cfg) + if qerr != nil { + LogStateChange(order.PayNo, order.Status, order.Status, "手动查询", "查询渠道状态失败:"+qerr.Error()) + return order, nil + } + if _, serr := applyChannelState(ctx, order, st, "手动查询", "主动查询渠道状态同步"); serr != nil { + beelog.Warn("支付单 %s 渠道状态同步失败: %v", order.PayNo, serr) + } + return GetOrderByPayNo(payNo) +} + +// HandleNotify 渠道异步通知统一入口:验签 -> 幂等 -> 状态机 -> 佣金,返回需回给渠道的响应体 +func HandleNotify(ctx context.Context, channel string, r *http.Request, ip string) (string, error) { + adapter, err := GetChannelAdapter(channel) + if err != nil { + return "", err + } + cfg, err := LoadChannelConfig(channel) // 停用渠道的历史回调仍需处理 + if err != nil { + return "", err + } + + result, err := adapter.ParseNotify(ctx, r, cfg) + if err != nil { + insertCallbackLog(channel, "", "", "", 0, 0, false, false, err.Error(), "", ip) + return "", err + } + + // 幂等:同一事件(channel + event_id)已成功处理过则直接命中 + duplicate := existsCallbackHandled(channel, result.EventID, result.ChannelTradeNo, result.EventType) + insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, duplicate, false, "", result.Raw, ip) + if duplicate { + return result.AckBody, nil + } + + order, oerr := GetOrderByPayNo(result.PayNo) + if oerr != nil { + // 再尝试按业务订单号找最近一笔(回调里可能只回传 out_trade_no) + order, oerr = findLatestOrderByOutTradeNo(result.OutTradeNo) + } + if oerr != nil { + insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, false, "支付单不存在", result.Raw, ip) + return "", fmt.Errorf("支付单不存在: %s", result.PayNo) + } + + // 非成功/失败/关闭事件只记录日志 + if result.TradeState != StateSuccess && result.TradeState != StateFailed && result.TradeState != StateClosed { + insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "事件无需变更状态: "+result.EventType, result.Raw, ip) + return result.AckBody, nil + } + + changed, serr := applyChannelState(ctx, order, &ChannelState{ + ChannelTradeNo: result.ChannelTradeNo, + TradeState: result.TradeState, + Amount: result.Amount, + }, "渠道回调", "渠道通知同步,事件:"+result.EventType) + if serr != nil { + insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, false, "状态更新失败: "+serr.Error(), result.Raw, ip) + return "", serr + } + if !changed { + insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "状态未变化(幂等)", result.Raw, ip) + } else { + insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "状态已同步为 "+result.TradeState, result.Raw, ip) + } + return result.AckBody, nil +} + +// existsCallbackHandled 是否已存在处理成功的同事件回调 +func existsCallbackHandled(channel, eventID, channelTradeNo, eventType string) bool { + qs := models.Orm.QueryTable(new(models.PlatformPaymentCallbackLog)). + Filter("channel", channel). + Filter("handle_result", 1). + Filter("delete_time__isnull", true) + if eventID != "" { + qs = qs.Filter("event_id", eventID) + } else { + qs = qs.Filter("channel_trade_no", channelTradeNo).Filter("event_type", eventType) + } + cnt, _ := qs.Count() + return cnt > 0 +} + +func insertCallbackLog(channel, payNo, outTradeNo, channelTradeNo string, amount int64, verifyResult int8, duplicate, handled bool, msg, raw, ip string) { + row := &models.PlatformPaymentCallbackLog{ + Channel: channel, + PayNo: payNo, + OutTradeNo: outTradeNo, + ChannelTradeNo: channelTradeNo, + Amount: amount, + VerifyResult: verifyResult, + HandleMsg: msg, + ClientIP: ip, + } + if duplicate { + row.IsDuplicate = 1 + } + if handled { + row.HandleResult = 1 + } + if raw != "" { + raw = truncateStr(raw, 60000) + row.RawBody = &raw + } + if _, err := models.Orm.Insert(row); err != nil { + beelog.Warn("回调日志写入失败: %v", err) + } +} + +func findLatestOrderByOutTradeNo(outTradeNo string) (*models.PlatformPaymentOrder, error) { + row := &models.PlatformPaymentOrder{} + err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("out_trade_no", outTradeNo). + Filter("delete_time__isnull", true). + OrderBy("-id"). + One(row) + return row, err +} + +// applyChannelState 把渠道状态落到本地(回调与主动查询共用),返回是否发生状态变更。 +// 支付成功采用条件更新(仅 created/pending/paying 可置为 paid)保证并发幂等。 +func applyChannelState(ctx context.Context, order *models.PlatformPaymentOrder, st *ChannelState, operator, remark string) (bool, error) { + if st == nil { + return false, nil + } + now := time.Now() + active := []interface{}{models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying} + + switch st.TradeState { + case StateSuccess: + if order.Status == models.PayStatusPaid { + if order.ChannelTradeNo == "" && st.ChannelTradeNo != "" { + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Update(orm.Params{"channel_trade_no": st.ChannelTradeNo, "update_time": now}) + } + return false, nil + } + note := remark + if st.Amount > 0 && st.Amount != order.Amount { + note = fmt.Sprintf("%s(注意:渠道金额 %d 分与本地 %d 分不一致,请核对)", remark, st.Amount, order.Amount) + } + updates := orm.Params{ + "status": models.PayStatusPaid, + "paid_at": now, + "notify_at": now, + "order_synced": 0, + "update_time": now, + } + if st.ChannelTradeNo != "" { + updates["channel_trade_no"] = st.ChannelTradeNo + } + n, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Filter("status__in", active...). + Update(updates) + if err != nil { + return false, err + } + if n == 0 { + return false, nil // 并发下已被其他通知处理 + } + order.Status = models.PayStatusPaid + order.PaidAt = &now + if st.ChannelTradeNo != "" { + order.ChannelTradeNo = st.ChannelTradeNo + } + LogStateChange(order.PayNo, models.PayStatusPaying, models.PayStatusPaid, operator, note) + + // 1. 同步业务订单状态(钩子;失败保留 order_synced=0 由补偿任务重试) + if orderSyncHook == nil { + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Update(orm.Params{"order_synced": 1, "update_time": time.Now()}) + } else if serr := orderSyncHook(ctx, order); serr != nil { + LogStateChange(order.PayNo, models.PayStatusPaid, models.PayStatusPaid, operator, "业务订单同步失败:"+serr.Error()) + } else { + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Update(orm.Params{"order_synced": 1, "update_time": time.Now()}) + } + + // 2. 生成推广佣金(平台自有资金支出,失败不影响收款状态) + if _, cerr := OnOrderPaid(ctx, order); cerr != nil { + beelog.Warn("支付单 %s 佣金生成失败: %v", order.PayNo, cerr) + } + return true, nil + + case StateClosed: + if order.Status == models.PayStatusPaid { + return false, nil + } + _, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Filter("status__in", active...). + Update(orm.Params{"status": models.PayStatusClosed, "closed_at": now, "update_time": now}) + if err != nil { + return false, err + } + order.Status = models.PayStatusClosed + LogStateChange(order.PayNo, models.PayStatusPending, models.PayStatusClosed, operator, remark) + return true, nil + + case StateFailed: + if order.Status == models.PayStatusPaid { + return false, nil + } + _, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Filter("status__in", active...). + Update(orm.Params{"status": models.PayStatusFailed, "update_time": now}) + if err != nil { + return false, err + } + order.Status = models.PayStatusFailed + LogStateChange(order.PayNo, models.PayStatusPending, models.PayStatusFailed, operator, remark) + return true, nil + + case StatePending: + if order.Status == models.PayStatusCreated { + _, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Update(orm.Params{"status": models.PayStatusPending, "update_time": now}) + if err != nil { + return false, err + } + order.Status = models.PayStatusPending + LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusPending, operator, remark) + return true, nil + } + return false, nil + + default: + return false, nil + } +} + +// RefundInput 手动原路退回入参(兜底能力) +type RefundInput struct { + PayNo string + Amount int64 // 分;<=0 表示全额退回 + Reason string // 必填 + OperatorID string + OperatorName string +} + +// ManualRefund 手动原路退回(不做业务层退款流程) +func ManualRefund(ctx context.Context, in RefundInput) (*models.PlatformPaymentRefund, error) { + if strings.TrimSpace(in.Reason) == "" { + return nil, errors.New("退回原因不能为空") + } + order, err := GetOrderByPayNo(in.PayNo) + if err != nil { + return nil, fmt.Errorf("支付单不存在") + } + if order.Status != models.PayStatusPaid { + return nil, fmt.Errorf("仅支付成功的支付单支持原路退回(当前状态 %s)", order.Status) + } + remain := order.Amount - order.RefundAmount + amount := in.Amount + if amount <= 0 { + amount = remain + } + if amount > remain { + return nil, fmt.Errorf("退回金额 %d 分超过可退余额 %d 分", amount, remain) + } + + refund := &models.PlatformPaymentRefund{ + RefundNo: NextRefundNo(), + PayNo: order.PayNo, + OutTradeNo: order.OutTradeNo, + Channel: order.Channel, + Amount: amount, + Reason: in.Reason, + Status: models.RefundStatusProcessing, + OperatorID: in.OperatorID, + OperatorName: in.OperatorName, + } + if _, err := models.Orm.Insert(refund); err != nil { + return nil, fmt.Errorf("创建退回单失败: %w", err) + } + + adapter, aerr := GetChannelAdapter(order.Channel) + cfg, cerr := LoadChannelConfig(order.Channel) + if aerr != nil || cerr != nil { + failRefund(refund, "渠道适配器/配置不可用") + return refund, fmt.Errorf("渠道适配器/配置不可用") + } + + channelRefundNo, rerr := adapter.Refund(ctx, order, refund.RefundNo, amount, in.Reason, cfg) + now := time.Now() + if rerr != nil { + failRefund(refund, rerr.Error()) + LogStateChange(order.PayNo, order.Status, order.Status, "平台管理员", "手动原路退回失败:"+rerr.Error()) + return refund, rerr + } + + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentRefund)). + Filter("id", refund.ID). + Update(orm.Params{"status": models.RefundStatusSuccess, "channel_refund_no": channelRefundNo, "finished_at": now, "update_time": now}) + refund.Status = models.RefundStatusSuccess + refund.ChannelRefundNo = channelRefundNo + refund.FinishedAt = &now + + newRefunded := order.RefundAmount + amount + toStatus := models.PayStatusRefunding + if newRefunded >= order.Amount { + toStatus = models.PayStatusRefunded + } + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", order.ID). + Update(orm.Params{"refund_amount": newRefunded, "status": toStatus, "update_time": now}) + order.RefundAmount = newRefunded + order.Status = toStatus + LogStateChange(order.PayNo, models.PayStatusPaid, toStatus, "平台管理员", fmt.Sprintf("手动原路退回 ¥%s:%s", FenToYuan(amount), in.Reason)) + return refund, nil +} + +func failRefund(refund *models.PlatformPaymentRefund, msg string) { + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentRefund)). + Filter("id", refund.ID). + Update(orm.Params{"status": models.RefundStatusFailed, "fail_reason": truncateStr(msg, 480), "update_time": time.Now()}) + refund.Status = models.RefundStatusFailed + refund.FailReason = msg +} + +// CloseExpiredOrders 关闭超时未支付订单(补偿任务),返回关闭数量 +func CloseExpiredOrders(ctx context.Context) (int, error) { + var orders []models.PlatformPaymentOrder + _, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("delete_time__isnull", true). + Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying). + Filter("expire_at__lt", time.Now()). + All(&orders) + if err != nil { + return 0, err + } + closed := 0 + now := time.Now() + for i := range orders { + _, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", orders[i].ID). + Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying). + Update(orm.Params{"status": models.PayStatusClosed, "closed_at": now, "update_time": now}) + if err != nil { + continue + } + LogStateChange(orders[i].PayNo, orders[i].Status, models.PayStatusClosed, "定时补偿", "支付超时自动关闭") + closed++ + } + return closed, nil +} + +// SyncPendingOrders 重试同步「已支付但业务订单未更新」的支付单(补偿任务),返回处理数量 +func SyncPendingOrders(ctx context.Context) (int, error) { + if orderSyncHook == nil { + return 0, nil + } + var orders []models.PlatformPaymentOrder + _, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("delete_time__isnull", true). + Filter("status", models.PayStatusPaid). + Filter("order_synced", 0). + All(&orders) + if err != nil { + return 0, err + } + done := 0 + for i := range orders { + if serr := orderSyncHook(ctx, &orders[i]); serr != nil { + beelog.Warn("支付单 %s 业务订单同步重试失败: %v", orders[i].PayNo, serr) + continue + } + _, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)). + Filter("id", orders[i].ID). + Update(orm.Params{"order_synced": 1, "update_time": time.Now()}) + LogStateChange(orders[i].PayNo, models.PayStatusPaid, models.PayStatusPaid, "定时补偿", "业务订单同步成功(重试)") + done++ + } + return done, nil +} + +// StartPaymentScheduler 支付补偿任务:关闭超时订单 + 重试业务订单同步(5 分钟一轮) +func StartPaymentScheduler(stop <-chan struct{}) { + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + ctx := context.Background() + if n, err := CloseExpiredOrders(ctx); err != nil { + beelog.Warn("支付超时关单失败: %v", err) + } else if n > 0 { + beelog.Info("支付超时关单 %d 笔", n) + } + if n, err := SyncPendingOrders(ctx); err != nil { + beelog.Warn("业务订单同步重试失败: %v", err) + } else if n > 0 { + beelog.Info("业务订单同步重试完成 %d 笔", n) + } + } + } + }() +} diff --git a/platform/src/api/payment.js b/platform/src/api/payment.js new file mode 100644 index 0000000..adddc48 --- /dev/null +++ b/platform/src/api/payment.js @@ -0,0 +1,331 @@ +import request from "@/utils/request"; + +/************************************************* + *************** 平台端支付模块接口 *************** + * 业务:平台向租户收取平台使用费,资金直接进平台对公账户; + * 不涉及退款流程(仅保留后台手动原路退回兜底); + * 推广佣金为平台自有资金支出,与收款解耦;不跨境。 + *************************************************/ + +/** + * 支付模块接口前缀。 + * 需求约定里接口写作 /api/payment/*、/api/commission/*, + * 但本项目平台端接口统一走 /platform 前缀(vite.config.js 的 dev 代理也只转发 /platform、/backend)。 + * 若后端最终把支付路由挂在 /api 下,只需要改下面这两个常量。 + */ +const PAY = "/platform/payment"; +const COMMISSION = "/platform/commission"; + +/* ================= 支付单 ================= */ + +/** + * 创建支付单 + * TODO: 后端实现 POST /api/payment/create + * 约定入参:{ out_trade_no, tenant_id, order_type, amount(分), channel, subject, return_url, client_ip } + * 约定返回:{ pay_no, channel, pay_params } pay_params 交给前端拉起渠道支付 + * @param {Object} data 下单参数 + * @returns {Promise} + */ +export function createPayment(data) { + return request({ + url: `${PAY}/create`, + method: "post", + data, + }); +} + +/** + * 查询支付单(本地库) + * TODO: 后端实现 GET /api/payment/query/:payNo + * @param {string} payNo 支付单号 + * @param {Object} [params] { sync: true } 时同步渠道最新状态后再返回 + * @returns {Promise} + */ +export function queryPayment(payNo, params = {}) { + return request({ + url: `${PAY}/query/${payNo}`, + method: "get", + params, + }); +} + +/** + * 手动原路退回(兜底,不做业务层退款流程) + * TODO: 后端实现 POST /api/payment/refund + * @param {Object} data { pay_no, amount(分), reason } + * @returns {Promise} + */ +export function refundPayment(data) { + return request({ + url: `${PAY}/refund`, + method: "post", + data, + }); +} + +/** + * 支付单列表 + * TODO: 后端实现 GET /api/payment/orders + * @param {Object} params { page, pageSize, tenant_id, channel, status, start_time, end_time } + * @returns {Promise} + */ +export function getOrders(params) { + return request({ + url: `${PAY}/orders`, + method: "get", + params, + }); +} + +/** + * 支付单详情(含状态流转记录) + * TODO: 后端实现 GET /api/payment/orders/:payNo + * @param {string} payNo 支付单号 + * @returns {Promise} + */ +export function getOrderDetail(payNo) { + return request({ + url: `${PAY}/orders/${payNo}`, + method: "get", + }); +} + +/* ================= 渠道配置 ================= */ + +/** + * 渠道配置列表(首页概览用) + * TODO: 后端实现 GET /api/payment/channels + * @returns {Promise} + */ +export function getChannelList() { + return request({ + url: `${PAY}/channels`, + method: "get", + }); +} + +/** + * 获取单个渠道配置 + * TODO: 后端实现 GET /api/payment/channels/:channel + * 注意:敏感字段(密钥/私钥/Secret)后端只返回掩码,前端提交时掩码值应原样回传表示“不修改” + * @param {string} channel 渠道标识 wechat / alipay / unionpay / cloudpay / paypal + * @returns {Promise} + */ +export function getChannelConfig(channel) { + return request({ + url: `${PAY}/channels/${channel}`, + method: "get", + }); +} + +/** + * 保存渠道配置 + * TODO: 后端实现 POST /api/payment/channels/:channel + * @param {string} channel 渠道标识 + * @param {Object} data 渠道参数 + * @returns {Promise} + */ +export function saveChannelConfig(channel, data) { + return request({ + url: `${PAY}/channels/${channel}`, + method: "post", + data, + }); +} + +/** + * 测试渠道连通性(校验参数 + 渠道鉴权) + * TODO: 后端实现 POST /api/payment/channels/:channel/test + * @param {string} channel 渠道标识 + * @param {Object} [data] 未保存的参数可一并提交 + * @returns {Promise} + */ +export function testChannelConnection(channel, data) { + return request({ + url: `${PAY}/channels/${channel}/test`, + method: "post", + data, + }); +} + +/** + * 启用 / 停用渠道 + * TODO: 后端实现 POST /api/payment/channels/:channel/status + * @param {string} channel 渠道标识 + * @param {boolean} enabled 是否启用 + * @returns {Promise} + */ +export function toggleChannel(channel, enabled) { + return request({ + url: `${PAY}/channels/${channel}/status`, + method: "post", + data: { enabled }, + }); +} + +/** + * 上传渠道证书(微信 apiclient_cert.pem / apiclient_key.pem、银联证书等) + * TODO: 后端实现 POST /api/payment/channels/:channel/certificate + * @param {string} channel 渠道标识 + * @param {File} file 证书文件 + * @param {string} [certType] 证书类型 cert / key + * @returns {Promise} + */ +export function uploadChannelCert(channel, file, certType = "cert") { + const formData = new FormData(); + formData.append("file", file); + formData.append("cert_type", certType); + return request({ + url: `${PAY}/channels/${channel}/certificate`, + method: "post", + data: formData, + timeout: 0, + }); +} + +/* ================= 推广佣金 ================= */ + +/** + * 佣金台账列表 + * TODO: 后端实现 GET /api/commission/list + * @param {Object} params { page, pageSize, promoter, status, start_time, end_time } + * @returns {Promise} + */ +export function getCommissionList(params) { + return request({ + url: `${COMMISSION}/list`, + method: "get", + params, + }); +} + +/** + * 佣金统计(应付总额 / 已付总额 / 待处理笔数) + * TODO: 后端实现 GET /api/commission/stats + * @param {Object} [params] 统计维度(时间范围等) + * @returns {Promise} + */ +export function getCommissionStats(params) { + return request({ + url: `${COMMISSION}/stats`, + method: "get", + params, + }); +} + +/** + * 标记佣金已发放 + * TODO: 后端实现 POST /api/commission/:id/pay + * @param {number|string} id 佣金单 id + * @param {Object} [data] { pay_method, remark } + * @returns {Promise} + */ +export function markCommissionPaid(id, data = {}) { + return request({ + url: `${COMMISSION}/markPaid/${id}`, + method: "post", + data, + }); +} + +/** + * 佣金规则列表 + * TODO: 后端实现 GET /api/commission/rules + * @param {Object} [params] { page, pageSize, keyword } + * @returns {Promise} + */ +export function getCommissionRules(params) { + return request({ + url: `${COMMISSION}/rules`, + method: "get", + params, + }); +} + +/** + * 新增佣金规则 + * TODO: 后端实现 POST /api/commission/rules + * @param {Object} data 规则数据 + * @returns {Promise} + */ +export function saveCommissionRule(data) { + return request({ + url: `${COMMISSION}/rules`, + method: "post", + data, + }); +} + +/** + * 更新佣金规则 + * TODO: 后端实现 POST /api/commission/rules/:id + * @param {number|string} id 规则 id + * @param {Object} data 规则数据 + * @returns {Promise} + */ +export function updateCommissionRule(id, data) { + return request({ + url: `${COMMISSION}/rules/${id}`, + method: "post", + data, + }); +} + +/** + * 删除佣金规则 + * TODO: 后端实现 DELETE /api/commission/rules/:id + * @param {number|string} id 规则 id + * @returns {Promise} + */ +export function deleteCommissionRule(id) { + return request({ + url: `${COMMISSION}/rules/${id}`, + method: "delete", + }); +} + +/** + * 启用 / 停用佣金规则 + * TODO: 后端实现 POST /api/commission/rules/:id/status + * @param {number|string} id 规则 id + * @param {number} status 1 启用 / 0 停用 + * @returns {Promise} + */ +export function toggleCommissionRule(id, status) { + return request({ + url: `${COMMISSION}/rules/${id}/status`, + method: "post", + data: { status }, + }); +} + +/* ================= 对账 ================= */ + +/** + * 对账差异列表 + * TODO: 后端实现 GET /api/payment/reconcile + * @param {Object} params { page, pageSize, channel, reconcile_date, diff_type, handle_status } + * @returns {Promise} + */ +export function getReconcileList(params) { + return request({ + url: `${PAY}/reconcile`, + method: "get", + params, + }); +} + +/** + * 标记对账差异已处理 + * TODO: 后端实现 POST /api/payment/reconcile/:id/handle + * @param {number|string} id 差异记录 id + * @param {Object} [data] { handle_type, remark } + * @returns {Promise} + */ +export function markReconcileHandled(id, data = {}) { + return request({ + url: `${PAY}/reconcile/handle/${id}`, + method: "post", + data, + }); +} diff --git a/platform/src/views/system/payment/channels/alipay.vue b/platform/src/views/system/payment/channels/alipay.vue new file mode 100644 index 0000000..b11560e --- /dev/null +++ b/platform/src/views/system/payment/channels/alipay.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/platform/src/views/system/payment/channels/cloudpay.vue b/platform/src/views/system/payment/channels/cloudpay.vue new file mode 100644 index 0000000..a967146 --- /dev/null +++ b/platform/src/views/system/payment/channels/cloudpay.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/platform/src/views/system/payment/channels/paypal.vue b/platform/src/views/system/payment/channels/paypal.vue new file mode 100644 index 0000000..e1e1bb3 --- /dev/null +++ b/platform/src/views/system/payment/channels/paypal.vue @@ -0,0 +1,334 @@ + + + + + diff --git a/platform/src/views/system/payment/channels/unionpay.vue b/platform/src/views/system/payment/channels/unionpay.vue new file mode 100644 index 0000000..f72ad96 --- /dev/null +++ b/platform/src/views/system/payment/channels/unionpay.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/platform/src/views/system/payment/channels/wechat.vue b/platform/src/views/system/payment/channels/wechat.vue new file mode 100644 index 0000000..8a8a175 --- /dev/null +++ b/platform/src/views/system/payment/channels/wechat.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/platform/src/views/system/payment/commission/index.vue b/platform/src/views/system/payment/commission/index.vue new file mode 100644 index 0000000..c45d2ad --- /dev/null +++ b/platform/src/views/system/payment/commission/index.vue @@ -0,0 +1,421 @@ + + + + + diff --git a/platform/src/views/system/payment/commission/rules.vue b/platform/src/views/system/payment/commission/rules.vue new file mode 100644 index 0000000..17d4990 --- /dev/null +++ b/platform/src/views/system/payment/commission/rules.vue @@ -0,0 +1,633 @@ + + + + + diff --git a/platform/src/views/system/payment/components/ChannelCard.vue b/platform/src/views/system/payment/components/ChannelCard.vue new file mode 100644 index 0000000..1070fa4 --- /dev/null +++ b/platform/src/views/system/payment/components/ChannelCard.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/platform/src/views/system/payment/components/StatusTag.vue b/platform/src/views/system/payment/components/StatusTag.vue new file mode 100644 index 0000000..efa64ab --- /dev/null +++ b/platform/src/views/system/payment/components/StatusTag.vue @@ -0,0 +1,113 @@ + + + diff --git a/platform/src/views/system/payment/guide/config.vue b/platform/src/views/system/payment/guide/config.vue new file mode 100644 index 0000000..b3293a9 --- /dev/null +++ b/platform/src/views/system/payment/guide/config.vue @@ -0,0 +1,370 @@ + + + + + diff --git a/platform/src/views/system/payment/guide/faq.vue b/platform/src/views/system/payment/guide/faq.vue new file mode 100644 index 0000000..8445d2f --- /dev/null +++ b/platform/src/views/system/payment/guide/faq.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/platform/src/views/system/payment/guide/index.vue b/platform/src/views/system/payment/guide/index.vue new file mode 100644 index 0000000..965cf6a --- /dev/null +++ b/platform/src/views/system/payment/guide/index.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/platform/src/views/system/payment/guide/usage.vue b/platform/src/views/system/payment/guide/usage.vue new file mode 100644 index 0000000..c3d9d64 --- /dev/null +++ b/platform/src/views/system/payment/guide/usage.vue @@ -0,0 +1,495 @@ + + + + + diff --git a/platform/src/views/system/payment/index.vue b/platform/src/views/system/payment/index.vue new file mode 100644 index 0000000..0107573 --- /dev/null +++ b/platform/src/views/system/payment/index.vue @@ -0,0 +1,549 @@ + + + + + diff --git a/platform/src/views/system/payment/orders/detail.vue b/platform/src/views/system/payment/orders/detail.vue new file mode 100644 index 0000000..80da306 --- /dev/null +++ b/platform/src/views/system/payment/orders/detail.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/platform/src/views/system/payment/orders/index.vue b/platform/src/views/system/payment/orders/index.vue new file mode 100644 index 0000000..a25f777 --- /dev/null +++ b/platform/src/views/system/payment/orders/index.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/platform/src/views/system/payment/reconcile/index.vue b/platform/src/views/system/payment/reconcile/index.vue new file mode 100644 index 0000000..6a29bc7 --- /dev/null +++ b/platform/src/views/system/payment/reconcile/index.vue @@ -0,0 +1,461 @@ + + + + +