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. 40
      model/billing.go
  4. 40
      model/dock.go
  5. 5
      model/sim_recharge.go
  6. 13
      route/route.go
  7. 16
      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. 152
      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)
}
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) {
var req vo.SimCardPageReq
bindPage(c, &req.Pagination)

68
handler/platform_billing_handler.go

@ -29,6 +29,74 @@ func GrantTrafficForTest(c *gin.Context) {
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) {
data, e := service.DefaultPlatformBillingService.GetPool()
if e != nil {

40
model/billing.go

@ -73,17 +73,19 @@ func (SimCard) TableName() string {
// SimRechargeLog SIM 卡充值记录
type SimRechargeLog struct {
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"`
UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"`
AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"`
UnitPrice float64 `gorm:"column:unit_price;type:DECIMAL(6,2)" json:"unitPrice"`
TotalPrice float64 `gorm:"column:total_price;type:DECIMAL(10,2)" json:"totalPrice"`
PayStatus string `gorm:"column:pay_status;type:VARCHAR(16);default:unpaid" json:"payStatus"`
RechargeStatus string `gorm:"column:recharge_status;type:VARCHAR(16);default:pending" json:"rechargeStatus"` // pending / success / failed
RechargeMsg string `gorm:"column:recharge_msg;type:VARCHAR(256);default:''" json:"rechargeMsg"`
IdempotentKey string `gorm:"column:idempotent_key;type:VARCHAR(64);not null;uniqueIndex:uk_recharge_idem" json:"idempotentKey"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
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"`
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"`
AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"`
UnitPrice float64 `gorm:"column:unit_price;type:DECIMAL(6,2)" json:"unitPrice"`
TotalPrice float64 `gorm:"column:total_price;type:DECIMAL(10,2)" json:"totalPrice"`
PayStatus string `gorm:"column:pay_status;type:VARCHAR(16);default:unpaid" json:"payStatus"`
RechargeStatus string `gorm:"column:recharge_status;type:VARCHAR(16);default:pending" json:"rechargeStatus"` // pending / success / failed
RechargeMsg string `gorm:"column:recharge_msg;type:VARCHAR(256);default:''" json:"rechargeMsg"`
IdempotentKey string `gorm:"column:idempotent_key;type:VARCHAR(64);not null;uniqueIndex:uk_recharge_idem" json:"idempotentKey"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (SimRechargeLog) TableName() string {
@ -92,13 +94,15 @@ func (SimRechargeLog) TableName() string {
// SimUsageRecord SIM 卡用量快照
type SimUsageRecord struct {
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"`
PlanGb int `gorm:"column:plan_gb;type:INT" json:"planGb"`
UsedGb float64 `gorm:"column:used_gb;type:DECIMAL(10,4)" json:"usedGb"`
RemainGb float64 `gorm:"column:remain_gb;type:DECIMAL(10,4)" json:"remainGb"`
CarrierStatus string `gorm:"column:carrier_status;type:VARCHAR(16)" json:"carrierStatus"`
SyncedAt time.Time `gorm:"column:synced_at;index:idx_record_sim" json:"syncedAt"`
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"`
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"`
UsedGb float64 `gorm:"column:used_gb;type:DECIMAL(10,4)" json:"usedGb"`
RemainGb float64 `gorm:"column:remain_gb;type:DECIMAL(10,4)" json:"remainGb"`
CarrierStatus string `gorm:"column:carrier_status;type:VARCHAR(16)" json:"carrierStatus"`
SyncedAt time.Time `gorm:"column:synced_at;index:idx_record_sim" json:"syncedAt"`
}
func (SimUsageRecord) TableName() string {

40
model/dock.go

@ -1,26 +1,30 @@
package model
import "time"
import (
"time"
"gorm.io/gorm"
)
// Dock 机巢
type Dock struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"`
DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null;uniqueIndex:idx_dock_id" json:"dockId"`
Name string `gorm:"column:name;type:VARCHAR(64);default:''" json:"name"`
Code string `gorm:"column:code;type:VARCHAR(32);default:''" json:"code"`
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"`
Latitude float64 `gorm:"column:latitude;type:DOUBLE;default:0" json:"latitude"`
Altitude float64 `gorm:"column:altitude;type:DOUBLE;default:0" json:"altitude"`
Location string `gorm:"column:location;type:VARCHAR(128);default:''" json:"location"`
RegisterStatus string `gorm:"column:register_status;type:VARCHAR(16);default:pending" json:"registerStatus"`
DockIDSource string `gorm:"column:dock_id_source;type:VARCHAR(32);default:''" json:"dockIdSource"`
SoftwareVer string `gorm:"column:software_ver;type:VARCHAR(16);default:''" json:"softwareVer"`
ProtocolVer string `gorm:"column:protocol_ver;type:VARCHAR(8);default:'1.0'" json:"protocolVer"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"`
DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null;uniqueIndex:idx_dock_id" json:"dockId"`
Name string `gorm:"column:name;type:VARCHAR(64);default:''" json:"name"`
Code string `gorm:"column:code;type:VARCHAR(32);default:''" json:"code"`
SN string `gorm:"column:sn;type:VARCHAR(64);default:''" json:"sn"`
Longitude float64 `gorm:"column:longitude;type:DOUBLE;default:0" json:"longitude"`
Latitude float64 `gorm:"column:latitude;type:DOUBLE;default:0" json:"latitude"`
Altitude float64 `gorm:"column:altitude;type:DOUBLE;default:0" json:"altitude"`
Location string `gorm:"column:location;type:VARCHAR(128);default:''" json:"location"`
RegisterStatus string `gorm:"column:register_status;type:VARCHAR(16);default:pending" json:"registerStatus"`
DockIDSource string `gorm:"column:dock_id_source;type:VARCHAR(32);default:''" json:"dockIdSource"`
SoftwareVer string `gorm:"column:software_ver;type:VARCHAR(16);default:''" json:"softwareVer"`
ProtocolVer string `gorm:"column:protocol_ver;type:VARCHAR(8);default:'1.0'" json:"protocolVer"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
}
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"`
Name string `gorm:"column:name;type:VARCHAR(128);not null" json:"name"`
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"`
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"`
Status string `gorm:"column:status;type:VARCHAR(16);not null;default:active" json:"status"`
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"`
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:"-"`
CarrierSnapshot string `gorm:"column:carrier_snapshot;type:VARCHAR(16)" json:"carrierSnapshot"`
AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"`
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"`

13
route/route.go

@ -62,6 +62,8 @@ func InitRouter(port int32) {
account.POST("/payments", handler.CreateWechatPayment)
account.GET("/payments/:id", handler.GetPaymentTransaction)
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-packages", handler.ListSimPackages)
account.POST("/sim-cards/:id/recharge-orders", handler.CreateSimRechargeOrder)
@ -198,7 +200,16 @@ func InitRouter(port int32) {
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.GET("", handler.GetUserPage)

16
service/billing_service.go

@ -686,13 +686,15 @@ func (b *BillingService) syncSimCard(card *model.SimCard, now time.Time) {
remainGb := float64(card.PlanGb) - usedGb
record := &model.SimUsageRecord{
ID: mustID(),
SimCardID: card.ID,
PlanGb: card.PlanGb,
UsedGb: usedGb,
RemainGb: remainGb,
CarrierStatus: carrierStatus,
SyncedAt: now,
ID: mustID(),
SimCardID: card.ID,
IccidSnapshot: card.Iccid,
CarrierSnapshot: card.Carrier,
PlanGb: card.PlanGb,
UsedGb: usedGb,
RemainGb: remainGb,
CarrierStatus: carrierStatus,
SyncedAt: now,
}
if err := common.DB.Create(record).Error; err != nil {
logger.ERROR("写入 SIM 用量快照失败", err)

137
service/dock_service.go

@ -94,7 +94,28 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
}
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 {
return nil, common.ErrDockExists
}
@ -112,9 +133,6 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
if req.SN != "" {
updates["sn"] = req.SN
}
if req.Iccid != "" {
updates["iccid"] = req.Iccid
}
if 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
}
common.DB.First(&existing, existing.ID)
if req.Iccid != "" {
DefaultSimRechargeService.EnsureCardFromICCID(owner, existing.DockID, req.Iccid)
}
return &existing, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
logger.ERROR("查询机巢失败", err)
@ -149,7 +170,6 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
Name: req.Name,
Code: req.Code,
SN: req.SN,
Iccid: req.Iccid,
Location: req.Location,
Longitude: req.Longitude,
Latitude: req.Latitude,
@ -167,6 +187,9 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
logger.ERROR("登记机巢失败", err)
return nil, common.ErrInternal
}
if req.Iccid != "" {
DefaultSimRechargeService.EnsureCardFromICCID(owner, dock.DockID, req.Iccid)
}
return dock, nil
}
@ -227,10 +250,13 @@ func (s *DockService) Update(userID int64, isAdmin bool, id int64, req *vo.DockU
return nil, common.ErrInternal
}
common.DB.First(&dock, id)
if req.RegisterAs == "registered" && req.Iccid != "" {
DefaultSimRechargeService.EnsureCardFromICCID(dock.UserID, dock.DockID, req.Iccid)
}
return &dock, nil
}
// Delete 硬删除机巢及其关联设备数据
// Delete 软删除机巢:保留任务、直播、告警、指令、视频等历史数据用于审计。
func (s *DockService) Delete(userID int64, isAdmin bool, id int64) *common.BusiError {
var dock model.Dock
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
}
var taskIDs []string
var droneSNs []string
var simCardIDs []int64
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 {
// 防止删除后 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
}
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 {
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
}); err != nil {
logger.ERROR("删除机巢失败", err)
logger.ERROR("软删除机巢失败", err)
return common.ErrInternal
}
for _, taskID := range taskIDs {
DefaultScheduler.RemoveTask(taskID)
}
DefaultScheduler.RemoveTaskForDock(dock.DockID)
_ = common.SetRemove(cache.OnlineDockSetKey, dock.DockID)
_ = common.Delete(cache.DockHeartbeatKeyOf(dock.DockID))
_ = common.Delete(cache.DockStatusKeyOf(dock.DockID))
@ -316,9 +300,14 @@ func (s *DockService) realtime(dockID string) (bool, map[string]string) {
// EnsureFromOnline 设备自发现:不存在则登记为 pending,存在则更新在线状态与软件版本
func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
online := st.Status == "online"
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) {
id, e := tool.NextID()
if e != nil {
@ -326,29 +315,16 @@ func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
return
}
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 {
logger.ERROR("登记机巢失败", err)
return
}
} else if err == nil {
updates := map[string]any{"updated_at": time.Now()}
if dock.DeletedAt.Valid {
updates["deleted_at"] = nil
}
if 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 {
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)
}
} else {
logger.ERROR("查询机巢在线状态失败", err)
return
}
// 在线集合
if online {
_ = common.SetAdd(cache.OnlineDockSetKey, dockID)
refreshDeviceHeartbeat(cache.DockHeartbeatKeyOf(dockID))
@ -380,11 +357,5 @@ func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
_ = common.SetRemove(cache.OnlineDockSetKey, 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
}
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) {
id, err := tool.NextID()
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 视角,绕过用户过滤)
func (s *Scheduler) executeTask(taskID string) {
if _, busiErr := DefaultTaskService.Execute(0, true, taskID); busiErr != nil {

106
service/sim_recharge_service.go

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"gorm.io/gorm"
@ -14,6 +15,7 @@ import (
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type simbossAdapter struct {
@ -38,6 +40,108 @@ type SimRechargeService struct {
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) {
var card model.SimCard
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 {
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
})
if err != nil {

11
sql/001_schema.sql

@ -36,7 +36,6 @@ CREATE TABLE IF NOT EXISTS dock (
name VARCHAR(64) DEFAULT '' COMMENT '名称',
code VARCHAR(32) DEFAULT '' COMMENT '业务编码',
sn VARCHAR(64) DEFAULT '' COMMENT '设备序列号',
iccid VARCHAR(32) DEFAULT '' COMMENT 'SIM 卡 ICCID', -- 冗余,权威见 sim_card.iccid
longitude DOUBLE DEFAULT 0 COMMENT '经度',
latitude 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 '协议版本',
created_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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -386,7 +388,8 @@ CREATE TABLE IF NOT EXISTS sim_card (
dock_id VARCHAR(64) COMMENT '机巢标识',
carrier 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)',
used_gb DECIMAL(10,4) DEFAULT 0 COMMENT '已使用流量(GB)',
expired_at DATE COMMENT '到期日期',
@ -400,6 +403,8 @@ CREATE TABLE IF NOT EXISTS sim_card (
CREATE TABLE IF NOT EXISTS sim_recharge_log (
id BIGINT PRIMARY KEY COMMENT '主键 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',
amount_gb INT NOT NULL COMMENT '流量额度(GB)',
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 (
id BIGINT PRIMARY KEY COMMENT '主键 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)',
used_gb DECIMAL(10,4) COMMENT '已使用流量(GB)',
remain_gb DECIMAL(10,4) COMMENT '剩余流量(GB)',

152
sql/003_seed.sql

@ -1,70 +1,84 @@
-- =============================================================
-- 种子数据
-- =============================================================
USE laic;
-- ---------------------------------------------------------------
-- 默认管理员账号
-- 手机号: 13800000000
-- 密码: admin123456 (bcrypt 加密)
-- ⚠ 上线前务必修改默认密码
-- ---------------------------------------------------------------
INSERT INTO `user` (id, name, phone, email, password, role, traffic_balance, status, created_at, updated_at)
VALUES (1, '管理员', '13800000000', 'admin@laic.local',
'$2a$10$vpTDi8nUl/Lyb4KBjtHLBevsCQ08FB46bLgJG3JxVACzqYOWD3YSq',
'admin', 0, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE role = 'admin';
-- ---------------------------------------------------------------
-- 演示业务账号(远程监控 / 设备控制走普通用户)
-- 手机号: 13900000000
-- 密码: admin123456
-- ---------------------------------------------------------------
INSERT INTO `user` (id, name, phone, email, password, role, traffic_balance, status, created_at, updated_at)
VALUES (2, '演示用户', '13900000000', 'demo@laic.local',
'$2a$10$vpTDi8nUl/Lyb4KBjtHLBevsCQ08FB46bLgJG3JxVACzqYOWD3YSq',
'user', 10737418240, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE
password = VALUES(password),
role = 'user',
status = 1;
SET @demo_user_id = (SELECT id FROM `user` WHERE phone = '13900000000');
-- ---------------------------------------------------------------
-- 演示机巢 / 无人机(与 mock 设备 dock-1/2/3 对应)
-- 若 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)
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())
ON DUPLICATE KEY UPDATE
user_id = @demo_user_id,
name = VALUES(name),
code = VALUES(code),
sn = VALUES(sn),
iccid = VALUES(iccid),
longitude = VALUES(longitude),
latitude = VALUES(latitude),
location = VALUES(location),
register_status = 'registered';
INSERT INTO drone (id, user_id, drone_sn, dock_id, name, code, model, battery, firmware_ver, created_at, updated_at)
VALUES
(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()),
(2003, @demo_user_id, 'JG-UAV-003', 'dock-3', '巡检无人机 003', 'JG-UAV-003', 'LAIC-X1', 41, '1.0.0', NOW(), NOW())
ON DUPLICATE KEY UPDATE
user_id = @demo_user_id,
dock_id = VALUES(dock_id),
name = VALUES(name),
code = VALUES(code),
model = VALUES(model);
DELETE FROM drone WHERE drone_sn = 'JG-UAV-MOCK01';
DELETE FROM dock WHERE dock_id IN ('JG-DK-MOCK01', '333');
-- =============================================================
-- 种子数据
-- =============================================================
USE laic;
-- ---------------------------------------------------------------
-- 默认管理员账号
-- 手机号: 13800000000
-- 密码: admin123456 (bcrypt 加密)
-- ⚠ 上线前务必修改默认密码
-- ---------------------------------------------------------------
INSERT INTO `user` (id, name, phone, email, password, role, traffic_balance, status, created_at, updated_at)
VALUES (1, '管理员', '13800000000', 'admin@laic.local',
'$2a$10$vpTDi8nUl/Lyb4KBjtHLBevsCQ08FB46bLgJG3JxVACzqYOWD3YSq',
'admin', 0, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE role = 'admin';
-- ---------------------------------------------------------------
-- 演示业务账号(远程监控 / 设备控制走普通用户)
-- 手机号: 13900000000
-- 密码: admin123456
-- ---------------------------------------------------------------
INSERT INTO `user` (id, name, phone, email, password, role, traffic_balance, status, created_at, updated_at)
VALUES (2, '演示用户', '13900000000', 'demo@laic.local',
'$2a$10$vpTDi8nUl/Lyb4KBjtHLBevsCQ08FB46bLgJG3JxVACzqYOWD3YSq',
'user', 10737418240, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE
password = VALUES(password),
role = 'user',
status = 1;
SET @demo_user_id = (SELECT id FROM `user` WHERE phone = '13900000000');
-- ---------------------------------------------------------------
-- 演示机巢 / 无人机(与 mock 设备 dock-1/2/3 对应)
-- 若 MQTT 已自发现同 dock_id 的 pending 记录,则认领并补全名称坐标。
-- ---------------------------------------------------------------
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
(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
user_id = @demo_user_id,
name = VALUES(name),
code = VALUES(code),
sn = VALUES(sn),
longitude = VALUES(longitude),
latitude = VALUES(latitude),
location = VALUES(location),
register_status = 'registered';
-- 演示通信卡:通信卡以 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
(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()),
(2003, @demo_user_id, 'JG-UAV-003', 'dock-3', '巡检无人机 003', 'JG-UAV-003', 'LAIC-X1', 41, '1.0.0', NOW(), NOW())
ON DUPLICATE KEY UPDATE
user_id = @demo_user_id,
dock_id = VALUES(dock_id),
name = VALUES(name),
code = VALUES(code),
model = VALUES(model);
DELETE FROM drone WHERE drone_sn = 'JG-UAV-MOCK01';
DELETE FROM dock WHERE dock_id IN ('JG-DK-MOCK01', '333');
-- 角色由 user.role 维护,管理员接口由路由 AdminMiddleware 保护。

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"`
}
// SimCardPageReq SIM 卡分页
type SimCardCreateReq struct {
Iccid string `json:"iccid" validate:"required,max=32"`
DockID string `json:"dockId" validate:"max=64"`
}
type SimCardPageReq struct {
common.Pagination
Status string `json:"status" form:"status"` // active / expired

1
vo/dock_vo.go

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

33
vo/platform_billing_vo.go

@ -54,6 +54,39 @@ type TrafficPackagePageReq struct {
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 {
ID int64 `json:"id"`
Code string `json:"code"`

Loading…
Cancel
Save