Browse Source

feat: 完善通信卡管理与机巢软删除

master
root 2 weeks ago
parent
commit
cffa7a5e4d
  1. 27
      handler/account_handler.go
  2. 68
      handler/platform_billing_handler.go
  3. 4
      model/billing.go
  4. 8
      model/dock.go
  5. 5
      model/sim_recharge.go
  6. 13
      route/route.go
  7. 2
      service/billing_service.go
  8. 137
      service/dock_service.go
  9. 74
      service/platform_billing_service.go
  10. 12
      service/scheduler.go
  11. 106
      service/sim_recharge_service.go
  12. 11
      sql/001_schema.sql
  13. 26
      sql/003_seed.sql
  14. 28
      sql/020_sim_card_authoritative.sql
  15. 7
      sql/021_dock_soft_delete.sql
  16. 20
      sql/022_sim_audit_snapshots.sql
  17. 6
      vo/account_vo.go
  18. 1
      vo/dock_vo.go
  19. 33
      vo/platform_billing_vo.go

27
handler/account_handler.go

@ -110,6 +110,33 @@ func CancelTrafficOrder(c *gin.Context) {
} }
common.OK(c) common.OK(c)
} }
func CreateSimCard(c *gin.Context) {
var req vo.SimCardCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
data, e := service.DefaultSimRechargeService.CreateCard(common.GetUserId(c), &req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, data)
}
func DeleteSimCard(c *gin.Context) {
id, e := parseID(c)
if e != nil {
common.FailWithBusiError(c, e)
return
}
if e := service.DefaultSimRechargeService.DeleteCard(common.GetUserId(c), id); e != nil {
common.FailWithBusiError(c, e)
return
}
common.OK(c)
}
func ListSimCards(c *gin.Context) { func ListSimCards(c *gin.Context) {
var req vo.SimCardPageReq var req vo.SimCardPageReq
bindPage(c, &req.Pagination) bindPage(c, &req.Pagination)

68
handler/platform_billing_handler.go

@ -29,6 +29,74 @@ func GrantTrafficForTest(c *gin.Context) {
common.OKWithData(c, data) common.OKWithData(c, data)
} }
func GetSimProviderPlans(c *gin.Context) {
var req vo.SimPackageSyncReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
plans, e := service.DefaultSimRechargeService.ListProviderPlans(req.Iccid)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, plans)
}
func GetSimPackagePage(c *gin.Context) {
var req vo.SimPackagePageReq
bindPage(c, &req.Pagination)
_ = c.ShouldBindQuery(&req)
data, e := service.DefaultPlatformBillingService.GetSimPackagePage(&req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, data)
}
func CreateSimPackage(c *gin.Context) {
var req vo.SimPackageCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
data, e := service.DefaultPlatformBillingService.CreateSimPackage(&req)
if e != nil {
common.FailWithBusiError(c, e)
return
}
common.OKWithData(c, data)
}
func UpdateSimPackage(c *gin.Context) {
id, e := parseID(c)
if e != nil {
common.FailWithBusiError(c, e)
return
}
var req vo.SimPackageUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
if e := service.DefaultPlatformBillingService.UpdateSimPackage(id, &req); e != nil {
common.FailWithBusiError(c, e)
return
}
common.OK(c)
}
func DeleteSimPackage(c *gin.Context) {
id, e := parseID(c)
if e != nil {
common.FailWithBusiError(c, e)
return
}
if e := service.DefaultPlatformBillingService.DisableSimPackage(id); e != nil {
common.FailWithBusiError(c, e)
return
}
common.OK(c)
}
func GetPlatformResourcePool(c *gin.Context) { func GetPlatformResourcePool(c *gin.Context) {
data, e := service.DefaultPlatformBillingService.GetPool() data, e := service.DefaultPlatformBillingService.GetPool()
if e != nil { if e != nil {

4
model/billing.go

@ -75,6 +75,8 @@ func (SimCard) TableName() string {
type SimRechargeLog struct { type SimRechargeLog struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
SimCardID int64 `gorm:"column:sim_card_id;type:BIGINT;not null" json:"simCardId"` SimCardID int64 `gorm:"column:sim_card_id;type:BIGINT;not null" json:"simCardId"`
IccidSnapshot string `gorm:"column:iccid_snapshot;type:VARCHAR(32)" json:"iccidSnapshot"`
CarrierSnapshot string `gorm:"column:carrier_snapshot;type:VARCHAR(16)" json:"carrierSnapshot"`
UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"`
AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"` AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"`
UnitPrice float64 `gorm:"column:unit_price;type:DECIMAL(6,2)" json:"unitPrice"` UnitPrice float64 `gorm:"column:unit_price;type:DECIMAL(6,2)" json:"unitPrice"`
@ -94,6 +96,8 @@ func (SimRechargeLog) TableName() string {
type SimUsageRecord struct { type SimUsageRecord struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
SimCardID int64 `gorm:"column:sim_card_id;type:BIGINT;not null;index:idx_record_sim" json:"simCardId"` SimCardID int64 `gorm:"column:sim_card_id;type:BIGINT;not null;index:idx_record_sim" json:"simCardId"`
IccidSnapshot string `gorm:"column:iccid_snapshot;type:VARCHAR(32)" json:"iccidSnapshot"`
CarrierSnapshot string `gorm:"column:carrier_snapshot;type:VARCHAR(16)" json:"carrierSnapshot"`
PlanGb int `gorm:"column:plan_gb;type:INT" json:"planGb"` PlanGb int `gorm:"column:plan_gb;type:INT" json:"planGb"`
UsedGb float64 `gorm:"column:used_gb;type:DECIMAL(10,4)" json:"usedGb"` UsedGb float64 `gorm:"column:used_gb;type:DECIMAL(10,4)" json:"usedGb"`
RemainGb float64 `gorm:"column:remain_gb;type:DECIMAL(10,4)" json:"remainGb"` RemainGb float64 `gorm:"column:remain_gb;type:DECIMAL(10,4)" json:"remainGb"`

8
model/dock.go

@ -1,6 +1,10 @@
package model package model
import "time"
import (
"time"
"gorm.io/gorm"
)
// Dock 机巢 // Dock 机巢
type Dock struct { type Dock struct {
@ -10,7 +14,6 @@ type Dock struct {
Name string `gorm:"column:name;type:VARCHAR(64);default:''" json:"name"` Name string `gorm:"column:name;type:VARCHAR(64);default:''" json:"name"`
Code string `gorm:"column:code;type:VARCHAR(32);default:''" json:"code"` Code string `gorm:"column:code;type:VARCHAR(32);default:''" json:"code"`
SN string `gorm:"column:sn;type:VARCHAR(64);default:''" json:"sn"` SN string `gorm:"column:sn;type:VARCHAR(64);default:''" json:"sn"`
Iccid string `gorm:"column:iccid;type:VARCHAR(32);default:''" json:"iccid"` // 冗余,权威见 sim_card.iccid
Longitude float64 `gorm:"column:longitude;type:DOUBLE;default:0" json:"longitude"` Longitude float64 `gorm:"column:longitude;type:DOUBLE;default:0" json:"longitude"`
Latitude float64 `gorm:"column:latitude;type:DOUBLE;default:0" json:"latitude"` Latitude float64 `gorm:"column:latitude;type:DOUBLE;default:0" json:"latitude"`
Altitude float64 `gorm:"column:altitude;type:DOUBLE;default:0" json:"altitude"` Altitude float64 `gorm:"column:altitude;type:DOUBLE;default:0" json:"altitude"`
@ -21,6 +24,7 @@ type Dock struct {
ProtocolVer string `gorm:"column:protocol_ver;type:VARCHAR(8);default:'1.0'" json:"protocolVer"` ProtocolVer string `gorm:"column:protocol_ver;type:VARCHAR(8);default:'1.0'" json:"protocolVer"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
} }
func (Dock) TableName() string { func (Dock) TableName() string {

5
model/sim_recharge.go

@ -7,10 +7,10 @@ type SimPackage struct {
Code string `gorm:"column:code;type:VARCHAR(64);not null" json:"code"` Code string `gorm:"column:code;type:VARCHAR(64);not null" json:"code"`
Name string `gorm:"column:name;type:VARCHAR(128);not null" json:"name"` Name string `gorm:"column:name;type:VARCHAR(128);not null" json:"name"`
Carrier string `gorm:"column:carrier;type:VARCHAR(16);not null" json:"carrier"` Carrier string `gorm:"column:carrier;type:VARCHAR(16);not null" json:"carrier"`
ProviderRatePlanID int64 `gorm:"column:provider_rate_plan_id;type:BIGINT;not null" json:"-"`
ProviderRatePlanID int64 `gorm:"column:provider_rate_plan_id;type:BIGINT;not null" json:"providerRatePlanId"`
AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"` AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"`
ValidityMonths int `gorm:"column:validity_months;type:INT;not null" json:"validityMonths"` ValidityMonths int `gorm:"column:validity_months;type:INT;not null" json:"validityMonths"`
CostFeeFen int64 `gorm:"column:cost_fee_fen;type:BIGINT;not null" json:"-"`
CostFeeFen int64 `gorm:"column:cost_fee_fen;type:BIGINT;not null" json:"costFeeFen"`
SaleFeeFen int64 `gorm:"column:sale_fee_fen;type:BIGINT;not null" json:"saleFeeFen"` SaleFeeFen int64 `gorm:"column:sale_fee_fen;type:BIGINT;not null" json:"saleFeeFen"`
Status string `gorm:"column:status;type:VARCHAR(16);not null;default:active" json:"status"` Status string `gorm:"column:status;type:VARCHAR(16);not null;default:active" json:"status"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
@ -27,6 +27,7 @@ type SimRechargeOrder struct {
PackageCode string `gorm:"column:package_code;type:VARCHAR(64);not null" json:"packageCode"` PackageCode string `gorm:"column:package_code;type:VARCHAR(64);not null" json:"packageCode"`
ProviderRatePlanID int64 `gorm:"column:provider_rate_plan_id;type:BIGINT;not null" json:"-"` ProviderRatePlanID int64 `gorm:"column:provider_rate_plan_id;type:BIGINT;not null" json:"-"`
IccidSnapshot string `gorm:"column:iccid_snapshot;type:VARCHAR(32);not null" json:"-"` IccidSnapshot string `gorm:"column:iccid_snapshot;type:VARCHAR(32);not null" json:"-"`
CarrierSnapshot string `gorm:"column:carrier_snapshot;type:VARCHAR(16)" json:"carrierSnapshot"`
AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"` AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"`
ValidityMonths int `gorm:"column:validity_months;type:INT;not null" json:"validityMonths"` ValidityMonths int `gorm:"column:validity_months;type:INT;not null" json:"validityMonths"`
TotalFeeFen int64 `gorm:"column:total_fee_fen;type:BIGINT;not null" json:"totalFeeFen"` TotalFeeFen int64 `gorm:"column:total_fee_fen;type:BIGINT;not null" json:"totalFeeFen"`

13
route/route.go

@ -62,6 +62,8 @@ func InitRouter(port int32) {
account.POST("/payments", handler.CreateWechatPayment) account.POST("/payments", handler.CreateWechatPayment)
account.GET("/payments/:id", handler.GetPaymentTransaction) account.GET("/payments/:id", handler.GetPaymentTransaction)
account.GET("/sim-cards", handler.ListSimCards) account.GET("/sim-cards", handler.ListSimCards)
account.POST("/sim-cards", handler.CreateSimCard)
account.DELETE("/sim-cards/:id", handler.DeleteSimCard)
account.GET("/sim-cards/:id", handler.GetSimCard) account.GET("/sim-cards/:id", handler.GetSimCard)
account.GET("/sim-packages", handler.ListSimPackages) account.GET("/sim-packages", handler.ListSimPackages)
account.POST("/sim-cards/:id/recharge-orders", handler.CreateSimRechargeOrder) account.POST("/sim-cards/:id/recharge-orders", handler.CreateSimRechargeOrder)
@ -198,7 +200,16 @@ func InitRouter(port int32) {
trafficAdmin.GET("/ledger", handler.GetTrafficLedgerPage) trafficAdmin.GET("/ledger", handler.GetTrafficLedgerPage)
} }
// 系统管理(admin)
// SIM 通信卡套餐管理(admin)
simPackages := v1.Group("/admin/sim-packages", middleware.AuthMiddleware(), middleware.AdminMiddleware())
{
simPackages.POST("/provider-plans", handler.GetSimProviderPlans)
simPackages.GET("", handler.GetSimPackagePage)
simPackages.POST("", handler.CreateSimPackage)
simPackages.PUT("/:id", handler.UpdateSimPackage)
simPackages.DELETE("/:id", handler.DeleteSimPackage)
}
users := v1.Group("/users", middleware.AuthMiddleware(), middleware.AdminMiddleware()) users := v1.Group("/users", middleware.AuthMiddleware(), middleware.AdminMiddleware())
{ {
users.GET("", handler.GetUserPage) users.GET("", handler.GetUserPage)

2
service/billing_service.go

@ -688,6 +688,8 @@ func (b *BillingService) syncSimCard(card *model.SimCard, now time.Time) {
record := &model.SimUsageRecord{ record := &model.SimUsageRecord{
ID: mustID(), ID: mustID(),
SimCardID: card.ID, SimCardID: card.ID,
IccidSnapshot: card.Iccid,
CarrierSnapshot: card.Carrier,
PlanGb: card.PlanGb, PlanGb: card.PlanGb,
UsedGb: usedGb, UsedGb: usedGb,
RemainGb: remainGb, RemainGb: remainGb,

137
service/dock_service.go

@ -94,7 +94,28 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
} }
var existing model.Dock var existing model.Dock
if err := common.DB.Where("dock_id = ?", req.DockID).First(&existing).Error; err == nil {
if err := common.DB.Unscoped().Where("dock_id = ?", req.DockID).First(&existing).Error; err == nil {
if existing.DeletedAt.Valid {
updates := map[string]any{
"user_id": owner, "deleted_at": nil, "register_status": "registered", "updated_at": time.Now(),
"name": req.Name, "code": req.Code, "sn": req.SN, "location": req.Location,
"longitude": req.Longitude, "latitude": req.Latitude, "altitude": req.Altitude,
}
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Model(&existing).Updates(updates).Error; err != nil {
return err
}
return tx.Model(&model.Drone{}).Where("dock_id = ?", existing.DockID).Update("user_id", owner).Error
}); err != nil {
logger.ERROR("恢复已删除机巢失败", err)
return nil, common.ErrInternal
}
common.DB.First(&existing, existing.ID)
if req.Iccid != "" {
DefaultSimRechargeService.EnsureCardFromICCID(owner, existing.DockID, req.Iccid)
}
return &existing, nil
}
if existing.UserID != 0 { if existing.UserID != 0 {
return nil, common.ErrDockExists return nil, common.ErrDockExists
} }
@ -112,9 +133,6 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
if req.SN != "" { if req.SN != "" {
updates["sn"] = req.SN updates["sn"] = req.SN
} }
if req.Iccid != "" {
updates["iccid"] = req.Iccid
}
if req.Location != "" { if req.Location != "" {
updates["location"] = req.Location updates["location"] = req.Location
} }
@ -131,6 +149,9 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
return nil, common.ErrInternal return nil, common.ErrInternal
} }
common.DB.First(&existing, existing.ID) common.DB.First(&existing, existing.ID)
if req.Iccid != "" {
DefaultSimRechargeService.EnsureCardFromICCID(owner, existing.DockID, req.Iccid)
}
return &existing, nil return &existing, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) { } else if !errors.Is(err, gorm.ErrRecordNotFound) {
logger.ERROR("查询机巢失败", err) logger.ERROR("查询机巢失败", err)
@ -149,7 +170,6 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
Name: req.Name, Name: req.Name,
Code: req.Code, Code: req.Code,
SN: req.SN, SN: req.SN,
Iccid: req.Iccid,
Location: req.Location, Location: req.Location,
Longitude: req.Longitude, Longitude: req.Longitude,
Latitude: req.Latitude, Latitude: req.Latitude,
@ -167,6 +187,9 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
logger.ERROR("登记机巢失败", err) logger.ERROR("登记机巢失败", err)
return nil, common.ErrInternal return nil, common.ErrInternal
} }
if req.Iccid != "" {
DefaultSimRechargeService.EnsureCardFromICCID(owner, dock.DockID, req.Iccid)
}
return dock, nil return dock, nil
} }
@ -227,10 +250,13 @@ func (s *DockService) Update(userID int64, isAdmin bool, id int64, req *vo.DockU
return nil, common.ErrInternal return nil, common.ErrInternal
} }
common.DB.First(&dock, id) common.DB.First(&dock, id)
if req.RegisterAs == "registered" && req.Iccid != "" {
DefaultSimRechargeService.EnsureCardFromICCID(dock.UserID, dock.DockID, req.Iccid)
}
return &dock, nil return &dock, nil
} }
// Delete 硬删除机巢及其关联设备数据
// Delete 软删除机巢:保留任务、直播、告警、指令、视频等历史数据用于审计。
func (s *DockService) Delete(userID int64, isAdmin bool, id int64) *common.BusiError { func (s *DockService) Delete(userID int64, isAdmin bool, id int64) *common.BusiError {
var dock model.Dock var dock model.Dock
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, id).Error; err != nil { if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, id).Error; err != nil {
@ -241,62 +267,20 @@ func (s *DockService) Delete(userID int64, isAdmin bool, id int64) *common.BusiE
return common.ErrInternal return common.ErrInternal
} }
var taskIDs []string
var droneSNs []string
var simCardIDs []int64
if err := common.DB.Transaction(func(tx *gorm.DB) error { if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.TaskPlan{}).Where("dock_id = ?", dock.DockID).Pluck("id", &taskIDs).Error; err != nil {
return err
}
if err := tx.Model(&model.Drone{}).Where("dock_id = ?", dock.DockID).Pluck("drone_sn", &droneSNs).Error; err != nil {
return err
}
if err := tx.Model(&model.SimCard{}).Where("dock_id = ?", dock.DockID).Pluck("id", &simCardIDs).Error; err != nil {
return err
}
if len(droneSNs) > 0 {
var videoIDs []int64
if err := tx.Model(&model.Video{}).Where("drone_sn IN ?", droneSNs).Pluck("id", &videoIDs).Error; err != nil {
return err
}
if len(videoIDs) > 0 {
if err := tx.Where("video_id IN ?", videoIDs).Delete(&model.DownloadLog{}).Error; err != nil {
// 防止删除后 cron 任务仍继续向已移除的设备下发指令;任务历史保留。
if err := tx.Model(&model.TaskPlan{}).
Where("dock_id = ? AND schedule_type = ? AND status = ?", dock.DockID, "cron", "enabled").
Updates(map[string]any{"status": "disabled", "updated_at": time.Now()}).Error; err != nil {
return err return err
} }
if err := tx.Where("id IN ?", videoIDs).Delete(&model.Video{}).Error; err != nil {
return err
}
}
}
if len(simCardIDs) > 0 {
if err := tx.Where("sim_card_id IN ?", simCardIDs).Delete(&model.SimUsageRecord{}).Error; err != nil {
return err
}
if err := tx.Where("sim_card_id IN ?", simCardIDs).Delete(&model.SimRechargeLog{}).Error; err != nil {
return err
}
if err := tx.Where("id IN ?", simCardIDs).Delete(&model.SimCard{}).Error; err != nil {
return err
}
}
for _, item := range []any{
&model.Alarm{}, &model.DeviceCommandLog{}, &model.WorkflowState{},
&model.TaskExecution{}, &model.TaskPlan{}, &model.LiveSession{}, &model.Drone{},
} {
if err := tx.Where("dock_id = ?", dock.DockID).Delete(item).Error; err != nil {
return err
}
}
return tx.Delete(&dock).Error return tx.Delete(&dock).Error
}); err != nil { }); err != nil {
logger.ERROR("删除机巢失败", err)
logger.ERROR("软删除机巢失败", err)
return common.ErrInternal return common.ErrInternal
} }
for _, taskID := range taskIDs {
DefaultScheduler.RemoveTask(taskID)
}
DefaultScheduler.RemoveTaskForDock(dock.DockID)
_ = common.SetRemove(cache.OnlineDockSetKey, dock.DockID) _ = common.SetRemove(cache.OnlineDockSetKey, dock.DockID)
_ = common.Delete(cache.DockHeartbeatKeyOf(dock.DockID)) _ = common.Delete(cache.DockHeartbeatKeyOf(dock.DockID))
_ = common.Delete(cache.DockStatusKeyOf(dock.DockID)) _ = common.Delete(cache.DockStatusKeyOf(dock.DockID))
@ -316,9 +300,14 @@ func (s *DockService) realtime(dockID string) (bool, map[string]string) {
// EnsureFromOnline 设备自发现:不存在则登记为 pending,存在则更新在线状态与软件版本 // EnsureFromOnline 设备自发现:不存在则登记为 pending,存在则更新在线状态与软件版本
func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) { func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
online := st.Status == "online" online := st.Status == "online"
var dock model.Dock var dock model.Dock
err := common.DB.Where("dock_id = ?", dockID).First(&dock).Error
err := common.DB.Unscoped().Where("dock_id = ?", dockID).First(&dock).Error
if err == nil && dock.DeletedAt.Valid {
// 已删除设备不会因 MQTT 自动发现而复活,必须通过“添加/认领机巢”显式恢复。
_ = common.SetRemove(cache.OnlineDockSetKey, dockID)
_ = common.Delete(cache.DockHeartbeatKeyOf(dockID))
return
}
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
id, e := tool.NextID() id, e := tool.NextID()
if e != nil { if e != nil {
@ -326,29 +315,16 @@ func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
return return
} }
now := time.Now() now := time.Now()
dock = model.Dock{
ID: id,
UserID: 0, // 待认领
DockID: dockID,
Name: st.Name,
Code: dockID,
SN: dockID,
Location: st.Location,
Latitude: st.Latitude,
Longitude: st.Longitude,
RegisterStatus: "pending",
DockIDSource: "auto",
SoftwareVer: st.SoftwareVersion,
ProtocolVer: st.ProtocolVersion,
CreatedAt: now,
UpdatedAt: now,
}
dock = model.Dock{ID: id, UserID: 0, DockID: dockID, Name: st.Name, Code: dockID, SN: dockID, Location: st.Location, Latitude: st.Latitude, Longitude: st.Longitude, RegisterStatus: "pending", DockIDSource: "auto", SoftwareVer: st.SoftwareVersion, ProtocolVer: st.ProtocolVersion, CreatedAt: now, UpdatedAt: now}
if err := common.DB.Create(&dock).Error; err != nil { if err := common.DB.Create(&dock).Error; err != nil {
logger.ERROR("登记机巢失败", err) logger.ERROR("登记机巢失败", err)
return return
} }
} else if err == nil { } else if err == nil {
updates := map[string]any{"updated_at": time.Now()} updates := map[string]any{"updated_at": time.Now()}
if dock.DeletedAt.Valid {
updates["deleted_at"] = nil
}
if st.SoftwareVersion != "" { if st.SoftwareVersion != "" {
updates["software_ver"] = st.SoftwareVersion updates["software_ver"] = st.SoftwareVersion
} }
@ -367,12 +343,13 @@ func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
if dock.Longitude == 0 && st.Longitude != 0 { if dock.Longitude == 0 && st.Longitude != 0 {
updates["longitude"] = st.Longitude updates["longitude"] = st.Longitude
} }
if err := common.DB.Model(&dock).Updates(updates).Error; err != nil {
if err := common.DB.Unscoped().Model(&dock).Updates(updates).Error; err != nil {
logger.ERROR("更新机巢在线状态失败", err) logger.ERROR("更新机巢在线状态失败", err)
} }
} else {
logger.ERROR("查询机巢在线状态失败", err)
return
} }
// 在线集合
if online { if online {
_ = common.SetAdd(cache.OnlineDockSetKey, dockID) _ = common.SetAdd(cache.OnlineDockSetKey, dockID)
refreshDeviceHeartbeat(cache.DockHeartbeatKeyOf(dockID)) refreshDeviceHeartbeat(cache.DockHeartbeatKeyOf(dockID))
@ -380,11 +357,5 @@ func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
_ = common.SetRemove(cache.OnlineDockSetKey, dockID) _ = common.SetRemove(cache.OnlineDockSetKey, dockID)
_ = common.Delete(cache.DockHeartbeatKeyOf(dockID)) _ = common.Delete(cache.DockHeartbeatKeyOf(dockID))
} }
broadcast("dock.status", map[string]any{
"dockId": dockID,
"online": online,
"softwareVer": st.SoftwareVersion,
"protocolVer": st.ProtocolVersion,
})
broadcast("dock.status", map[string]any{"dockId": dockID, "online": online, "softwareVer": st.SoftwareVersion, "protocolVer": st.ProtocolVersion})
} }

74
service/platform_billing_service.go

@ -122,6 +122,80 @@ func (s *PlatformBillingService) GetPurchasePage(req *vo.PlatformPurchasePageReq
return common.Page(req.Pagination, total, list), nil return common.Page(req.Pagination, total, list), nil
} }
func (s *PlatformBillingService) GetSimPackagePage(req *vo.SimPackagePageReq) (*common.PageResponse[model.SimPackage], *common.BusiError) {
db := common.DB.Model(&model.SimPackage{})
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.Carrier != "" {
db = db.Where("carrier = ?", req.Carrier)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, common.ErrInternal
}
var list []model.SimPackage
if err := db.Scopes(req.Paginate).Order("status ASC, sale_fee_fen ASC, id DESC").Find(&list).Error; err != nil {
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
func (s *PlatformBillingService) CreateSimPackage(req *vo.SimPackageCreateReq) (*model.SimPackage, *common.BusiError) {
status := req.Status
if status == "" {
status = "active"
}
pkg := &model.SimPackage{ID: mustPlatformID(), Code: req.Code, Name: req.Name, Carrier: req.Carrier, ProviderRatePlanID: req.ProviderRatePlanID, AmountGb: req.AmountGb, ValidityMonths: req.ValidityMonths, CostFeeFen: req.CostFeeFen, SaleFeeFen: req.SaleFeeFen, Status: status, CreatedAt: time.Now(), UpdatedAt: time.Now()}
if pkg.ID == 0 {
return nil, common.ErrInternal
}
if err := common.DB.Create(pkg).Error; err != nil {
if code, _ := common.ParseError(err); code == 1062 {
return nil, common.NewBusiError(common.ParamError, "套餐编码或供应商套餐已存在")
}
return nil, common.ErrInternal
}
return pkg, nil
}
func (s *PlatformBillingService) UpdateSimPackage(id int64, req *vo.SimPackageUpdateReq) *common.BusiError {
var existing model.SimPackage
if err := common.DB.First(&existing, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrNotFound
}
return common.ErrInternal
}
// 供应商套餐 ID 是履约映射的稳定标识。已有平台套餐如需切换供应商套餐,
// 应新建一个平台套餐,避免历史订单与未来订单指向关系发生歧义。
if existing.ProviderRatePlanID != req.ProviderRatePlanID || existing.Carrier != req.Carrier {
return common.NewBusiError(common.ParamError, "供应商套餐ID和运营商不可修改,请新建套餐")
}
result := common.DB.Model(&model.SimPackage{}).Where("id = ?", id).Updates(map[string]any{"name": req.Name, "amount_gb": req.AmountGb, "validity_months": req.ValidityMonths, "cost_fee_fen": req.CostFeeFen, "sale_fee_fen": req.SaleFeeFen, "status": req.Status, "updated_at": time.Now()})
if result.Error != nil {
if code, _ := common.ParseError(result.Error); code == 1062 {
return common.NewBusiError(common.ParamError, "供应商套餐已存在")
}
return common.ErrInternal
}
if result.RowsAffected == 0 {
return common.ErrNotFound
}
return nil
}
func (s *PlatformBillingService) DisableSimPackage(id int64) *common.BusiError {
result := common.DB.Model(&model.SimPackage{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive", "updated_at": time.Now()})
if result.Error != nil {
return common.ErrInternal
}
if result.RowsAffected == 0 {
return common.ErrNotFound
}
return nil
}
func (s *PlatformBillingService) CreatePackage(req *vo.TrafficPackageCreateReq) (*model.TrafficPackage, *common.BusiError) { func (s *PlatformBillingService) CreatePackage(req *vo.TrafficPackageCreateReq) (*model.TrafficPackage, *common.BusiError) {
id, err := tool.NextID() id, err := tool.NextID()
if err != nil { if err != nil {

12
service/scheduler.go

@ -104,6 +104,18 @@ func (s *Scheduler) RemoveTask(taskID string) {
} }
} }
// RemoveTaskForDock 移除指定机巢所有 cron 调度。软删除机巢时调用,避免继续下发任务。
func (s *Scheduler) RemoveTaskForDock(dockID string) {
var taskIDs []string
if err := common.DB.Model(&model.TaskPlan{}).Where("dock_id = ?", dockID).Pluck("id", &taskIDs).Error; err != nil {
logger.ERROR("查询机巢任务计划失败", err)
return
}
for _, taskID := range taskIDs {
s.RemoveTask(taskID)
}
}
// executeTask 定时触发任务执行(admin 视角,绕过用户过滤) // executeTask 定时触发任务执行(admin 视角,绕过用户过滤)
func (s *Scheduler) executeTask(taskID string) { func (s *Scheduler) executeTask(taskID string) {
if _, busiErr := DefaultTaskService.Execute(0, true, taskID); busiErr != nil { if _, busiErr := DefaultTaskService.Execute(0, true, taskID); busiErr != nil {

106
service/sim_recharge_service.go

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@ -14,6 +15,7 @@ import (
"laic-backend/logger" "laic-backend/logger"
"laic-backend/model" "laic-backend/model"
"laic-backend/tool" "laic-backend/tool"
"laic-backend/vo"
) )
type simbossAdapter struct { type simbossAdapter struct {
@ -38,6 +40,108 @@ type SimRechargeService struct {
var DefaultSimRechargeService = &SimRechargeService{} 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(&current, cardID).Error; err != nil || s.simboss == nil {
return
}
if err := s.refreshCard(&current); 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(&current, id).Error == nil {
_ = s.refreshCard(&current)
}
}(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) { func (s *SimRechargeService) GetCard(userID, cardID int64, refresh bool) (*model.SimCard, *common.BusiError) {
var card model.SimCard var card model.SimCard
if err := common.DB.Where("id = ? AND user_id = ?", cardID, userID).First(&card).Error; err != nil { if err := common.DB.Where("id = ? AND user_id = ?", cardID, userID).First(&card).Error; err != nil {
@ -102,7 +206,7 @@ func (s *SimRechargeService) CreateOrder(userID, cardID, packageID int64) (*mode
if err != nil { if err != nil {
return err return err
} }
order = model.SimRechargeOrder{ID: id, UserID: userID, SimCardID: card.ID, PackageID: pkg.ID, PackageCode: pkg.Code, ProviderRatePlanID: pkg.ProviderRatePlanID, IccidSnapshot: card.Iccid, AmountGb: pkg.AmountGb, ValidityMonths: pkg.ValidityMonths, TotalFeeFen: pkg.SaleFeeFen, PaymentStatus: "unpaid", FulfillmentStatus: "pending", ExternalOrderNo: "sim-" + strconv.FormatInt(id, 10), CreatedAt: now, UpdatedAt: now}
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 return tx.Create(&order).Error
}) })
if err != nil { if err != nil {

11
sql/001_schema.sql

@ -36,7 +36,6 @@ CREATE TABLE IF NOT EXISTS dock (
name VARCHAR(64) DEFAULT '' COMMENT '名称', name VARCHAR(64) DEFAULT '' COMMENT '名称',
code VARCHAR(32) DEFAULT '' COMMENT '业务编码', code VARCHAR(32) DEFAULT '' COMMENT '业务编码',
sn VARCHAR(64) DEFAULT '' COMMENT '设备序列号', sn VARCHAR(64) DEFAULT '' COMMENT '设备序列号',
iccid VARCHAR(32) DEFAULT '' COMMENT 'SIM 卡 ICCID', -- 冗余,权威见 sim_card.iccid
longitude DOUBLE DEFAULT 0 COMMENT '经度', longitude DOUBLE DEFAULT 0 COMMENT '经度',
latitude DOUBLE DEFAULT 0 COMMENT '纬度', latitude DOUBLE DEFAULT 0 COMMENT '纬度',
altitude DOUBLE DEFAULT 0 COMMENT '海拔高度(米)', altitude DOUBLE DEFAULT 0 COMMENT '海拔高度(米)',
@ -47,6 +46,9 @@ CREATE TABLE IF NOT EXISTS dock (
protocol_ver VARCHAR(8) DEFAULT '1.0' COMMENT '协议版本', protocol_ver VARCHAR(8) DEFAULT '1.0' COMMENT '协议版本',
created_at DATETIME COMMENT '创建时间', created_at DATETIME COMMENT '创建时间',
updated_at DATETIME COMMENT '更新时间', updated_at DATETIME COMMENT '更新时间',
deleted_at DATETIME NULL COMMENT '软删除时间',
KEY idx_dock_user_deleted (user_id, deleted_at),
KEY idx_dock_deleted (deleted_at),
UNIQUE KEY idx_dock_id (dock_id) UNIQUE KEY idx_dock_id (dock_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -386,7 +388,8 @@ CREATE TABLE IF NOT EXISTS sim_card (
dock_id VARCHAR(64) COMMENT '机巢标识', dock_id VARCHAR(64) COMMENT '机巢标识',
carrier VARCHAR(16) COMMENT '运营商', -- 移动/联通/电信 carrier VARCHAR(16) COMMENT '运营商', -- 移动/联通/电信
phone VARCHAR(16) COMMENT '手机号', phone VARCHAR(16) COMMENT '手机号',
iccid VARCHAR(32) COMMENT 'SIM 卡 ICCID', -- 权威字段(查询/充值的入参)
iccid VARCHAR(32) NOT NULL COMMENT 'SIM 卡 ICCID,通信卡权威标识',
UNIQUE KEY uk_sim_card_iccid (iccid),
plan_gb INT COMMENT '套餐流量(GB)', plan_gb INT COMMENT '套餐流量(GB)',
used_gb DECIMAL(10,4) DEFAULT 0 COMMENT '已使用流量(GB)', used_gb DECIMAL(10,4) DEFAULT 0 COMMENT '已使用流量(GB)',
expired_at DATE COMMENT '到期日期', expired_at DATE COMMENT '到期日期',
@ -400,6 +403,8 @@ CREATE TABLE IF NOT EXISTS sim_card (
CREATE TABLE IF NOT EXISTS sim_recharge_log ( CREATE TABLE IF NOT EXISTS sim_recharge_log (
id BIGINT PRIMARY KEY COMMENT '主键 ID', id BIGINT PRIMARY KEY COMMENT '主键 ID',
sim_card_id BIGINT NOT NULL COMMENT 'SIM 卡记录 ID', sim_card_id BIGINT NOT NULL COMMENT 'SIM 卡记录 ID',
iccid_snapshot VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'ICCID 快照',
carrier_snapshot VARCHAR(16) NOT NULL DEFAULT '' COMMENT '运营商快照',
user_id BIGINT NOT NULL COMMENT '所属用户 ID', user_id BIGINT NOT NULL COMMENT '所属用户 ID',
amount_gb INT NOT NULL COMMENT '流量额度(GB)', amount_gb INT NOT NULL COMMENT '流量额度(GB)',
unit_price DECIMAL(6,2) COMMENT '单价', unit_price DECIMAL(6,2) COMMENT '单价',
@ -415,6 +420,8 @@ CREATE TABLE IF NOT EXISTS sim_recharge_log (
CREATE TABLE IF NOT EXISTS sim_usage_record ( CREATE TABLE IF NOT EXISTS sim_usage_record (
id BIGINT PRIMARY KEY COMMENT '主键 ID', id BIGINT PRIMARY KEY COMMENT '主键 ID',
sim_card_id BIGINT NOT NULL COMMENT 'SIM 卡记录 ID', sim_card_id BIGINT NOT NULL COMMENT 'SIM 卡记录 ID',
iccid_snapshot VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'ICCID 快照',
carrier_snapshot VARCHAR(16) NOT NULL DEFAULT '' COMMENT '运营商快照',
plan_gb INT COMMENT '套餐流量(GB)', plan_gb INT COMMENT '套餐流量(GB)',
used_gb DECIMAL(10,4) COMMENT '已使用流量(GB)', used_gb DECIMAL(10,4) COMMENT '已使用流量(GB)',
remain_gb DECIMAL(10,4) COMMENT '剩余流量(GB)', remain_gb DECIMAL(10,4) COMMENT '剩余流量(GB)',

26
sql/003_seed.sql

@ -36,23 +36,37 @@ SET @demo_user_id = (SELECT id FROM `user` WHERE phone = '13900000000');
-- 演示机巢 / 无人机(与 mock 设备 dock-1/2/3 对应) -- 演示机巢 / 无人机(与 mock 设备 dock-1/2/3 对应)
-- 若 MQTT 已自发现同 dock_id 的 pending 记录,则认领并补全名称坐标。 -- 若 MQTT 已自发现同 dock_id 的 pending 记录,则认领并补全名称坐标。
-- --------------------------------------------------------------- -- ---------------------------------------------------------------
INSERT INTO dock (id, user_id, dock_id, name, code, sn, iccid, longitude, latitude, altitude, location, register_status, dock_id_source, software_ver, protocol_ver, created_at, updated_at)
INSERT INTO dock (id, user_id, dock_id, name, code, sn, longitude, latitude, altitude, location, register_status, dock_id_source, software_ver, protocol_ver, created_at, updated_at)
VALUES VALUES
(1001, @demo_user_id, 'dock-1', '青山湖 01 号机巢', 'JG-DOCK-001', 'dock-1', '8986000000000000001', 119.802631, 30.262438, 15, '青山湖科技城 A 区', 'registered', 'seed', '1.3.0', '1.0', NOW(), NOW()),
(1002, @demo_user_id, 'dock-2', '青山湖 02 号机巢', 'JG-DOCK-002', 'dock-2', '8986000000000000002', 119.818264, 30.255917, 18, '青山湖科技城 B 区', 'registered', 'seed', '1.3.0', '1.0', NOW(), NOW()),
(1003, @demo_user_id, 'dock-3', '青山湖 03 号机巢', 'JG-DOCK-003', 'dock-3', '8986000000000000003', 119.791450, 30.248610, 12, '青山湖科技城 C 区', 'registered', 'seed', '1.3.0', '1.0', NOW(), NOW())
(1001, @demo_user_id, 'dock-1', '青山湖 01 号机巢', 'JG-DOCK-001', 'dock-1', 119.802631, 30.262438, 15, '青山湖科技城 A 区', 'registered', 'seed', '1.3.0', '1.0', NOW(), NOW()),
(1002, @demo_user_id, 'dock-2', '青山湖 02 号机巢', 'JG-DOCK-002', 'dock-2', 119.818264, 30.255917, 18, '青山湖科技城 B 区', 'registered', 'seed', '1.3.0', '1.0', NOW(), NOW()),
(1003, @demo_user_id, 'dock-3', '青山湖 03 号机巢', 'JG-DOCK-003', 'dock-3', 119.791450, 30.248610, 12, '青山湖科技城 C 区', 'registered', 'seed', '1.3.0', '1.0', NOW(), NOW())
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
user_id = @demo_user_id, user_id = @demo_user_id,
name = VALUES(name), name = VALUES(name),
code = VALUES(code), code = VALUES(code),
sn = VALUES(sn), sn = VALUES(sn),
iccid = VALUES(iccid),
longitude = VALUES(longitude), longitude = VALUES(longitude),
latitude = VALUES(latitude), latitude = VALUES(latitude),
location = VALUES(location), location = VALUES(location),
register_status = 'registered'; register_status = 'registered';
INSERT INTO drone (id, user_id, drone_sn, dock_id, name, code, model, battery, firmware_ver, created_at, updated_at)
-- 演示通信卡:通信卡以 ICCID 为业务主体,dock_id 仅记录当前来源/归属,不施加一对一约束。
INSERT INTO sim_card (id, user_id, dock_id, carrier, phone, iccid, plan_gb, used_gb, status, carrier_status, created_at, updated_at)
VALUES
(3001, @demo_user_id, 'dock-1', '中国移动', '', '8986000000000000001', 100, 0, 'active', 'normal', NOW(), NOW()),
(3002, @demo_user_id, 'dock-2', '中国移动', '', '8986000000000000002', 100, 0, 'active', 'normal', NOW(), NOW()),
(3003, @demo_user_id, 'dock-3', '中国移动', '', '8986000000000000003', 100, 0, 'active', 'normal', NOW(), NOW())
ON DUPLICATE KEY UPDATE
user_id = VALUES(user_id),
dock_id = VALUES(dock_id),
carrier = VALUES(carrier),
plan_gb = VALUES(plan_gb),
status = VALUES(status),
carrier_status = VALUES(carrier_status),
updated_at = NOW();
VALUES VALUES
(2001, @demo_user_id, 'JG-UAV-001', 'dock-1', '巡检无人机 001', 'JG-UAV-001', 'LAIC-X1', 86, '1.0.0', NOW(), NOW()), (2001, @demo_user_id, 'JG-UAV-001', 'dock-1', '巡检无人机 001', 'JG-UAV-001', 'LAIC-X1', 86, '1.0.0', NOW(), NOW()),
(2002, @demo_user_id, 'JG-UAV-002', 'dock-2', '巡检无人机 002', 'JG-UAV-002', 'LAIC-X1', 72, '1.0.0', NOW(), NOW()), (2002, @demo_user_id, 'JG-UAV-002', 'dock-2', '巡检无人机 002', 'JG-UAV-002', 'LAIC-X1', 72, '1.0.0', NOW(), NOW()),

28
sql/020_sim_card_authoritative.sql

@ -0,0 +1,28 @@
-- 通信卡以 sim_card.iccid 为权威数据;机巢不再保存冗余 ICCID。
-- 执行前请备份数据库。本迁移会先将已登记机巢中的旧 ICCID 回填到通信卡表,随后删除 dock.iccid。
-- 不给 dock_id 加唯一约束:通信卡与机巢仅保留可选关联,不限制一对一。
SET @next_sim_card_id := (SELECT COALESCE(MAX(id), 0) FROM sim_card);
SET @row_number := 0;
INSERT INTO sim_card (
id, user_id, dock_id, carrier, phone, iccid, plan_gb, used_gb,
status, carrier_status, created_at, updated_at
)
SELECT
@next_sim_card_id := @next_sim_card_id + 1,
source.user_id, source.dock_id, '', '', source.iccid, 0, 0,
'active', 'normal', NOW(), NOW()
FROM (
SELECT d.user_id, MIN(d.dock_id) AS dock_id, d.iccid
FROM dock d
LEFT JOIN sim_card s ON s.iccid = d.iccid
WHERE d.user_id > 0
AND d.iccid IS NOT NULL
AND TRIM(d.iccid) <> ''
AND s.id IS NULL
GROUP BY d.user_id, d.iccid
ORDER BY d.user_id, d.iccid
) AS source;
ALTER TABLE dock DROP COLUMN iccid;

7
sql/021_dock_soft_delete.sql

@ -0,0 +1,7 @@
-- 机巢软删除支持:保留直播、告警、任务、指令、视频等历史数据用于审计。
-- 执行前请备份数据库。
ALTER TABLE dock
ADD COLUMN deleted_at DATETIME NULL COMMENT '软删除时间' AFTER updated_at,
ADD KEY idx_dock_user_deleted (user_id, deleted_at),
ADD KEY idx_dock_deleted (deleted_at);

20
sql/022_sim_audit_snapshots.sql

@ -0,0 +1,20 @@
-- 为 SIM 充值日志和用量快照增加不可变身份快照,允许删除 sim_card 后继续审计。
-- 执行前请备份数据库。
ALTER TABLE sim_recharge_log
ADD COLUMN iccid_snapshot VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'ICCID 快照' AFTER sim_card_id,
ADD COLUMN carrier_snapshot VARCHAR(16) NOT NULL DEFAULT '' COMMENT '运营商快照' AFTER iccid_snapshot;
ALTER TABLE sim_usage_record
ADD COLUMN iccid_snapshot VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'ICCID 快照' AFTER sim_card_id,
ADD COLUMN carrier_snapshot VARCHAR(16) NOT NULL DEFAULT '' COMMENT '运营商快照' AFTER iccid_snapshot;
UPDATE sim_recharge_log l
JOIN sim_card c ON c.id = l.sim_card_id
SET l.iccid_snapshot = c.iccid, l.carrier_snapshot = c.carrier
WHERE l.iccid_snapshot = '';
UPDATE sim_usage_record r
JOIN sim_card c ON c.id = r.sim_card_id
SET r.iccid_snapshot = c.iccid, r.carrier_snapshot = c.carrier
WHERE r.iccid_snapshot = '';

6
vo/account_vo.go

@ -77,7 +77,11 @@ type PaymentTransactionVO struct {
PaidAt *time.Time `json:"paidAt"` PaidAt *time.Time `json:"paidAt"`
} }
// SimCardPageReq SIM 卡分页
type SimCardCreateReq struct {
Iccid string `json:"iccid" validate:"required,max=32"`
DockID string `json:"dockId" validate:"max=64"`
}
type SimCardPageReq struct { type SimCardPageReq struct {
common.Pagination common.Pagination
Status string `json:"status" form:"status"` // active / expired Status string `json:"status" form:"status"` // active / expired

1
vo/dock_vo.go

@ -34,6 +34,7 @@ type DockUpdateReq struct {
Longitude *float64 `json:"longitude"` Longitude *float64 `json:"longitude"`
Latitude *float64 `json:"latitude"` Latitude *float64 `json:"latitude"`
Altitude *float64 `json:"altitude"` Altitude *float64 `json:"altitude"`
Iccid string `json:"iccid" validate:"max=32"`
UserID int64 `json:"userId"` // 仅 admin 可改归属 UserID int64 `json:"userId"` // 仅 admin 可改归属
RegisterAs string `json:"registerAs"` // 空则不修改;registered 表示完成登记 RegisterAs string `json:"registerAs"` // 空则不修改;registered 表示完成登记
} }

33
vo/platform_billing_vo.go

@ -54,6 +54,39 @@ type TrafficPackagePageReq struct {
Status string `json:"status" form:"status"` Status string `json:"status" form:"status"`
} }
type SimPackagePageReq struct {
common.Pagination
Status string `json:"status" form:"status"`
Carrier string `json:"carrier" form:"carrier"`
}
type SimPackageCreateReq struct {
Code string `json:"code" validate:"required,max=64"`
Name string `json:"name" validate:"required,max=128"`
Carrier string `json:"carrier" validate:"required,max=16"`
ProviderRatePlanID int64 `json:"providerRatePlanId" validate:"required,gt=0"`
AmountGb int `json:"amountGb" validate:"required,gt=0"`
ValidityMonths int `json:"validityMonths" validate:"required,gt=0"`
CostFeeFen int64 `json:"costFeeFen" validate:"gte=0"`
SaleFeeFen int64 `json:"saleFeeFen" validate:"required,gt=0"`
Status string `json:"status" validate:"omitempty,oneof=active inactive"`
}
type SimPackageUpdateReq struct {
Name string `json:"name" validate:"required,max=128"`
Carrier string `json:"carrier" validate:"required,max=16"`
ProviderRatePlanID int64 `json:"providerRatePlanId" validate:"required,gt=0"`
AmountGb int `json:"amountGb" validate:"required,gt=0"`
ValidityMonths int `json:"validityMonths" validate:"required,gt=0"`
CostFeeFen int64 `json:"costFeeFen" validate:"gte=0"`
SaleFeeFen int64 `json:"saleFeeFen" validate:"required,gt=0"`
Status string `json:"status" validate:"required,oneof=active inactive"`
}
type SimPackageSyncReq struct {
Iccid string `json:"iccid" validate:"required,max=32"`
}
type TrafficPackageVO struct { type TrafficPackageVO struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Code string `json:"code"` Code string `json:"code"`

Loading…
Cancel
Save