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.
428 lines
16 KiB
428 lines
16 KiB
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
|
|
"laic-backend/client"
|
|
"laic-backend/common"
|
|
"laic-backend/logger"
|
|
"laic-backend/model"
|
|
"laic-backend/tool"
|
|
"laic-backend/vo"
|
|
)
|
|
|
|
type simbossAdapter struct {
|
|
client *client.SimbossClient
|
|
}
|
|
|
|
func InitSimboss(conf common.Simboss) error {
|
|
if conf.AppID == "" && conf.Secret == "" {
|
|
return nil
|
|
}
|
|
instance, err := client.NewSimbossClient(conf.AppID, conf.Secret, conf.APIBase)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
DefaultSimRechargeService.simboss = &simbossAdapter{client: instance}
|
|
return nil
|
|
}
|
|
|
|
type SimRechargeService struct {
|
|
simboss *simbossAdapter
|
|
}
|
|
|
|
var DefaultSimRechargeService = &SimRechargeService{}
|
|
|
|
// EnsureCardFromICCID 创建或复用用户的通信卡记录。
|
|
// ICCID 是通信卡的业务入口,机巢与通信卡不做一对一约束;dockID 仅作为当前归属/来源信息保存。
|
|
// 创建成功后异步向 SIMBOSS 查询详情,供应商不可用或查询失败不影响通信卡展示。
|
|
func (s *SimRechargeService) EnsureCardFromICCID(userID int64, dockID, iccid string) {
|
|
if userID <= 0 || iccid == "" {
|
|
return
|
|
}
|
|
var card model.SimCard
|
|
err := common.DB.Where("iccid = ?", iccid).First(&card).Error
|
|
if err == nil {
|
|
if card.UserID != userID {
|
|
logger.WARN("通信卡已归属其他用户,跳过绑定", iccid, userID)
|
|
return
|
|
}
|
|
if dockID != "" && card.DockID != dockID {
|
|
_ = common.DB.Model(&card).Updates(map[string]any{"dock_id": dockID, "updated_at": time.Now()}).Error
|
|
}
|
|
return
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
logger.ERROR("查询通信卡失败", err)
|
|
return
|
|
}
|
|
id, err := tool.NextID()
|
|
if err != nil {
|
|
logger.ERROR("生成通信卡 ID 失败", err)
|
|
return
|
|
}
|
|
now := time.Now()
|
|
card = model.SimCard{
|
|
ID: id, UserID: userID, DockID: dockID, Iccid: iccid,
|
|
Status: "active", CarrierStatus: "normal", CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := common.DB.Create(&card).Error; err != nil {
|
|
logger.ERROR("根据 ICCID 创建通信卡失败", err)
|
|
return
|
|
}
|
|
// 查询供应商不阻塞 MQTT/HTTP 主流程;即使失败,卡记录仍可在账户中心显示。
|
|
go func(cardID int64) {
|
|
var current model.SimCard
|
|
if err := common.DB.First(¤t, cardID).Error; err != nil || s.simboss == nil {
|
|
return
|
|
}
|
|
if err := s.refreshCard(¤t); err != nil {
|
|
logger.WARN("新建通信卡后查询 SIMBOSS 详情失败", iccid, err)
|
|
}
|
|
}(card.ID)
|
|
}
|
|
|
|
func (s *SimRechargeService) CreateCard(userID int64, req *vo.SimCardCreateReq) (*model.SimCard, *common.BusiError) {
|
|
if userID <= 0 || strings.TrimSpace(req.Iccid) == "" {
|
|
return nil, common.ErrParam
|
|
}
|
|
iccid := strings.TrimSpace(req.Iccid)
|
|
var card model.SimCard
|
|
if err := common.DB.Where("iccid = ?", iccid).First(&card).Error; err == nil {
|
|
if card.UserID != userID {
|
|
return nil, common.NewBusiError(common.ParamError, "该通信卡已归属其他用户")
|
|
}
|
|
return &card, nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, common.ErrInternal
|
|
}
|
|
now := time.Now()
|
|
card = model.SimCard{ID: mustID(), UserID: userID, DockID: strings.TrimSpace(req.DockID), Iccid: iccid, Status: "active", CarrierStatus: "normal", CreatedAt: now, UpdatedAt: now}
|
|
if card.ID == 0 || common.DB.Create(&card).Error != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
if s.simboss != nil {
|
|
go func(id int64) {
|
|
var current model.SimCard
|
|
if common.DB.First(¤t, id).Error == nil {
|
|
_ = s.refreshCard(¤t)
|
|
}
|
|
}(card.ID)
|
|
}
|
|
return &card, nil
|
|
}
|
|
|
|
func (s *SimRechargeService) DeleteCard(userID, cardID int64) *common.BusiError {
|
|
result := common.DB.Where("id = ? AND user_id = ?", cardID, userID).Delete(&model.SimCard{})
|
|
if result.Error != nil {
|
|
logger.ERROR("删除通信卡失败", result.Error)
|
|
return common.ErrInternal
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return common.ErrSimCardNotFound
|
|
}
|
|
return nil
|
|
}
|
|
func (s *SimRechargeService) ListProviderPlans(iccid string) ([]client.SimbossRatePlan, *common.BusiError) {
|
|
if s.simboss == nil {
|
|
return nil, common.ErrCarrierUnavailable
|
|
}
|
|
plans, err := s.simboss.client.GetRatePlans(iccid)
|
|
if err != nil {
|
|
logger.WARN("查询 SIMBOSS 套餐失败", iccid, err)
|
|
return nil, common.ErrCarrierUnavailable
|
|
}
|
|
return plans, nil
|
|
}
|
|
|
|
func (s *SimRechargeService) GetCard(userID, cardID int64, refresh bool) (*model.SimCard, *common.BusiError) {
|
|
var card model.SimCard
|
|
if err := common.DB.Where("id = ? AND user_id = ?", cardID, userID).First(&card).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, common.ErrSimCardNotFound
|
|
}
|
|
return nil, common.ErrInternal
|
|
}
|
|
if refresh {
|
|
if s.simboss == nil {
|
|
return nil, common.ErrCarrierUnavailable
|
|
}
|
|
if err := s.refreshCard(&card); err != nil {
|
|
logger.WARN("刷新 SIM 卡信息失败", card.ID, err)
|
|
return nil, common.ErrCarrierUnavailable
|
|
}
|
|
}
|
|
return &card, nil
|
|
}
|
|
|
|
func (s *SimRechargeService) ListPackages() ([]model.SimPackage, *common.BusiError) {
|
|
var packages []model.SimPackage
|
|
if err := common.DB.Where("status = ?", "active").Order("sale_fee_fen ASC, id ASC").Find(&packages).Error; err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
return packages, nil
|
|
}
|
|
|
|
func (s *SimRechargeService) CreateOrder(userID, cardID, packageID int64) (*model.SimRechargeOrder, *common.BusiError) {
|
|
var order model.SimRechargeOrder
|
|
now := time.Now()
|
|
var busiErr *common.BusiError
|
|
err := common.DB.Transaction(func(tx *gorm.DB) error {
|
|
var card model.SimCard
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", cardID, userID).First(&card).Error; err != nil {
|
|
return err
|
|
}
|
|
var pending model.SimRechargeOrder
|
|
if err := tx.Where("sim_card_id = ? AND payment_status IN ?", cardID, []string{"unpaid", "processing"}).First(&pending).Error; err == nil {
|
|
busiErr = common.ErrPendingOrderExists
|
|
return busiErr
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
if card.Status != "active" || card.CarrierStatus == "cancelled" || card.CarrierStatus == "suspended" || card.Iccid == "" {
|
|
busiErr = common.ErrParam
|
|
return busiErr
|
|
}
|
|
var pkg model.SimPackage
|
|
if err := tx.Where("id = ? AND status = ?", packageID, "active").First(&pkg).Error; err != nil {
|
|
return err
|
|
}
|
|
if pkg.ProviderRatePlanID <= 0 || pkg.AmountGb <= 0 || pkg.ValidityMonths <= 0 || pkg.SaleFeeFen <= 0 {
|
|
busiErr = common.ErrParam
|
|
return busiErr
|
|
}
|
|
if card.Carrier != "" && pkg.Carrier != "" && card.Carrier != pkg.Carrier {
|
|
busiErr = common.ErrParam
|
|
return busiErr
|
|
}
|
|
id, err := tool.NextID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
order = model.SimRechargeOrder{ID: id, UserID: userID, SimCardID: card.ID, PackageID: pkg.ID, PackageCode: pkg.Code, IccidSnapshot: card.Iccid, CarrierSnapshot: card.Carrier, AmountGb: pkg.AmountGb, ValidityMonths: pkg.ValidityMonths, TotalFeeFen: pkg.SaleFeeFen, PaymentStatus: "unpaid", FulfillmentStatus: "pending", ExternalOrderNo: "sim-" + strconv.FormatInt(id, 10), CreatedAt: now, UpdatedAt: now}
|
|
return tx.Create(&order).Error
|
|
})
|
|
if err != nil {
|
|
if busiErr != nil {
|
|
return nil, busiErr
|
|
}
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, common.ErrSimCardNotFound
|
|
}
|
|
if code, _ := common.ParseError(err); code == 1062 {
|
|
return nil, common.ErrPendingOrderExists
|
|
}
|
|
logger.ERROR("创建 SIM 续费订单失败", err)
|
|
return nil, common.ErrInternal
|
|
}
|
|
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "创建SIM续费订单", fmt.Sprintf("orderId=%d simCardId=%d feeFen=%d", order.ID, order.SimCardID, order.TotalFeeFen), "success", "user_api")
|
|
return &order, nil
|
|
}
|
|
|
|
func (s *SimRechargeService) CloseExpiredOrders(cutoff time.Time) {
|
|
var orders []model.SimRechargeOrder
|
|
if err := common.DB.Where("payment_status IN ? AND created_at <= ?", []string{"unpaid", "processing"}, cutoff).Limit(100).Find(&orders).Error; err != nil {
|
|
logger.ERROR("扫描超时 SIM 订单失败", err)
|
|
return
|
|
}
|
|
for i := range orders {
|
|
now := time.Now()
|
|
closed := false
|
|
err := common.DB.Transaction(func(tx *gorm.DB) error {
|
|
result := tx.Model(&model.SimRechargeOrder{}).Where("id = ? AND payment_status IN ? AND created_at <= ?", orders[i].ID, []string{"unpaid", "processing"}, cutoff).Updates(map[string]any{"payment_status": "closed", "closed_at": now, "updated_at": now})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return nil
|
|
}
|
|
closed = true
|
|
return tx.Model(&model.PaymentTransaction{}).Where("business_type = ? AND business_order_id = ? AND status IN ?", "sim_recharge_order", orders[i].ID, []string{"unpaid", "processing"}).Updates(map[string]any{"status": "closed", "updated_at": now}).Error
|
|
})
|
|
if err != nil {
|
|
logger.ERROR("关闭超时 SIM 订单失败", err)
|
|
continue
|
|
}
|
|
if closed {
|
|
DefaultOperationLogService.RecordEvent(orders[i].UserID, "充值与履约", "自动关闭SIM续费订单", fmt.Sprintf("orderId=%d status=closed", orders[i].ID), "success", "system")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *SimRechargeService) CancelOrder(userID, orderID int64) *common.BusiError {
|
|
now := time.Now()
|
|
var busiErr *common.BusiError
|
|
err := common.DB.Transaction(func(tx *gorm.DB) error {
|
|
var order model.SimRechargeOrder
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
|
|
return err
|
|
}
|
|
if order.PaymentStatus != "unpaid" && order.PaymentStatus != "processing" {
|
|
busiErr = common.ErrOrderNotCancellable
|
|
return busiErr
|
|
}
|
|
result := tx.Model(&order).Where("id = ? AND payment_status IN ?", orderID, []string{"unpaid", "processing"}).Updates(map[string]any{"payment_status": "cancelled", "closed_at": now, "updated_at": now})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
busiErr = common.ErrOrderNotCancellable
|
|
return busiErr
|
|
}
|
|
return tx.Model(&model.PaymentTransaction{}).Where("business_type = ? AND business_order_id = ? AND status IN ?", "sim_recharge_order", orderID, []string{"unpaid", "processing"}).Updates(map[string]any{"status": "cancelled", "updated_at": now}).Error
|
|
})
|
|
if err != nil {
|
|
if busiErr != nil {
|
|
return busiErr
|
|
}
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return common.ErrOrderNotFound
|
|
}
|
|
logger.ERROR("取消 SIM 订单失败", err)
|
|
return common.ErrInternal
|
|
}
|
|
DefaultOperationLogService.RecordEvent(userID, "充值与履约", "取消SIM续费订单", fmt.Sprintf("orderId=%d status=cancelled", orderID), "success", "user_api")
|
|
return nil
|
|
}
|
|
|
|
func (s *SimRechargeService) GetOrder(userID, orderID int64) (*model.SimRechargeOrder, *common.BusiError) {
|
|
var order model.SimRechargeOrder
|
|
if err := common.DB.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, common.ErrOrderNotFound
|
|
}
|
|
return nil, common.ErrInternal
|
|
}
|
|
return &order, nil
|
|
}
|
|
|
|
func (s *SimRechargeService) SubmitPaidOrderOnce(order *model.SimRechargeOrder) {
|
|
const supportMessage = "续费处理异常,请联系管理员。"
|
|
DefaultOperationLogService.RecordEvent(order.UserID, "充值与履约", "提交SIMBOSS履约", fmt.Sprintf("orderId=%d attempt=1", order.ID), "success", "simboss")
|
|
if s.simboss == nil {
|
|
DefaultOperationLogService.RecordEvent(order.UserID, "充值与履约", "SIMBOSS履约转人工处理", fmt.Sprintf("orderId=%d errorCode=simboss_unavailable", order.ID), "failed", "simboss")
|
|
s.markManualReview(order.ID, "simboss_unavailable", supportMessage, errors.New("SIMBOSS is not configured"))
|
|
return
|
|
}
|
|
providerOrderNo, err := s.simboss.client.Recharge(order.IccidSnapshot, order.ProviderRatePlanID, order.ValidityMonths, order.ExternalOrderNo)
|
|
if err != nil {
|
|
logger.ERROR("SIM 续费提交 SIMBOSS 失败", err)
|
|
DefaultOperationLogService.RecordEvent(order.UserID, "充值与履约", "SIMBOSS履约转人工处理", fmt.Sprintf("orderId=%d errorCode=simboss_request_failed", order.ID), "failed", "simboss")
|
|
s.markManualReview(order.ID, "simboss_request_failed", supportMessage, err)
|
|
return
|
|
}
|
|
if providerOrderNo == "" {
|
|
err = errors.New("SIMBOSS returned an empty order number")
|
|
logger.ERROR("SIMBOSS 返回空订单号", err)
|
|
DefaultOperationLogService.RecordEvent(order.UserID, "充值与履约", "SIMBOSS履约转人工处理", fmt.Sprintf("orderId=%d errorCode=simboss_empty_order_no", order.ID), "failed", "simboss")
|
|
s.markManualReview(order.ID, "simboss_empty_order_no", supportMessage, err)
|
|
return
|
|
}
|
|
now := time.Now()
|
|
result := common.DB.Model(&model.SimRechargeOrder{}).Where("id = ? AND payment_status = ? AND fulfillment_status = ? AND attempt_count = ?", order.ID, "paid", "submitting", 1).Updates(map[string]any{
|
|
"fulfillment_status": "success", "provider_order_no": providerOrderNo, "fulfilled_at": now,
|
|
"fulfillment_message": "", "last_error_code": "", "last_error_message": "", "updated_at": now,
|
|
})
|
|
if result.Error != nil {
|
|
logger.ERROR("更新 SIM 续费履约结果失败", result.Error)
|
|
return
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
logger.WARN("SIM 续费履约状态已变化", order.ID, errors.New("stale fulfillment state"))
|
|
}
|
|
}
|
|
|
|
func (s *SimRechargeService) markManualReview(orderID int64, code, message string, cause error) {
|
|
now := time.Now()
|
|
result := common.DB.Model(&model.SimRechargeOrder{}).Where("id = ? AND payment_status = ? AND fulfillment_status = ? AND attempt_count = ?", orderID, "paid", "submitting", 1).Updates(map[string]any{
|
|
"fulfillment_status": "manual_review", "fulfillment_message": message,
|
|
"last_error_code": code, "last_error_message": cause.Error(), "next_attempt_at": nil,
|
|
"fulfillment_lease_token": nil, "fulfillment_lease_until": nil, "updated_at": now,
|
|
})
|
|
if result.Error != nil {
|
|
logger.ERROR("标记 SIM 续费人工处理失败", result.Error)
|
|
return
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
logger.WARN("SIM 续费人工处理状态已变化", orderID, errors.New("stale fulfillment state"))
|
|
}
|
|
}
|
|
|
|
func (s *SimRechargeService) refreshCard(card *model.SimCard) error {
|
|
detail, err := s.simboss.client.GetDeviceDetail(card.Iccid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
updates := map[string]any{
|
|
"carrier": detail.Carrier, "carrier_status": normalizeCarrierStatus(detail.Status, detail.DeviceStatus),
|
|
"used_gb": detail.DataUsage / 1024, "last_sync_at": now, "updated_at": now,
|
|
}
|
|
if detail.RatePlanExpirationDate != "" {
|
|
if expiresAt, err := parseSimbossDate(detail.RatePlanExpirationDate); err == nil {
|
|
updates["expired_at"] = expiresAt
|
|
}
|
|
}
|
|
if updates["carrier_status"] == "cancelled" {
|
|
updates["status"] = "expired"
|
|
}
|
|
if err := common.DB.Model(card).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
return common.DB.First(card, card.ID).Error
|
|
}
|
|
|
|
func parseSimbossDate(value string) (time.Time, error) {
|
|
for _, layout := range []string{"2006-01-02", time.RFC3339, "2006-01-02 15:04:05"} {
|
|
if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil {
|
|
return parsed, nil
|
|
}
|
|
}
|
|
return time.Time{}, errors.New("invalid SIMBOSS date")
|
|
}
|
|
|
|
func normalizeCarrierStatus(status, deviceStatus string) string {
|
|
value := status + " " + deviceStatus
|
|
switch {
|
|
case containsFold(value, "cancel"):
|
|
return "cancelled"
|
|
case containsFold(value, "arrear"):
|
|
return "arrears"
|
|
case containsFold(value, "suspend"):
|
|
return "suspended"
|
|
default:
|
|
return "normal"
|
|
}
|
|
}
|
|
|
|
func containsFold(value, fragment string) bool {
|
|
for i := 0; i+len(fragment) <= len(value); i++ {
|
|
if equalFoldASCII(value[i:i+len(fragment)], fragment) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func equalFoldASCII(value, fragment string) bool {
|
|
for i := range value {
|
|
left, right := value[i], fragment[i]
|
|
if left >= 'A' && left <= 'Z' {
|
|
left += 'a' - 'A'
|
|
}
|
|
if right >= 'A' && right <= 'Z' {
|
|
right += 'a' - 'A'
|
|
}
|
|
if left != right {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|