569 lines
17 KiB
Go
569 lines
17 KiB
Go
package controllers
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"server/models"
|
||
"server/pkg/jwtutil"
|
||
"server/services/payment"
|
||
|
||
beego "github.com/beego/beego/v2/server/web"
|
||
)
|
||
|
||
// BackendProductPurchaseController 租户端「产品购买」接口(只读产品 + 下单 + 订单状态)。
|
||
// 注意:与 CMS 产品管理的 BackendProductController 区分开。
|
||
// 租户身份来自 JWT:UserType=backend,TenantId 即租户ID。
|
||
type BackendProductPurchaseController struct {
|
||
beego.Controller
|
||
}
|
||
|
||
func (c *BackendProductPurchaseController) claims() (*jwtutil.Claims, error) {
|
||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||
if auth == "" {
|
||
return nil, errors.New("未登录")
|
||
}
|
||
parts := strings.SplitN(auth, " ", 2)
|
||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||
return nil, errors.New("认证信息格式错误")
|
||
}
|
||
claims, err := jwtutil.ParseToken(parts[1])
|
||
if err != nil {
|
||
return nil, errors.New("无效的token")
|
||
}
|
||
if claims.UserType != "backend" {
|
||
return nil, errors.New("无权访问")
|
||
}
|
||
return claims, nil
|
||
}
|
||
|
||
func (c *BackendProductPurchaseController) 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 *BackendProductPurchaseController) jsonOK(data interface{}, msg string) {
|
||
if msg == "" {
|
||
msg = "success"
|
||
}
|
||
resp := map[string]interface{}{"code": 200, "msg": msg}
|
||
if data != nil {
|
||
resp["data"] = data
|
||
}
|
||
c.Data["json"] = resp
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
func (c *BackendProductPurchaseController) tenantName(tid uint64) string {
|
||
if tid == 0 {
|
||
return ""
|
||
}
|
||
tenant := &models.SystemTenant{}
|
||
if err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||
Filter("id", tid).
|
||
Filter("delete_time__isnull", true).
|
||
One(tenant); err != nil {
|
||
return ""
|
||
}
|
||
return tenant.TenantName
|
||
}
|
||
|
||
// GetProductList GET /backend/product/list
|
||
// 上架产品列表(含功能节点与启用档位),供购买页/产品列表展示
|
||
func (c *BackendProductPurchaseController) GetProductList() {
|
||
if _, err := c.claims(); err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
var rows []models.PlatformProduct
|
||
if _, err := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||
Filter("delete_time__isnull", true).
|
||
Filter("status", 1).
|
||
OrderBy("sort", "id").
|
||
All(&rows); err != nil {
|
||
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||
return
|
||
}
|
||
ids := make([]uint64, 0, len(rows))
|
||
for _, r := range rows {
|
||
ids = append(ids, r.ID)
|
||
}
|
||
featureMap := loadProductFeatures(ids)
|
||
|
||
list := make([]map[string]interface{}, 0, len(rows))
|
||
for _, r := range rows {
|
||
list = append(list, productToMap(r, featureMap[r.ID]))
|
||
}
|
||
c.jsonOK(map[string]interface{}{"list": list, "total": len(list)}, "获取成功")
|
||
}
|
||
|
||
// GetProductDetailByCode GET /backend/product/detail/:code
|
||
// 按产品编码取详情(租户端 home 卡片按模块 code 跳转购买页时使用)
|
||
func (c *BackendProductPurchaseController) GetProductDetailByCode() {
|
||
if _, err := c.claims(); err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
code := strings.TrimSpace(c.Ctx.Input.Param(":code"))
|
||
if code == "" {
|
||
c.jsonErr(400, 400, "产品编码不能为空")
|
||
return
|
||
}
|
||
var row models.PlatformProduct
|
||
if err := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||
Filter("code", code).
|
||
Filter("delete_time__isnull", true).
|
||
Filter("status", 1).
|
||
One(&row); err != nil {
|
||
c.jsonErr(404, 404, "产品不存在或未上架")
|
||
return
|
||
}
|
||
c.jsonOK(productToMap(row, loadProductFeatures([]uint64{row.ID})[row.ID]), "获取成功")
|
||
}
|
||
|
||
type createProductOrderPayload struct {
|
||
ProductCode string `json:"product_code"`
|
||
Channel string `json:"channel"`
|
||
PayType string `json:"pay_type"`
|
||
ReturnURL string `json:"return_url"`
|
||
}
|
||
|
||
// CreateOrder POST /backend/product/order/create
|
||
// 创建购买订单;channel 非空时同时创建支付单并返回拉起支付参数。
|
||
func (c *BackendProductPurchaseController) CreateOrder() {
|
||
claims, err := c.claims()
|
||
if err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
tid := uint64(claims.TenantId)
|
||
if tid == 0 {
|
||
c.jsonErr(401, 401, "未获取到租户信息")
|
||
return
|
||
}
|
||
|
||
body, _ := io.ReadAll(c.Ctx.Request.Body)
|
||
var p createProductOrderPayload
|
||
if err := json.Unmarshal(body, &p); err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
p.ProductCode = strings.TrimSpace(p.ProductCode)
|
||
p.Channel = strings.TrimSpace(p.Channel)
|
||
if p.ProductCode == "" {
|
||
c.jsonErr(400, 400, "产品不能为空")
|
||
return
|
||
}
|
||
|
||
var product models.PlatformProduct
|
||
if err := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||
Filter("code", p.ProductCode).
|
||
Filter("delete_time__isnull", true).
|
||
Filter("status", 1).
|
||
One(&product); err != nil {
|
||
c.jsonErr(404, 404, "产品不存在或未上架")
|
||
return
|
||
}
|
||
if product.Price <= 0 {
|
||
c.jsonErr(400, 400, "该产品未配置价格,请联系平台")
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
order := &models.PlatformProductOrder{
|
||
OrderNo: "PO" + strconv.FormatInt(now.UnixNano(), 10),
|
||
Tid: tid,
|
||
TenantName: c.tenantName(tid),
|
||
ProductID: product.ID,
|
||
ProductCode: product.Code,
|
||
ProductName: product.Name,
|
||
Amount: product.Price,
|
||
Status: models.ProductOrderStatusPending,
|
||
UpdateTime: &now,
|
||
}
|
||
|
||
// 未选择支付渠道:仅落单,等待平台确认(线下转账/试用审批场景)
|
||
if p.Channel == "" {
|
||
if _, err := models.Orm.Insert(order); err != nil {
|
||
c.jsonErr(500, 500, "下单失败:"+err.Error())
|
||
return
|
||
}
|
||
c.jsonOK(map[string]interface{}{
|
||
"order_no": order.OrderNo,
|
||
"pay_no": "",
|
||
"amount": order.Amount,
|
||
"offline": true,
|
||
}, "下单成功,请等待平台开通")
|
||
return
|
||
}
|
||
|
||
amountFen := int64(order.Amount*100 + 0.5)
|
||
subject := product.Name
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
payOrder, params, err := payment.CreatePayment(ctx, payment.CreateInput{
|
||
OutTradeNo: order.OrderNo,
|
||
OrderType: models.CommissionOrderTypeModuleShop,
|
||
TenantID: strconv.FormatUint(tid, 10),
|
||
TenantName: order.TenantName,
|
||
Amount: amountFen,
|
||
Channel: p.Channel,
|
||
Subject: subject,
|
||
ReturnURL: strings.TrimSpace(p.ReturnURL),
|
||
ClientIP: c.Ctx.Input.IP(),
|
||
PayType: p.PayType,
|
||
})
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "创建支付单失败:"+err.Error())
|
||
return
|
||
}
|
||
|
||
order.PayNo = payOrder.PayNo
|
||
order.Channel = p.Channel
|
||
if _, err := models.Orm.Insert(order); err != nil {
|
||
c.jsonErr(500, 500, "下单失败:"+err.Error())
|
||
return
|
||
}
|
||
c.jsonOK(map[string]interface{}{
|
||
"order_no": order.OrderNo,
|
||
"pay_no": payOrder.PayNo,
|
||
"amount": order.Amount,
|
||
"channel": p.Channel,
|
||
"pay_params": params,
|
||
"offline": false,
|
||
}, "下单成功")
|
||
}
|
||
|
||
// GetOrderStatus GET /backend/product/order/status?order_no=xxx
|
||
// 查询订单状态;在线支付时同步渠道状态,已支付则自动开通套餐。
|
||
func (c *BackendProductPurchaseController) GetOrderStatus() {
|
||
claims, err := c.claims()
|
||
if err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
orderNo := strings.TrimSpace(c.GetString("order_no"))
|
||
if orderNo == "" {
|
||
c.jsonErr(400, 400, "订单号不能为空")
|
||
return
|
||
}
|
||
var order models.PlatformProductOrder
|
||
if err := models.Orm.QueryTable(new(models.PlatformProductOrder)).
|
||
Filter("order_no", orderNo).
|
||
Filter("tid", uint64(claims.TenantId)).
|
||
Filter("delete_time__isnull", true).
|
||
One(&order); err != nil {
|
||
// 支付宝同步回跳地址携带的 out_trade_no 是支付单号(pay_no),此处兜底按支付单号查询,
|
||
// 保证「支付完成自动跳回」的场景也能直接触发状态同步与自动开通。
|
||
if err2 := models.Orm.QueryTable(new(models.PlatformProductOrder)).
|
||
Filter("pay_no", orderNo).
|
||
Filter("tid", uint64(claims.TenantId)).
|
||
Filter("delete_time__isnull", true).
|
||
OrderBy("-id").
|
||
One(&order); err2 != nil {
|
||
c.jsonErr(404, 404, "订单不存在")
|
||
return
|
||
}
|
||
}
|
||
|
||
// 在线支付:同步渠道状态,支付成功后自动开通
|
||
if order.Status == models.ProductOrderStatusPending && order.PayNo != "" {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||
defer cancel()
|
||
if payOrder, qerr := payment.QueryPayment(ctx, order.PayNo, true); qerr == nil && payOrder.Status == models.PayStatusPaid {
|
||
now := time.Now()
|
||
order.Status = models.ProductOrderStatusPaid
|
||
order.UpdateTime = &now
|
||
_, _ = models.Orm.Update(&order, "Status", "UpdateTime")
|
||
}
|
||
}
|
||
if order.Status == models.ProductOrderStatusPaid {
|
||
if err := activateProductOrder(&order, "支付成功自动开通"); err == nil {
|
||
_ = models.Orm.Read(&order)
|
||
}
|
||
}
|
||
|
||
c.jsonOK(map[string]interface{}{
|
||
"order_no": order.OrderNo,
|
||
"status": order.Status,
|
||
"amount": order.Amount,
|
||
"product_name": order.ProductName,
|
||
"pay_no": order.PayNo,
|
||
"activate_time": order.ActivateTime,
|
||
}, "获取成功")
|
||
}
|
||
|
||
// GetMyOrders GET /backend/product/order/list 本租户购买记录
|
||
func (c *BackendProductPurchaseController) GetMyOrders() {
|
||
claims, err := c.claims()
|
||
if err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
var rows []models.PlatformProductOrder
|
||
if _, err := models.Orm.QueryTable(new(models.PlatformProductOrder)).
|
||
Filter("tid", uint64(claims.TenantId)).
|
||
Filter("delete_time__isnull", true).
|
||
OrderBy("-id").
|
||
Limit(50).
|
||
All(&rows); err != nil {
|
||
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||
return
|
||
}
|
||
c.jsonOK(map[string]interface{}{"list": rows, "total": len(rows)}, "获取成功")
|
||
}
|
||
|
||
// ============================ 套餐购买 ============================
|
||
|
||
// GetPackageList GET /backend/package/list
|
||
// 租户端可购买套餐列表(仅启用的套餐)
|
||
func (c *BackendProductPurchaseController) GetPackageList() {
|
||
claims, err := c.claims()
|
||
if err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
var rows []models.SystemTenantPackage
|
||
if _, err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||
Filter("delete_time__isnull", true).
|
||
Filter("status", 1).
|
||
OrderBy("sort", "id").
|
||
All(&rows); err != nil {
|
||
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||
return
|
||
}
|
||
|
||
ids := make([]uint64, 0, len(rows))
|
||
for _, r := range rows {
|
||
ids = append(ids, r.ID)
|
||
}
|
||
moduleMap := loadPackageModules(ids)
|
||
|
||
// 该租户已购买过(已支付/已开通)的套餐:用于前端对「仅可购买一次」的体验套餐禁用购买按钮
|
||
purchased := map[uint64]bool{}
|
||
if tid := uint64(claims.TenantId); tid > 0 && len(ids) > 0 {
|
||
var orders []models.PlatformProductOrder
|
||
if _, err := models.Orm.QueryTable(new(models.PlatformProductOrder)).
|
||
Filter("tid", tid).
|
||
Filter("package_id__in", ids).
|
||
Filter("status__in", models.ProductOrderStatusPaid, models.ProductOrderStatusActivated).
|
||
Filter("delete_time__isnull", true).
|
||
All(&orders, "ID", "PackageID"); err == nil {
|
||
for _, o := range orders {
|
||
purchased[o.PackageID] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
list := make([]map[string]interface{}, 0, len(rows))
|
||
for _, r := range rows {
|
||
modules := moduleMap[r.ID]
|
||
if modules == nil {
|
||
modules = []tenantPackageModuleItem{}
|
||
}
|
||
list = append(list, map[string]interface{}{
|
||
"id": r.ID,
|
||
"name": r.Name,
|
||
"code": r.Code,
|
||
"description": r.Description,
|
||
"price": r.Price,
|
||
"duration_days": r.DurationDays,
|
||
"user_quota": r.UserQuota,
|
||
"extra_user_price": r.ExtraUserPrice,
|
||
"is_default": r.IsDefault,
|
||
"allow_repurchase": r.AllowRepurchase,
|
||
"purchased": purchased[r.ID],
|
||
"sort": r.Sort,
|
||
"status": r.Status,
|
||
"remark": r.Remark,
|
||
"modules": modules,
|
||
"module_codes": moduleCodes(modules),
|
||
"create_time": r.CreateTime,
|
||
"update_time": r.UpdateTime,
|
||
})
|
||
}
|
||
c.jsonOK(map[string]interface{}{"list": list, "total": len(list)}, "获取成功")
|
||
}
|
||
|
||
// GetPackageDetailByCode GET /backend/package/detail/:code
|
||
// 按套餐编码获取详情(租户端购买页使用)
|
||
func (c *BackendProductPurchaseController) GetPackageDetailByCode() {
|
||
if _, err := c.claims(); err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
code := strings.TrimSpace(c.Ctx.Input.Param(":code"))
|
||
if code == "" {
|
||
c.jsonErr(400, 400, "套餐编码不能为空")
|
||
return
|
||
}
|
||
var row models.SystemTenantPackage
|
||
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||
Filter("code", code).
|
||
Filter("delete_time__isnull", true).
|
||
Filter("status", 1).
|
||
One(&row); err != nil {
|
||
c.jsonErr(404, 404, "套餐不存在或已下架")
|
||
return
|
||
}
|
||
modules := loadPackageModules([]uint64{row.ID})[row.ID]
|
||
if modules == nil {
|
||
modules = []tenantPackageModuleItem{}
|
||
}
|
||
c.jsonOK(map[string]interface{}{
|
||
"id": row.ID,
|
||
"name": row.Name,
|
||
"code": row.Code,
|
||
"description": row.Description,
|
||
"price": row.Price,
|
||
"duration_days": row.DurationDays,
|
||
"user_quota": row.UserQuota,
|
||
"extra_user_price": row.ExtraUserPrice,
|
||
"is_default": row.IsDefault,
|
||
"sort": row.Sort,
|
||
"status": row.Status,
|
||
"remark": row.Remark,
|
||
"modules": modules,
|
||
"module_codes": moduleCodes(modules),
|
||
"create_time": row.CreateTime,
|
||
"update_time": row.UpdateTime,
|
||
}, "获取成功")
|
||
}
|
||
|
||
type createPackageOrderPayload struct {
|
||
PackageCode string `json:"package_code"`
|
||
Channel string `json:"channel"`
|
||
PayType string `json:"pay_type"`
|
||
ReturnURL string `json:"return_url"`
|
||
}
|
||
|
||
// CreatePackageOrder POST /backend/package/order/create
|
||
// 创建套餐购买订单;channel 非空时同时创建支付单并返回拉起支付参数。
|
||
// 订单绑定 PackageID,支付成功后自动为租户开通对应套餐。
|
||
func (c *BackendProductPurchaseController) CreatePackageOrder() {
|
||
claims, err := c.claims()
|
||
if err != nil {
|
||
c.jsonErr(401, 401, err.Error())
|
||
return
|
||
}
|
||
tid := uint64(claims.TenantId)
|
||
if tid == 0 {
|
||
c.jsonErr(401, 401, "未获取到租户信息")
|
||
return
|
||
}
|
||
|
||
body, _ := io.ReadAll(c.Ctx.Request.Body)
|
||
var p createPackageOrderPayload
|
||
if err := json.Unmarshal(body, &p); err != nil {
|
||
c.jsonErr(400, 400, "参数错误")
|
||
return
|
||
}
|
||
p.PackageCode = strings.TrimSpace(p.PackageCode)
|
||
p.Channel = strings.TrimSpace(p.Channel)
|
||
if p.PackageCode == "" {
|
||
c.jsonErr(400, 400, "套餐不能为空")
|
||
return
|
||
}
|
||
|
||
var pkg models.SystemTenantPackage
|
||
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||
Filter("code", p.PackageCode).
|
||
Filter("delete_time__isnull", true).
|
||
Filter("status", 1).
|
||
One(&pkg); err != nil {
|
||
c.jsonErr(404, 404, "套餐不存在或已下架")
|
||
return
|
||
}
|
||
if pkg.Price <= 0 {
|
||
c.jsonErr(400, 400, "该套餐未配置价格,请联系平台")
|
||
return
|
||
}
|
||
// 体验套餐(allow_repurchase=0):每个租户仅可购买一次
|
||
if pkg.AllowRepurchase == 0 {
|
||
cnt, _ := models.Orm.QueryTable(new(models.PlatformProductOrder)).
|
||
Filter("tid", tid).
|
||
Filter("package_id", pkg.ID).
|
||
Filter("status__in", models.ProductOrderStatusPaid, models.ProductOrderStatusActivated).
|
||
Filter("delete_time__isnull", true).
|
||
Count()
|
||
if cnt > 0 {
|
||
c.jsonErr(400, 400, "该套餐为体验套餐,每个租户仅可购买一次")
|
||
return
|
||
}
|
||
}
|
||
|
||
now := time.Now()
|
||
order := &models.PlatformProductOrder{
|
||
OrderNo: "PO" + strconv.FormatInt(now.UnixNano(), 10),
|
||
Tid: tid,
|
||
TenantName: c.tenantName(tid),
|
||
ProductID: 0, // 套餐购买不关联产品
|
||
ProductCode: pkg.Code,
|
||
ProductName: pkg.Name,
|
||
Amount: pkg.Price,
|
||
PackageID: pkg.ID,
|
||
Status: models.ProductOrderStatusPending,
|
||
UpdateTime: &now,
|
||
}
|
||
|
||
// 未选择支付渠道:仅落单,等待平台确认(线下转账/试用审批场景)
|
||
if p.Channel == "" {
|
||
if _, err := models.Orm.Insert(order); err != nil {
|
||
c.jsonErr(500, 500, "下单失败:"+err.Error())
|
||
return
|
||
}
|
||
c.jsonOK(map[string]interface{}{
|
||
"order_no": order.OrderNo,
|
||
"pay_no": "",
|
||
"amount": order.Amount,
|
||
"offline": true,
|
||
}, "下单成功,请等待平台开通")
|
||
return
|
||
}
|
||
|
||
amountFen := int64(order.Amount*100 + 0.5)
|
||
subject := pkg.Name
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
payOrder, params, err := payment.CreatePayment(ctx, payment.CreateInput{
|
||
OutTradeNo: order.OrderNo,
|
||
OrderType: models.CommissionOrderTypeModuleShop,
|
||
TenantID: strconv.FormatUint(tid, 10),
|
||
TenantName: order.TenantName,
|
||
Amount: amountFen,
|
||
Channel: p.Channel,
|
||
Subject: subject,
|
||
ReturnURL: strings.TrimSpace(p.ReturnURL),
|
||
ClientIP: c.Ctx.Input.IP(),
|
||
PayType: p.PayType,
|
||
})
|
||
if err != nil {
|
||
c.jsonErr(400, 400, "创建支付单失败:"+err.Error())
|
||
return
|
||
}
|
||
|
||
order.PayNo = payOrder.PayNo
|
||
order.Channel = p.Channel
|
||
if _, err := models.Orm.Insert(order); err != nil {
|
||
c.jsonErr(500, 500, "下单失败:"+err.Error())
|
||
return
|
||
}
|
||
c.jsonOK(map[string]interface{}{
|
||
"order_no": order.OrderNo,
|
||
"pay_no": payOrder.PayNo,
|
||
"amount": order.Amount,
|
||
"channel": p.Channel,
|
||
"pay_params": params,
|
||
"offline": false,
|
||
}, "下单成功")
|
||
}
|
||
|