diff --git a/handler/account_handler.go b/handler/account_handler.go index 22e68c0..91832d6 100644 --- a/handler/account_handler.go +++ b/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) diff --git a/handler/platform_billing_handler.go b/handler/platform_billing_handler.go index 685e064..35149c4 100644 --- a/handler/platform_billing_handler.go +++ b/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 { diff --git a/model/billing.go b/model/billing.go index 35f13bd..bdd785e 100644 --- a/model/billing.go +++ b/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 { diff --git a/model/dock.go b/model/dock.go index 602993d..a14292a 100644 --- a/model/dock.go +++ b/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 { diff --git a/model/sim_recharge.go b/model/sim_recharge.go index 5d927c1..285b284 100644 --- a/model/sim_recharge.go +++ b/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"` diff --git a/route/route.go b/route/route.go index 21df179..7f6e780 100644 --- a/route/route.go +++ b/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) diff --git a/service/billing_service.go b/service/billing_service.go index 8cb7609..3bbbd6d 100644 --- a/service/billing_service.go +++ b/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) diff --git a/service/dock_service.go b/service/dock_service.go index 27941e9..4366d6b 100644 --- a/service/dock_service.go +++ b/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}) } diff --git a/service/platform_billing_service.go b/service/platform_billing_service.go index 72b90a8..1788961 100644 --- a/service/platform_billing_service.go +++ b/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 { diff --git a/service/scheduler.go b/service/scheduler.go index 30b14f2..754eae9 100644 --- a/service/scheduler.go +++ b/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 { diff --git a/service/sim_recharge_service.go b/service/sim_recharge_service.go index f147023..b6743df 100644 --- a/service/sim_recharge_service.go +++ b/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(¤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 { @@ -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 { diff --git a/sql/001_schema.sql b/sql/001_schema.sql index 287639a..360428a 100644 --- a/sql/001_schema.sql +++ b/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)', diff --git a/sql/003_seed.sql b/sql/003_seed.sql index 8ccdc9e..b2c7941 100644 --- a/sql/003_seed.sql +++ b/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 保护。 \ No newline at end of file diff --git a/sql/020_sim_card_authoritative.sql b/sql/020_sim_card_authoritative.sql new file mode 100644 index 0000000..9dbe1b8 --- /dev/null +++ b/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; diff --git a/sql/021_dock_soft_delete.sql b/sql/021_dock_soft_delete.sql new file mode 100644 index 0000000..e49731e --- /dev/null +++ b/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); diff --git a/sql/022_sim_audit_snapshots.sql b/sql/022_sim_audit_snapshots.sql new file mode 100644 index 0000000..5f8ba37 --- /dev/null +++ b/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 = ''; diff --git a/vo/account_vo.go b/vo/account_vo.go index 404fced..894f59a 100644 --- a/vo/account_vo.go +++ b/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 diff --git a/vo/dock_vo.go b/vo/dock_vo.go index 834819c..3c02e68 100644 --- a/vo/dock_vo.go +++ b/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 表示完成登记 } diff --git a/vo/platform_billing_vo.go b/vo/platform_billing_vo.go index a78fe66..6203c9b 100644 --- a/vo/platform_billing_vo.go +++ b/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"`