低空智控平台 后端go
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

436 lines
13 KiB

package service
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
const (
gbBytes = 1024 * 1024 * 1024 // 1 GB 字节数
defaultTrafficUnitPrice = 10.0 // 云媒体流量单价(元/GB)
)
// trafficDeductLua 原子扣减:key 不存在时用 MySQL 快照兜底初始化,再判断并扣减。
// 返回 {status, before, after}:status=1 成功,0 余额不足。
const trafficDeductLua = `
local key = KEYS[1]
local delta = tonumber(ARGV[1])
local fallback = tonumber(ARGV[2])
local before = 0
if redis.call('EXISTS', key) == 0 then
redis.call('SET', key, fallback)
before = fallback
else
before = tonumber(redis.call('GET', key))
end
if before < delta then
return {0, before, before}
end
local after = redis.call('DECRBY', key, delta)
return {1, before, after}
`
type BillingService struct{}
var DefaultBillingService = &BillingService{}
// CarrierAPI 运营商接口抽象(查询用量 / 充值)
type CarrierAPI interface {
QueryUsage(iccid string) (usedGb float64, carrierStatus string, err error)
Recharge(iccid string, amountGb int) (orderNo string, err error)
}
// MockCarrierAPI 运营商 Mock(仅供开发注入,不作为默认生产通道)
type MockCarrierAPI struct{}
func (m *MockCarrierAPI) QueryUsage(iccid string) (float64, string, error) {
return 0.0, "normal", nil
}
func (m *MockCarrierAPI) Recharge(iccid string, amountGb int) (string, error) {
return fmt.Sprintf("MOCK-%d", time.Now().UnixNano()), nil
}
// carrier 默认禁用,未接入真实运营商前不产生虚假用量或充值成功。
var carrier CarrierAPI
// mysqlBalance 读取用户 MySQL 流量快照
func (b *BillingService) mysqlBalance(userID int64) (int64, error) {
var u model.User
if err := common.DB.Select("traffic_balance").First(&u, userID).Error; err != nil {
return 0, err
}
return u.TrafficBalance, nil
}
// GetBalance 查询流量余额(Redis 权威,缺 key 时用 MySQL 快照兜底)
func (b *BillingService) GetBalance(userID int64) (*vo.TrafficBalanceVO, *common.BusiError) {
balance, err := b.currentBalance(userID)
if err != nil {
logger.ERROR("查询流量余额失败", err)
return nil, common.ErrInternal
}
return &vo.TrafficBalanceVO{
BalanceBytes: balance,
BalanceGb: float64(balance) / float64(gbBytes),
}, nil
}
// currentBalance 返回 Redis 权威余额;缺 key 时先用 MySQL 快照 SetNX 初始化
func (b *BillingService) currentBalance(userID int64) (int64, error) {
key := cache.TrafficKeyOf(userID)
bal, err := common.GetInt64(key)
if err == nil {
return bal, nil
}
fallback, err := b.mysqlBalance(userID)
if err != nil {
return 0, err
}
if _, err := common.SetNX(key, fallback); err != nil {
return 0, err
}
// SetNX 失败说明并发下已有值,重读一次
bal, err = common.GetInt64(key)
if err != nil {
return fallback, nil
}
return bal, nil
}
// Deduct 扣减流量(直播/回放/下载计费),原子 Lua + 账本落库
func (b *BillingService) Deduct(userID int64, bytes int64, sourceType string, sourceID int64) (bool, *common.BusiError) {
if bytes <= 0 {
return true, nil
}
fallback, err := b.mysqlBalance(userID)
if err != nil {
logger.ERROR("读取流量快照失败", err)
return false, common.ErrInternal
}
key := cache.TrafficKeyOf(userID)
res, err := common.GetLuaInt64s(trafficDeductLua, []string{key}, bytes, fallback)
if err != nil {
logger.ERROR("流量扣减 Lua 执行失败", err)
return false, common.ErrInternal
}
if len(res) < 3 {
return false, common.ErrInternal
}
status, before, after := res[0], res[1], res[2]
if status == 0 {
return false, common.ErrTrafficNotEnough
}
log := &model.TrafficUsageLog{
ID: mustID(),
UserID: userID,
SourceType: sourceType,
SourceID: sourceID,
BytesUsed: bytes,
BalanceBefore: before,
BalanceAfter: after,
CreatedAt: time.Now(),
}
if err := common.DB.Create(log).Error; err != nil {
logger.ERROR("流量账本落库失败", err)
return false, common.ErrInternal
}
return true, nil
}
// credit 充值入账:Redis 权威自增 + MySQL 快照同步
func (b *BillingService) credit(userID int64, bytes int64) error {
key := cache.TrafficKeyOf(userID)
if _, err := b.currentBalance(userID); err != nil {
return err
}
_, err := common.IncrBy(key, bytes)
return err
}
// GetUsagePage 流量消费流水分页
func (b *BillingService) GetUsagePage(userID int64, req *vo.UsagePageReq) (*common.PageResponse[model.TrafficUsageLog], *common.BusiError) {
db := common.DB.Model(&model.TrafficUsageLog{}).Where("user_id = ?", userID)
if req.SourceType != "" {
db = db.Where("source_type = ?", req.SourceType)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, common.ErrInternal
}
var list []model.TrafficUsageLog
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// GetOrderPage 流量订单分页
func (b *BillingService) GetOrderPage(userID int64, req *vo.OrderPageReq) (*common.PageResponse[model.TrafficOrder], *common.BusiError) {
db := common.DB.Model(&model.TrafficOrder{}).Where("user_id = ?", userID)
if req.PayStatus != "" {
db = db.Where("pay_status = ?", req.PayStatus)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, common.ErrInternal
}
var list []model.TrafficOrder
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// CreateOrder 创建流量充值订单(待支付)
func (b *BillingService) CreateOrder(userID int64, amountGb int) (*model.TrafficOrder, *common.BusiError) {
order := &model.TrafficOrder{
ID: mustID(),
UserID: userID,
AmountGb: amountGb,
UnitPrice: defaultTrafficUnitPrice,
TotalPrice: float64(amountGb) * defaultTrafficUnitPrice,
PayStatus: "unpaid",
CreatedAt: time.Now(),
}
if err := common.DB.Create(order).Error; err != nil {
logger.ERROR("创建流量订单失败", err)
return nil, common.ErrInternal
}
return order, nil
}
// PayOrder 订单支付入账(admin 确认支付)
func (b *BillingService) PayOrder(orderID int64) (*model.TrafficOrder, *common.BusiError) {
var order model.TrafficOrder
now := time.Now()
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return err
}
if order.PayStatus == "paid" {
return common.ErrOrderPaid
}
bytes := int64(order.AmountGb) * gbBytes
if err := tx.Model(&order).Updates(map[string]any{"pay_status": "paid", "paid_at": now}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", order.UserID).
UpdateColumn("traffic_balance", gorm.Expr("traffic_balance + ?", bytes)).Error
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrOrderNotFound
}
if busiErr, ok := err.(*common.BusiError); ok {
return nil, busiErr
}
logger.ERROR("订单支付失败", err)
return nil, common.ErrInternal
}
bytes := int64(order.AmountGb) * gbBytes
if err := b.credit(order.UserID, bytes); err != nil {
logger.ERROR("订单支付 Redis 入账失败", err)
return nil, common.ErrInternal
}
order.PayStatus = "paid"
order.PaidAt = &now
return &order, nil
}
// ListSimCards SIM 卡列表
func (b *BillingService) ListSimCards(userID int64, req *vo.SimCardPageReq) (*common.PageResponse[model.SimCard], *common.BusiError) {
db := common.DB.Model(&model.SimCard{}).Where("user_id = ?", userID)
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, common.ErrInternal
}
var list []model.SimCard
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// GetSimRechargeLogPage SIM 卡充值记录分页
func (b *BillingService) GetSimRechargeLogPage(userID int64, req *vo.SimRechargeLogPageReq) (*common.PageResponse[model.SimRechargeLog], *common.BusiError) {
db := common.DB.Model(&model.SimRechargeLog{}).Where("user_id = ?", userID)
if req.SimCardID > 0 {
db = db.Where("sim_card_id = ?", req.SimCardID)
}
if req.RechargeStatus != "" {
db = db.Where("recharge_status = ?", req.RechargeStatus)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, common.ErrInternal
}
var list []model.SimRechargeLog
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// RechargeSimCard 当前未接入支付确认流程,不向运营商提交充值订单。
func (b *BillingService) RechargeSimCard(userID, simCardID int64, amountGb int) (*model.SimRechargeLog, *common.BusiError) {
return nil, common.ErrCarrierUnavailable
}
// mustID 生成雪花 ID(失败返回 0,由 DB 约束兜底)
func mustID() int64 {
id, err := tool.NextID()
if err != nil {
logger.ERROR("生成雪花 ID 失败", err)
return 0
}
return id
}
// chargeLiveSessions 直播计费(定时任务,每 60s):扫描进行中的会话按码率估算扣减
func (b *BillingService) chargeLiveSessions() {
var sessions []model.LiveSession
if err := common.DB.Where("phase IN ('starting','streaming')").Find(&sessions).Error; err != nil {
logger.ERROR("扫描直播会话失败", err)
return
}
now := time.Now()
for i := range sessions {
b.chargeLiveSession(&sessions[i], now)
}
}
// chargeLiveSession 对单个直播会话按时长增量扣减;余额耗尽则停止推流
func (b *BillingService) chargeLiveSession(session *model.LiveSession, now time.Time) {
var dock model.Dock
if err := common.DB.Select("user_id").Where("dock_id = ?", session.DockID).First(&dock).Error; err != nil {
return
}
lastKey := cache.LiveBilledKeyOf(session.ID)
lastTs := int64(0)
if v, err := common.GetInt64(lastKey); err == nil {
lastTs = v
} else if session.StartedAt != nil {
lastTs = session.StartedAt.Unix()
} else {
lastTs = session.CreatedAt.Unix()
}
nowTs := now.Unix()
if nowTs <= lastTs {
return
}
bytes := (nowTs - lastTs) * session.MaxBitrateBps / 8
sourceID, _ := strconv.ParseInt(session.ID, 10, 64)
if bytes > 0 {
ok, _ := b.Deduct(dock.UserID, bytes, "live", sourceID)
if !ok {
if _, busiErr := DefaultLiveService.stopForBilling(session.DockID, session.ID); busiErr != nil {
logger.ERROR("直播余额不足停止推流失败", busiErr)
}
logger.WARN("直播余额耗尽,停止推流:", session.DockID)
}
}
_ = common.SetValue(lastKey, nowTs)
}
// flushBalanceSnapshot 余额快照刷新(定时任务,每 30min):Redis 权威余额回写 MySQL
func (b *BillingService) flushBalanceSnapshot() {
keys, err := common.GetKeysWithPrefix(cache.TrafficKeyPrefix)
if err != nil {
logger.ERROR("扫描流量余额 key 失败", err)
return
}
for _, key := range keys {
idStr := strings.TrimPrefix(key, cache.TrafficKeyPrefix)
userID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
continue
}
bal, err := common.GetInt64(key)
if err != nil {
continue
}
if err := common.DB.Model(&model.User{}).Where("id = ?", userID).
UpdateColumn("traffic_balance", bal).Error; err != nil {
logger.ERROR("回写余额快照失败", err)
}
}
}
// syncSimUsage SIM 卡用量同步(定时任务,每 1h):运营商查询回填 + 用量快照 + 阈值告警
func (b *BillingService) syncSimUsage() {
var cards []model.SimCard
if err := common.DB.Where("status = ?", "active").Find(&cards).Error; err != nil {
logger.ERROR("扫描 SIM 卡失败", err)
return
}
now := time.Now()
for i := range cards {
b.syncSimCard(&cards[i], now)
}
}
func (b *BillingService) syncSimCard(card *model.SimCard, now time.Time) {
if carrier == nil {
logger.INFO("SIM 运营商通道未接入:", card.Iccid)
return
}
usedGb, carrierStatus, err := carrier.QueryUsage(card.Iccid)
if err != nil {
logger.WARN("查询 SIM 用量失败:", card.Iccid, err)
return
}
updates := map[string]any{
"used_gb": usedGb,
"carrier_status": carrierStatus,
"last_sync_at": now,
}
if carrierStatus == "cancelled" {
updates["status"] = "expired"
}
if err := common.DB.Model(&model.SimCard{}).Where("id = ?", card.ID).Updates(updates).Error; err != nil {
logger.ERROR("回填 SIM 用量失败", err)
}
remainGb := float64(card.PlanGb) - usedGb
record := &model.SimUsageRecord{
ID: mustID(),
SimCardID: card.ID,
PlanGb: card.PlanGb,
UsedGb: usedGb,
RemainGb: remainGb,
CarrierStatus: carrierStatus,
SyncedAt: now,
}
if err := common.DB.Create(record).Error; err != nil {
logger.ERROR("写入 SIM 用量快照失败", err)
}
if remainGb < float64(card.PlanGb)*0.1 {
logger.WARN("SIM 卡流量即将耗尽:", card.Iccid, "剩余", remainGb, "GB")
}
if carrierStatus == "suspended" || carrierStatus == "arrears" {
logger.WARN("SIM 卡运营商状态异常:", card.Iccid, carrierStatus)
}
}