Browse Source

feat: implement device operations and live workflow

Add the backend application, MQTT device lifecycle, task execution tracking, media control, and supporting persistence.
master
刘浩东 1 month ago
parent
commit
835670af15
  1. 1031
      DESIGN.md
  2. 10
      Makefile
  3. 82
      cache/cache_key.go
  4. 17
      cache/warmup.go
  5. 14
      casbin.model
  6. 46
      common/auth_util.go
  7. 128
      common/busi_error.go
  8. 54
      common/casbin.go
  9. 99
      common/config.go
  10. 128
      common/gin.go
  11. 87
      common/mysql.go
  12. 101
      common/oss.go
  13. 28
      common/pageutil.go
  14. 264
      common/redis.go
  15. 34
      common/tdengine.go
  16. 81
      common/validator.go
  17. 47
      config.yaml
  18. 94
      go.mod
  19. 409
      go.sum
  20. 166
      handler/account_handler.go
  21. 55
      handler/alarm_handler.go
  22. 98
      handler/auth_handler.go
  23. 62
      handler/command_handler.go
  24. 106
      handler/dock_handler.go
  25. 91
      handler/drone_handler.go
  26. 57
      handler/execution_handler.go
  27. 120
      handler/firmware_handler.go
  28. 133
      handler/live_handler.go
  29. 91
      handler/route_handler.go
  30. 102
      handler/system_handler.go
  31. 105
      handler/task_handler.go
  32. 27
      handler/util.go
  33. 89
      handler/video_handler.go
  34. 61
      live/adapter.go
  35. 43
      live/factory.go
  36. 90
      live/providers.go
  37. 80
      logger/gorm_log.go
  38. 318
      logger/logger.go
  39. 69
      main.go
  40. 75
      middleware/auth.go
  41. 30
      middleware/cros.go
  42. 25
      middleware/default_log.go
  43. 155
      middleware/operation_log.go
  44. 47
      middleware/rate_limit.go
  45. 26
      middleware/trace.go
  46. 638
      mock/dock.go
  47. 73
      mock/main.go
  48. 705
      mock/mission.go
  49. 109
      mock/mock.log
  50. 37
      model/alarm.go
  51. 91
      model/billing.go
  52. 25
      model/command.go
  53. 49
      model/dock.go
  54. 24
      model/firmware.go
  55. 80
      model/media.go
  56. 20
      model/operation_log.go
  57. 35
      model/route.go
  58. 62
      model/task.go
  59. 22
      model/user.go
  60. 54
      mqtt/message.go
  61. 72
      mqtt/mqtt.go
  62. 171
      route/route.go
  63. 94
      service/account_service.go
  64. 200
      service/alarm_service.go
  65. 478
      service/billing_service.go
  66. 90
      service/casbin_bootstrap.go
  67. 304
      service/command_service.go
  68. 355
      service/dock_service.go
  69. 206
      service/drone_service.go
  70. 75
      service/execution_service.go
  71. 220
      service/firmware_service.go
  72. 90
      service/heartbeat.go
  73. 393
      service/live_service.go
  74. 408
      service/mqtt_subscriber.go
  75. 80
      service/operation_log_service.go
  76. 190
      service/route_service.go
  77. 104
      service/scheduler.go
  78. 152
      service/system_service.go
  79. 372
      service/task_service.go
  80. 171
      service/telemetry_store.go
  81. 133
      service/trajectory_store.go
  82. 207
      service/user_service.go
  83. 231
      service/video_service.go
  84. 129
      service/workflow_service.go
  85. 360
      sql/001_schema.sql
  86. 144
      sql/002_alarm_codes.sql
  87. 73
      sql/003_seed.sql
  88. 27
      sql/004_live_control.sql
  89. 115
      sql/005_live_control_upgrade.sql
  90. 41
      sql/tdengine_schema.sql
  91. 121
      token/token.go
  92. 18
      tool/geo.go
  93. 117
      tool/snowflake.go
  94. 35
      tool/str.go
  95. 28
      tool/time.go
  96. 50
      vo/account_vo.go
  97. 13
      vo/alarm_vo.go
  98. 81
      vo/auth_vo.go
  99. 20
      vo/command_vo.go
  100. 54
      vo/dock_vo.go

1031
DESIGN.md

File diff suppressed because it is too large

10
Makefile

@ -0,0 +1,10 @@
.PHONY: build run clean
build:
go build -o laic-backend .
run:
go run .
clean:
rm -f laic-backend

82
cache/cache_key.go

@ -0,0 +1,82 @@
package cache
import "fmt"
const (
UserTokenKey = "laic:token:%d:%s" // 用户 access token 存证(userId, shortId)
RefreshTokenKey = "laic:refresh:%d:%s" // 用户 refresh token 存证(userId, shortId)
SmsCodeKey = "laic:sms:%s" // 短信验证码(phone)
DockStatusKey = "device:dock:%s:status" // 机巢实时状态 Hash(dockId)
DockAlarmKey = "device:dock:%s:alarm_codes" // 机巢告警码 Set(dockId)
DroneTelemetryKey = "device:drone:%s:telemetry" // 无人机遥测 Hash(dockId)
DroneAlarmKey = "device:drone:%s:alarm_codes" // 无人机告警码 Set(dockId)
OtaStatusKey = "device:dock:%s:ota" // 机巢 OTA 升级进度 Hash(dockId)
WorkflowKey = "device:workflow:%s" // 当前工作流状态 Hash(dockId)
VideoKey = "device:video:%s" // 当前视频推流状态 Hash(dockId)
OnlineDockSetKey = "laic:devices:online:dock" // 在线机巢 Set
OnlineDroneSetKey = "laic:devices:online:drone" // 在线无人机 Set
DockHeartbeatKey = "laic:devices:heartbeat:dock:%s"
DroneHeartbeatKey = "laic:devices:heartbeat:drone:%s"
TrafficKey = "laic:traffic:%d" // 用户云媒体流量余额(字节,Redis 为权威)
TrafficKeyPrefix = "laic:traffic:" // 流量余额 key 前缀(快照刷新扫描用)
LiveBilledKey = "laic:live:billed:%s" // 直播会话上次计费时间戳(sessionId)
)
func UserTokenKeyOf(userID int64, shortID string) string {
return fmt.Sprintf(UserTokenKey, userID, shortID)
}
func RefreshTokenKeyOf(userID int64, shortID string) string {
return fmt.Sprintf(RefreshTokenKey, userID, shortID)
}
func SmsCodeKeyOf(phone string) string {
return fmt.Sprintf(SmsCodeKey, phone)
}
func DockStatusKeyOf(dockID string) string {
return fmt.Sprintf(DockStatusKey, dockID)
}
func DockAlarmKeyOf(dockID string) string {
return fmt.Sprintf(DockAlarmKey, dockID)
}
func DroneTelemetryKeyOf(dockID string) string {
return fmt.Sprintf(DroneTelemetryKey, dockID)
}
func DroneAlarmKeyOf(dockID string) string {
return fmt.Sprintf(DroneAlarmKey, dockID)
}
func OtaStatusKeyOf(dockID string) string {
return fmt.Sprintf(OtaStatusKey, dockID)
}
func WorkflowKeyOf(dockID string) string {
return fmt.Sprintf(WorkflowKey, dockID)
}
func VideoKeyOf(dockID string) string {
return fmt.Sprintf(VideoKey, dockID)
}
func DockHeartbeatKeyOf(dockID string) string {
return fmt.Sprintf(DockHeartbeatKey, dockID)
}
func DroneHeartbeatKeyOf(droneSN string) string {
return fmt.Sprintf(DroneHeartbeatKey, droneSN)
}
func TrafficKeyOf(userID int64) string {
return fmt.Sprintf(TrafficKey, userID)
}
func LiveBilledKeyOf(sessionID string) string {
return fmt.Sprintf(LiveBilledKey, sessionID)
}

17
cache/warmup.go

@ -0,0 +1,17 @@
package cache
import (
"laic-backend/common"
"laic-backend/model"
)
// WarmUp 预热缓存:将用户流量余额从 MySQL 快照 SetNX 到 Redis(仅缺 key 时写入)
func WarmUp() {
var users []model.User
if err := common.DB.Find(&users).Error; err != nil {
return
}
for _, u := range users {
_, _ = common.SetNX(TrafficKeyOf(u.ID), u.TrafficBalance)
}
}

14
casbin.model

@ -0,0 +1,14 @@
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*")

46
common/auth_util.go

@ -0,0 +1,46 @@
package common
import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
RoleAdmin = "admin"
RoleUser = "user"
)
// IsAdmin 判断当前用户是否为 admin(全量数据)
func IsAdmin(c *gin.Context) bool {
roles := c.MustGet("roles").([]string)
for _, role := range roles {
if role == RoleAdmin {
return true
}
}
return false
}
// GetUserId 从上下文获取当前用户 ID
func GetUserId(c *gin.Context) int64 {
return c.GetInt64("user_id")
}
// GetAuthFilter admin 返回空 filter(全量),user 返回 user_id 过滤
func GetAuthFilter(c *gin.Context) map[string]interface{} {
filter := make(map[string]interface{})
if IsAdmin(c) {
return filter
}
filter["user_id"] = GetUserId(c)
return filter
}
// WithUserFilter admin 不过滤,user 按 user_id 过滤(GORM scope)
func WithUserFilter(c *gin.Context) func(db *gorm.DB) *gorm.DB {
if IsAdmin(c) {
return func(db *gorm.DB) *gorm.DB { return db }
}
uid := GetUserId(c)
return func(db *gorm.DB) *gorm.DB { return db.Where("user_id = ?", uid) }
}

128
common/busi_error.go

@ -0,0 +1,128 @@
package common
// BusiError 业务错误类型
type BusiError struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
func (e *BusiError) Error() string {
return e.Msg
}
// 通用错误码
const (
Success = 200
ParamError = 400
Unauthorized = 401
Forbidden = 403
NotFound = 404
InternalError = 500
)
// 业务错误码(按模块分段)
const (
// 认证/用户 51xxx
UserPhoneExists = 51001
UserNotFound = 51002
PasswordError = 51003
TokenInvalid = 51004
TokenExpired = 51005
SmsCodeError = 51006
// 机巢 52xxx
DockNotFound = 52001
DockOffline = 52002
DockPending = 52003
DockExists = 52004
CommandNotFound = 52005
// 无人机 53xxx
DroneNotFound = 53001
// 告警 54xxx
AlarmNotFound = 54001
// 固件 55xxx
FirmwareNotFound = 55001
FirmwareVersionExist = 55002
FirmwareNotReleased = 55003
// 任务/航线 56xxx
TaskNotFound = 56001
RouteNotFound = 56002
TaskStateErr = 56003
ExecutionNotFound = 56004
// 媒体 57xxx
LiveNotFound = 57001
VideoNotFound = 57002
VideoUploading = 57003
LiveProviderDisabled = 57004
LiveProviderInvalid = 57005
LiveLeaseNotFound = 57006
// 计费 58xxx
TrafficNotEnough = 58001
OrderPaid = 58002
SimCardNotFound = 58003
OrderNotFound = 58004
)
// 预定义的业务错误
var (
ErrSuccess = &BusiError{Code: Success, Msg: "成功"}
ErrParam = &BusiError{Code: ParamError, Msg: "参数错误"}
ErrUnauthorized = &BusiError{Code: Unauthorized, Msg: "未登录"}
ErrToken = &BusiError{Code: Unauthorized, Msg: "Token异常"}
SignOutToken = &BusiError{Code: Unauthorized, Msg: "Token已失效"}
ErrForbidden = &BusiError{Code: Forbidden, Msg: "无权限访问"}
ErrNotFound = &BusiError{Code: NotFound, Msg: "资源不存在"}
ErrInternal = &BusiError{Code: InternalError, Msg: "系统内部错误"}
ErrUserPhoneExists = &BusiError{Code: UserPhoneExists, Msg: "该手机号已注册"}
ErrUserNotFound = &BusiError{Code: UserNotFound, Msg: "用户不存在"}
ErrPasswordError = &BusiError{Code: PasswordError, Msg: "用户名或密码错误"}
ErrTokenInvalid = &BusiError{Code: TokenInvalid, Msg: "Token无效"}
ErrTokenExpired = &BusiError{Code: TokenExpired, Msg: "Token已过期"}
ErrSmsCodeError = &BusiError{Code: SmsCodeError, Msg: "短信验证码错误"}
ErrDockNotFound = &BusiError{Code: DockNotFound, Msg: "机巢不存在"}
ErrDockOffline = &BusiError{Code: DockOffline, Msg: "机巢离线"}
ErrDockPending = &BusiError{Code: DockPending, Msg: "机巢待登记"}
ErrDockExists = &BusiError{Code: DockExists, Msg: "该机巢编号已存在"}
ErrCommandNotFound = &BusiError{Code: CommandNotFound, Msg: "指令记录不存在"}
ErrDroneNotFound = &BusiError{Code: DroneNotFound, Msg: "无人机不存在"}
ErrAlarmNotFound = &BusiError{Code: AlarmNotFound, Msg: "告警不存在"}
ErrFirmwareNotFound = &BusiError{Code: FirmwareNotFound, Msg: "固件版本不存在"}
ErrFirmwareVersionExist = &BusiError{Code: FirmwareVersionExist, Msg: "该固件版本已存在"}
ErrFirmwareNotReleased = &BusiError{Code: FirmwareNotReleased, Msg: "固件未发布,无法下发升级"}
ErrTaskNotFound = &BusiError{Code: TaskNotFound, Msg: "任务不存在"}
ErrRouteNotFound = &BusiError{Code: RouteNotFound, Msg: "航线不存在"}
ErrTaskStateErr = &BusiError{Code: TaskStateErr, Msg: "任务状态不允许此操作"}
ErrExecutionNotFound = &BusiError{Code: ExecutionNotFound, Msg: "执行记录不存在"}
ErrLiveNotFound = &BusiError{Code: LiveNotFound, Msg: "直播会话不存在"}
ErrLiveProviderDisabled = &BusiError{Code: LiveProviderDisabled, Msg: "直播服务未配置"}
ErrLiveProviderInvalid = &BusiError{Code: LiveProviderInvalid, Msg: "直播服务配置无效"}
ErrLiveLeaseNotFound = &BusiError{Code: LiveLeaseNotFound, Msg: "直播观看租约不存在或已过期"}
ErrVideoNotFound = &BusiError{Code: VideoNotFound, Msg: "视频不存在"}
ErrVideoUploading = &BusiError{Code: VideoUploading, Msg: "视频上传中"}
ErrTrafficNotEnough = &BusiError{Code: TrafficNotEnough, Msg: "流量不足,请充值"}
ErrOrderPaid = &BusiError{Code: OrderPaid, Msg: "该订单已支付"}
ErrSimCardNotFound = &BusiError{Code: SimCardNotFound, Msg: "SIM卡不存在"}
ErrOrderNotFound = &BusiError{Code: OrderNotFound, Msg: "订单不存在"}
)
// NewBusiError 创建自定义业务错误
func NewBusiError(code int, msg string) *BusiError {
return &BusiError{
Code: code,
Msg: msg,
}
}

54
common/casbin.go

@ -0,0 +1,54 @@
package common
import (
"fmt"
"strconv"
"laic-backend/logger"
"github.com/casbin/casbin/v3"
gormadapter "github.com/casbin/gorm-adapter/v3"
)
var CasbinEnforcer *casbin.Enforcer
// SetUserRole 同步用户在 Casbin 中的角色映射(g 策略),role 为空则清空
func SetUserRole(userID int64, role string) error {
if CasbinEnforcer == nil {
return nil
}
uid := strconv.FormatInt(userID, 10)
if _, err := CasbinEnforcer.DeleteRolesForUser(uid); err != nil {
return err
}
if role == "" {
return nil
}
_, err := CasbinEnforcer.AddRoleForUser(uid, role)
return err
}
func InitCasbinEnforcer() {
e, err := mysqlCasbin()
if err != nil {
logger.ERROR("初始化Casbin失败", err)
panic(fmt.Sprintf("初始化Casbin失败: %v", err))
}
CasbinEnforcer = e
logger.INFO("初始化Casbin完成")
}
func mysqlCasbin() (*casbin.Enforcer, error) {
a, err := gormadapter.NewAdapterByDB(DB)
if err != nil {
return nil, err
}
e, err := casbin.NewEnforcer("casbin.model", a)
if err != nil {
return nil, err
}
if err = e.LoadPolicy(); err != nil {
return nil, err
}
return e, nil
}

99
common/config.go

@ -0,0 +1,99 @@
package common
import (
"github.com/spf13/viper"
)
type AppConfig struct {
ApiPort int `mapstructure:"api-port"`
Mysql Mysql `mapstructure:"mysql"`
TDengine TDengine `mapstructure:"tdengine"`
Redis string `mapstructure:"redis"`
MQTT MQTT `mapstructure:"mqtt"`
JWT JWT `mapstructure:"jwt"`
Log Log `mapstructure:"log"`
OSS OSS `mapstructure:"oss"`
Live Live `mapstructure:"live"`
Heartbeat Heartbeat `mapstructure:"heartbeat"`
}
// AppConf 全局配置(LoadConfig 后可用)
var AppConf *AppConfig
type Mysql struct {
Url string `mapstructure:"url"`
Idle int `mapstructure:"idle"`
MaxConn int `mapstructure:"max-conn"`
MaxWait int `mapstructure:"max-wait"`
}
type TDengine struct {
Dsn string `mapstructure:"dsn"`
}
type MQTT struct {
Broker string `mapstructure:"broker"`
ClientId string `mapstructure:"client-id"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
type Log struct {
Level string `mapstructure:"level"`
Path string `mapstructure:"path"`
}
type JWT struct {
Secret string `mapstructure:"secret"`
AccessExpireH int `mapstructure:"access-expire-hours"`
RefreshExpireH int `mapstructure:"refresh-expire-hours"`
}
// OSS 对象存储(S3 兼容,MinIO / 阿里云 OSS)
type OSS struct {
Endpoint string `mapstructure:"endpoint"` // http://127.0.0.1:9000 或 https://oss-cn-hangzhou.aliyuncs.com
AccessKey string `mapstructure:"access-key"`
SecretKey string `mapstructure:"secret-key"`
Bucket string `mapstructure:"bucket"`
Region string `mapstructure:"region"`
UseSSL bool `mapstructure:"use-ssl"`
}
// Live 直播(阿里云直播)
type Live struct {
Mode string `mapstructure:"mode"` // disabled / fake / aliyun
Provider string `mapstructure:"provider"`
PushDomain string `mapstructure:"push-domain"`
PlayDomain string `mapstructure:"play-domain"`
AppName string `mapstructure:"app-name"`
AuthKey string `mapstructure:"auth-key"`
PlayAuthKey string `mapstructure:"play-auth-key"`
AuthExpireSeconds int64 `mapstructure:"auth-expire-seconds"`
PlayURLTTLSeconds int64 `mapstructure:"play-url-ttl-seconds"`
Protocol string `mapstructure:"protocol"`
SRTPort int `mapstructure:"srt-port"`
MaxSessionSeconds int64 `mapstructure:"max-session-seconds"`
ViewerLeaseSeconds int `mapstructure:"viewer-lease-seconds"`
StopGraceSeconds int `mapstructure:"stop-grace-seconds"`
ReconcileIntervalSeconds int `mapstructure:"reconcile-interval-seconds"`
RecordPolicy string `mapstructure:"record-policy"`
AllowRealCloud bool `mapstructure:"allow-real-cloud"`
}
type Heartbeat struct {
TimeoutSeconds int `mapstructure:"timeout-seconds"`
ScanSeconds int `mapstructure:"scan-seconds"`
}
// LoadConfig 使用 Viper 加载本地 config.yaml
func LoadConfig(path string, conf *AppConfig) {
viper.SetConfigFile(path)
viper.SetConfigType("yaml")
if err := viper.ReadInConfig(); err != nil {
panic("加载配置文件失败: " + err.Error())
}
if err := viper.Unmarshal(conf); err != nil {
panic("解析配置文件失败: " + err.Error())
}
AppConf = conf
}

128
common/gin.go

@ -0,0 +1,128 @@
package common
import (
"fmt"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
var _engine *gin.Engine
type R struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
func Server(debug bool) {
if !debug {
gin.SetMode(gin.ReleaseMode)
}
_engine = gin.New()
_engine.Use(gin.Recovery())
}
func GetEngine() *gin.Engine {
return _engine
}
func SetWriter(w io.Writer) {
gin.DefaultWriter = w
gin.DefaultErrorWriter = w
}
func RouteGroup(path string) *gin.RouterGroup {
return _engine.Group(path)
}
func ServerRun(port int32) {
_engine.Run(fmt.Sprintf(":%d", port))
}
func OK(c *gin.Context) {
c.JSON(http.StatusOK, R{Code: http.StatusOK, Msg: "ok"})
c.Abort()
}
func OKNoData(c *gin.Context) {
c.JSON(http.StatusNoContent, R{Code: http.StatusOK, Msg: "ok"})
c.Abort()
}
func OKWithData(c *gin.Context, obj interface{}) {
c.JSON(http.StatusOK, R{Code: http.StatusOK, Msg: "ok", Data: obj})
c.Abort()
}
func FailWithCodeAndMsg(c *gin.Context, code int, message string) {
c.JSON(http.StatusBadRequest, R{Code: code, Msg: message, Data: nil})
c.Abort()
}
func FailWithBusiError(c *gin.Context, busiError *BusiError) {
c.JSON(http.StatusBadRequest, R{Code: busiError.Code, Msg: busiError.Msg, Data: nil})
c.Abort()
}
func FailWithBusiErrorWithHttpStatus(c *gin.Context, status int, busiError *BusiError) {
c.JSON(status, R{Code: busiError.Code, Msg: busiError.Msg, Data: nil})
c.Abort()
}
func FailWithMsgWithHttpStatus(c *gin.Context, status int, msg string) {
c.JSON(status, R{Code: status, Msg: msg, Data: nil})
c.Abort()
}
func FailWithBusiErrorByServer(c *gin.Context, busiError *BusiError) {
c.JSON(http.StatusInternalServerError, R{Code: busiError.Code, Msg: busiError.Msg, Data: nil})
c.Abort()
}
func FailWithMsgByServer(c *gin.Context, message string) {
c.JSON(http.StatusInternalServerError, R{Code: http.StatusInternalServerError, Msg: message, Data: nil})
c.Abort()
}
func FailWithMsgAndErrByServer(c *gin.Context, message string, err error) {
c.JSON(http.StatusInternalServerError, R{Code: http.StatusInternalServerError, Msg: message, Data: err})
c.Abort()
}
func FailWithBindError(c *gin.Context, busiError *BusiError, err error) {
var msg string
if validationErrors, ok := err.(validator.ValidationErrors); ok {
errorMessages := validationErrors.Translate(GetTrans())
messages := make([]string, 0)
for _, m := range errorMessages {
messages = append(messages, m)
}
msg = strings.Join(messages, "; ")
}
c.JSON(http.StatusBadRequest, R{Code: busiError.Code, Msg: msg, Data: msg})
c.Abort()
}
func FailWithBusiErrorAndData(c *gin.Context, busiError *BusiError, data any) {
c.JSON(http.StatusBadRequest, R{Code: busiError.Code, Msg: busiError.Msg, Data: data})
c.Abort()
}
func FailWithError(c *gin.Context, err error) {
c.JSON(http.StatusBadRequest, R{Code: http.StatusBadRequest, Msg: err.Error(), Data: nil})
c.Abort()
}
func FailWithMsg(c *gin.Context, message string) {
c.JSON(http.StatusBadRequest, R{Code: http.StatusInternalServerError, Msg: message, Data: nil})
c.Abort()
}
func FailWithMsgAndError(c *gin.Context, msg string, err error) {
c.JSON(http.StatusBadRequest, R{Code: http.StatusBadRequest, Msg: msg, Data: nil})
c.Abort()
}

87
common/mysql.go

@ -0,0 +1,87 @@
package common
import (
"errors"
"strings"
"time"
"laic-backend/logger"
mysql_ "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var DB *gorm.DB
var err error
func InitConnection(config *Mysql) {
url := config.Url
if !strings.Contains(url, "loc=") && !strings.Contains(url, "time_zone=") {
if strings.Contains(url, "?") {
url += "&loc=Local"
} else {
url += "?loc=Local"
}
}
idle := config.Idle
maxConn := config.MaxConn
maxWait := time.Duration(config.MaxWait)
logger.DEBUG("mysql config:", config)
gConfig := &gorm.Config{
Logger: logger.NewGormLogger(),
}
DB, err = gorm.Open(mysql.Open(url), gConfig)
if err != nil {
logger.ERROR("mysql connect error", err)
panic(err)
}
DB = DB.Debug()
sqlDB, err := DB.DB()
if err != nil {
logger.ERROR("init mysql data pool error", err)
panic(err)
}
sqlDB.SetMaxIdleConns(idle)
sqlDB.SetMaxOpenConns(maxConn)
sqlDB.SetConnMaxLifetime(maxWait * time.Second)
logger.INFO("mysql ready")
}
type Pagination struct {
PageNum int64 `json:"pageNum" form:"pageNum" binding:"required" minimum:"1"`
PageSize int64 `json:"pageSize" form:"pageSize" binding:"required" minimum:"1"`
}
func (p *Pagination) Paginate(db *gorm.DB) *gorm.DB {
offset := int((p.PageNum - 1) * p.PageSize)
return db.Offset(offset).Limit(int(p.PageSize))
}
// ParseError 解析 MySQL 唯一键冲突等错误,返回错误号和约束名
func ParseError(err error) (uint16, string) {
if err != nil {
var mysqlErr *mysql_.MySQLError
if errors.As(err, &mysqlErr) {
if mysqlErr.Number == 1062 {
return mysqlErr.Number, extractConstraintName(mysqlErr.Message)
}
return mysqlErr.Number, ""
} else {
logger.ERROR("未知错误,gorm error:", err)
}
}
return 0, ""
}
func extractConstraintName(errMsg string) string {
start := strings.LastIndex(errMsg, "for key '") + len("for key '")
end := strings.LastIndex(errMsg, "'")
if start < end {
return errMsg[start:end]
}
return ""
}

101
common/oss.go

@ -0,0 +1,101 @@
package common
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/url"
"strconv"
"strings"
"time"
)
// PresignOSS 生成 S3 兼容的预签名 URL(MinIO / 阿里云 OSS),method 为 HTTP 方法(GET/PUT)
// objectKey 为对象 key(不含 bucket),expires 为有效期秒数。
func PresignOSS(method, objectKey string, expires int64) (string, error) {
oss := AppConf.OSS
if oss.Endpoint == "" {
return "", NewBusiError(InternalError, "OSS 未配置")
}
u, err := url.Parse(oss.Endpoint)
if err != nil {
return "", err
}
host := u.Host
scheme := u.Scheme
if scheme == "" {
scheme = "https"
}
region := oss.Region
if region == "" {
region = "us-east-1"
}
const service = "s3"
now := time.Now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
credentialScope := dateStamp + "/" + region + "/" + service + "/aws4_request"
canonicalURI := encodeObjectPath(oss.Bucket + "/" + objectKey)
query := url.Values{}
query.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
query.Set("X-Amz-Credential", oss.AccessKey+"/"+credentialScope)
query.Set("X-Amz-Date", amzDate)
query.Set("X-Amz-Expires", strconv.FormatInt(expires, 10))
query.Set("X-Amz-SignedHeaders", "host")
canonicalHeaders := "host:" + host + "\n"
const signedHeaders = "host"
const payloadHash = "UNSIGNED-PAYLOAD"
canonicalRequest := strings.Join([]string{
method,
canonicalURI,
query.Encode(),
canonicalHeaders,
signedHeaders,
payloadHash,
}, "\n")
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
sha256Hex([]byte(canonicalRequest)),
}, "\n")
signingKey := deriveSigningKey(oss.SecretKey, dateStamp, region, service)
signature := hex.EncodeToString(hmacSHA256(signingKey, []byte(stringToSign)))
query.Set("X-Amz-Signature", signature)
return scheme + "://" + host + canonicalURI + "?" + query.Encode(), nil
}
// encodeObjectPath 对对象路径做分段 URL 编码(保留 "/" 分隔符),并以 "/" 开头
func encodeObjectPath(key string) string {
segs := strings.Split(key, "/")
for i, s := range segs {
segs[i] = url.PathEscape(s)
}
return "/" + strings.Join(segs, "/")
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func hmacSHA256(key, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
return h.Sum(nil)
}
func deriveSigningKey(secret, date, region, service string) []byte {
kDate := hmacSHA256([]byte("AWS4"+secret), []byte(date))
kRegion := hmacSHA256(kDate, []byte(region))
kService := hmacSHA256(kRegion, []byte(service))
return hmacSHA256(kService, []byte("aws4_request"))
}

28
common/pageutil.go

@ -0,0 +1,28 @@
package common
import "math"
type PageRequest[T any] struct {
Page Pagination `json:"page"`
OrderBy string `json:"order_by"`
Desc bool `json:"desc"`
Query T `json:"query"`
}
type PageResponse[T any] struct {
PageNum int64 `json:"pageNum"`
PageSize int64 `json:"pageSize"`
Pages int `json:"pages"`
Total int `json:"total"`
Records []T `json:"records"`
}
func Page[T any](page Pagination, total int64, records []T) *PageResponse[T] {
return &PageResponse[T]{
PageNum: page.PageNum,
PageSize: page.PageSize,
Pages: int(math.Ceil(float64(total) / float64(page.PageSize))),
Total: int(total),
Records: records,
}
}

264
common/redis.go

@ -0,0 +1,264 @@
package common
import (
"encoding/json"
"time"
"laic-backend/logger"
"github.com/gomodule/redigo/redis"
)
var _redis *redis.Pool
func InitRedis(source string) {
_redis = &redis.Pool{
MaxIdle: 3,
IdleTimeout: 240 * time.Second,
Dial: func() (redis.Conn, error) { return redis.DialURL(source) },
TestOnBorrow: func(c redis.Conn, t time.Time) error {
if time.Since(t) < time.Minute {
return nil
}
_, err := c.Do("PING")
return err
},
}
logger.INFO("redis ready")
}
func GetRedisPool() *redis.Pool {
return _redis
}
func GetString(key string) (string, error) {
c := _redis.Get()
defer c.Close()
return redis.String(c.Do("GET", key))
}
func GetBytes(key string) ([]byte, error) {
c := _redis.Get()
defer c.Close()
return redis.Bytes(c.Do("GET", key))
}
func GetInt64(key string) (int64, error) {
c := _redis.Get()
defer c.Close()
return redis.Int64(c.Do("GET", key))
}
func GetJSON[T any](key string) (*T, error) {
c := _redis.Get()
defer c.Close()
r, err := redis.Bytes(c.Do("GET", key))
if err != nil {
return nil, err
}
var v T
err = json.Unmarshal(r, &v)
return &v, err
}
func SetValue(key string, n any) error {
c := _redis.Get()
defer c.Close()
_, err := c.Do("SET", key, n)
return err
}
func SetJSON(key string, n any) error {
c := _redis.Get()
defer c.Close()
data, err := json.Marshal(n)
if err != nil {
return err
}
_, err = c.Do("SET", key, data)
return err
}
func SetJSONWithExpired(key string, n any, second uint) error {
c := _redis.Get()
defer c.Close()
data, err := json.Marshal(n)
if err != nil {
return err
}
_, err = c.Do("SET", key, data, "EX", second)
return err
}
func SetValueWithExpired(key string, n any, second uint) error {
c := _redis.Get()
defer c.Close()
_, err := c.Do("SET", key, n, "EX", second)
return err
}
func SetRemoveIfKeyMissing(setKey, presenceKey, member string) (bool, error) {
const script = `if redis.call('EXISTS', KEYS[2]) == 0 then return redis.call('SREM', KEYS[1], ARGV[1]) end return 0`
n, err := redis.Int(exec_lua(script, []string{setKey, presenceKey}, member))
return n == 1, err
}
// SetNX 仅当 key 不存在时写入,返回是否写入成功
func SetNX(key string, value any) (bool, error) {
c := _redis.Get()
defer c.Close()
n, err := redis.Int(c.Do("SET", key, value, "NX"))
return n == 1, err
}
// IncrBy 原子自增,返回自增后的值(计费充值用)
func IncrBy(key string, delta int64) (int64, error) {
c := _redis.Get()
defer c.Close()
return redis.Int64(c.Do("INCRBY", key, delta))
}
// DecrBy 原子自减,返回自减后的值(计费扣减用)
func DecrBy(key string, delta int64) (int64, error) {
c := _redis.Get()
defer c.Close()
return redis.Int64(c.Do("DECRBY", key, delta))
}
func HasKey(key string) bool {
c := _redis.Get()
defer c.Close()
exists, err := redis.Int(c.Do("EXISTS", key))
if err != nil {
return false
}
return exists == 1
}
func HashGet(key string, field string) (string, error) {
c := _redis.Get()
defer c.Close()
return redis.String(c.Do("HGET", key, field))
}
func HashGetAll(key string) (map[string]string, error) {
c := _redis.Get()
defer c.Close()
return redis.StringMap(c.Do("HGETALL", key))
}
func HashSetValue(key string, field string, n any) error {
c := _redis.Get()
defer c.Close()
_, err := c.Do("HSET", key, field, n)
return err
}
func HashSetValues(key string, values map[string]any) error {
c := _redis.Get()
defer c.Close()
args := make([]any, 0, len(values)*2+1)
args = append(args, key)
for k, v := range values {
args = append(args, k, v)
}
_, err := c.Do("HMSET", args...)
return err
}
func SetMembers(key string) ([]string, error) {
c := _redis.Get()
defer c.Close()
return redis.Strings(c.Do("SMEMBERS", key))
}
func SetAdd(key string, n any) error {
c := _redis.Get()
defer c.Close()
_, err := c.Do("SADD", key, n)
return err
}
func SetRemove(key string, n any) error {
c := _redis.Get()
defer c.Close()
_, err := c.Do("SREM", key, n)
return err
}
func SetMemberExists(key string, value string) (bool, error) {
c := _redis.Get()
defer c.Close()
exists, err := redis.Int(c.Do("SISMEMBER", key, value))
if err != nil {
return false, err
}
return exists == 1, nil
}
func exec_lua(lua string, keys []string, args ...any) (any, error) {
c := _redis.Get()
defer c.Close()
keylen := 0
if keys != nil {
keylen = len(keys)
}
arr := make([]any, 0, keylen+len(args)+2)
arr = append(arr, lua)
arr = append(arr, keylen)
for _, k := range keys {
arr = append(arr, k)
}
arr = append(arr, args...)
return c.Do("EVAL", arr...)
}
func ExecLua(lua string, keys []string, args ...any) error {
_, err := exec_lua(lua, keys, args...)
return err
}
func GetLuaInt64(lua string, keys []string, args ...any) (int64, error) {
return redis.Int64(exec_lua(lua, keys, args...))
}
func GetLuaStrings(lua string, keys []string, args ...any) ([]string, error) {
return redis.Strings(exec_lua(lua, keys, args...))
}
func GetLuaInt64s(lua string, keys []string, args ...any) ([]int64, error) {
return redis.Int64s(exec_lua(lua, keys, args...))
}
func Delete(key ...string) error {
c := _redis.Get()
defer c.Close()
param := make([]any, len(key))
for i, k := range key {
param[i] = k
}
_, err := c.Do("DEL", param...)
return err
}
func GetKeysWithPrefix(prefix string) ([]string, error) {
c := _redis.Get()
defer c.Close()
cursor := 0
var keys []string
for {
values, err := redis.Values(c.Do("SCAN", cursor, "MATCH", prefix+"*"))
if err != nil {
return nil, err
}
var items []string
if _, err := redis.Scan(values, &cursor, &items); err != nil {
return nil, err
}
keys = append(keys, items...)
if cursor == 0 {
break
}
}
return keys, nil
}

34
common/tdengine.go

@ -0,0 +1,34 @@
package common
import (
"database/sql"
"time"
"laic-backend/logger"
_ "github.com/taosdata/driver-go/v3/taosWS"
)
var TD *sql.DB
// InitTDengine 初始化 TDengine 连接池。Dsn 为空时跳过;连接失败仅告警不 panic(部署期确认)
func InitTDengine(config *TDengine) {
if config == nil || config.Dsn == "" {
logger.WARN("TDengine dsn 为空,跳过初始化")
return
}
db, err := sql.Open("taosWS", config.Dsn)
if err != nil {
logger.ERROR("TDengine connect error", err)
return
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(time.Hour)
if err := db.Ping(); err != nil {
logger.ERROR("TDengine ping error", err)
return
}
TD = db
logger.INFO("TDengine ready")
}

81
common/validator.go

@ -0,0 +1,81 @@
package common
import (
"regexp"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/locales/zh"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
zh_translations "github.com/go-playground/validator/v10/translations/zh"
)
var (
phoneRegexp = regexp.MustCompile(`^1[3456789]\d{9}$`)
emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
)
// IsPhoneValid 验证手机号格式
func IsPhoneValid(fl validator.FieldLevel) bool {
return phoneRegexp.MatchString(fl.Field().String())
}
// IsEmailValid 验证邮箱格式
func IsEmailValid(fl validator.FieldLevel) bool {
return emailRegexp.MatchString(fl.Field().String())
}
func ValidatePassword(fl validator.FieldLevel) bool {
password := fl.Field().String()
if password == "" {
return true
}
if len(password) < 8 || len(password) > 16 {
return false
}
if !regexp.MustCompile(`[0-9]`).MatchString(password) ||
!regexp.MustCompile(`[a-zA-Z]`).MatchString(password) {
return false
}
return true
}
var trans ut.Translator
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("password", ValidatePassword)
v.RegisterValidation("email", IsEmailValid)
v.RegisterValidation("phone", IsPhoneValid)
zhCn := zh.New()
uni := ut.New(zhCn, zhCn)
trans, _ = uni.GetTranslator("zh")
zh_translations.RegisterDefaultTranslations(v, trans)
v.RegisterTranslation("password", trans, func(ut ut.Translator) error {
return ut.Add("password", "{0}必须包含数字、字母,长度在8-16位之间", true)
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("password", fe.Field())
return t
})
v.RegisterTranslation("phone", trans, func(ut ut.Translator) error {
return ut.Add("phone", "{0}格式不正确", true)
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("phone", fe.Field())
return t
})
v.RegisterTranslation("email", trans, func(ut ut.Translator) error {
return ut.Add("email", "{0}格式不正确", true)
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("email", fe.Field())
return t
})
}
}
func GetTrans() ut.Translator {
return trans
}

47
config.yaml

@ -0,0 +1,47 @@
api-port: 8080
mysql:
url: root:123456@tcp(127.0.0.1:3306)/laic?charset=utf8mb4&parseTime=True&loc=Local
idle: 10
max-conn: 50
max-wait: 3600
tdengine:
dsn: root:taosdata@ws(127.0.0.1:6041)/laic
redis: redis://127.0.0.1:6379/0
mqtt:
broker: tcp://127.0.0.1:1883
client-id: laic-backend
username: laic
password: ""
jwt:
secret: laic-jwt-secret-change-me
access-expire-hours: 168 # 7 天
refresh-expire-hours: 720 # 30 天
oss:
endpoint: http://127.0.0.1:9000
access-key: minioadmin
secret-key: minioadmin
bucket: laic-media
region: us-east-1
use-ssl: false
live:
mode: fake
provider: fake
push-domain: local-push
play-domain: local-play
app-name: dock-live
auth-expire-seconds: 7200
play-url-ttl-seconds: 300
protocol: srt
srt-port: 1105
max-session-seconds: 7200
viewer-lease-seconds: 30
stop-grace-seconds: 15
reconcile-interval-seconds: 10
record-policy: disabled
allow-real-cloud: false
heartbeat:
timeout-seconds: 30
scan-seconds: 5
log:
level: debug
path: ./logs/

94
go.mod

@ -0,0 +1,94 @@
module laic-backend
go 1.26.4
require (
github.com/bluenviron/gomavlib/v3 v3.3.5
github.com/casbin/casbin/v3 v3.8.1
github.com/casbin/gorm-adapter/v3 v3.41.0
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/gin-gonic/gin v1.12.0
github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.30.3
github.com/go-sql-driver/mysql v1.10.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gomodule/redigo v1.9.3
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.21.0
github.com/taosdata/driver-go/v3 v3.3.1
golang.org/x/crypto v0.52.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.2
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microsoft/go-mssqldb v1.9.5 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.bug.st/serial v1.7.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlserver v1.6.3 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
)

409
go.sum

@ -0,0 +1,409 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/bluenviron/gomavlib/v3 v3.3.5 h1:4e0rxKqPH1ar1cJFkft0mjsdBDnVJKWUhDQQuuqhvyk=
github.com/bluenviron/gomavlib/v3 v3.3.5/go.mod h1:tm3CnJMWdWqDGtHwUpmM2B8VY7TF67hCrCNBrwjYJpc=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo=
github.com/casbin/casbin/v3 v3.8.1/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=
github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrHfzpBpPcNTyMGeGA=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.0.6 h1:k1mQU06bmmX143qSWgXFqSH1KUJceQvIUuVH/K5ELWw=
github.com/pion/transport/v3 v3.0.6/go.mod h1:HvJr2N/JwNJAfipsRleqwFoR3t/pWyHeZUs89v3+t5s=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/taosdata/driver-go/v3 v3.3.1 h1:UXIoE+TbdbdRHtaNjIuMAQn+SjAAKUJHyeONAt5F1eI=
github.com/taosdata/driver-go/v3 v3.3.1/go.mod h1:H2vo/At+rOPY1aMzUV9P49SVX7NlXb3LAbKw+MCLrmU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

166
handler/account_handler.go

@ -0,0 +1,166 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetProfile 查询个人资料
func GetProfile(c *gin.Context) {
user, busiErr := service.DefaultAccountService.GetProfile(common.GetUserId(c))
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, user)
}
// UpdateProfile 更新个人资料
func UpdateProfile(c *gin.Context) {
var req vo.ProfileUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
user, busiErr := service.DefaultAccountService.UpdateProfile(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, user)
}
// GetTrafficBalance 查询流量余额
func GetTrafficBalance(c *gin.Context) {
bal, busiErr := service.DefaultAccountService.GetTrafficBalance(common.GetUserId(c))
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, bal)
}
// GetUsagePage 流量消费流水分页
func GetUsagePage(c *gin.Context) {
var req vo.UsagePageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultAccountService.GetUsagePage(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// GetOrderPage 流量订单分页
func GetOrderPage(c *gin.Context) {
var req vo.OrderPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultAccountService.GetOrderPage(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// CreateTrafficOrder 创建流量充值订单
func CreateTrafficOrder(c *gin.Context) {
var req vo.TrafficOrderCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
order, busiErr := service.DefaultAccountService.CreateTrafficOrder(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, order)
}
// PayTrafficOrder 订单支付入账(admin)
func PayTrafficOrder(c *gin.Context) {
orderID, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
order, busiErr := service.DefaultAccountService.PayTrafficOrder(orderID)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, order)
}
// ListSimCards SIM 卡列表
func ListSimCards(c *gin.Context) {
var req vo.SimCardPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultAccountService.ListSimCards(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// GetSimRechargeLogPage SIM 卡充值记录分页
func GetSimRechargeLogPage(c *gin.Context) {
var req vo.SimRechargeLogPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultAccountService.GetSimRechargeLogPage(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// RechargeSimCard SIM 卡充值
func RechargeSimCard(c *gin.Context) {
simCardID, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
var req vo.SimRechargeReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
log, busiErr := service.DefaultAccountService.RechargeSimCard(common.GetUserId(c), simCardID, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, log)
}

55
handler/alarm_handler.go

@ -0,0 +1,55 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetAlarmPage 告警分页列表
func GetAlarmPage(c *gin.Context) {
var req vo.AlarmPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultAlarmService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// AcknowledgeAlarm 确认告警
func AcknowledgeAlarm(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
if busiErr := service.DefaultAlarmService.Acknowledge(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// ResolveAlarm 关闭告警
func ResolveAlarm(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
if busiErr := service.DefaultAlarmService.Resolve(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}

98
handler/auth_handler.go

@ -0,0 +1,98 @@
package handler
import (
"strings"
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// Register 用户注册
func Register(c *gin.Context) {
var req vo.RegisterReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
resp, busiErr := service.DefaultUserService.Register(req.Name, req.Phone, req.Email, req.Password)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, resp)
}
// Login 用户登录
func Login(c *gin.Context) {
var req vo.UserLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
resp, busiErr := service.DefaultUserService.Login(req.Phone, req.Password)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, resp)
}
// Refresh 刷新令牌
func Refresh(c *gin.Context) {
var req vo.RefreshReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
resp, busiErr := service.DefaultUserService.Refresh(req.RefreshToken)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, resp)
}
// Logout 登出
func Logout(c *gin.Context) {
var req vo.LogoutReq
_ = c.ShouldBindJSON(&req) // refreshToken 可选,忽略绑定错误
accessToken := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
userID := common.GetUserId(c)
if busiErr := service.DefaultUserService.Logout(userID, accessToken, req.RefreshToken); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// SmsCode 发送短信验证码
func SmsCode(c *gin.Context) {
var req vo.SmsCodeReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
if _, busiErr := service.DefaultUserService.SendSmsCode(req.Phone); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// ResetPassword 重置密码
func ResetPassword(c *gin.Context) {
var req vo.ResetPasswordReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
if busiErr := service.DefaultUserService.ResetPassword(req.Phone, req.SmsCode, req.NewPassword); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}

62
handler/command_handler.go

@ -0,0 +1,62 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// DispatchCommand 下发设备指令
func DispatchCommand(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
var req vo.CommandReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
cmd, busiErr := service.DefaultCommandService.Dispatch(common.GetUserId(c), common.IsAdmin(c), id, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, cmd)
}
// GetCommandPage 指令历史分页列表
func GetCommandPage(c *gin.Context) {
var req vo.CommandPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultCommandService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// GetCommand 指令详情
func GetCommand(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
cmd, busiErr := service.DefaultCommandService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, cmd)
}

106
handler/dock_handler.go

@ -0,0 +1,106 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetDockPage 机巢分页列表
func GetDockPage(c *gin.Context) {
var req vo.DockPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultDockService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// GetDock 机巢详情
func GetDock(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
dock, busiErr := service.DefaultDockService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, dock)
}
// GetDockStatus 机巢实时状态
func GetDockStatus(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
status, busiErr := service.DefaultDockService.GetStatus(common.GetUserId(c), common.IsAdmin(c), id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, status)
}
// CreateDock 手动登记机巢
func CreateDock(c *gin.Context) {
var req vo.DockCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
dock, busiErr := service.DefaultDockService.Create(common.GetUserId(c), common.IsAdmin(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, dock)
}
// UpdateDock 更新/认领机巢
func UpdateDock(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
var req vo.DockUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
dock, busiErr := service.DefaultDockService.Update(common.GetUserId(c), common.IsAdmin(c), id, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, dock)
}
// DeleteDock 删除机巢
func DeleteDock(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
if busiErr := service.DefaultDockService.Delete(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}

91
handler/drone_handler.go

@ -0,0 +1,91 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetDronePage 无人机分页列表
func GetDronePage(c *gin.Context) {
var req vo.DronePageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultDroneService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// GetDrone 无人机详情
func GetDrone(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
drone, busiErr := service.DefaultDroneService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, drone)
}
// GetDroneTelemetry 无人机实时遥测
func GetDroneTelemetry(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
telemetry, busiErr := service.DefaultDroneService.GetTelemetry(common.GetUserId(c), common.IsAdmin(c), id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, telemetry)
}
// UpdateDrone 更新无人机
func UpdateDrone(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
var req vo.DroneUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
drone, busiErr := service.DefaultDroneService.Update(common.GetUserId(c), common.IsAdmin(c), id, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, drone)
}
// DeleteDrone 删除无人机
func DeleteDrone(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
if busiErr := service.DefaultDroneService.Delete(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}

57
handler/execution_handler.go

@ -0,0 +1,57 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetExecutionPage 执行记录分页列表
func GetExecutionPage(c *gin.Context) {
var req vo.ExecutionPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultExecutionService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// GetExecution 执行记录详情
func GetExecution(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
exec, busiErr := service.DefaultExecutionService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, exec)
}
// GetTrajectory 执行轨迹
func GetTrajectory(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
traj, busiErr := service.DefaultExecutionService.GetTrajectory(common.GetUserId(c), common.IsAdmin(c), id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, traj)
}

120
handler/firmware_handler.go

@ -0,0 +1,120 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetFirmwarePage 固件分页列表(admin)
func GetFirmwarePage(c *gin.Context) {
var req vo.FirmwarePageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultFirmwareService.GetPage(&req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// GetReleasedFirmwares 已发布固件列表(普通用户选择升级)
func GetReleasedFirmwares(c *gin.Context) {
list, busiErr := service.DefaultFirmwareService.GetReleasedList()
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, list)
}
// GetFirmware 固件详情(admin)
func GetFirmware(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
fw, busiErr := service.DefaultFirmwareService.GetDetail(id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, fw)
}
// CreateFirmware 新增固件版本(admin)
func CreateFirmware(c *gin.Context) {
var req vo.FirmwareCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
fw, busiErr := service.DefaultFirmwareService.Create(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, fw)
}
// UpdateFirmware 编辑固件版本(admin)
func UpdateFirmware(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
var req vo.FirmwareUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
if busiErr := service.DefaultFirmwareService.Update(id, &req); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// DeleteFirmware 删除固件版本(admin)
func DeleteFirmware(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
if busiErr := service.DefaultFirmwareService.Delete(id); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// UpgradeFirmware 下发 OTA 升级(对所属机巢)
func UpgradeFirmware(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
var req vo.FirmwareUpgradeReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
result, busiErr := service.DefaultFirmwareService.Upgrade(common.GetUserId(c), common.IsAdmin(c), id, req.FirmwareID)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, result)
}

133
handler/live_handler.go

@ -0,0 +1,133 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetLivePage 直播会话分页列表
func GetLivePage(c *gin.Context) {
var req vo.LivePageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultLiveService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// CreateLiveSession 创建或加入机巢的唯一活动直播会话,并创建观看租约。
func CreateLiveSession(c *gin.Context) {
dockID := c.Param("dockId")
if dockID == "" {
common.FailWithBusiError(c, common.ErrParam)
return
}
var req vo.LiveSessionCreateReq
_ = c.ShouldBindJSON(&req)
result, busiErr := service.DefaultLiveService.Join(common.GetUserId(c), common.IsAdmin(c), dockID, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, result)
}
// GetLiveSession 获取指定直播会话。
func GetLiveSession(c *gin.Context) {
dockID, sessionID := c.Param("dockId"), c.Param("streamSessionId")
if dockID == "" || sessionID == "" {
common.FailWithBusiError(c, common.ErrParam)
return
}
session, busiErr := service.DefaultLiveService.GetSession(common.GetUserId(c), common.IsAdmin(c), dockID, sessionID)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, session)
}
// HeartbeatLiveSession 续期当前用户的观看租约。
func HeartbeatLiveSession(c *gin.Context) {
dockID, sessionID := c.Param("dockId"), c.Param("streamSessionId")
if dockID == "" || sessionID == "" {
common.FailWithBusiError(c, common.ErrParam)
return
}
result, busiErr := service.DefaultLiveService.Heartbeat(common.GetUserId(c), common.IsAdmin(c), dockID, sessionID)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, result)
}
// LeaveLiveSession 释放当前用户的观看租约。
func LeaveLiveSession(c *gin.Context) {
dockID, sessionID := c.Param("dockId"), c.Param("streamSessionId")
if dockID == "" || sessionID == "" {
common.FailWithBusiError(c, common.ErrParam)
return
}
if busiErr := service.DefaultLiveService.Leave(common.GetUserId(c), common.IsAdmin(c), dockID, sessionID); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// StartLive 启动直播推流
func StartLive(c *gin.Context) {
dockID := c.Param("dockId")
if dockID == "" {
common.FailWithBusiError(c, common.ErrParam)
return
}
var req vo.LiveStartReq
_ = c.ShouldBindJSON(&req)
session, busiErr := service.DefaultLiveService.Start(common.GetUserId(c), common.IsAdmin(c), dockID, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, session)
}
func StopLive(c *gin.Context) {
dockID := c.Param("dockId")
if dockID == "" {
common.FailWithBusiError(c, common.ErrParam)
return
}
if busiErr := service.DefaultLiveService.Stop(common.GetUserId(c), common.IsAdmin(c), dockID); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// GetLivePlayURL 获取直播播放地址
func GetLivePlayURL(c *gin.Context) {
dockID := c.Param("dockId")
if dockID == "" {
common.FailWithBusiError(c, common.ErrParam)
return
}
play, busiErr := service.DefaultLiveService.GetPlayURL(common.GetUserId(c), common.IsAdmin(c), dockID)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, play)
}

91
handler/route_handler.go

@ -0,0 +1,91 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetRoutePage 航线分页列表
func GetRoutePage(c *gin.Context) {
var req vo.RoutePageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultRouteService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// GetRoute 航线详情
func GetRoute(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
route, busiErr := service.DefaultRouteService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, route)
}
// CreateRoute 新增航线
func CreateRoute(c *gin.Context) {
var req vo.RouteCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
route, busiErr := service.DefaultRouteService.Create(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, route)
}
// UpdateRoute 编辑航线
func UpdateRoute(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
var req vo.RouteUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
route, busiErr := service.DefaultRouteService.Update(common.GetUserId(c), common.IsAdmin(c), id, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, route)
}
// DeleteRoute 删除航线
func DeleteRoute(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
if busiErr := service.DefaultRouteService.Delete(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}

102
handler/system_handler.go

@ -0,0 +1,102 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetUserPage 用户分页列表(admin)
func GetUserPage(c *gin.Context) {
var req vo.UserPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultSystemService.GetUserPage(&req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// CreateUser 创建用户(admin)
func CreateUser(c *gin.Context) {
var req vo.UserCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
user, busiErr := service.DefaultSystemService.CreateUser(&req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, user)
}
// UpdateUser 编辑用户(admin)
func UpdateUser(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
var req vo.UserUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
user, busiErr := service.DefaultSystemService.UpdateUser(id, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, user)
}
// DeleteUser 删除用户(admin)
func DeleteUser(c *gin.Context) {
id, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
if busiErr := service.DefaultSystemService.DeleteUser(id); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// GetRoles 角色列表(admin)
func GetRoles(c *gin.Context) {
common.OKWithData(c, service.DefaultSystemService.GetRoles())
}
// GetOperationLogPage 操作日志分页(admin 全量,user 仅本人)
func GetOperationLogPage(c *gin.Context) {
var req vo.OperationLogPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
if !common.IsAdmin(c) {
req.UserID = common.GetUserId(c)
}
page, busiErr := service.DefaultOperationLogService.GetPage(&req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}

105
handler/task_handler.go

@ -0,0 +1,105 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// GetTaskPage 任务分页列表
func GetTaskPage(c *gin.Context) {
var req vo.TaskPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultTaskService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// GetTask 任务详情
func GetTask(c *gin.Context) {
id, busiErr := parseStringID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
task, busiErr := service.DefaultTaskService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, task)
}
// CreateTask 新增任务
func CreateTask(c *gin.Context) {
var req vo.TaskCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
task, busiErr := service.DefaultTaskService.Create(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, task)
}
// UpdateTask 编辑任务
func UpdateTask(c *gin.Context) {
id, busiErr := parseStringID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
var req vo.TaskUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
if busiErr := service.DefaultTaskService.Update(common.GetUserId(c), common.IsAdmin(c), id, &req); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// DeleteTask 删除任务
func DeleteTask(c *gin.Context) {
id, busiErr := parseStringID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
if busiErr := service.DefaultTaskService.Delete(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OK(c)
}
// ExecuteTask 立即执行任务
func ExecuteTask(c *gin.Context) {
id, busiErr := parseStringID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
result, busiErr := service.DefaultTaskService.Execute(common.GetUserId(c), common.IsAdmin(c), id)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, result)
}

27
handler/util.go

@ -0,0 +1,27 @@
package handler
import (
"strconv"
"github.com/gin-gonic/gin"
"laic-backend/common"
)
// parseID 解析路径参数 :id 为 int64
func parseID(c *gin.Context) (int64, *common.BusiError) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
return 0, common.ErrParam
}
return id, nil
}
// parseStringID 解析路径参数 :id 为字符串(如任务 ID)
func parseStringID(c *gin.Context) (string, *common.BusiError) {
id := c.Param("id")
if id == "" {
return "", common.ErrParam
}
return id, nil
}

89
handler/video_handler.go

@ -0,0 +1,89 @@
package handler
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/service"
"laic-backend/vo"
)
// CreateVideoUpload 申请视频上传
func CreateVideoUpload(c *gin.Context) {
var req vo.VideoUploadReq
if err := c.ShouldBindJSON(&req); err != nil {
common.FailWithBindError(c, common.ErrParam, err)
return
}
result, busiErr := service.DefaultVideoService.CreateUpload(common.GetUserId(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, result)
}
// CompleteVideoUpload 上传完成确认
func CompleteVideoUpload(c *gin.Context) {
videoID, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
var req vo.VideoCompleteReq
_ = c.ShouldBindJSON(&req)
video, busiErr := service.DefaultVideoService.Complete(common.GetUserId(c), videoID, &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, video)
}
// GetVideoPage 视频分页列表
func GetVideoPage(c *gin.Context) {
var req vo.VideoPageReq
_ = c.ShouldBindQuery(&req)
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
page, busiErr := service.DefaultVideoService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, page)
}
// GetVideo 视频详情
func GetVideo(c *gin.Context) {
videoID, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
video, busiErr := service.DefaultVideoService.GetDetail(common.GetUserId(c), common.IsAdmin(c), videoID)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, video)
}
// DownloadVideo 下载视频
func DownloadVideo(c *gin.Context) {
videoID, busiErr := parseID(c)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
result, busiErr := service.DefaultVideoService.Download(common.GetUserId(c), common.IsAdmin(c), videoID)
if busiErr != nil {
common.FailWithBusiError(c, busiErr)
return
}
common.OKWithData(c, result)
}

61
live/adapter.go

@ -0,0 +1,61 @@
package live
import (
"errors"
"time"
)
var ErrDisabled = errors.New("live provider disabled")
var ErrInvalidConfig = errors.New("live provider configuration invalid")
type Config struct {
Mode string
Provider string
PushDomain string
PlayDomain string
AppName string
AuthKey string
PlayAuthKey string
AuthExpireSeconds int64
PlayURLTTLSeconds int64
Protocol string
SRTPort int
AllowRealCloud bool
}
type StreamRequest struct {
StreamName string
ExpiresAt time.Time
MaxBitrateBps int64
}
type StreamCredentials struct {
Provider string
PushURL string
ExpiresAt time.Time
}
type PlayRequest struct {
StreamName string
ExpiresAt time.Time
}
type PlayURLs struct {
HLS string
FLV string
RTMP string
ExpiresAt time.Time
}
type OnlineStatus struct {
Online bool
BitrateBps int64
ClientCount int
}
type Adapter interface {
Provider() string
CreateStream(req StreamRequest) (StreamCredentials, error)
CreatePlayURLs(req PlayRequest) (PlayURLs, error)
QueryOnline(streamName string) (OnlineStatus, error)
}

43
live/factory.go

@ -0,0 +1,43 @@
package live
import (
"strings"
"laic-backend/common"
)
func NewAdapter(conf common.Live) (Adapter, error) {
cfg := Config{
Mode: strings.ToLower(conf.Mode),
Provider: strings.ToLower(conf.Provider),
PushDomain: conf.PushDomain,
PlayDomain: conf.PlayDomain,
AppName: conf.AppName,
AuthKey: conf.AuthKey,
PlayAuthKey: conf.PlayAuthKey,
AuthExpireSeconds: conf.AuthExpireSeconds,
PlayURLTTLSeconds: conf.PlayURLTTLSeconds,
Protocol: strings.ToLower(conf.Protocol),
SRTPort: conf.SRTPort,
AllowRealCloud: conf.AllowRealCloud,
}
switch cfg.Mode {
case "", "disabled":
return DisabledAdapter{}, nil
case "fake":
if cfg.PushDomain == "" {
cfg.PushDomain = "local-push"
}
if cfg.PlayDomain == "" {
cfg.PlayDomain = "local-play"
}
if cfg.AppName == "" {
cfg.AppName = "dock-live"
}
return FakeAdapter{PushDomain: cfg.PushDomain, PlayDomain: cfg.PlayDomain, AppName: cfg.AppName}, nil
case "aliyun":
return NewAliyunAdapter(cfg)
default:
return nil, ErrInvalidConfig
}
}

90
live/providers.go

@ -0,0 +1,90 @@
package live
import (
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"strconv"
"time"
)
type DisabledAdapter struct{}
func (DisabledAdapter) Provider() string { return "disabled" }
func (DisabledAdapter) CreateStream(StreamRequest) (StreamCredentials, error) {
return StreamCredentials{}, ErrDisabled
}
func (DisabledAdapter) CreatePlayURLs(PlayRequest) (PlayURLs, error) { return PlayURLs{}, ErrDisabled }
func (DisabledAdapter) QueryOnline(string) (OnlineStatus, error) { return OnlineStatus{}, ErrDisabled }
// FakeAdapter is a local provider that never contacts a cloud service.
type FakeAdapter struct {
PlayDomain string
PushDomain string
AppName string
}
func (f FakeAdapter) Provider() string { return "fake" }
func (f FakeAdapter) CreateStream(req StreamRequest) (StreamCredentials, error) {
return StreamCredentials{
Provider: f.Provider(),
PushURL: "fake://" + f.PushDomain + "/" + f.AppName + "/" + req.StreamName,
ExpiresAt: req.ExpiresAt,
}, nil
}
func (f FakeAdapter) CreatePlayURLs(req PlayRequest) (PlayURLs, error) {
return PlayURLs{
HLS: "fake://" + f.PlayDomain + "/" + f.AppName + "/" + req.StreamName + ".m3u8",
ExpiresAt: req.ExpiresAt,
}, nil
}
func (f FakeAdapter) QueryOnline(string) (OnlineStatus, error) {
return OnlineStatus{Online: true}, nil
}
type AliyunAdapter struct {
cfg Config
}
func NewAliyunAdapter(cfg Config) (Adapter, error) {
if !cfg.AllowRealCloud || cfg.PushDomain == "" || cfg.PlayDomain == "" || cfg.AppName == "" || cfg.AuthKey == "" || cfg.PlayAuthKey == "" {
return nil, ErrInvalidConfig
}
if cfg.Protocol == "" {
cfg.Protocol = "srt"
}
if cfg.SRTPort <= 0 {
cfg.SRTPort = 1105
}
return AliyunAdapter{cfg: cfg}, nil
}
func (a AliyunAdapter) Provider() string { return "aliyun" }
func (a AliyunAdapter) CreateStream(req StreamRequest) (StreamCredentials, error) {
return StreamCredentials{Provider: a.Provider(), PushURL: a.pushURL(req.StreamName, req.ExpiresAt), ExpiresAt: req.ExpiresAt}, nil
}
func (a AliyunAdapter) CreatePlayURLs(req PlayRequest) (PlayURLs, error) {
expires := req.ExpiresAt
path := "/" + a.cfg.AppName + "/" + req.StreamName
signature := sign(path, expires.Unix(), a.cfg.PlayAuthKey)
base := "https://" + a.cfg.PlayDomain + "/" + a.cfg.AppName + "/" + req.StreamName
return PlayURLs{HLS: base + ".m3u8?auth_key=" + signature, FLV: base + ".flv?auth_key=" + signature, RTMP: "rtmp://" + a.cfg.PlayDomain + "/" + a.cfg.AppName + "/" + req.StreamName, ExpiresAt: expires}, nil
}
func (a AliyunAdapter) QueryOnline(string) (OnlineStatus, error) {
return OnlineStatus{}, errors.New("aliyun online query is not configured")
}
func sign(path string, expiresAt int64, key string) string {
rand := strconv.FormatInt(expiresAt%10000, 10)
sum := md5.Sum([]byte(fmt.Sprintf("%s-%d-%s-%s-%s", path, expiresAt, rand, "0", key)))
return fmt.Sprintf("%d-%s-0-%s", expiresAt, rand, hex.EncodeToString(sum[:]))
}
func (a AliyunAdapter) pushURL(streamName string, expires time.Time) string {
path := "/" + a.cfg.AppName + "/" + streamName
auth := sign(path, expires.Unix(), a.cfg.AuthKey)
if a.cfg.Protocol == "srt" {
return "srt://" + a.cfg.PushDomain + ":" + strconv.Itoa(a.cfg.SRTPort) + "?streamid=#!::h=" + a.cfg.PushDomain + ",r=" + path + "?auth_key=" + auth + ",m=publish"
}
return "rtmps://" + a.cfg.PushDomain + "/" + a.cfg.AppName + "/" + streamName + "?auth_key=" + auth
}

80
logger/gorm_log.go

@ -0,0 +1,80 @@
package logger
import (
"context"
"errors"
"fmt"
"log"
"path/filepath"
"runtime"
"time"
"gorm.io/gorm/logger"
)
type GormLogger struct {
LogLevel logger.LogLevel
logger *log.Logger
}
func NewGormLogger() logger.Interface {
return &GormLogger{
LogLevel: logger.Info,
logger: log.New(DefaultWriter, "", log.LstdFlags|log.Lshortfile),
}
}
func (gl *GormLogger) LogMode(level logger.LogLevel) logger.Interface {
newLogger := *gl
newLogger.LogLevel = level
return &newLogger
}
func (gl *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
if gl.LogLevel <= logger.Info {
DEBUG(fmt.Sprintf(msg, data...))
}
}
func (gl *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
if gl.LogLevel <= logger.Warn {
WARN(fmt.Sprintf(msg, data...))
}
}
func (gl *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
if gl.LogLevel <= logger.Error {
ERROR(fmt.Sprintf(msg, data...), nil)
}
}
func (gl *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
if gl.LogLevel > logger.Silent {
elapsed := time.Since(begin)
sql, rows := fc()
_, file, line, _ := runtime.Caller(3)
source := fmt.Sprintf("%s:%d", filepath.Base(file), line)
logMsg := struct {
Source string
Duration string
Rows int64
SQL string
}{
Source: source,
Duration: fmt.Sprintf("%.2f:ms", float64(elapsed.Nanoseconds())/1e6),
Rows: rows,
SQL: sql,
}
switch {
case err != nil && errors.Is(err, logger.ErrRecordNotFound):
ERROR(logMsg, err)
case err != nil:
ERROR(logMsg, err)
default:
DEBUG(logMsg)
}
}
}

318
logger/logger.go

@ -0,0 +1,318 @@
package logger
import (
"bytes"
"fmt"
"io"
"log"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"gopkg.in/natefinch/lumberjack.v2"
)
const (
Level_T Level = iota
Level_D
Level_I
Level_W
Level_E
)
const (
Reset = "\033[0m"
Red = "\033[31m"
Green = "\033[32m"
Yellow = "\033[33m"
Blue = "\033[34m"
Magenta = "\033[35m"
Cyan = "\033[36m"
White = "\033[37m"
)
// LogConfig 日志配置
type LogConfig struct {
LogPath string
MaxSize int
MaxBackups int
MaxAge int
Compress bool
}
var defaultConfig = LogConfig{
LogPath: "./log",
MaxSize: 50,
MaxBackups: 20,
MaxAge: 30,
Compress: true,
}
type Level int
// TraceContext 存储跟踪信息
type TraceContext struct {
TraceID string
SpanID string
}
// goroutineID 到 traceID 的映射
var traceMap sync.Map
// 获取当前 goroutine 的 ID
func getGoroutineID() uint64 {
b := make([]byte, 64)
b = b[:runtime.Stack(b, false)]
b = bytes.TrimPrefix(b, []byte("goroutine "))
b = b[:bytes.IndexByte(b, ' ')]
n, _ := strconv.ParseUint(string(b), 10, 64)
return n
}
// SetTraceID 设置当前 goroutine 的 traceID
func SetTraceID(traceID string) {
traceMap.Store(getGoroutineID(), traceID)
}
// GetTraceID 获取当前 goroutine 的 traceID
func GetTraceID() string {
if id, ok := traceMap.Load(getGoroutineID()); ok {
return id.(string)
}
return ""
}
// CleanTraceID 清理当前 goroutine 的 traceID
func CleanTraceID() {
traceMap.Delete(getGoroutineID())
}
type customLogWriter struct {
prefix string
output io.Writer
}
var DefaultWriter io.Writer
var (
loglevel Level
_trace *log.Logger
_debug *log.Logger
_info *log.Logger
_warn *log.Logger
_error *log.Logger
)
func SetLevel(level Level) {
loglevel = level
}
func GetLevel() string {
return loglevel.String()
}
func formatLogWithTrace(data ...any) string {
logData := formatLogData(data...)
return fmt.Sprintf("===> %v", logData)
}
func formatLogData(data ...any) string {
var formattedData []string
for _, item := range data {
switch v := item.(type) {
case string:
formattedData = append(formattedData, v)
case int, int64, float64, bool:
formattedData = append(formattedData, fmt.Sprintf("%v", v))
case []interface{}:
for _, elem := range v {
formattedData = append(formattedData, fmt.Sprintf("%+v", elem))
}
default:
formattedData = append(formattedData, fmt.Sprintf("%+v", v))
}
}
return strings.Join(formattedData, " ")
}
func TRACE(data ...any) {
if loglevel <= Level_T {
_trace.Println(formatLogWithTrace(data...))
}
}
func INFO(data ...any) {
if loglevel <= Level_I {
_info.Println(formatLogWithTrace(data...))
}
}
func DEBUG(data ...any) {
if loglevel <= Level_D {
_debug.Println(formatLogWithTrace(data...))
}
}
func ERROR(data any, e error) {
if loglevel <= Level_E {
_error.Println(formatLogWithTrace(data), e)
}
}
func WARN(data ...any) {
if loglevel <= Level_W {
_warn.Println(formatLogWithTrace(data...))
}
}
// AsyncWriter 异步写入器
type AsyncWriter struct {
output io.Writer
ch chan []byte
wg sync.WaitGroup
}
func NewAsyncWriter(output io.Writer) *AsyncWriter {
w := &AsyncWriter{
output: output,
ch: make(chan []byte, 1000),
}
w.wg.Add(1)
go w.run()
return w
}
func NewAsyncWriterWithSize(output io.Writer, size int) *AsyncWriter {
w := &AsyncWriter{
output: output,
ch: make(chan []byte, size),
}
w.wg.Add(1)
go w.run()
return w
}
func (w *AsyncWriter) run() {
defer w.wg.Done()
for data := range w.ch {
w.output.Write(data)
}
}
func (w *AsyncWriter) Write(p []byte) (n int, err error) {
w.ch <- append([]byte(nil), p...)
return len(p), nil
}
func (w *AsyncWriter) Close() {
close(w.ch)
w.wg.Wait()
}
func InitCustomLog(level Level, config *LogConfig, logName string) {
if config == nil {
config = &defaultConfig
}
SetLevel(level)
if err := os.MkdirAll(config.LogPath, 0o755); err != nil {
panic(err)
}
infoLogger := newLevelLogger(fmt.Sprintf("%s-info", logName), config)
errorLogger := newLevelLogger(fmt.Sprintf("%s-error", logName), config)
env := os.Getenv("LAIC_ENV")
var output io.Writer
if env != "prod" && loglevel <= Level_D {
output = io.MultiWriter(os.Stdout, infoLogger)
} else {
output = infoLogger
}
DefaultWriter = NewAsyncWriter(output)
var errorOutput io.Writer
if loglevel <= Level_D {
errorOutput = NewAsyncWriter(io.MultiWriter(os.Stdout, errorLogger, infoLogger))
} else {
errorOutput = NewAsyncWriter(io.MultiWriter(errorLogger, infoLogger))
}
_trace = newLogger(Level_T, Magenta, DefaultWriter)
_debug = newLogger(Level_D, Blue, DefaultWriter)
_info = newLogger(Level_I, Green, DefaultWriter)
_warn = newLogger(Level_W, Yellow, DefaultWriter)
_error = newLogger(Level_E, Red, errorOutput)
log.SetOutput(DefaultWriter)
log.SetFlags(0)
}
func newLevelLogger(level string, config *LogConfig) io.Writer {
return &lumberjack.Logger{
Filename: filepath.Join(config.LogPath, level+".log"),
MaxSize: config.MaxSize,
MaxBackups: config.MaxBackups,
MaxAge: config.MaxAge,
Compress: config.Compress,
}
}
func newLogger(level Level, color string, output io.Writer) *log.Logger {
logger := log.New(output, "", 0)
logger.SetOutput(&customLogWriter{
prefix: fmt.Sprintf("%s[%s]: %s", color, strings.ToUpper(level.String()), Reset),
output: output,
})
return logger
}
// String 方法
func (l Level) String() string {
switch l {
case Level_T:
return "TRACE"
case Level_D:
return "DEBUG"
case Level_I:
return "INFO"
case Level_W:
return "WARN"
case Level_E:
return "ERROR"
default:
return "UNKNOWN"
}
}
func (c *customLogWriter) Write(p []byte) (n int, err error) {
buf := &bytes.Buffer{}
traceID := GetTraceID()
_, file, line, ok := runtime.Caller(4)
if !ok {
file = "unknown"
line = 0
}
file = file[strings.LastIndex(file, "/")+1:]
fmt.Fprintf(buf, "[%s] ", time.Now().Format("2006-01-02 15:04:05.999"))
fmt.Fprintf(buf, "%s", c.prefix)
if traceID != "" {
fmt.Fprintf(buf, " (%s) ", traceID)
}
fmt.Fprintf(buf, "[%s:%d] %s", file, line, p)
return c.output.Write(buf.Bytes())
}
func GetLogWriter() io.Writer {
return DefaultWriter
}

69
main.go

@ -0,0 +1,69 @@
package main
import (
"os"
"os/signal"
"syscall"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/mqtt"
"laic-backend/route"
"laic-backend/service"
"laic-backend/token"
)
func main() {
// 1. 初始化日志(先用默认配置,便于后续报错输出)
logger.InitCustomLog(logger.Level_D, nil, "laic-backend")
// 2. 加载 config.yaml
conf := &common.AppConfig{}
common.LoadConfig("config.yaml", conf)
if err := service.InitLiveProvider(conf.Live); err != nil {
logger.ERROR("直播提供商初始化失败", err)
panic(err)
}
setLogLevel(conf.Log.Level)
token.Init(conf.JWT.Secret, conf.JWT.AccessExpireH, conf.JWT.RefreshExpireH)
// 3. 初始化基础设施
common.InitConnection(&conf.Mysql)
common.InitTDengine(&conf.TDengine)
common.InitRedis(conf.Redis)
common.InitCasbinEnforcer()
// 4. 预热缓存 + 引导 Casbin 策略 + 启动遥测批量写入
cache.WarmUp()
service.BootstrapCasbin()
service.BootstrapOperationLog()
service.DefaultTelemetryStore.Start()
// 5. 启动 MQTT(订阅设备上行消息)与 HTTP
mqtt.InitMQTT(&conf.MQTT)
service.InitMQTTSubscriber()
service.DefaultCommandService.StartRetryScanner()
service.DefaultScheduler.Start()
route.InitRouter(int32(conf.ApiPort))
logger.INFO("laic-backend starting on port:", conf.ApiPort)
common.ServerRun(int32(conf.ApiPort))
// 5. 优雅退出
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
}
func setLogLevel(level string) {
switch level {
case "info":
logger.SetLevel(logger.Level_I)
case "warn":
logger.SetLevel(logger.Level_W)
case "error":
logger.SetLevel(logger.Level_E)
default:
logger.SetLevel(logger.Level_D)
}
}

75
middleware/auth.go

@ -0,0 +1,75 @@
package middleware
import (
"net/http"
"slices"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/token"
)
// AuthMiddleware 鉴权中间件:解析 JWT + Redis 存证校验 + 注入上下文
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString := c.GetHeader("Authorization")
if tokenString == "" {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.ErrUnauthorized)
return
}
tokenString = strings.TrimPrefix(tokenString, "Bearer ")
if tokenString == "" {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.ErrUnauthorized)
return
}
claims, err := token.ParseToken(tokenString)
if err != nil {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.ErrToken)
return
}
// Redis 存证校验:登出会删除该 key,从而让已签发 token 失效
storeKey := cache.UserTokenKeyOf(claims.UserID, token.GenerateShortID(tokenString))
if !common.HasKey(storeKey) {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.SignOutToken)
return
}
roles := claims.Roles
if roles == nil {
roles = []string{}
}
isAdmin := slices.Contains(roles, common.RoleAdmin)
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("roles", roles)
c.Set("is_admin", isAdmin)
// Casbin 权限校验(admin 与普通用户均按 p/g 策略校验,admin 仅限平台管理职能)
if common.CasbinEnforcer != nil {
ok, err := common.CasbinEnforcer.Enforce(strconv.FormatInt(claims.UserID, 10), c.Request.URL.Path, c.Request.Method)
if err != nil || !ok {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusForbidden, common.ErrForbidden)
c.Abort()
return
}
}
c.Next()
}
}
// AdminMiddleware 仅管理员可访问
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !c.GetBool("is_admin") {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusForbidden, common.ErrForbidden)
return
}
c.Next()
}
}

30
middleware/cros.go

@ -0,0 +1,30 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CORSMiddleware 跨域中间件
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
if origin == "" {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
} else {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
}
c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Length, Content-Type")
c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers")
c.Header("Access-Control-Max-Age", "172800")
c.Header("Access-Control-Allow-Credentials", "true")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusOK)
return
}
c.Next()
}
}

25
middleware/default_log.go

@ -0,0 +1,25 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"laic-backend/logger"
)
// DefaultLogMiddleware 访问日志:方法、路径、状态码、耗时、来源 IP
func DefaultLogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
latency := time.Since(start)
logger.INFO(
c.Request.Method,
c.Request.URL.Path,
"status", c.Writer.Status(),
"ip", c.ClientIP(),
"latency", latency.String(),
)
}
}

155
middleware/operation_log.go

@ -0,0 +1,155 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/model"
"laic-backend/service"
)
// OperationLogMiddleware 记录设备控制写操作(POST/PUT/DELETE)到 operation_log
func OperationLogMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !isMutating(c.Request.Method) {
c.Next()
return
}
path := c.Request.URL.Path
if !isDeviceOperation(path) {
c.Next()
return
}
c.Next()
userID := c.GetInt64("user_id")
if userID == 0 {
return
}
result := "success"
if c.Writer.Status() >= http.StatusBadRequest {
result = "failed"
}
userName := c.GetString("username")
if name := resolveUserName(userID); name != "" {
userName = name
}
service.DefaultOperationLogService.Record(
userID, userName,
resolveModule(path), resolveAction(c.Request.Method, path),
path, result, c.ClientIP(),
)
}
}
func isMutating(method string) bool {
return method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete
}
func isDeviceOperation(path string) bool {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 3 || parts[0] != "v1" {
return false
}
switch parts[1] {
case "docks":
return len(parts) >= 4 && (parts[3] == "command" || parts[3] == "firmware")
case "tasks":
return len(parts) >= 4 && parts[3] == "execute"
case "alarms":
return len(parts) >= 4 && (parts[3] == "acknowledge" || parts[3] == "resolve")
case "live":
return len(parts) >= 4 && (parts[3] == "start" || parts[3] == "stop")
default:
return false
}
}
// resolveUserName 从 DB 读取用户显示名(快照),失败回退为空(由调用方回退 phone)
func resolveUserName(userID int64) string {
var u model.User
if err := common.DB.Select("name").First(&u, userID).Error; err == nil {
return u.Name
}
return ""
}
// resolveModule 将路径首个资源段映射为中文模块名
func resolveModule(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 2 {
return "系统"
}
switch parts[1] {
case "users":
return "用户管理"
case "roles":
return "角色管理"
case "docks":
return "机巢管理"
case "drones":
return "无人机管理"
case "alarms":
return "告警管理"
case "commands":
return "指令管理"
case "routes":
return "航线管理"
case "tasks":
return "任务管理"
case "executions":
return "执行记录"
case "firmwares":
return "固件管理"
case "live":
return "直播管理"
case "videos":
return "视频管理"
case "account":
return "账户中心"
case "logs":
return "系统管理"
case "auth":
return "认证"
default:
return "系统"
}
}
// resolveAction 将 HTTP 方法 + 路径末尾段映射为中文动作
func resolveAction(method, path string) string {
seg := path
if i := strings.LastIndex(seg, "/"); i >= 0 {
seg = seg[i+1:]
}
special := map[string]string{
"execute": "执行任务",
"command": "下发指令",
"upgrade": "下发升级",
"pay": "支付订单",
"download": "下载视频",
"start": "开始直播",
"stop": "停止直播",
"complete": "确认上传",
"recharge": "充值",
"resolve": "处理告警",
"acknowledge": "确认告警",
"logout": "退出登录",
}
if a, ok := special[seg]; ok {
return a
}
switch method {
case http.MethodPost:
return "新增"
case http.MethodPut:
return "修改"
case http.MethodDelete:
return "删除"
default:
return method
}
}

47
middleware/rate_limit.go

@ -0,0 +1,47 @@
package middleware
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/logger"
)
// rateLimitLua 固定窗口计数:首次计数设置过期时间,返回当前窗口计数
const rateLimitLua = `
local key = KEYS[1]
local ttl = tonumber(ARGV[1])
local n = redis.call('INCR', key)
if n == 1 then
redis.call('EXPIRE', key, ttl)
end
return n
`
// RateLimitMiddleware 基于 Redis 固定窗口的按 IP 限流(默认 300 次/分钟)
func RateLimitMiddleware(limit int64, windowSeconds int64) gin.HandlerFunc {
if limit <= 0 {
limit = 300
}
if windowSeconds <= 0 {
windowSeconds = 60
}
return func(c *gin.Context) {
key := fmt.Sprintf("laic:ratelimit:%s", c.ClientIP())
n, err := common.GetLuaInt64(rateLimitLua, []string{key}, windowSeconds)
if err != nil {
// 限流依赖 Redis,异常时放行(避免 Redis 故障拖垮全站)
logger.ERROR("限流计数失败", err)
c.Next()
return
}
if n > limit {
common.FailWithBusiErrorWithHttpStatus(c, http.StatusTooManyRequests, common.NewBusiError(http.StatusTooManyRequests, "请求过于频繁,请稍后再试"))
return
}
c.Next()
}
}

26
middleware/trace.go

@ -0,0 +1,26 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"laic-backend/logger"
)
// TraceMiddleware 为每个请求生成/透传 traceID,便于链路日志追踪
func TraceMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
traceID := c.GetHeader("X-Trace-Id")
if traceID == "" {
traceID = strings.ReplaceAll(uuid.New().String(), "-", "")[:16]
}
logger.SetTraceID(traceID)
defer logger.CleanTraceID()
c.Header("X-Trace-Id", traceID)
c.Set("trace_id", traceID)
c.Next()
}
}

638
mock/dock.go

@ -0,0 +1,638 @@
package main
import (
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
)
// downlinkEnvelope 后台下发指令的通用外层(协议文档 §4)
type downlinkEnvelope struct {
RequestID string `json:"requestId"`
DockID string `json:"dockId"`
DroneSN string `json:"droneSn"`
Payload json.RawMessage `json:"payload"`
}
// commandMsg 后台下发的指令载荷(协议文档 §5.1,位于 payload 内)
type commandMsg struct {
CommandID string `json:"commandId"`
Type string `json:"type"`
TTLMs int `json:"ttlMs"`
Params map[string]any `json:"params"`
RequestID string // 来自通用外层
DroneSN string // 来自通用外层
}
// droneTelemetry 是机巢直接生成的无人机模拟状态
type droneTelemetry struct {
armed bool
flightMode string
latitude float64
longitude float64
altitude float64
groundSpeed float64
heading float64
roll float64
pitch float64
yaw float64
batteryPct int
batteryV float64
satellites int
gpsQuality string
batteryPctDrain int
}
// MockDock 模拟机巢:通过 MQTT 上云并直接上报机巢、无人机状态
type MockDock struct {
spec dockSpec
client paho.Client
mu sync.Mutex
doorState string
droneOnline bool
tele droneTelemetry
inMission bool
missionPaused bool
missionCancel chan struct{}
missionCmdID string
uploadedRoute []missionWaypoint
liveSessionID string
liveStreaming bool
videoVersion int64
}
func newMockDock(spec dockSpec) *MockDock {
d := &MockDock{
spec: spec,
doorState: "closed",
tele: droneTelemetry{
flightMode: "STANDBY",
latitude: spec.DroneLat,
longitude: spec.DroneLon,
batteryPct: spec.Battery,
batteryV: 25.2,
satellites: 22,
gpsQuality: "RTK_FIX",
},
droneOnline: true,
}
if spec.Flying {
d.doorState = "open"
d.tele.armed = true
d.tele.flightMode = "AUTO"
d.tele.altitude = 58
d.tele.groundSpeed = 4.2
d.tele.heading = 120
}
return d
}
func (d *MockDock) start(broker, username, password string) error {
// 1. MQTT 连接后台 EMQX
opts := paho.NewClientOptions().
AddBroker(broker).
SetClientID("mock-dock-" + d.spec.DockID).
SetUsername(username).
SetPassword(password).
SetAutoReconnect(true).
SetCleanSession(true)
opts.SetOnConnectHandler(func(c paho.Client) { d.onMqttConnect(c) })
d.client = paho.NewClient(opts)
if tok := d.client.Connect(); tok.Wait() && tok.Error() != nil {
return fmt.Errorf("MQTT 连接失败: %w", tok.Error())
}
go d.publishLoop()
log.Printf("[%s] 机巢启动(MQTT 直接模拟无人机数据)", d.spec.DockID)
return nil
}
func (d *MockDock) topic(sub string) string {
return fmt.Sprintf("dock-edge/v1/dock/%s/%s", d.spec.DockID, sub)
}
// publish 上发一条不含 droneSn 的消息(外层统一包 envelope)
func (d *MockDock) publish(topic string, qos byte, retained bool, payload any) {
d.publishEnvelope(topic, qos, retained, "", "", payload)
}
// publishDrone 上发一条携带 droneSn 的消息
func (d *MockDock) publishDrone(topic string, qos byte, retained bool, droneSN string, payload any) {
d.publishEnvelope(topic, qos, retained, "", droneSN, payload)
}
func (d *MockDock) publishEnvelope(topic string, qos byte, retained bool, requestID, droneSN string, payload any) {
if d.client == nil || !d.client.IsConnected() {
return
}
b, err := json.Marshal(d.wrap(requestID, droneSN, payload))
if err != nil {
return
}
d.client.Publish(topic, qos, retained, b)
}
func (d *MockDock) publishVideoState(payload any, version int64) {
if d.client == nil || !d.client.IsConnected() {
return
}
b, err := json.Marshal(map[string]any{
"requestId": nil,
"eventId": fmt.Sprintf("video-%s-%d", d.spec.DockID, version),
"version": version,
"dockId": d.spec.DockID,
"droneSn": nil,
"timestamp": time.Now().UnixMilli(),
"payload": payload,
})
if err != nil {
return
}
d.client.Publish(d.topic("state/video"), 1, true, b)
}
// wrap 构造通用外层;requestId / droneSn 为空时序列化为 null
func (d *MockDock) wrap(requestID, droneSN string, payload any) map[string]any {
return map[string]any{
"requestId": nilIfEmpty(requestID),
"dockId": d.spec.DockID,
"droneSn": nilIfEmpty(droneSN),
"timestamp": time.Now().UnixMilli(),
"payload": payload,
}
}
func (d *MockDock) onMqttConnect(c paho.Client) {
log.Printf("[%s] MQTT 已连接", d.spec.DockID)
if tok := c.Subscribe(d.topic("command"), 1, d.onCommand); tok.Wait() && tok.Error() != nil {
log.Printf("[%s] 订阅指令失败: %v", d.spec.DockID, tok.Error())
}
d.publishStatusOnline()
d.publishStateDock()
d.publishStateDrone()
d.publishStateVideo()
}
func (d *MockDock) publishLoop() {
tick1 := time.NewTicker(time.Second)
tick5 := time.NewTicker(5 * time.Second)
defer tick1.Stop()
defer tick5.Stop()
for {
select {
case <-tick1.C:
d.publishTelemetry()
case <-tick5.C:
d.publishStateDrone()
d.publishStateDock()
}
}
}
// ---- 指令下发 ----
func (d *MockDock) onCommand(_ paho.Client, msg paho.Message) {
var env downlinkEnvelope
if err := json.Unmarshal(msg.Payload(), &env); err != nil {
log.Printf("[%s] 解析指令外层失败: %v", d.spec.DockID, err)
return
}
var cmd commandMsg
if err := json.Unmarshal(env.Payload, &cmd); err != nil {
log.Printf("[%s] 解析指令载荷失败: %v", d.spec.DockID, err)
return
}
cmd.RequestID = env.RequestID
cmd.DroneSN = env.DroneSN
log.Printf("[%s] 收到指令 %s (commandId=%s)", d.spec.DockID, cmd.Type, cmd.CommandID)
switch {
case strings.HasPrefix(cmd.Type, "dock."):
d.handleDockCommand(cmd)
case strings.HasPrefix(cmd.Type, "drone."):
d.handleDroneCommand(cmd)
case strings.HasPrefix(cmd.Type, "workflow."):
d.handleWorkflowCommand(cmd)
case strings.HasPrefix(cmd.Type, "video."):
d.handleVideoCommand(cmd)
default:
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
}
}
func (d *MockDock) handleDockCommand(cmd commandMsg) {
switch cmd.Type {
case "dock.open":
d.setDoor("open")
case "dock.close":
d.setDoor("closed")
case "dock.reset":
d.setDoor("closed")
case "dock.prepare_takeoff", "dock.complete_takeoff", "dock.prepare_landing", "dock.complete_landing",
"dock.charge_start", "dock.drone_power_on", "dock.drone_power_off",
"dock.centering_loose", "dock.centering_tight", "dock.clear_alarm", "dock.emergency_stop":
// 无状态变化,仅确认
default:
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
return
}
d.publishStateDock()
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
}
func (d *MockDock) handleDroneCommand(cmd commandMsg) {
switch cmd.Type {
case "drone.takeoff":
d.mu.Lock()
d.tele.armed = true
d.tele.flightMode = "AUTO"
d.tele.altitude = 50
d.tele.groundSpeed = 4.2
d.doorState = "open"
d.mu.Unlock()
case "drone.land", "drone.return":
if d.cancelMission() {
// 任务协程收到取消后会自行返航落地
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
return
}
d.snapHomeLanded()
case "drone.mission_upload":
wps := parseWaypoints(cmd.Params)
d.mu.Lock()
d.uploadedRoute = wps
d.mu.Unlock()
log.Printf("[%s] 已缓存航线,航点数=%d", d.spec.DockID, len(wps))
case "drone.mission_start":
d.mu.Lock()
wps := append([]missionWaypoint(nil), d.uploadedRoute...)
d.mu.Unlock()
if len(wps) == 0 {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "NO_ROUTE")
return
}
if !d.beginMission(cmd.CommandID) {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "MISSION_IN_PROGRESS")
return
}
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
go d.flyUploadedMission(wps)
return
case "drone.mission_pause":
d.setMissionPaused(true)
case "drone.mission_resume":
d.setMissionPaused(false)
case "drone.mission_cancel":
d.cancelMission()
default:
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
return
}
d.publishStateDrone()
d.publishStateDock()
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
}
func (d *MockDock) handleVideoCommand(cmd commandMsg) {
streamSessionID, _ := cmd.Params["streamSessionId"].(string)
switch cmd.Type {
case "video.start_stream":
if streamSessionID == "" {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "SESSION_ID_REQUIRED")
return
}
d.mu.Lock()
if d.liveStreaming && d.liveSessionID != streamSessionID {
d.mu.Unlock()
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "STREAM_IN_PROGRESS")
return
}
d.liveSessionID = streamSessionID
d.liveStreaming = true
d.mu.Unlock()
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.publishStateVideo()
case "video.stop_stream":
d.mu.Lock()
matches := streamSessionID != "" && streamSessionID == d.liveSessionID
if matches {
d.liveStreaming = false
}
d.mu.Unlock()
if !matches {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "SESSION_NOT_FOUND")
return
}
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
d.publishStateVideo()
default:
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
}
}
func (d *MockDock) handleWorkflowCommand(cmd commandMsg) {
switch cmd.Type {
case "workflow.cancel", "workflow.stop_task":
d.cancelMission()
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
return
case "workflow.one_key_return", "workflow.one_key_landing":
if !d.cancelMission() {
go d.rtlAndLand()
}
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
return
}
if d.isRepeatCommand(cmd.CommandID) {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
return
}
if !d.beginMission(cmd.CommandID) {
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "MISSION_IN_PROGRESS")
return
}
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
switch cmd.Type {
case "workflow.start_task":
go d.runStartTask(cmd)
case "workflow.one_key_takeoff":
go d.runOneKeyTakeoff(cmd)
default:
go d.runGenericWorkflow(cmd)
}
}
func (d *MockDock) setDoor(state string) {
d.mu.Lock()
d.doorState = state
d.mu.Unlock()
}
// ---- 上发各类消息 ----
func (d *MockDock) ack(requestID, droneSN, commandID string, accepted bool, resultCode string) {
d.publishEnvelope(d.topic("command/ack"), 1, false, requestID, droneSN, map[string]any{
"commandId": commandID,
"accepted": accepted,
"resultCode": resultCode,
})
}
func (d *MockDock) publishStatusOnline() {
d.publish(d.topic("status/online"), 1, true, map[string]any{
"status": "online",
"bootId": "boot-" + d.spec.DockID,
"dockIdSource": "dmi_product_serial",
"softwareVersion": "1.3.0",
"protocolVersion": "1.0",
"uptimeSec": 86400,
"timeSynced": true,
"mqttConnected": true,
"modbusConnected": true,
"mavlinkConnected": false,
"updating": false,
"name": d.spec.Name,
"location": d.spec.Location,
"latitude": d.spec.Latitude,
"longitude": d.spec.Longitude,
})
}
func (d *MockDock) publishStateDock() {
d.mu.Lock()
door := d.doorState
flying := d.tele.armed || d.tele.altitude > 0
d.mu.Unlock()
chargingState := "charging"
chargingVoltage := 25.2
chargingCurrent := 8.4
dronePresent := true
if flying {
chargingState = "idle"
chargingVoltage = 0
chargingCurrent = 0
dronePresent = false
door = "open"
}
alarms := []string{}
emergency := false
if d.spec.Alarm {
alarms = []string{"DOCK_EMERGENCY_STOP"}
emergency = true
}
d.publish(d.topic("state/dock"), 1, true, map[string]any{
"plcConnected": true,
"controlMode": "auto",
"door": map[string]any{"left": door, "right": door},
"centering": map[string]any{"leftRight": "loose", "frontBack": "loose"},
"chargingState": chargingState,
"chargingVoltage": chargingVoltage,
"chargingCurrent": chargingCurrent,
"emergencyStop": emergency,
"alarmCodes": alarms,
"chargeStartComplete": chargingState == "charging",
"dronePowerOnComplete": dronePresent,
"dronePowerOffComplete": false,
"dronePresent": dronePresent,
"takeoffPreparationComplete": false,
"hangarActionComplete": false,
"landingPreparationComplete": false,
"landingExecutionComplete": false,
"doorCloseComplete": door == "closed",
"doorOpenComplete": door == "open",
"centeringLooseComplete": false,
"centeringTightComplete": false,
"resetComplete": true,
"environment": dockEnvironment(d.spec.DockID),
})
}
func dockEnvironment(dockID string) map[string]any {
switch dockID {
case "dock-2":
return map[string]any{
"rain": false, "windSpeed": 3.6, "outsideTemperature": 22.4,
"outsideHumidity": 61.0, "insideTemperature": 26.1, "insideHumidity": 48.0,
}
case "dock-3":
return map[string]any{
"rain": true, "windSpeed": 5.1, "outsideTemperature": 19.8,
"outsideHumidity": 78.0, "insideTemperature": 24.6, "insideHumidity": 62.0,
}
default:
return map[string]any{
"rain": false, "windSpeed": 1.8, "outsideTemperature": 25.1,
"outsideHumidity": 55.0, "insideTemperature": 27.3, "insideHumidity": 50.0,
}
}
}
func (d *MockDock) publishStateDrone() {
d.mu.Lock()
t := d.tele
online := d.droneOnline
d.mu.Unlock()
d.publishDrone(d.topic("state/drone"), 1, true, d.spec.DroneSN, map[string]any{
"droneSn": d.spec.DroneSN,
"name": d.spec.DroneName,
"currentSysId": 1,
"online": online,
"armed": t.armed,
"flightMode": t.flightMode,
"flightModeCode": 11,
"latitude": t.latitude,
"longitude": t.longitude,
"altitude": t.altitude,
"groundSpeed": t.groundSpeed,
"roll": t.roll,
"pitch": t.pitch,
"yaw": t.yaw,
"batteryPercent": float64(t.batteryPct),
"batteryVoltage": t.batteryV,
"batteryCurrent": 0.0,
"satellites": t.satellites,
"gpsQuality": t.gpsQuality,
"linkQuality": 98.0,
"homeSet": true,
"alarmCodes": []string{},
})
}
func (d *MockDock) publishTelemetry() {
d.mu.Lock()
t := d.tele
d.mu.Unlock()
d.publishDrone(d.topic("telemetry"), 0, false, d.spec.DroneSN, map[string]any{
"ts": time.Now().UnixMilli(),
"longitude": t.longitude,
"latitude": t.latitude,
"altitude": t.altitude,
"groundSpeed": t.groundSpeed,
"roll": t.roll,
"pitch": t.pitch,
"yaw": t.yaw,
"batteryPct": t.batteryPct,
"batteryV": t.batteryV,
"satellites": t.satellites,
"gpsQuality": t.gpsQuality,
"linkQuality": 96,
"flightMode": t.flightMode,
"armed": boolToInt(t.armed),
})
}
func (d *MockDock) publishStateVideo() {
d.mu.Lock()
streamSessionID := d.liveSessionID
streaming := d.liveStreaming
d.videoVersion++
version := d.videoVersion
d.mu.Unlock()
phase := "idle"
if streaming {
phase = "streaming"
}
provider := "fake"
d.publishVideoState(map[string]any{
"provider": provider,
"phase": phase,
"inputOnline": streaming,
"streaming": streaming,
"inputCodec": "h264",
"uplinkProtocol": "srt",
"streamSessionId": nilIfEmpty(streamSessionID),
"width": nil,
"height": nil,
"frameRate": nil,
"bitrateBps": liveBitrate(streaming),
"retryCount": 0,
"stopReason": nil,
"errorCode": nil,
"updatedAt": time.Now().UnixMilli(),
}, version)
}
func liveBitrate(streaming bool) int {
if streaming {
return 1500000
}
return 0
}
func (d *MockDock) publishWorkflow(cmd commandMsg, taskID, missionID, state, step string) {
resultCode := any(nil)
switch state {
case "succeeded":
resultCode = "OK"
case "cancelled":
resultCode = "CANCELLED"
case "failed":
resultCode = "TIMEOUT"
}
recordOriginalVideo := any(nil)
if cmd.Type == "workflow.start_task" {
recordOriginalVideo = asBool(cmd.Params["recordOriginalVideo"])
}
d.publishEnvelope(d.topic("state/workflow"), 1, true, cmd.RequestID, "", map[string]any{
"commandId": cmd.CommandID,
"type": cmd.Type,
"taskId": nilIfEmpty(taskID),
"missionId": nilIfEmpty(missionID),
"recordOriginalVideo": recordOriginalVideo,
"state": state,
"step": step,
"resultCode": resultCode,
"updatedAt": time.Now().UnixMilli(),
})
}
// ---- 工具函数 ----
func boolToInt(b bool) int8 {
if b {
return 1
}
return 0
}
func nilIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
func flightModeFromBaseMode(base string) string {
return base
}
func gpsQualityFromFixType(fix int) string {
switch fix {
case 6:
return "RTK_FIX"
case 5:
return "RTK_FLOAT"
case 3:
return "3D_FIX"
case 2:
return "2D_FIX"
default:
return "NO_FIX"
}
}

73
mock/main.go

@ -0,0 +1,73 @@
package main
import (
"log"
"os"
"os/signal"
"syscall"
"time"
)
// dockSpec 描述机巢及其直接模拟上报的绑定无人机。
type dockSpec struct {
DockID string
Name string
Location string
DroneSN string
DroneName string
Latitude float64
Longitude float64
DroneLat float64
DroneLon float64
Flying bool
Battery int
Alarm bool
}
func main() {
broker := envOr("MOCK_MQTT_BROKER", "tcp://127.0.0.1:1883")
username := envOr("MOCK_MQTT_USERNAME", "laic")
password := envOr("MOCK_MQTT_PASSWORD", "")
specs := []dockSpec{
{
DockID: "dock-1", Name: "青山湖 01 号机巢", Location: "青山湖科技城 A 区",
DroneSN: "JG-UAV-001", DroneName: "巡检无人机 001",
Latitude: 30.262438, Longitude: 119.802631, DroneLat: 30.262438, DroneLon: 119.802631,
Battery: 86,
},
{
DockID: "dock-2", Name: "青山湖 02 号机巢", Location: "青山湖科技城 B 区",
DroneSN: "JG-UAV-002", DroneName: "巡检无人机 002",
Latitude: 30.255917, Longitude: 119.818264, DroneLat: 30.256500, DroneLon: 119.818900,
Flying: true, Battery: 72,
},
{
DockID: "dock-3", Name: "青山湖 03 号机巢", Location: "青山湖科技城 C 区",
DroneSN: "JG-UAV-003", DroneName: "巡检无人机 003",
Latitude: 30.248610, Longitude: 119.791450, DroneLat: 30.248610, DroneLon: 119.791450,
Battery: 41, Alarm: true,
},
}
for _, spec := range specs {
dock := newMockDock(spec)
if err := dock.start(broker, username, password); err != nil {
log.Fatalf("启动机巢 %s 失败: %v", spec.DockID, err)
}
}
log.Println("模拟设备已启动(机巢 MQTT 直接上报机巢与无人机数据),按 Ctrl+C 退出")
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("正在退出...")
time.Sleep(200 * time.Millisecond)
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}

705
mock/mission.go

@ -0,0 +1,705 @@
package main
import (
"bytes"
"io"
"log"
"math"
"net/http"
"sort"
"strconv"
"time"
)
const (
missionTick = 200 * time.Millisecond
missionTimeScale = 10.0 // mock 加速倍数,便于在短时间内跑完航线
defaultCruiseSpeed = 8.0 // m/s
minLegDuration = 2 * time.Second
maxLegDuration = 12 * time.Second
maxHoldSec = 8
defaultTakeoffAlt = 50.0
)
// missionWaypoint 与后台 workflow.start_task params.waypoints 字段对齐
type missionWaypoint struct {
Seq int
Longitude float64
Latitude float64
Altitude float64
Speed float64
Yaw float64
HoldSec int
}
func (d *MockDock) beginMission(commandID string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.inMission {
return false
}
d.inMission = true
d.missionPaused = false
d.missionCmdID = commandID
d.missionCancel = make(chan struct{})
return true
}
func (d *MockDock) endMission() {
d.mu.Lock()
defer d.mu.Unlock()
d.inMission = false
d.missionPaused = false
d.missionCmdID = ""
d.missionCancel = nil
}
func (d *MockDock) isRepeatCommand(commandID string) bool {
d.mu.Lock()
defer d.mu.Unlock()
return commandID != "" && d.inMission && d.missionCmdID == commandID
}
// cancelMission 通知当前任务中止,返回是否有正在执行的任务
func (d *MockDock) cancelMission() bool {
d.mu.Lock()
defer d.mu.Unlock()
if !d.inMission || d.missionCancel == nil {
return false
}
select {
case <-d.missionCancel:
default:
close(d.missionCancel)
}
d.missionPaused = false
return true
}
func (d *MockDock) setMissionPaused(paused bool) {
d.mu.Lock()
defer d.mu.Unlock()
if d.inMission {
d.missionPaused = paused
}
}
func (d *MockDock) currentCancel() <-chan struct{} {
d.mu.Lock()
defer d.mu.Unlock()
return d.missionCancel
}
func (d *MockDock) currentLLA() (lat, lon, alt float64) {
d.mu.Lock()
defer d.mu.Unlock()
return d.tele.latitude, d.tele.longitude, d.tele.altitude
}
func (d *MockDock) setPose(lat, lon, alt, speed, heading float64) {
d.mu.Lock()
d.tele.latitude = lat
d.tele.longitude = lon
d.tele.altitude = alt
d.tele.groundSpeed = speed
d.tele.heading = heading
d.tele.yaw = heading
if d.tele.batteryPct > 20 && speed > 0.5 {
d.tele.batteryPctDrain++
if d.tele.batteryPctDrain >= 20 {
d.tele.batteryPctDrain = 0
d.tele.batteryPct--
d.tele.batteryV = 22.0 + float64(d.tele.batteryPct)*0.037
}
}
d.mu.Unlock()
}
func (d *MockDock) setArmedFlying(alt float64) {
d.mu.Lock()
d.tele.armed = true
d.tele.flightMode = "AUTO"
d.tele.altitude = alt
d.doorState = "open"
d.mu.Unlock()
}
func (d *MockDock) snapHomeLanded() {
d.mu.Lock()
d.tele.armed = false
d.tele.flightMode = "STANDBY"
d.tele.groundSpeed = 0
d.tele.altitude = 0
d.tele.latitude = d.spec.DroneLat
d.tele.longitude = d.spec.DroneLon
d.tele.heading = 0
d.tele.yaw = 0
d.doorState = "closed"
d.mu.Unlock()
}
// runStartTask 执行 workflow.start_task:按航点飞行并持续改写遥测坐标
func (d *MockDock) runStartTask(cmd commandMsg) {
defer d.endMission()
taskID, missionID := extractTaskIDs(cmd)
waypoints := parseWaypoints(cmd.Params)
recordOriginalVideo := asBool(cmd.Params["recordOriginalVideo"])
originalVideo := parseOriginalVideo(cmd.Params["originalVideo"])
d.mu.Lock()
d.uploadedRoute = waypoints
d.mu.Unlock()
log.Printf("[%s] 开始执行任务 taskId=%s 航点数=%d", d.spec.DockID, taskID, len(waypoints))
for _, wp := range waypoints {
log.Printf("[%s] 航点 seq=%d lon=%.6f lat=%.6f alt=%.1f speed=%.1f hold=%ds",
d.spec.DockID, wp.Seq, wp.Longitude, wp.Latitude, wp.Altitude, wp.Speed, wp.HoldSec)
}
d.setDoor("open")
d.publishStateDock()
prep := []string{
"preparing_dock_takeoff",
"waiting_drone_online",
"checking_drone",
"route_uploading",
"route_ready",
}
for _, step := range prep {
d.publishWorkflow(cmd, taskID, missionID, "running", step)
if !d.sleepOrAbort(400 * time.Millisecond) {
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
}
takeoffAlt := defaultTakeoffAlt
if len(waypoints) > 0 && waypoints[0].Altitude > 0 {
takeoffAlt = waypoints[0].Altitude
}
d.publishWorkflow(cmd, taskID, missionID, "running", "taking_off")
d.setArmedFlying(0)
d.publishStateDrone()
if !d.climbTo(takeoffAlt) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.publishStateDrone()
d.publishWorkflow(cmd, taskID, missionID, "running", "finishing_takeoff")
if !d.sleepOrAbort(400 * time.Millisecond) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.publishWorkflow(cmd, taskID, missionID, "running", "starting_mission")
if !d.sleepOrAbort(300 * time.Millisecond) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.publishWorkflow(cmd, taskID, missionID, "running", "mission_running")
if !d.flyWaypoints(waypoints) {
d.publishWorkflow(cmd, taskID, missionID, "running", "returning_to_home")
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.publishWorkflow(cmd, taskID, missionID, "running", "returning_to_home")
if !d.returnHomeAndLand(false) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.finishWorkflow(cmd, taskID, missionID, "succeeded")
if recordOriginalVideo {
d.uploadOriginalVideo(cmd, taskID, originalVideo)
}
log.Printf("[%s] 任务执行完成 taskId=%s", d.spec.DockID, taskID)
}
func (d *MockDock) runOneKeyTakeoff(cmd commandMsg) {
defer d.endMission()
taskID, missionID := extractTaskIDs(cmd)
d.setDoor("open")
d.publishStateDock()
d.publishWorkflow(cmd, taskID, missionID, "running", "taking_off")
d.setArmedFlying(0)
if !d.climbTo(defaultTakeoffAlt) {
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.publishStateDrone()
d.publishWorkflow(cmd, taskID, missionID, "succeeded", "idle")
}
func (d *MockDock) runGenericWorkflow(cmd commandMsg) {
defer d.endMission()
taskID, missionID := extractTaskIDs(cmd)
d.setDoor("open")
d.publishStateDock()
steps := []string{
"preparing_dock_takeoff",
"waiting_drone_online",
"checking_drone",
"taking_off",
"finishing_takeoff",
"returning_to_home",
"preparing_dock_landing",
"returning",
"finishing_landing",
}
for _, step := range steps {
d.publishWorkflow(cmd, taskID, missionID, "running", step)
if step == "taking_off" {
d.setArmedFlying(0)
if !d.climbTo(defaultTakeoffAlt) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
}
if !d.sleepOrAbort(time.Second) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
}
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "succeeded")
}
func (d *MockDock) flyUploadedMission(waypoints []missionWaypoint) {
defer d.endMission()
log.Printf("[%s] 执行已上传航线,航点数=%d", d.spec.DockID, len(waypoints))
d.setArmedFlying(math.Max(d.currentAlt(), defaultTakeoffAlt))
if !d.flyWaypoints(waypoints) {
d.returnHomeAndLand(true)
return
}
d.returnHomeAndLand(true)
}
func (d *MockDock) flyWaypoints(waypoints []missionWaypoint) bool {
for i, wp := range waypoints {
alt := wp.Altitude
if alt <= 0 {
_, _, alt = d.currentLLA()
if alt <= 0 {
alt = defaultTakeoffAlt
}
}
speed := wp.Speed
if speed <= 0 {
speed = defaultCruiseSpeed
}
log.Printf("[%s] 飞向航点 %d/%d (seq=%d)", d.spec.DockID, i+1, len(waypoints), wp.Seq)
if !d.flyTo(wp.Latitude, wp.Longitude, alt, speed, false) {
return false
}
if wp.HoldSec > 0 {
if !d.holdAt(wp.HoldSec, wp.Yaw) {
return false
}
} else if wp.Yaw != 0 {
d.mu.Lock()
d.tele.heading = wp.Yaw
d.tele.yaw = wp.Yaw
d.mu.Unlock()
}
d.publishStateDrone()
}
return true
}
func (d *MockDock) climbTo(alt float64) bool {
lat, lon, cur := d.currentLLA()
if cur < 1 {
d.setPose(lat, lon, 1, 0, d.currentHeading())
}
return d.flyTo(lat, lon, alt, 5, false)
}
func (d *MockDock) currentAlt() float64 {
_, _, alt := d.currentLLA()
return alt
}
func (d *MockDock) currentHeading() float64 {
d.mu.Lock()
defer d.mu.Unlock()
return d.tele.heading
}
func (d *MockDock) returnHomeAndLand(forced bool) bool {
homeAlt := math.Max(d.currentAlt(), 20)
if !d.flyTo(d.spec.DroneLat, d.spec.DroneLon, homeAlt, defaultCruiseSpeed, forced) && !forced {
return false
}
if !d.flyTo(d.spec.DroneLat, d.spec.DroneLon, 0, 3, forced) && !forced {
return false
}
d.snapHomeLanded()
d.publishStateDrone()
d.publishStateDock()
return true
}
func (d *MockDock) rtlAndLand() {
if !d.beginMission("rtl") {
return
}
defer d.endMission()
d.returnHomeAndLand(true)
}
func (d *MockDock) finishWorkflow(cmd commandMsg, taskID, missionID, state string) {
if state == "cancelled" {
d.setDoor("closed")
d.publishStateDock()
}
d.publishWorkflow(cmd, taskID, missionID, state, "idle")
}
// flyTo 按球面插值飞向目标点;forced 时忽略暂停/取消(返航落地用)
func (d *MockDock) uploadOriginalVideo(cmd commandMsg, taskID string, video originalVideoUpload) {
if video.VideoID == 0 || video.ExecutionID == 0 || video.UploadURL == "" {
d.publishOriginalVideo(cmd, taskID, video, "failed", "UPLOAD_CONFIG_INVALID", 0)
return
}
if video.UploadExpireAt > 0 && time.Now().UnixMilli() >= video.UploadExpireAt {
d.publishOriginalVideo(cmd, taskID, video, "failed", "UPLOAD_URL_EXPIRED", 0)
return
}
body := []byte("mock original video fixture\n")
req, err := http.NewRequest(http.MethodPut, video.UploadURL, bytes.NewReader(body))
if err != nil {
d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0)
return
}
req.Header.Set("Content-Type", "video/mp4")
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0)
return
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0)
return
}
d.publishOriginalVideo(cmd, taskID, video, "completed", "", int64(len(body)))
}
func (d *MockDock) publishOriginalVideo(cmd commandMsg, taskID string, video originalVideoUpload, eventType, errorCode string, fileSize int64) {
d.publishDrone(d.topic("internal/original-video"), 1, false, d.spec.DroneSN, map[string]any{
"extension": "laic.mock.original-video.v1",
"eventId": "mock-original-video-" + strconv.FormatInt(video.VideoID, 10),
"eventType": eventType,
"videoId": video.VideoID,
"executionId": video.ExecutionID,
"taskId": taskID,
"commandId": cmd.CommandID,
"fileSize": fileSize,
"duration": 1,
"errorCode": nilIfEmpty(errorCode),
"uploadedAt": time.Now().UnixMilli(),
})
}
type originalVideoUpload struct {
VideoID int64
ExecutionID int64
UploadURL string
UploadExpireAt int64
}
func parseOriginalVideo(value any) originalVideoUpload {
data, ok := value.(map[string]any)
if !ok {
return originalVideoUpload{}
}
return originalVideoUpload{
VideoID: asInt64(data["videoId"]),
ExecutionID: asInt64(data["executionId"]),
UploadURL: asString(data["uploadUrl"]),
UploadExpireAt: asInt64(data["uploadExpireAt"]),
}
}
func asString(value any) string {
text, _ := value.(string)
return text
}
func asBool(value any) bool {
valueBool, _ := value.(bool)
return valueBool
}
func asInt64(value any) int64 {
switch v := value.(type) {
case float64:
return int64(v)
case string:
parsed, _ := strconv.ParseInt(v, 10, 64)
return parsed
default:
return 0
}
}
func (d *MockDock) flyTo(lat, lon, alt, speed float64, forced bool) bool {
if speed <= 0 {
speed = defaultCruiseSpeed
}
fromLat, fromLon, fromAlt := d.currentLLA()
dist := haversineM(fromLat, fromLon, lat, lon)
dAlt := math.Abs(alt - fromAlt)
heading := bearingDeg(fromLat, fromLon, lat, lon)
if dist < 0.5 {
heading = d.currentHeading()
}
horizSec := dist / speed / missionTimeScale
vertSec := dAlt / 5.0 / missionTimeScale
sec := math.Max(horizSec, vertSec)
dur := time.Duration(sec * float64(time.Second))
if (dist > 1 || dAlt > 1) && dur < minLegDuration {
dur = minLegDuration
}
if dur > maxLegDuration {
dur = maxLegDuration
}
if dur < missionTick {
dur = missionTick
}
steps := int(dur / missionTick)
if steps < 1 {
steps = 1
}
for i := 1; i <= steps; i++ {
if !forced && !d.waitMissionTick() {
return false
}
if forced {
time.Sleep(missionTick)
}
t := float64(i) / float64(steps)
d.setPose(
lerp(fromLat, lat, t),
lerp(fromLon, lon, t),
lerp(fromAlt, alt, t),
speed,
heading,
)
}
d.setPose(lat, lon, alt, 0, heading)
return true
}
func (d *MockDock) holdAt(seconds int, yaw float64) bool {
if seconds > maxHoldSec {
seconds = maxHoldSec
}
if yaw != 0 {
d.mu.Lock()
d.tele.heading = yaw
d.tele.yaw = yaw
d.tele.groundSpeed = 0
d.mu.Unlock()
} else {
d.setPoseKeepLLA(0)
}
ticks := seconds * int(time.Second/missionTick)
for i := 0; i < ticks; i++ {
if !d.waitMissionTick() {
return false
}
}
return true
}
func (d *MockDock) setPoseKeepLLA(speed float64) {
d.mu.Lock()
d.tele.groundSpeed = speed
d.mu.Unlock()
}
func (d *MockDock) waitMissionTick() bool {
cancel := d.currentCancel()
for {
if isClosed(cancel) {
return false
}
d.mu.Lock()
paused := d.missionPaused
d.mu.Unlock()
if !paused {
if cancel == nil {
time.Sleep(missionTick)
return true
}
select {
case <-cancel:
return false
case <-time.After(missionTick):
return true
}
}
if cancel == nil {
time.Sleep(100 * time.Millisecond)
continue
}
select {
case <-cancel:
return false
case <-time.After(100 * time.Millisecond):
}
}
}
func (d *MockDock) sleepOrAbort(wait time.Duration) bool {
cancel := d.currentCancel()
if cancel == nil {
time.Sleep(wait)
return true
}
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-cancel:
return false
case <-t.C:
return true
}
}
func extractTaskIDs(cmd commandMsg) (taskID, missionID string) {
if cmd.Params == nil {
return "", ""
}
if v, ok := cmd.Params["taskId"].(string); ok {
taskID = v
}
if v, ok := cmd.Params["missionId"].(string); ok {
missionID = v
}
return taskID, missionID
}
func parseWaypoints(params map[string]any) []missionWaypoint {
if params == nil {
return nil
}
raw, ok := params["waypoints"]
if !ok || raw == nil {
return nil
}
arr, ok := raw.([]any)
if !ok {
return nil
}
out := make([]missionWaypoint, 0, len(arr))
for i, item := range arr {
m, ok := item.(map[string]any)
if !ok {
continue
}
wp := missionWaypoint{
Seq: asInt(m["seq"]),
Longitude: asFloat(m["longitude"]),
Latitude: asFloat(m["latitude"]),
Altitude: asFloat(m["altitude"]),
Speed: asFloat(m["speed"]),
Yaw: asFloat(m["yaw"]),
HoldSec: asInt(m["holdSec"]),
}
if wp.Seq == 0 {
wp.Seq = i
}
if wp.Longitude == 0 && wp.Latitude == 0 {
continue
}
out = append(out, wp)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Seq < out[j].Seq })
return out
}
func asFloat(v any) float64 {
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int32:
return float64(n)
case int64:
return float64(n)
case uint64:
return float64(n)
default:
return 0
}
}
func asInt(v any) int {
return int(asFloat(v))
}
func isClosed(ch <-chan struct{}) bool {
if ch == nil {
return false
}
select {
case <-ch:
return true
default:
return false
}
}
func lerp(a, b, t float64) float64 {
return a + (b-a)*t
}
func haversineM(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusM = 6371000.0
phi1 := lat1 * math.Pi / 180
phi2 := lat2 * math.Pi / 180
dPhi := (lat2 - lat1) * math.Pi / 180
dLambda := (lon2 - lon1) * math.Pi / 180
a := math.Sin(dPhi/2)*math.Sin(dPhi/2) +
math.Cos(phi1)*math.Cos(phi2)*math.Sin(dLambda/2)*math.Sin(dLambda/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadiusM * c
}
func bearingDeg(lat1, lon1, lat2, lon2 float64) float64 {
phi1 := lat1 * math.Pi / 180
phi2 := lat2 * math.Pi / 180
dLambda := (lon2 - lon1) * math.Pi / 180
y := math.Sin(dLambda) * math.Cos(phi2)
x := math.Cos(phi1)*math.Sin(phi2) - math.Sin(phi1)*math.Cos(phi2)*math.Cos(dLambda)
deg := math.Atan2(y, x) * 180 / math.Pi
if deg < 0 {
deg += 360
}
return deg
}

109
mock/mock.log

@ -0,0 +1,109 @@
2026/08/14 15:49:00 [JG-UAV-001] 无人机启动(MAVLink TCP server 127.0.0.1:5760)
2026/08/14 15:49:00 [JG-UAV-002] 无人机启动(MAVLink TCP server 127.0.0.1:5761)
2026/08/14 15:49:01 [dock-1] MQTT 已连接
2026/08/14 15:49:01 [dock-1] 机巢启动(MQTT 上云 + MAVLink→127.0.0.1:5760)
2026/08/14 15:49:01 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 15:49:01 [dock-2] MQTT 已连接
2026/08/14 15:49:01 [dock-2] 机巢启动(MQTT 上云 + MAVLink→127.0.0.1:5761)
2026/08/14 15:49:01 模拟设备已启动(机巢=MQTT,无人机=MAVLink),按 Ctrl+C 退出
2026/08/14 15:49:01 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:50:01 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:50:01 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:50:03 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 15:50:03 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:51:03 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:51:03 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:51:05 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 15:51:05 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:52:05 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:52:05 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:52:07 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 15:52:07 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:52:15 [dock-1] 收到指令 dock.open (commandId=10179956445184)
2026/08/14 15:52:56 [dock-1] 收到指令 drone.takeoff (commandId=10185279803392)
2026/08/14 15:52:56 [dock-1] 已向无人机下发 MAVLink 指令 drone.takeoff
2026/08/14 15:52:56 [dock-1] 收到无人机 MAVLink ACK: drone.takeoff accepted=true
2026/08/14 15:53:07 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:53:09 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:53:56 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:53:58 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 15:54:09 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:54:11 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:54:30 [dock-1] 收到指令 workflow.start_task (commandId=10197644873728)
2026/08/14 15:55:11 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:55:13 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:55:35 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:55:37 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 15:56:13 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:56:15 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:56:37 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:56:39 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 15:57:15 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:57:17 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:57:39 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:57:41 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 15:58:17 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:58:19 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:58:41 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:58:43 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 15:58:47 [dock-1] 收到指令 workflow.start_task (commandId=10231313338368)
2026/08/14 15:59:19 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:59:21 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 15:59:52 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 15:59:54 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 16:00:21 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:00:23 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 16:00:54 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:00:56 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 16:01:23 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:01:25 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 16:01:56 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:01:58 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 16:02:25 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:02:27 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 16:02:58 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:03:00 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 16:03:27 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:03:29 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 16:04:00 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:04:02 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 16:04:29 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:04:31 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 16:05:02 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:05:04 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 16:05:31 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:05:33 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 16:06:04 [dock-1] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:06:06 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 16:06:33 [dock-2] 无人机 MAVLink 通道断开: EOF
2026/08/14 16:06:35 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 16:10:58 [JG-UAV-001] 无人机启动(MAVLink TCP server 127.0.0.1:5760)
2026/08/14 16:10:58 [JG-UAV-002] 无人机启动(MAVLink TCP server 127.0.0.1:5761)
2026/08/14 16:10:58 [dock-1] MQTT 已连接
2026/08/14 16:10:58 [dock-1] 机巢启动(MQTT 上云 + MAVLink→127.0.0.1:5760)
2026/08/14 16:10:58 [dock-1] 无人机 MAVLink 通道已建立
2026/08/14 16:10:58 [dock-2] MQTT 已连接
2026/08/14 16:10:58 [dock-2] 机巢启动(MQTT 上云 + MAVLink→127.0.0.1:5761)
2026/08/14 16:10:58 模拟设备已启动(机巢=MQTT,无人机=MAVLink),按 Ctrl+C 退出
2026/08/14 16:10:58 [dock-2] 无人机 MAVLink 通道已建立
2026/08/14 17:08:55 [dock-1] 收到指令 workflow.start_task (commandId=10782857760768)
2026/08/14 17:09:09 [dock-1] 收到指令 dock.open (commandId=10784631296000)
2026/08/14 17:09:11 [dock-1] 收到指令 workflow.start_task (commandId=10784979947520)
2026/08/14 17:09:13 [dock-1] 收到指令 dock.close (commandId=10785230032896)
2026/08/14 17:09:15 [dock-1] 收到指令 dock.self_check (commandId=10785475792896)
2026/08/14 17:09:16 [dock-1] 收到指令 dock.reset (commandId=10785655754752)
2026/08/14 17:09:22 [dock-1] 收到指令 drone.takeoff (commandId=10786424360960)
2026/08/14 17:09:22 [dock-1] 已向无人机下发 MAVLink 指令 drone.takeoff
2026/08/14 17:09:22 [dock-1] 收到无人机 MAVLink ACK: drone.takeoff accepted=true
2026/08/14 17:09:24 [dock-1] 收到指令 drone.land (commandId=10786613104640)
2026/08/14 17:09:24 [dock-1] 已向无人机下发 MAVLink 指令 drone.land
2026/08/14 17:09:24 [dock-1] 收到无人机 MAVLink ACK: drone.land accepted=true
2026/08/14 17:09:26 [dock-1] 收到指令 drone.return (commandId=10786925318144)
2026/08/14 17:09:26 [dock-1] 已向无人机下发 MAVLink 指令 drone.return
2026/08/14 17:09:26 [dock-1] 收到无人机 MAVLink ACK: drone.return accepted=true
2026/08/14 17:09:28 [dock-1] 收到指令 drone.hover (commandId=10787145781248)
2026/08/14 17:09:30 [dock-1] 收到指令 drone.emergency_stop (commandId=10787445149696)
2026/08/14 17:10:30 [dock-2] 收到指令 dock.open (commandId=10795332276224)
2026/08/14 17:10:32 [dock-2] 收到指令 dock.close (commandId=10795596517376)
2026/08/14 17:10:33 [dock-2] 收到指令 dock.reset (commandId=10795735977984)
2026/08/14 17:10:35 [dock-2] 收到指令 dock.self_check (commandId=10795919740928)

37
model/alarm.go

@ -0,0 +1,37 @@
package model
import "time"
// AlarmCode 告警码字典
type AlarmCode struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
Code string `gorm:"column:code;type:VARCHAR(64);not null;uniqueIndex:idx_code" json:"code"`
Category string `gorm:"column:category;type:VARCHAR(32);not null" json:"category"` // dock / drone
MessageCn string `gorm:"column:message_cn;type:VARCHAR(256);not null" json:"messageCn"`
Level string `gorm:"column:level;type:VARCHAR(16);default:critical" json:"level"`
Source string `gorm:"column:source;type:VARCHAR(128);default:''" json:"source"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (AlarmCode) TableName() string {
return "alarm_code"
}
// Alarm 告警记录
type Alarm struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"`
Code string `gorm:"column:code;type:VARCHAR(64);not null" json:"code"`
DeviceType string `gorm:"column:device_type;type:VARCHAR(8);not null" json:"deviceType"` // dock / drone
MessageCn string `gorm:"column:message_cn;type:VARCHAR(256);not null" json:"messageCn"`
Level string `gorm:"column:level;type:VARCHAR(16);not null" json:"level"`
Status string `gorm:"column:status;type:VARCHAR(16);default:active" json:"status"` // active / acknowledged / resolved
TriggeredAt *time.Time `gorm:"column:triggered_at" json:"triggeredAt"`
ResolvedAt *time.Time `gorm:"column:resolved_at" json:"resolvedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (Alarm) TableName() string {
return "alarm"
}

91
model/billing.go

@ -0,0 +1,91 @@
package model
import "time"
// TrafficOrder 云媒体流量订单
type TrafficOrder 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"`
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"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
PaidAt *time.Time `gorm:"column:paid_at" json:"paidAt"`
}
func (TrafficOrder) TableName() string {
return "traffic_order"
}
// TrafficUsageLog 云媒体消费流水(账本,不可变)
type TrafficUsageLog 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"`
SourceType string `gorm:"column:source_type;type:VARCHAR(16)" json:"sourceType"` // live / replay / download
SourceID int64 `gorm:"column:source_id;type:BIGINT" json:"sourceId"`
BytesUsed int64 `gorm:"column:bytes_used;type:BIGINT" json:"bytesUsed"`
BalanceBefore int64 `gorm:"column:balance_before;type:BIGINT" json:"balanceBefore"`
BalanceAfter int64 `gorm:"column:balance_after;type:BIGINT" json:"balanceAfter"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (TrafficUsageLog) TableName() string {
return "traffic_usage_log"
}
// SimCard SIM 卡
type SimCard 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)" json:"dockId"`
Carrier string `gorm:"column:carrier;type:VARCHAR(16)" json:"carrier"` // 移动/联通/电信
Phone string `gorm:"column:phone;type:VARCHAR(16)" json:"phone"`
Iccid string `gorm:"column:iccid;type:VARCHAR(32)" json:"iccid"` // 权威字段
PlanGb int `gorm:"column:plan_gb;type:INT" json:"planGb"`
UsedGb float64 `gorm:"column:used_gb;type:DECIMAL(10,4);default:0" json:"usedGb"`
ExpiredAt *time.Time `gorm:"column:expired_at;type:DATE" json:"expiredAt"`
Status string `gorm:"column:status;type:VARCHAR(16);default:active" json:"status"` // active / expired
CarrierStatus string `gorm:"column:carrier_status;type:VARCHAR(16);default:normal" json:"carrierStatus"` // normal / suspended / arrears / cancelled
LastSyncAt *time.Time `gorm:"column:last_sync_at" json:"lastSyncAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (SimCard) TableName() string {
return "sim_card"
}
// 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:paid" 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 {
return "sim_recharge_log"
}
// 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"`
}
func (SimUsageRecord) TableName() string {
return "sim_usage_record"
}

25
model/command.go

@ -0,0 +1,25 @@
package model
import "time"
// DeviceCommandLog 设备指令日志
type DeviceCommandLog struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` // = commandId (Snowflake)
DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"`
CommandType string `gorm:"column:command_type;type:VARCHAR(64);not null" json:"commandType"` // dock.open / drone.takeoff / workflow.start_task
Params string `gorm:"column:params;type:JSON" json:"params"`
DroneSN string `gorm:"column:drone_sn;type:VARCHAR(32)" json:"droneSn"`
RequestID string `gorm:"column:request_id;type:VARCHAR(64)" json:"requestId"`
TTLMs int `gorm:"column:ttl_ms;type:INT;default:30000" json:"ttlMs"`
AckAccepted int8 `gorm:"column:ack_accepted;type:TINYINT" json:"ackAccepted"`
AckResultCode string `gorm:"column:ack_result_code;type:VARCHAR(64)" json:"ackResultCode"`
Status string `gorm:"column:status;type:VARCHAR(16);default:sent" json:"status"` // sent / acked / timeout / terminal
RetryCount int `gorm:"column:retry_count;type:INT;default:0" json:"retryCount"`
SentAt *time.Time `gorm:"column:sent_at" json:"sentAt"`
AckedAt *time.Time `gorm:"column:acked_at" json:"ackedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (DeviceCommandLog) TableName() string {
return "device_command_log"
}

49
model/dock.go

@ -0,0 +1,49 @@
package model
import "time"
// 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"`
Status string `gorm:"column:status;type:VARCHAR(32);default:offline" json:"status"`
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"`
}
func (Dock) TableName() string {
return "dock"
}
// Drone 无人机(与机巢默认一对一绑定)
type Drone 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"`
DroneSN string `gorm:"column:drone_sn;type:VARCHAR(32);not null;uniqueIndex:idx_drone_sn" json:"droneSn"`
DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null;uniqueIndex:idx_drone_dock" json:"dockId"`
Name string `gorm:"column:name;type:VARCHAR(64);default:''" json:"name"`
Code string `gorm:"column:code;type:VARCHAR(32);default:''" json:"code"`
Model string `gorm:"column:model;type:VARCHAR(32);default:''" json:"model"`
Status string `gorm:"column:status;type:VARCHAR(32);default:offline" json:"status"`
Battery int `gorm:"column:battery;type:INT;default:0" json:"battery"`
FirmwareVer string `gorm:"column:firmware_ver;type:VARCHAR(16);default:''" json:"firmwareVer"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Drone) TableName() string {
return "drone"
}

24
model/firmware.go

@ -0,0 +1,24 @@
package model
import "time"
// Firmware 固件版本
type Firmware struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
Component string `gorm:"column:component;type:VARCHAR(32);not null" json:"component"` // 固定 dock-edge-agent
Version string `gorm:"column:version;type:VARCHAR(32);not null" json:"version"` // 1.3.0
Description string `gorm:"column:description;type:VARCHAR(512);default:''" json:"description"`
FileURL string `gorm:"column:file_url;type:VARCHAR(512);not null" json:"fileUrl"`
Sha256 string `gorm:"column:sha256;type:VARCHAR(128);not null" json:"sha256"`
Signature string `gorm:"column:signature;type:TEXT;not null" json:"signature"`
FileSize int64 `gorm:"column:file_size;type:BIGINT;default:0" json:"fileSize"`
Mandatory int8 `gorm:"column:mandatory;type:TINYINT;default:0" json:"mandatory"`
Status string `gorm:"column:status;type:VARCHAR(16);default:draft" json:"status"` // draft / released / deprecated
CreatedBy int64 `gorm:"column:created_by;type:BIGINT;not null" json:"createdBy"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Firmware) TableName() string {
return "firmware"
}

80
model/media.go

@ -0,0 +1,80 @@
package model
import "time"
// LiveSession 直播会话
type LiveSession struct {
ID string `gorm:"primaryKey;column:id;type:VARCHAR(128);not null" json:"id"`
DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"`
Provider string `gorm:"column:provider;type:VARCHAR(16);default:aliyun" json:"provider"`
StreamName string `gorm:"column:stream_name;type:VARCHAR(128);not null" json:"streamName"`
PushURLHash string `gorm:"column:push_url_hash;type:VARCHAR(128)" json:"pushUrlHash"`
ExpiresAt int64 `gorm:"column:expires_at;type:BIGINT;not null" json:"expiresAt"`
MaxBitrateBps int64 `gorm:"column:max_bitrate_bps;type:BIGINT;default:1500000" json:"maxBitrateBps"`
Phase string `gorm:"column:phase;type:VARCHAR(16);default:idle" json:"phase"`
StopReason string `gorm:"column:stop_reason;type:VARCHAR(32)" json:"stopReason"`
ErrorCode string `gorm:"column:error_code;type:VARCHAR(64)" json:"errorCode"`
RequestedBy int64 `gorm:"column:requested_by;type:BIGINT" json:"requestedBy"`
StopDeadline *time.Time `gorm:"column:stop_deadline" json:"stopDeadline"`
DeviceStateVersion int64 `gorm:"column:device_state_version;type:BIGINT" json:"deviceStateVersion"`
DeviceEventID string `gorm:"column:device_event_id;type:VARCHAR(128)" json:"deviceEventId"`
DeviceUpdatedAt int64 `gorm:"column:device_updated_at;type:BIGINT" json:"deviceUpdatedAt"`
CloudConfirmedAt *time.Time `gorm:"column:cloud_confirmed_at" json:"cloudConfirmedAt"`
StartedAt *time.Time `gorm:"column:started_at" json:"startedAt"`
StoppedAt *time.Time `gorm:"column:stopped_at" json:"stoppedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (LiveSession) TableName() string {
return "live_session"
}
// LiveViewerLease 表示用户对直播会话的短期观看租约。
type LiveViewerLease struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
StreamSessionID string `gorm:"column:stream_session_id;type:VARCHAR(128);not null" json:"streamSessionId"`
ViewerID int64 `gorm:"column:viewer_id;type:BIGINT;not null" json:"viewerId"`
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expiresAt"`
ReleasedAt *time.Time `gorm:"column:released_at" json:"releasedAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (LiveViewerLease) TableName() string {
return "live_viewer_lease"
}
// Video 视频文件
type Video 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"`
DroneSN string `gorm:"column:drone_sn;type:VARCHAR(32)" json:"droneSn"`
ExecutionID int64 `gorm:"column:execution_id;type:BIGINT" json:"executionId"`
FileName string `gorm:"column:file_name;type:VARCHAR(256)" json:"fileName"`
FileSize int64 `gorm:"column:file_size;type:BIGINT" json:"fileSize"`
Duration int `gorm:"column:duration;type:INT" json:"duration"`
OssKey string `gorm:"column:oss_key;type:VARCHAR(256)" json:"ossKey"`
OssBucket string `gorm:"column:oss_bucket;type:VARCHAR(128)" json:"ossBucket"`
ThumbnailKey string `gorm:"column:thumbnail_key;type:VARCHAR(256)" json:"thumbnailKey"`
Status string `gorm:"column:status;type:VARCHAR(16);default:uploading" json:"status"`
UploadExpireAt *time.Time `gorm:"column:upload_expire_at" json:"uploadExpireAt"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (Video) TableName() string {
return "video"
}
// DownloadLog 下载记录
type DownloadLog struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
VideoID int64 `gorm:"column:video_id;type:BIGINT;not null" json:"videoId"`
UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"`
Bytes int64 `gorm:"column:bytes;type:BIGINT;not null" json:"bytes"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (DownloadLog) TableName() string {
return "download_log"
}

20
model/operation_log.go

@ -0,0 +1,20 @@
package model
import "time"
// OperationLog 操作日志(审计)
type OperationLog struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
UserID int64 `gorm:"column:user_id;type:BIGINT;not null;index:idx_op_user" json:"userId"`
UserName string `gorm:"column:user_name;type:VARCHAR(32)" json:"userName"`
Module string `gorm:"column:module;type:VARCHAR(32)" json:"module"`
Action string `gorm:"column:action;type:VARCHAR(64)" json:"action"`
Detail string `gorm:"column:detail;type:VARCHAR(256)" json:"detail"`
Result string `gorm:"column:result;type:VARCHAR(16)" json:"result"`
IP string `gorm:"column:ip;type:VARCHAR(64)" json:"ip"`
CreatedAt time.Time `gorm:"column:created_at;index:idx_op_user" json:"createdAt"`
}
func (OperationLog) TableName() string {
return "operation_log"
}

35
model/route.go

@ -0,0 +1,35 @@
package model
import "time"
// Route 航线
type Route 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"`
Name string `gorm:"column:name;type:VARCHAR(128);not null" json:"name"`
Description string `gorm:"column:description;type:VARCHAR(256)" json:"description"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Route) TableName() string {
return "route"
}
// RouteWaypoint 航线航点
type RouteWaypoint struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
RouteID int64 `gorm:"column:route_id;type:BIGINT;not null;uniqueIndex:uk_route_seq" json:"routeId"`
Seq int `gorm:"column:seq;type:INT;not null;uniqueIndex:uk_route_seq" json:"seq"`
Longitude float64 `gorm:"column:longitude;type:DOUBLE" json:"longitude"`
Latitude float64 `gorm:"column:latitude;type:DOUBLE" json:"latitude"`
Altitude float64 `gorm:"column:altitude;type:DOUBLE" json:"altitude"`
Speed float64 `gorm:"column:speed;type:DOUBLE" json:"speed"`
Yaw float64 `gorm:"column:yaw;type:DOUBLE" json:"yaw"`
HoldSec int `gorm:"column:hold_sec;type:INT;default:0" json:"holdSec"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (RouteWaypoint) TableName() string {
return "route_waypoint"
}

62
model/task.go

@ -0,0 +1,62 @@
package model
import (
"encoding/json"
"time"
)
// TaskPlan 任务计划
type TaskPlan struct {
ID string `gorm:"primaryKey;column:id;type:VARCHAR(128);not null" json:"id"`
UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"`
Name string `gorm:"column:name;type:VARCHAR(128);not null" json:"name"`
DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"`
RouteID int64 `gorm:"column:route_id;type:BIGINT" json:"routeId"`
ScheduleType string `gorm:"column:schedule_type;type:VARCHAR(16);default:once" json:"scheduleType"` // once / cron
ScheduleCron string `gorm:"column:schedule_cron;type:VARCHAR(32)" json:"scheduleCron"`
VideoPolicy string `gorm:"column:video_policy;type:VARCHAR(16);default:raw" json:"videoPolicy"`
Status string `gorm:"column:status;type:VARCHAR(16);default:draft" json:"status"`
CreatedBy int64 `gorm:"column:created_by;type:BIGINT" json:"createdBy"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (TaskPlan) TableName() string {
return "task_plan"
}
// TaskExecution 任务执行记录
type TaskExecution struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
TaskID string `gorm:"column:task_id;type:VARCHAR(128)" json:"taskId"`
CommandID string `gorm:"column:command_id;type:VARCHAR(64)" json:"commandId"`
DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"`
DroneSN string `gorm:"column:drone_sn;type:VARCHAR(32)" json:"droneSn"`
StartTime *time.Time `gorm:"column:start_time" json:"startTime"`
EndTime *time.Time `gorm:"column:end_time" json:"endTime"`
Status string `gorm:"column:status;type:VARCHAR(16);default:pending" json:"status"`
TrajectoryJSON json.RawMessage `gorm:"column:trajectory_json;type:JSON" json:"trajectoryJson"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (TaskExecution) TableName() string {
return "task_execution"
}
// WorkflowState 工作流状态(任务/一键流程执行步骤跟踪)
type WorkflowState struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"`
CommandID string `gorm:"column:command_id;type:VARCHAR(64)" json:"commandId"`
Type string `gorm:"column:type;type:VARCHAR(64)" json:"type"`
TaskID string `gorm:"column:task_id;type:VARCHAR(128)" json:"taskId"`
MissionID string `gorm:"column:mission_id;type:VARCHAR(128)" json:"missionId"`
State string `gorm:"column:state;type:VARCHAR(16);not null" json:"state"` // idle / running / succeeded / failed / cancelled
Step string `gorm:"column:step;type:VARCHAR(64);not null" json:"step"`
ResultCode string `gorm:"column:result_code;type:VARCHAR(64)" json:"resultCode"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (WorkflowState) TableName() string {
return "workflow_state"
}

22
model/user.go

@ -0,0 +1,22 @@
package model
import "time"
// User 用户表
type User struct {
ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"`
Name string `gorm:"column:name;type:VARCHAR(32);not null" json:"name"`
Phone string `gorm:"column:phone;type:VARCHAR(32);not null;uniqueIndex:idx_phone" json:"phone"`
Email string `gorm:"column:email;type:VARCHAR(64)" json:"email"`
Password string `gorm:"column:password;type:VARCHAR(64);not null" json:"-"`
Role string `gorm:"column:role;type:VARCHAR(16);default:user" json:"role"`
TrafficBalance int64 `gorm:"column:traffic_balance;type:BIGINT;default:0" json:"trafficBalance"`
Status int8 `gorm:"column:status;type:TINYINT;default:1" json:"status"`
LastLogin *time.Time `gorm:"column:last_login" json:"lastLogin"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (User) TableName() string {
return "user"
}

54
mqtt/message.go

@ -0,0 +1,54 @@
package mqtt
import (
"encoding/json"
"time"
)
// Envelope MQTT 消息通用外层(协议文档 §4)
//
// {
// "requestId": "req-uuid" | null,
// "dockId": "dock-...",
// "droneSn": "..." | null,
// "timestamp": 1784700000000,
// "payload": { ... }
// }
type Envelope struct {
RequestID string `json:"requestId"`
EventID string `json:"eventId"`
Version int64 `json:"version"`
DockID string `json:"dockId"`
DroneSN string `json:"droneSn"`
Timestamp int64 `json:"timestamp"`
Payload json.RawMessage `json:"payload"`
}
// ParseEnvelope 解析通用外层;payload 缺失(无外层的历史扁平消息)时把整个消息体当作 payload。
func ParseEnvelope(data []byte) *Envelope {
var env Envelope
if err := json.Unmarshal(data, &env); err != nil {
return &Envelope{Payload: data}
}
if len(env.Payload) == 0 {
env.Payload = data
}
return &env
}
// NewEnvelope 构造下行外层;payload 为业务字段(内部自动 marshal),timestamp 取当前毫秒。
func NewEnvelope(requestID, dockID, droneSN string, payload any) *Envelope {
var raw json.RawMessage
if payload != nil {
if b, err := json.Marshal(payload); err == nil {
raw = b
}
}
return &Envelope{
RequestID: requestID,
DockID: dockID,
DroneSN: droneSN,
Timestamp: time.Now().UnixMilli(),
Payload: raw,
}
}

72
mqtt/mqtt.go

@ -0,0 +1,72 @@
package mqtt
import (
"errors"
mqtt "github.com/eclipse/paho.mqtt.golang"
"laic-backend/common"
"laic-backend/logger"
)
var (
client mqtt.Client
subTopics = map[string]byte{}
subHandler mqtt.MessageHandler
)
// InitMQTT 初始化 MQTT 客户端(不依赖 service,订阅由上层注册)
func InitMQTT(config *common.MQTT) {
if config == nil || config.Broker == "" {
logger.WARN("MQTT broker 为空,跳过初始化")
return
}
opts := mqtt.NewClientOptions().
AddBroker(config.Broker).
SetClientID(config.ClientId).
SetUsername(config.Username).
SetPassword(config.Password).
SetAutoReconnect(true).
SetCleanSession(true).
SetOnConnectHandler(func(c mqtt.Client) {
logger.INFO("MQTT 已连接,重新订阅")
resubscribe(c)
})
client = mqtt.NewClient(opts)
if token := client.Connect(); token.Wait() && token.Error() != nil {
logger.ERROR("MQTT 连接失败", token.Error())
return
}
logger.INFO("MQTT ready")
}
// Subscribe 记录订阅关系并立即订阅;连接重建时自动重订阅
func Subscribe(topics map[string]byte, handler mqtt.MessageHandler) {
subHandler = handler
for topic, qos := range topics {
subTopics[topic] = qos
}
if client != nil && client.IsConnected() {
resubscribe(client)
}
}
func resubscribe(c mqtt.Client) {
for topic, qos := range subTopics {
if token := c.Subscribe(topic, qos, subHandler); token.Wait() && token.Error() != nil {
logger.ERROR("订阅失败 topic="+topic, token.Error())
}
}
}
// Publish 发布消息到指定 topic
func Publish(topic string, qos byte, retained bool, payload []byte) error {
if client == nil || !client.IsConnected() {
return errors.New("mqtt client not connected")
}
token := client.Publish(topic, qos, retained, payload)
token.Wait()
return token.Error()
}

171
route/route.go

@ -0,0 +1,171 @@
package route
import (
"github.com/gin-gonic/gin"
"laic-backend/common"
"laic-backend/handler"
"laic-backend/logger"
"laic-backend/middleware"
"laic-backend/websocket"
)
// InitRouter 初始化 gin 引擎与路由注册
func InitRouter(port int32) {
common.Server(true)
common.SetWriter(logger.GetLogWriter())
engine := common.GetEngine()
engine.Use(middleware.TraceMiddleware(), middleware.CORSMiddleware(), middleware.DefaultLogMiddleware(), middleware.RateLimitMiddleware(300, 60))
// 健康检查
engine.GET("/health", func(c *gin.Context) {
common.OKWithData(c, gin.H{"status": "up"})
})
// v1 业务路由
v1 := common.RouteGroup("/v1")
v1.Use(middleware.OperationLogMiddleware())
// 实时监控 WS(设备状态 + 告警推送)
v1.GET("/ws/monitor", middleware.AuthMiddleware(), websocket.HandleMonitor)
// 认证(公开)
auth := v1.Group("/auth")
{
auth.POST("/register", handler.Register)
auth.POST("/login", handler.Login)
auth.POST("/refresh", handler.Refresh)
auth.POST("/sms-code", handler.SmsCode)
auth.POST("/reset-password", handler.ResetPassword)
auth.POST("/logout", middleware.AuthMiddleware(), handler.Logout)
}
// 账户中心(资料 / 流量 / SIM 卡)
account := v1.Group("/account", middleware.AuthMiddleware())
{
account.GET("/profile", handler.GetProfile)
account.PUT("/profile", handler.UpdateProfile)
account.GET("/traffic/balance", handler.GetTrafficBalance)
account.GET("/traffic/usage", handler.GetUsagePage)
account.GET("/traffic/orders", handler.GetOrderPage)
account.POST("/traffic/orders", handler.CreateTrafficOrder)
account.POST("/traffic/orders/:id/pay", middleware.AdminMiddleware(), handler.PayTrafficOrder)
account.GET("/sim-cards", handler.ListSimCards)
account.GET("/sim-recharge-logs", handler.GetSimRechargeLogPage)
account.POST("/sim-cards/:id/recharge", handler.RechargeSimCard)
}
// 机巢 & 无人机
docks := v1.Group("/docks", middleware.AuthMiddleware())
{
docks.GET("", handler.GetDockPage)
docks.POST("", handler.CreateDock)
docks.GET("/:id", handler.GetDock)
docks.PUT("/:id", handler.UpdateDock)
docks.DELETE("/:id", handler.DeleteDock)
docks.GET("/:id/status", handler.GetDockStatus)
docks.POST("/:id/command", handler.DispatchCommand)
docks.POST("/:id/firmware/upgrade", handler.UpgradeFirmware)
}
drones := v1.Group("/drones", middleware.AuthMiddleware())
{
drones.GET("", handler.GetDronePage)
drones.GET("/:id", handler.GetDrone)
drones.PUT("/:id", handler.UpdateDrone)
drones.DELETE("/:id", handler.DeleteDrone)
drones.GET("/:id/telemetry", handler.GetDroneTelemetry)
}
// 告警
alarms := v1.Group("/alarms", middleware.AuthMiddleware())
{
alarms.GET("", handler.GetAlarmPage)
alarms.PUT("/:id/acknowledge", handler.AcknowledgeAlarm)
alarms.PUT("/:id/resolve", handler.ResolveAlarm)
}
// 指令历史
commands := v1.Group("/commands", middleware.AuthMiddleware())
{
commands.GET("", handler.GetCommandPage)
commands.GET("/:id", handler.GetCommand)
}
// 航线
routes := v1.Group("/routes", middleware.AuthMiddleware())
{
routes.GET("", handler.GetRoutePage)
routes.POST("", handler.CreateRoute)
routes.GET("/:id", handler.GetRoute)
routes.PUT("/:id", handler.UpdateRoute)
routes.DELETE("/:id", handler.DeleteRoute)
}
// 任务
tasks := v1.Group("/tasks", middleware.AuthMiddleware())
{
tasks.GET("", handler.GetTaskPage)
tasks.POST("", handler.CreateTask)
tasks.GET("/:id", handler.GetTask)
tasks.PUT("/:id", handler.UpdateTask)
tasks.DELETE("/:id", handler.DeleteTask)
tasks.POST("/:id/execute", handler.ExecuteTask)
}
// 执行记录
executions := v1.Group("/executions", middleware.AuthMiddleware())
{
executions.GET("", handler.GetExecutionPage)
executions.GET("/:id", handler.GetExecution)
executions.GET("/:id/trajectory", handler.GetTrajectory)
}
// 已发布固件列表(普通用户可选升级,无需 admin)
v1.GET("/firmwares/released", middleware.AuthMiddleware(), handler.GetReleasedFirmwares)
// 固件管理(admin)
firmwares := v1.Group("/firmwares", middleware.AuthMiddleware(), middleware.AdminMiddleware())
{
firmwares.GET("", handler.GetFirmwarePage)
firmwares.GET("/:id", handler.GetFirmware)
firmwares.POST("", handler.CreateFirmware)
firmwares.PUT("/:id", handler.UpdateFirmware)
firmwares.DELETE("/:id", handler.DeleteFirmware)
}
// 直播
live := v1.Group("/live", middleware.AuthMiddleware())
{
live.GET("", handler.GetLivePage)
live.POST("/:dockId/start", handler.StartLive)
live.POST("/:dockId/stop", handler.StopLive)
live.GET("/:dockId/play-url", handler.GetLivePlayURL)
live.POST("/:dockId/sessions", handler.CreateLiveSession)
live.GET("/:dockId/sessions/:streamSessionId", handler.GetLiveSession)
live.POST("/:dockId/sessions/:streamSessionId/heartbeat", handler.HeartbeatLiveSession)
live.DELETE("/:dockId/sessions/:streamSessionId/viewers/me", handler.LeaveLiveSession)
}
// 视频(OSS 上传/下载)
videos := v1.Group("/videos", middleware.AuthMiddleware())
{
videos.POST("", handler.CreateVideoUpload)
videos.POST("/:id/complete", handler.CompleteVideoUpload)
videos.GET("", handler.GetVideoPage)
videos.GET("/:id", handler.GetVideo)
videos.POST("/:id/download", handler.DownloadVideo)
}
// 系统管理(admin)
users := v1.Group("/users", middleware.AuthMiddleware(), middleware.AdminMiddleware())
{
users.GET("", handler.GetUserPage)
users.POST("", handler.CreateUser)
users.PUT("/:id", handler.UpdateUser)
users.DELETE("/:id", handler.DeleteUser)
}
v1.GET("/roles", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.GetRoles)
v1.GET("/logs/operation", middleware.AuthMiddleware(), handler.GetOperationLogPage)
}

94
service/account_service.go

@ -0,0 +1,94 @@
package service
import (
"errors"
"time"
"gorm.io/gorm"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/vo"
)
type AccountService struct{}
var DefaultAccountService = &AccountService{}
// GetProfile 查询个人资料
func (s *AccountService) GetProfile(userID int64) (*model.User, *common.BusiError) {
var user model.User
if err := common.DB.First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrUserNotFound
}
return nil, common.ErrInternal
}
return &user, nil
}
// UpdateProfile 更新个人资料(姓名/邮箱)
func (s *AccountService) UpdateProfile(userID int64, req *vo.ProfileUpdateReq) (*model.User, *common.BusiError) {
var user model.User
if err := common.DB.First(&user, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrUserNotFound
}
return nil, common.ErrInternal
}
updates := map[string]any{"updated_at": time.Now()}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Email != "" {
updates["email"] = req.Email
}
if err := common.DB.Model(&user).Updates(updates).Error; err != nil {
logger.ERROR("更新个人资料失败", err)
return nil, common.ErrInternal
}
common.DB.First(&user, userID)
return &user, nil
}
// GetTrafficBalance 查询云媒体流量余额
func (s *AccountService) GetTrafficBalance(userID int64) (*vo.TrafficBalanceVO, *common.BusiError) {
return DefaultBillingService.GetBalance(userID)
}
// GetUsagePage 流量消费流水分页
func (s *AccountService) GetUsagePage(userID int64, req *vo.UsagePageReq) (*common.PageResponse[model.TrafficUsageLog], *common.BusiError) {
return DefaultBillingService.GetUsagePage(userID, req)
}
// GetOrderPage 流量订单分页
func (s *AccountService) GetOrderPage(userID int64, req *vo.OrderPageReq) (*common.PageResponse[model.TrafficOrder], *common.BusiError) {
return DefaultBillingService.GetOrderPage(userID, req)
}
// CreateTrafficOrder 创建流量充值订单
func (s *AccountService) CreateTrafficOrder(userID int64, req *vo.TrafficOrderCreateReq) (*model.TrafficOrder, *common.BusiError) {
return DefaultBillingService.CreateOrder(userID, req.AmountGb)
}
// PayTrafficOrder 订单支付入账(admin)
func (s *AccountService) PayTrafficOrder(orderID int64) (*model.TrafficOrder, *common.BusiError) {
return DefaultBillingService.PayOrder(orderID)
}
// ListSimCards SIM 卡列表
func (s *AccountService) ListSimCards(userID int64, req *vo.SimCardPageReq) (*common.PageResponse[model.SimCard], *common.BusiError) {
return DefaultBillingService.ListSimCards(userID, req)
}
// GetSimRechargeLogPage SIM 卡充值记录分页
func (s *AccountService) GetSimRechargeLogPage(userID int64, req *vo.SimRechargeLogPageReq) (*common.PageResponse[model.SimRechargeLog], *common.BusiError) {
return DefaultBillingService.GetSimRechargeLogPage(userID, req)
}
// RechargeSimCard SIM 卡充值
func (s *AccountService) RechargeSimCard(userID, simCardID int64, req *vo.SimRechargeReq) (*model.SimRechargeLog, *common.BusiError) {
return DefaultBillingService.RechargeSimCard(userID, simCardID, req.AmountGb)
}

200
service/alarm_service.go

@ -0,0 +1,200 @@
package service
import (
"errors"
"time"
"gorm.io/gorm"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type AlarmService struct{}
var DefaultAlarmService = &AlarmService{}
// GetPage 告警分页列表(按所属机巢过滤)
func (s *AlarmService) GetPage(userID int64, isAdmin bool, req *vo.AlarmPageReq) (*common.PageResponse[model.Alarm], *common.BusiError) {
db := common.DB.Model(&model.Alarm{}).Scopes(withDockFilter(userID, isAdmin))
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.Level != "" {
db = db.Where("level = ?", req.Level)
}
if req.DeviceType != "" {
db = db.Where("device_type = ?", req.DeviceType)
}
if req.DockID != "" {
db = db.Where("dock_id = ?", req.DockID)
}
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
db = db.Where("code LIKE ? OR message_cn LIKE ?", kw, kw)
}
var total int64
if err := db.Count(&total).Error; err != nil {
logger.ERROR("统计告警失败", err)
return nil, common.ErrInternal
}
var list []model.Alarm
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
logger.ERROR("查询告警列表失败", err)
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// Acknowledge 确认告警(active → acknowledged)
func (s *AlarmService) Acknowledge(userID int64, isAdmin bool, id int64) *common.BusiError {
var alarm model.Alarm
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).First(&alarm, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrAlarmNotFound
}
return common.ErrInternal
}
if alarm.Status == "active" {
if err := common.DB.Model(&model.Alarm{}).Where("id = ?", id).Update("status", "acknowledged").Error; err != nil {
logger.ERROR("确认告警失败", err)
return common.ErrInternal
}
broadcast("alarm.acknowledged", map[string]any{
"id": id,
"dockId": alarm.DockID,
"code": alarm.Code,
})
}
return nil
}
// Resolve 关闭告警(active/acknowledged → resolved)
func (s *AlarmService) Resolve(userID int64, isAdmin bool, id int64) *common.BusiError {
var alarm model.Alarm
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).First(&alarm, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrAlarmNotFound
}
return common.ErrInternal
}
if alarm.Status != "resolved" {
now := time.Now()
if err := common.DB.Model(&model.Alarm{}).Where("id = ?", id).
Updates(map[string]any{"status": "resolved", "resolved_at": now}).Error; err != nil {
logger.ERROR("关闭告警失败", err)
return common.ErrInternal
}
broadcast("alarm.resolved", map[string]any{
"id": id,
"dockId": alarm.DockID,
"code": alarm.Code,
"resolvedAt": now,
})
}
return nil
}
// SyncByState 根据设备最新 alarmCodes 做 diff,生成/关闭告警
// deviceType: dock / drone
func (s *AlarmService) SyncByState(dockID, deviceType string, codes []string) {
key := s.alarmKey(dockID, deviceType)
last, err := common.SetMembers(key)
if err != nil {
last = []string{}
}
lastSet := toSet(last)
newSet := toSet(codes)
// 新增告警 → active
for code := range newSet {
if !lastSet[code] {
s.createAlarm(dockID, deviceType, code)
}
}
// 移除告警 → resolved
for code := range lastSet {
if !newSet[code] {
s.resolveAlarm(dockID, code)
}
}
// 更新当前告警码集合
_ = common.Delete(key)
for _, code := range codes {
_ = common.SetAdd(key, code)
}
}
func (s *AlarmService) createAlarm(dockID, deviceType, code string) {
messageCn, level := code, "critical"
var ac model.AlarmCode
if err := common.DB.Where("code = ?", code).First(&ac).Error; err == nil {
messageCn, level = ac.MessageCn, ac.Level
}
id, err := tool.NextID()
if err != nil {
logger.ERROR("生成告警 ID 失败", err)
return
}
now := time.Now()
alarm := model.Alarm{
ID: id,
DockID: dockID,
Code: code,
DeviceType: deviceType,
MessageCn: messageCn,
Level: level,
Status: "active",
TriggeredAt: &now,
CreatedAt: now,
}
if err := common.DB.Create(&alarm).Error; err != nil {
logger.ERROR("写入告警失败", err)
return
}
broadcast("alarm.created", map[string]any{
"dockId": dockID,
"alarm": alarm,
})
}
func (s *AlarmService) resolveAlarm(dockID, code string) {
now := time.Now()
res := common.DB.Model(&model.Alarm{}).Where("dock_id = ? AND code = ? AND status = 'active'", dockID, code).
Updates(map[string]any{"status": "resolved", "resolved_at": now})
if res.Error != nil {
logger.ERROR("关闭告警失败", res.Error)
return
}
if res.RowsAffected > 0 {
broadcast("alarm.resolved", map[string]any{
"dockId": dockID,
"code": code,
"resolvedAt": now,
})
}
}
func (s *AlarmService) alarmKey(dockID, deviceType string) string {
if deviceType == "drone" {
return cache.DroneAlarmKeyOf(dockID)
}
return cache.DockAlarmKeyOf(dockID)
}
func toSet(items []string) map[string]bool {
set := make(map[string]bool, len(items))
for _, it := range items {
set[it] = true
}
return set
}

478
service/billing_service.go

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

90
service/casbin_bootstrap.go

@ -0,0 +1,90 @@
package service
import (
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
)
// BootstrapCasbin 引导默认 RBAC 策略(幂等):p 策略(角色→路径→方法)+ 用户角色映射 g 策略
func BootstrapCasbin() {
e := common.CasbinEnforcer
if e == nil {
return
}
// 清理旧的 admin 全量策略,防止历史 "admin /* *" 残留导致越权(幂等)
if _, err := e.RemoveFilteredPolicy(0, "admin"); err != nil {
logger.ERROR("清理 admin 旧策略失败", err)
}
// p 策略:
// admin —— 平台管理职能:查看用户/机巢/无人机,管理固件,管理主账号云媒体流量
// user —— 业务操作:机巢/无人机/任务/航线等(自注册普通用户)
// keyMatch2 下 "/prefix/*" 匹配所有子路径(含多级),再补 "/prefix" 精确匹配列表/新增基路径。
adminPolicies := [][]string{
// 查看所有用户信息(暂只读)
{"admin", "/v1/users", "GET"},
{"admin", "/v1/users/*", "GET"},
// 查看机巢 / 无人机(含状态与遥测)
{"admin", "/v1/docks", "GET"},
{"admin", "/v1/docks/*", "GET"},
{"admin", "/v1/drones", "GET"},
{"admin", "/v1/drones/*", "GET"},
// 固件包管理
{"admin", "/v1/firmwares", "*"},
{"admin", "/v1/firmwares/*", "*"},
// 主账号云媒体流量管理(流量余额/订单/SIM 卡)
{"admin", "/v1/account/traffic", "*"},
{"admin", "/v1/account/traffic/*", "*"},
{"admin", "/v1/account/sim-cards", "*"},
{"admin", "/v1/account/sim-cards/*", "*"},
// 平台监控:角色字典
{"admin", "/v1/roles", "GET"},
}
userPrefixes := []string{
"/v1/docks",
"/v1/drones",
"/v1/alarms",
"/v1/commands",
"/v1/tasks",
"/v1/routes",
"/v1/executions",
"/v1/account",
"/v1/live",
"/v1/videos",
"/v1/ws",
}
policies := append([][]string{}, adminPolicies...)
for _, prefix := range userPrefixes {
policies = append(policies, []string{"user", prefix, "*"}, []string{"user", prefix + "/*", "*"})
}
// 普通用户可读取已发布固件列表(选择升级)
policies = append(policies, []string{"user", "/v1/firmwares/released", "GET"})
// 普通用户查看自己的操作记录
policies = append(policies, []string{"user", "/v1/logs/operation", "GET"})
for _, p := range policies {
args := make([]any, len(p))
for i, v := range p {
args[i] = v
}
if _, err := e.AddPolicy(args...); err != nil {
logger.ERROR("添加 Casbin 策略失败", err)
}
}
// g 策略:同步现有用户角色映射
var users []model.User
if err := common.DB.Find(&users).Error; err != nil {
logger.ERROR("查询用户同步 Casbin 角色失败", err)
return
}
for _, u := range users {
if err := common.SetUserRole(u.ID, u.Role); err != nil {
logger.ERROR("同步用户角色失败", err)
}
}
}

304
service/command_service.go

@ -0,0 +1,304 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/mqtt"
"laic-backend/tool"
"laic-backend/vo"
)
type CommandService struct{}
var DefaultCommandService = &CommandService{}
const maxRetryCount = 2
// Dispatch 下发指令:校验在线 → 落库 → MQTT 发布
func (s *CommandService) Dispatch(userID int64, isAdmin bool, id int64, req *vo.CommandReq) (*model.DeviceCommandLog, *common.BusiError) {
var dock model.Dock
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrDockNotFound
}
return nil, common.ErrInternal
}
dockID := dock.DockID
online, _ := common.SetMemberExists(cache.OnlineDockSetKey, dockID)
if !online {
return nil, common.ErrDockOffline
}
commandID, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
requestID := strings.ReplaceAll(uuid.New().String(), "-", "")
ttlMs := req.TTLMs
if ttlMs <= 0 {
ttlMs = 30000
}
paramsJSON := "{}"
if req.Params != nil {
if b, e := json.Marshal(req.Params); e == nil {
paramsJSON = string(b)
}
}
now := time.Now()
cmdLog := &model.DeviceCommandLog{
ID: commandID,
DockID: dockID,
CommandType: req.Type,
Params: paramsJSON,
DroneSN: req.DroneSN,
RequestID: requestID,
TTLMs: ttlMs,
Status: "sent",
SentAt: &now,
CreatedAt: now,
}
if err := common.DB.Create(cmdLog).Error; err != nil {
logger.ERROR("写入指令日志失败", err)
return nil, common.ErrInternal
}
if err := s.publish(cmdLog, req.Params); err != nil {
logger.ERROR("下发指令失败", err)
return nil, common.ErrInternal
}
return cmdLog, nil
}
// HandleAck 处理指令应答,按外层 requestId 更新一次发送尝试。
func (s *CommandService) HandleAck(dockID, requestID, commandIDStr string, accepted bool, resultCode string) {
if requestID == "" {
logger.WARN("指令应答缺少 requestId", dockID)
return
}
var cmd model.DeviceCommandLog
if err := common.DB.Where("dock_id = ? AND request_id = ? AND status = ?", dockID, requestID, "sent").First(&cmd).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
logger.ERROR("查询指令应答失败", err)
}
return
}
if commandIDStr != "" && commandIDStr != strconv.FormatInt(cmd.ID, 10) {
logger.WARN("指令应答 commandId 与 requestId 不匹配", dockID, requestID)
return
}
now := time.Now()
updates := map[string]any{
"ack_accepted": accepted,
"ack_result_code": resultCode,
"acked_at": now,
"status": "terminal",
}
if accepted {
updates["status"] = "acked"
}
result := common.DB.Model(&model.DeviceCommandLog{}).
Where("id = ? AND dock_id = ? AND request_id = ? AND status = ?", cmd.ID, dockID, requestID, "sent").
Updates(updates)
if result.Error != nil {
logger.ERROR("更新指令应答失败", result.Error)
return
}
if result.RowsAffected == 0 {
return
}
if !accepted {
failTaskExecution(cmd, resultCode)
}
DefaultLiveService.OnCommandAck(&cmd, accepted, resultCode)
}
func failTaskExecution(cmd model.DeviceCommandLog, reason string) {
if reason == "" {
reason = "COMMAND_REJECTED"
}
_ = common.DB.Model(&model.TaskExecution{}).
Where("dock_id = ? AND command_id = ? AND status IN ?", cmd.DockID, strconv.FormatInt(cmd.ID, 10), []string{"pending", "running"}).
Updates(map[string]any{"status": "failed", "end_time": time.Now()})
}
// DispatchToDock 持久化并下发已完成权限与在线校验的设备指令。
func (s *CommandService) DispatchToDock(dockID, commandType string, params map[string]any) (*model.DeviceCommandLog, error) {
commandID, err := tool.NextID()
if err != nil {
return nil, err
}
requestID := strings.ReplaceAll(uuid.New().String(), "-", "")
paramsJSON, err := json.Marshal(params)
if err != nil {
return nil, err
}
now := time.Now()
cmd := &model.DeviceCommandLog{
ID: commandID,
DockID: dockID,
CommandType: commandType,
Params: string(paramsJSON),
RequestID: requestID,
TTLMs: 30000,
Status: "sent",
SentAt: &now,
CreatedAt: now,
}
if err := common.DB.Create(cmd).Error; err != nil {
return nil, err
}
if err := s.publish(cmd, params); err != nil {
return nil, err
}
return cmd, nil
}
// StartRetryScanner 启动指令超时重试扫描器
func (s *CommandService) StartRetryScanner() {
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
s.retryTimeout()
}
}()
}
func (s *CommandService) retryTimeout() {
var pending []model.DeviceCommandLog
if err := common.DB.Where("status = 'sent'").Find(&pending).Error; err != nil {
return
}
now := time.Now()
for _, cmd := range pending {
if cmd.SentAt == nil {
continue
}
ttl := int64(cmd.TTLMs)
if ttl <= 0 {
ttl = 30000
}
if now.Sub(*cmd.SentAt).Milliseconds() <= ttl {
continue
}
if cmd.RetryCount >= maxRetryCount {
result := common.DB.Model(&model.DeviceCommandLog{}).Where("id = ? AND status = ?", cmd.ID, "sent").Update("status", "timeout")
if result.Error == nil && result.RowsAffected > 0 {
failTaskExecution(cmd, "COMMAND_ACK_TIMEOUT")
}
continue
}
// 重试:复用 commandId,换新 requestId(工控机按 commandId 幂等)
requestID := strings.ReplaceAll(uuid.New().String(), "-", "")
_ = common.DB.Model(&model.DeviceCommandLog{}).Where("id = ?", cmd.ID).Updates(map[string]any{
"request_id": requestID,
"retry_count": cmd.RetryCount + 1,
"sent_at": now,
})
var params map[string]any
if cmd.Params != "" {
_ = json.Unmarshal([]byte(cmd.Params), &params)
}
b, _ := json.Marshal(s.commandEnvelope(&cmd, requestID, params))
topic := fmt.Sprintf("dock-edge/v1/dock/%s/command", cmd.DockID)
_ = mqtt.Publish(topic, 1, false, b)
}
var acked []model.DeviceCommandLog
if err := common.DB.Where("status = 'acked'").Find(&acked).Error; err != nil {
return
}
for _, cmd := range acked {
if cmd.AckedAt == nil {
continue
}
ttl := int64(cmd.TTLMs)
if ttl <= 0 {
ttl = 30000
}
if now.Sub(*cmd.AckedAt).Milliseconds() > ttl {
failTaskExecution(cmd, "WORKFLOW_TIMEOUT")
}
}
}
// commandEnvelope 构造下行指令通用外层(文档 §5.1)
func (s *CommandService) commandEnvelope(cmd *model.DeviceCommandLog, requestID string, params map[string]any) *mqtt.Envelope {
return mqtt.NewEnvelope(requestID, cmd.DockID, cmd.DroneSN, map[string]any{
"commandId": strconv.FormatInt(cmd.ID, 10),
"type": cmd.CommandType,
"ttlMs": cmd.TTLMs,
"params": params,
})
}
func (s *CommandService) publish(cmd *model.DeviceCommandLog, params map[string]any) error {
b, err := json.Marshal(s.commandEnvelope(cmd, cmd.RequestID, params))
if err != nil {
return err
}
topic := fmt.Sprintf("dock-edge/v1/dock/%s/command", cmd.DockID)
return mqtt.Publish(topic, 1, false, b)
}
// GetPage 指令历史分页列表(按所属机巢过滤)
func (s *CommandService) GetPage(userID int64, isAdmin bool, req *vo.CommandPageReq) (*common.PageResponse[model.DeviceCommandLog], *common.BusiError) {
db := common.DB.Model(&model.DeviceCommandLog{}).Scopes(withDockFilter(userID, isAdmin))
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.CommandType != "" {
db = db.Where("command_type = ?", req.CommandType)
}
if req.DockID != "" {
db = db.Where("dock_id = ?", req.DockID)
}
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
db = db.Where("dock_id LIKE ? OR command_type LIKE ? OR drone_sn LIKE ?", kw, kw, kw)
}
var total int64
if err := db.Count(&total).Error; err != nil {
logger.ERROR("统计指令日志失败", err)
return nil, common.ErrInternal
}
var list []model.DeviceCommandLog
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
logger.ERROR("查询指令日志失败", err)
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// GetDetail 指令日志详情
func (s *CommandService) GetDetail(userID int64, isAdmin bool, id int64) (*model.DeviceCommandLog, *common.BusiError) {
var cmd model.DeviceCommandLog
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).First(&cmd, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrCommandNotFound
}
return nil, common.ErrInternal
}
return &cmd, nil
}

355
service/dock_service.go

@ -0,0 +1,355 @@
package service
import (
"errors"
"time"
"gorm.io/gorm"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type DockService struct{}
var DefaultDockService = &DockService{}
// withUserFilter admin 不过滤,普通用户按 user_id 过滤(GORM scope)
func withUserFilter(userID int64, isAdmin bool) func(*gorm.DB) *gorm.DB {
if isAdmin {
return func(db *gorm.DB) *gorm.DB { return db }
}
return func(db *gorm.DB) *gorm.DB { return db.Where("user_id = ?", userID) }
}
// withDockFilter admin 不过滤,普通用户按所属机巢过滤(用于无 user_id 的 dock 关联表,如 alarm / command_log)
func withDockFilter(userID int64, isAdmin bool) func(*gorm.DB) *gorm.DB {
if isAdmin {
return func(db *gorm.DB) *gorm.DB { return db }
}
return func(db *gorm.DB) *gorm.DB {
return db.Where("dock_id IN (SELECT dock_id FROM dock WHERE user_id = ?)", userID)
}
}
// GetPage 机巢分页列表
func (s *DockService) GetPage(userID int64, isAdmin bool, req *vo.DockPageReq) (*common.PageResponse[model.Dock], *common.BusiError) {
db := common.DB.Model(&model.Dock{}).Scopes(withUserFilter(userID, isAdmin))
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.RegisterStatus != "" {
db = db.Where("register_status = ?", req.RegisterStatus)
}
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
db = db.Where("name LIKE ? OR dock_id LIKE ? OR code LIKE ?", kw, kw, kw)
}
var total int64
if err := db.Count(&total).Error; err != nil {
logger.ERROR("统计机巢失败", err)
return nil, common.ErrInternal
}
var list []model.Dock
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
logger.ERROR("查询机巢列表失败", err)
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// GetDetail 机巢详情(含实时状态)
func (s *DockService) GetDetail(userID int64, isAdmin bool, id int64) (*vo.DockVO, *common.BusiError) {
var dock model.Dock
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrDockNotFound
}
return nil, common.ErrInternal
}
online, realtime := s.realtime(dock.DockID)
return &vo.DockVO{Dock: &dock, Online: online, Realtime: realtime}, nil
}
// GetStatus 机巢纯实时状态
func (s *DockService) GetStatus(userID int64, isAdmin bool, id int64) (*vo.DockStatusVO, *common.BusiError) {
var dock model.Dock
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).Select("dock_id").First(&dock, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrDockNotFound
}
return nil, common.ErrInternal
}
online, realtime := s.realtime(dock.DockID)
return &vo.DockStatusVO{DockID: dock.DockID, Online: online, Realtime: realtime}, nil
}
// Create 手动登记机巢(归属当前用户;admin 可指定 userID)
func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq) (*model.Dock, *common.BusiError) {
owner := userID
if isAdmin && req.UserID != 0 {
owner = req.UserID
}
id, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
now := time.Now()
dock := &model.Dock{
ID: id,
UserID: owner,
DockID: req.DockID,
Name: req.Name,
Code: req.Code,
SN: req.SN,
Iccid: req.Iccid,
Location: req.Location,
Longitude: req.Longitude,
Latitude: req.Latitude,
Altitude: req.Altitude,
Status: "offline",
RegisterStatus: "registered",
DockIDSource: "manual",
ProtocolVer: "1.0",
CreatedAt: now,
UpdatedAt: now,
}
if err := common.DB.Create(dock).Error; err != nil {
if code, _ := common.ParseError(err); code == 1062 {
return nil, common.ErrDockExists
}
logger.ERROR("登记机巢失败", err)
return nil, common.ErrInternal
}
return dock, nil
}
// Update 更新/认领机巢
func (s *DockService) Update(userID int64, isAdmin bool, id int64, req *vo.DockUpdateReq) (*model.Dock, *common.BusiError) {
var dock model.Dock
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrDockNotFound
}
return nil, common.ErrInternal
}
updates := map[string]any{"updated_at": time.Now()}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Code != "" {
updates["code"] = req.Code
}
if req.Location != nil {
updates["location"] = *req.Location
}
if req.Longitude != nil {
updates["longitude"] = *req.Longitude
}
if req.Latitude != nil {
updates["latitude"] = *req.Latitude
}
if req.Altitude != nil {
updates["altitude"] = *req.Altitude
}
if isAdmin && req.UserID != 0 {
updates["user_id"] = req.UserID
}
if req.RegisterAs == "registered" {
updates["register_status"] = "registered"
if isAdmin && req.UserID != 0 {
updates["user_id"] = req.UserID
} else {
updates["user_id"] = userID // 普通用户认领 → 归属自己
}
}
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&dock).Updates(updates).Error; err != nil {
return err
}
if owner, ok := updates["user_id"]; ok {
if err := tx.Model(&model.Drone{}).Where("dock_id = ?", dock.DockID).Update("user_id", owner).Error; err != nil {
return err
}
}
return nil
}); err != nil {
logger.ERROR("更新机巢失败", err)
return nil, common.ErrInternal
}
common.DB.First(&dock, id)
return &dock, nil
}
// 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 {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrDockNotFound
}
logger.ERROR("查询待删除机巢失败", err)
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 {
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)
return common.ErrInternal
}
for _, taskID := range taskIDs {
DefaultScheduler.RemoveTask(taskID)
}
_ = common.SetRemove(cache.OnlineDockSetKey, dock.DockID)
_ = common.Delete(cache.DockHeartbeatKeyOf(dock.DockID))
_ = common.Delete(cache.DockStatusKeyOf(dock.DockID))
return nil
}
// realtime 读取机巢实时状态(Redis Hash + 在线集合)
func (s *DockService) realtime(dockID string) (bool, map[string]string) {
realtime, err := common.HashGetAll(cache.DockStatusKeyOf(dockID))
if err != nil {
realtime = map[string]string{}
}
online, _ := common.SetMemberExists(cache.OnlineDockSetKey, dockID)
return online, realtime
}
// EnsureFromOnline 设备自发现:不存在则登记为 pending,存在则更新在线状态与软件版本
func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) {
online := st.Status == "online"
status := "offline"
if online {
status = "online"
}
var dock model.Dock
err := common.DB.Where("dock_id = ?", dockID).First(&dock).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
id, e := tool.NextID()
if e != nil {
logger.ERROR("生成机巢 ID 失败", e)
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,
Status: status,
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{"status": status, "updated_at": time.Now()}
if st.SoftwareVersion != "" {
updates["software_ver"] = st.SoftwareVersion
}
if st.ProtocolVersion != "" {
updates["protocol_ver"] = st.ProtocolVersion
}
if dock.Name == "" && st.Name != "" {
updates["name"] = st.Name
}
if dock.Location == "" && st.Location != "" {
updates["location"] = st.Location
}
if dock.Latitude == 0 && st.Latitude != 0 {
updates["latitude"] = st.Latitude
}
if dock.Longitude == 0 && st.Longitude != 0 {
updates["longitude"] = st.Longitude
}
if err := common.DB.Model(&dock).Updates(updates).Error; err != nil {
logger.ERROR("更新机巢在线状态失败", err)
}
}
// 在线集合
if online {
_ = common.SetAdd(cache.OnlineDockSetKey, dockID)
refreshDeviceHeartbeat(cache.DockHeartbeatKeyOf(dockID))
} else {
_ = 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,
})
}

206
service/drone_service.go

@ -0,0 +1,206 @@
package service
import (
"errors"
"time"
"gorm.io/gorm"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type DroneService struct{}
var DefaultDroneService = &DroneService{}
// GetPage 无人机分页列表
func (s *DroneService) GetPage(userID int64, isAdmin bool, req *vo.DronePageReq) (*common.PageResponse[model.Drone], *common.BusiError) {
db := common.DB.Model(&model.Drone{}).Scopes(withUserFilter(userID, isAdmin))
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
db = db.Where("drone_sn LIKE ? OR name LIKE ? OR code LIKE ? OR model LIKE ?", kw, kw, kw, kw)
}
var total int64
if err := db.Count(&total).Error; err != nil {
logger.ERROR("统计无人机失败", err)
return nil, common.ErrInternal
}
var list []model.Drone
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
logger.ERROR("查询无人机列表失败", err)
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// GetDetail 无人机详情(含实时遥测)
func (s *DroneService) GetDetail(userID int64, isAdmin bool, id int64) (*vo.DroneVO, *common.BusiError) {
var drone model.Drone
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&drone, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrDroneNotFound
}
return nil, common.ErrInternal
}
online, realtime := s.realtime(drone.DockID, drone.DroneSN)
return &vo.DroneVO{Drone: &drone, Online: online, Realtime: realtime}, nil
}
// GetTelemetry 无人机实时遥测(当前值,历史曲线走 TDengine,见后续阶段)
func (s *DroneService) GetTelemetry(userID int64, isAdmin bool, id int64) (map[string]any, *common.BusiError) {
var drone model.Drone
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&drone, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrDroneNotFound
}
return nil, common.ErrInternal
}
online, realtime := s.realtime(drone.DockID, drone.DroneSN)
return map[string]any{
"droneSn": drone.DroneSN,
"online": online,
"realtime": realtime,
}, nil
}
// Update 更新无人机基础信息
func (s *DroneService) Update(userID int64, isAdmin bool, id int64, req *vo.DroneUpdateReq) (*model.Drone, *common.BusiError) {
var drone model.Drone
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&drone, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrDroneNotFound
}
return nil, common.ErrInternal
}
updates := map[string]any{"updated_at": time.Now()}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Code != "" {
updates["code"] = req.Code
}
if req.Model != "" {
updates["model"] = req.Model
}
if err := common.DB.Model(&drone).Updates(updates).Error; err != nil {
logger.ERROR("更新无人机失败", err)
return nil, common.ErrInternal
}
common.DB.First(&drone, id)
return &drone, nil
}
// Delete 删除无人机
func (s *DroneService) Delete(userID int64, isAdmin bool, id int64) *common.BusiError {
res := common.DB.Scopes(withUserFilter(userID, isAdmin)).Delete(&model.Drone{}, id)
if res.Error != nil {
logger.ERROR("删除无人机失败", res.Error)
return common.ErrInternal
}
if res.RowsAffected == 0 {
return common.ErrDroneNotFound
}
return nil
}
// realtime 读取无人机实时遥测(Redis Hash,按 dock_id 存储)+ 在线集合
func (s *DroneService) realtime(dockID, droneSN string) (bool, map[string]string) {
realtime, err := common.HashGetAll(cache.DroneTelemetryKeyOf(dockID))
if err != nil {
realtime = map[string]string{}
}
online, _ := common.SetMemberExists(cache.OnlineDroneSetKey, droneSN)
return online, realtime
}
// EnsureFromState 无人机自动关联:按 droneSn 或 dockId 创建/更新(1:1 绑定)
func (s *DroneService) EnsureFromState(dockID, droneSN, name string, online bool, battery int, firmwareVer string) {
status := "offline"
if online {
status = "online"
}
if droneSN == "" {
return
}
var dock model.Dock
if err := common.DB.Select("user_id").Where("dock_id = ?", dockID).First(&dock).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
logger.ERROR("查询无人机关联机巢失败", err)
}
return
}
var drone model.Drone
err := common.DB.Where("drone_sn = ? OR dock_id = ?", droneSN, dockID).First(&drone).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
id, e := tool.NextID()
if e != nil {
logger.ERROR("生成无人机 ID 失败", e)
return
}
now := time.Now()
drone = model.Drone{
ID: id,
UserID: dock.UserID,
DroneSN: droneSN,
DockID: dockID,
Name: name,
Status: status,
Battery: battery,
FirmwareVer: firmwareVer,
CreatedAt: now,
UpdatedAt: now,
}
if err := common.DB.Create(&drone).Error; err != nil {
logger.ERROR("创建无人机失败", err)
return
}
} else if err == nil {
updates := map[string]any{
"user_id": dock.UserID,
"drone_sn": droneSN,
"dock_id": dockID,
"status": status,
"battery": battery,
"updated_at": time.Now(),
}
if firmwareVer != "" {
updates["firmware_ver"] = firmwareVer
}
if name != "" {
updates["name"] = name
}
if err := common.DB.Model(&drone).Updates(updates).Error; err != nil {
logger.ERROR("更新无人机失败", err)
}
}
// 在线集合
if online {
_ = common.SetAdd(cache.OnlineDroneSetKey, droneSN)
refreshDeviceHeartbeat(cache.DroneHeartbeatKeyOf(droneSN))
} else {
_ = common.SetRemove(cache.OnlineDroneSetKey, droneSN)
_ = common.Delete(cache.DroneHeartbeatKeyOf(droneSN))
}
broadcast("drone.status", map[string]any{
"dockId": dockID,
"droneSn": droneSN,
"online": online,
"battery": battery,
"firmwareVer": firmwareVer,
})
}

75
service/execution_service.go

@ -0,0 +1,75 @@
package service
import (
"encoding/json"
"errors"
"gorm.io/gorm"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/vo"
)
type ExecutionService struct{}
var DefaultExecutionService = &ExecutionService{}
// GetPage 执行记录分页列表(按所属机巢过滤)
func (s *ExecutionService) GetPage(userID int64, isAdmin bool, req *vo.ExecutionPageReq) (*common.PageResponse[model.TaskExecution], *common.BusiError) {
db := common.DB.Model(&model.TaskExecution{}).Scopes(withDockFilter(userID, isAdmin))
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.TaskID != "" {
db = db.Where("task_id = ?", req.TaskID)
}
if req.DockID != "" {
db = db.Where("dock_id = ?", req.DockID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
logger.ERROR("统计执行记录失败", err)
return nil, common.ErrInternal
}
var list []model.TaskExecution
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
logger.ERROR("查询执行记录失败", err)
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// GetDetail 执行记录详情
func (s *ExecutionService) GetDetail(userID int64, isAdmin bool, id int64) (*model.TaskExecution, *common.BusiError) {
var exec model.TaskExecution
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).First(&exec, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrExecutionNotFound
}
return nil, common.ErrInternal
}
return &exec, nil
}
// GetTrajectory 执行轨迹(解析 trajectory_json)
func (s *ExecutionService) GetTrajectory(userID int64, isAdmin bool, id int64) (*vo.TrajectoryVO, *common.BusiError) {
var exec model.TaskExecution
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).First(&exec, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrExecutionNotFound
}
return nil, common.ErrInternal
}
points := []vo.TrajectoryPoint{}
if len(exec.TrajectoryJSON) > 0 {
if err := json.Unmarshal([]byte(exec.TrajectoryJSON), &points); err != nil {
logger.ERROR("解析轨迹失败", err)
return nil, common.ErrInternal
}
}
return &vo.TrajectoryVO{ExecutionID: exec.ID, DockID: exec.DockID, Points: points}, nil
}

220
service/firmware_service.go

@ -0,0 +1,220 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"gorm.io/gorm"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/mqtt"
"laic-backend/tool"
"laic-backend/vo"
)
type FirmwareService struct{}
var DefaultFirmwareService = &FirmwareService{}
const defaultFirmwareComponent = "dock-edge-agent"
// GetPage 固件分页列表(admin)
func (s *FirmwareService) GetPage(req *vo.FirmwarePageReq) (*common.PageResponse[model.Firmware], *common.BusiError) {
db := common.DB.Model(&model.Firmware{})
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.Component != "" {
db = db.Where("component = ?", req.Component)
}
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
db = db.Where("version LIKE ? OR description LIKE ?", kw, kw)
}
var total int64
if err := db.Count(&total).Error; err != nil {
logger.ERROR("统计固件失败", err)
return nil, common.ErrInternal
}
var list []model.Firmware
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
logger.ERROR("查询固件列表失败", err)
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// GetReleasedList 已发布固件列表(供普通用户选择升级)
func (s *FirmwareService) GetReleasedList() ([]model.Firmware, *common.BusiError) {
var list []model.Firmware
if err := common.DB.Where("status = ?", "released").Order("id DESC").Find(&list).Error; err != nil {
logger.ERROR("查询已发布固件失败", err)
return nil, common.ErrInternal
}
return list, nil
}
// GetDetail 固件详情(admin)
func (s *FirmwareService) GetDetail(id int64) (*model.Firmware, *common.BusiError) {
var fw model.Firmware
if err := common.DB.First(&fw, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrFirmwareNotFound
}
return nil, common.ErrInternal
}
return &fw, nil
}
// Create 新增固件版本(admin)
func (s *FirmwareService) Create(adminID int64, req *vo.FirmwareCreateReq) (*model.Firmware, *common.BusiError) {
id, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
component := req.Component
if component == "" {
component = defaultFirmwareComponent
}
now := time.Now()
fw := &model.Firmware{
ID: id,
Component: component,
Version: req.Version,
Description: req.Description,
FileURL: req.FileURL,
Sha256: req.Sha256,
Signature: req.Signature,
FileSize: req.FileSize,
Mandatory: req.Mandatory,
Status: "draft",
CreatedBy: adminID,
CreatedAt: now,
UpdatedAt: now,
}
if err := common.DB.Create(fw).Error; err != nil {
if code, _ := common.ParseError(err); code == 1062 {
return nil, common.ErrFirmwareVersionExist
}
logger.ERROR("新增固件失败", err)
return nil, common.ErrInternal
}
return fw, nil
}
// Update 编辑固件(admin)
func (s *FirmwareService) Update(id int64, req *vo.FirmwareUpdateReq) *common.BusiError {
var fw model.Firmware
if err := common.DB.First(&fw, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrFirmwareNotFound
}
return common.ErrInternal
}
updates := map[string]any{"updated_at": time.Now()}
if req.Description != "" {
updates["description"] = req.Description
}
if req.FileURL != "" {
updates["file_url"] = req.FileURL
}
if req.Sha256 != "" {
updates["sha256"] = req.Sha256
}
if req.Signature != "" {
updates["signature"] = req.Signature
}
if req.FileSize != 0 {
updates["file_size"] = req.FileSize
}
if req.Mandatory != nil {
updates["mandatory"] = *req.Mandatory
}
if req.Status != "" {
updates["status"] = req.Status
}
if err := common.DB.Model(&fw).Updates(updates).Error; err != nil {
logger.ERROR("编辑固件失败", err)
return common.ErrInternal
}
return nil
}
// Delete 删除固件(admin)
func (s *FirmwareService) Delete(id int64) *common.BusiError {
res := common.DB.Delete(&model.Firmware{}, id)
if res.Error != nil {
logger.ERROR("删除固件失败", res.Error)
return common.ErrInternal
}
if res.RowsAffected == 0 {
return common.ErrFirmwareNotFound
}
return nil
}
// Upgrade 下发 OTA 升级:校验机巢归属+在线 → 校验固件已发布 → MQTT ota/desired
func (s *FirmwareService) Upgrade(userID int64, isAdmin bool, dockID int64, firmwareID int64) (*vo.FirmwareUpgradeVO, *common.BusiError) {
var dock model.Dock
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, dockID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrDockNotFound
}
return nil, common.ErrInternal
}
online, _ := common.SetMemberExists(cache.OnlineDockSetKey, dock.DockID)
if !online {
return nil, common.ErrDockOffline
}
var fw model.Firmware
if err := common.DB.First(&fw, firmwareID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrFirmwareNotFound
}
return nil, common.ErrInternal
}
if fw.Status != "released" {
return nil, common.ErrFirmwareNotReleased
}
updateID, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
payload := map[string]any{
"updateId": strconv.FormatInt(updateID, 10),
"component": fw.Component,
"version": fw.Version,
"url": fw.FileURL,
"sha256": fw.Sha256,
"signature": fw.Signature,
"mandatory": fw.Mandatory == 1,
"issuedAt": time.Now().UnixMilli(),
}
b, err := json.Marshal(payload)
if err != nil {
return nil, common.ErrInternal
}
topic := fmt.Sprintf("dock-edge/v1/dock/%s/ota/desired", dock.DockID)
if err := mqtt.Publish(topic, 1, true, b); err != nil {
logger.ERROR("下发 OTA 升级失败", err)
return nil, common.ErrInternal
}
return &vo.FirmwareUpgradeVO{
UpdateID: strconv.FormatInt(updateID, 10),
DockID: dock.DockID,
Firmware: &fw,
}, nil
}

90
service/heartbeat.go

@ -0,0 +1,90 @@
package service
import (
"time"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
)
func heartbeatTimeout() uint {
if common.AppConf != nil && common.AppConf.Heartbeat.TimeoutSeconds > 0 {
return uint(common.AppConf.Heartbeat.TimeoutSeconds)
}
return 30
}
func refreshDeviceHeartbeat(key string) {
if err := common.SetValueWithExpired(key, "1", heartbeatTimeout()); err != nil {
logger.WARN("刷新设备心跳失败:", err)
}
}
func expireDeviceHeartbeats() {
dockMembers, err := common.SetMembers(cache.OnlineDockSetKey)
if err != nil {
logger.WARN("扫描机巢在线状态失败:", err)
} else {
for _, dockID := range dockMembers {
removed, e := common.SetRemoveIfKeyMissing(cache.OnlineDockSetKey, cache.DockHeartbeatKeyOf(dockID), dockID)
if e != nil {
logger.WARN("检查机巢心跳失败:", e)
continue
}
if removed {
markDockOffline(dockID)
}
}
}
droneMembers, err := common.SetMembers(cache.OnlineDroneSetKey)
if err != nil {
logger.WARN("扫描无人机在线状态失败:", err)
return
}
for _, droneSN := range droneMembers {
removed, e := common.SetRemoveIfKeyMissing(cache.OnlineDroneSetKey, cache.DroneHeartbeatKeyOf(droneSN), droneSN)
if e != nil {
logger.WARN("检查无人机心跳失败:", e)
continue
}
if removed {
markDroneOffline(droneSN)
}
}
}
func markDockOffline(dockID string) {
result := common.DB.Model(&model.Dock{}).
Where("dock_id = ? AND status = ?", dockID, "online").
Update("status", "offline")
if result.Error != nil {
logger.WARN("更新机巢离线状态失败:", result.Error)
return
}
if result.RowsAffected > 0 {
broadcast("dock.status", map[string]any{"dockId": dockID, "online": false})
}
}
func markDroneOffline(droneSN string) {
result := common.DB.Model(&model.Drone{}).
Where("drone_sn = ? AND status = ?", droneSN, "online").
Update("status", "offline")
if result.Error != nil {
logger.WARN("更新无人机离线状态失败:", result.Error)
return
}
if result.RowsAffected > 0 {
broadcast("drone.status", map[string]any{"droneSn": droneSN, "online": false})
}
}
func heartbeatScanInterval() time.Duration {
if common.AppConf != nil && common.AppConf.Heartbeat.ScanSeconds > 0 {
return time.Duration(common.AppConf.Heartbeat.ScanSeconds) * time.Second
}
return 5 * time.Second
}

393
service/live_service.go

@ -0,0 +1,393 @@
package service
import (
"encoding/json"
"errors"
"strconv"
"time"
"gorm.io/gorm"
"laic-backend/cache"
"laic-backend/common"
liveprovider "laic-backend/live"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type LiveService struct {
provider liveprovider.Adapter
}
var DefaultLiveService = &LiveService{}
func InitLiveProvider(conf common.Live) error {
provider, err := liveprovider.NewAdapter(conf)
if err != nil {
return err
}
DefaultLiveService.provider = provider
return nil
}
func (s *LiveService) adapter() (liveprovider.Adapter, *common.BusiError) {
if s.provider == nil || s.provider.Provider() == "disabled" {
return nil, common.NewBusiError(common.LiveProviderDisabled, "直播服务未配置")
}
return s.provider, nil
}
func (s *LiveService) Join(userID int64, isAdmin bool, dockID string, req *vo.LiveSessionCreateReq) (*vo.LiveSessionVO, *common.BusiError) {
provider, busiErr := s.adapter()
if busiErr != nil {
return nil, busiErr
}
var dock model.Dock
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("dock_id = ?", dockID).First(&dock).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrDockNotFound
}
return nil, common.ErrInternal
}
online, _ := common.SetMemberExists(cache.OnlineDockSetKey, dockID)
if !online {
return nil, common.ErrDockOffline
}
var session model.LiveSession
var lease model.LiveViewerLease
var startParams map[string]any
err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("dock_id = ?", dockID).First(&model.Dock{}).Error; err != nil {
return err
}
if err := tx.Where("dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", dockID).Order("created_at DESC").First(&session).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if session.ID == "" {
maxBitrate := req.MaxBitrateBps
if maxBitrate <= 0 {
maxBitrate = 1500000
}
streamID, err := tool.NextID()
if err != nil {
return err
}
expireSec := common.AppConf.Live.AuthExpireSeconds
if expireSec <= 0 {
expireSec = 7200
}
expiresAt := time.Now().Add(time.Duration(expireSec) * time.Second)
streamName := strconv.FormatInt(streamID, 10)
credentials, err := provider.CreateStream(liveprovider.StreamRequest{StreamName: streamName, ExpiresAt: expiresAt, MaxBitrateBps: maxBitrate})
if err != nil {
return err
}
now := time.Now()
session = model.LiveSession{ID: streamName, DockID: dockID, Provider: credentials.Provider, StreamName: streamName, ExpiresAt: expiresAt.Unix(), MaxBitrateBps: maxBitrate, Phase: "starting", RequestedBy: userID, CreatedAt: now, UpdatedAt: now}
if err := tx.Create(&session).Error; err != nil {
return err
}
startParams = map[string]any{"provider": credentials.Provider, "streamSessionId": session.ID, "pushUrl": credentials.PushURL, "expiresAt": credentials.ExpiresAt.UnixMilli(), "maxBitrateBps": maxBitrate}
}
now := time.Now()
leaseID, err := tool.NextID()
if err != nil {
return err
}
lease = model.LiveViewerLease{ID: leaseID, StreamSessionID: session.ID, ViewerID: userID, ExpiresAt: now.Add(time.Duration(leaseSeconds()) * time.Second), CreatedAt: now, UpdatedAt: now}
return tx.Where("stream_session_id = ? AND viewer_id = ?", session.ID, userID).Assign(map[string]any{"expires_at": lease.ExpiresAt, "released_at": nil, "updated_at": now}).FirstOrCreate(&lease).Error
})
if err != nil {
logger.ERROR("创建直播观看租约失败", err)
return nil, common.ErrInternal
}
if startParams != nil {
if _, err := DefaultCommandService.DispatchToDock(dockID, "video.start_stream", startParams); err != nil {
logger.ERROR("下发推流指令失败", err)
_ = common.DB.Model(&model.LiveSession{}).Where("id = ?", session.ID).Updates(map[string]any{"phase": "failed", "error_code": "MQTT_PUBLISH_FAILED"}).Error
return nil, common.ErrInternal
}
}
return &vo.LiveSessionVO{Session: &session, LeaseExpiresAt: lease.ExpiresAt.Unix()}, nil
}
func leaseSeconds() int {
seconds := common.AppConf.Live.ViewerLeaseSeconds
if seconds <= 0 {
return 30
}
return seconds
}
func (s *LiveService) GetSession(userID int64, isAdmin bool, dockID, streamSessionID string) (*model.LiveSession, *common.BusiError) {
var session model.LiveSession
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ?", streamSessionID, dockID).First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrLiveNotFound
}
return nil, common.ErrInternal
}
return &session, nil
}
func (s *LiveService) Heartbeat(userID int64, isAdmin bool, dockID, streamSessionID string) (*vo.LiveSessionVO, *common.BusiError) {
var session model.LiveSession
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ?", streamSessionID, dockID).First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrLiveNotFound
}
return nil, common.ErrInternal
}
expiresAt := time.Now().Add(time.Duration(leaseSeconds()) * time.Second)
result := common.DB.Model(&model.LiveViewerLease{}).Where("stream_session_id = ? AND viewer_id = ? AND released_at IS NULL", streamSessionID, userID).Updates(map[string]any{"expires_at": expiresAt, "updated_at": time.Now()})
if result.Error != nil || result.RowsAffected == 0 {
return nil, common.ErrLiveNotFound
}
return &vo.LiveSessionVO{Session: &session, LeaseExpiresAt: expiresAt.Unix()}, nil
}
func (s *LiveService) Leave(userID int64, isAdmin bool, dockID, streamSessionID string) *common.BusiError {
var session model.LiveSession
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ?", streamSessionID, dockID).First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrLiveNotFound
}
return common.ErrInternal
}
now := time.Now()
if err := common.DB.Model(&model.LiveViewerLease{}).Where("stream_session_id = ? AND viewer_id = ? AND released_at IS NULL", streamSessionID, userID).Updates(map[string]any{"released_at": now, "updated_at": now}).Error; err != nil {
return common.ErrInternal
}
return nil
}
func stopGraceSeconds() time.Duration {
seconds := common.AppConf.Live.StopGraceSeconds
if seconds <= 0 {
seconds = 15
}
return time.Duration(seconds) * time.Second
}
// ReconcileLeases expires stale viewer leases and stops unviewed sessions after the grace period.
func (s *LiveService) ReconcileLeases() {
now := time.Now()
if err := common.DB.Model(&model.LiveViewerLease{}).Where("released_at IS NULL AND expires_at <= ?", now).Updates(map[string]any{"released_at": now, "updated_at": now}).Error; err != nil {
logger.ERROR("清理过期直播租约失败", err)
return
}
var active []model.LiveSession
if err := common.DB.Where("phase IN ('starting','streaming','reconnecting')").Find(&active).Error; err != nil {
logger.ERROR("扫描活动直播会话失败", err)
return
}
for _, session := range active {
if session.ExpiresAt > 0 && now.Unix() >= session.ExpiresAt {
if _, busiErr := s.stopForBilling(session.DockID, session.ID); busiErr != nil {
logger.ERROR("直播会话到期停止失败: "+session.ID, busiErr)
}
continue
}
var viewers int64
if err := common.DB.Model(&model.LiveViewerLease{}).Where("stream_session_id = ? AND released_at IS NULL AND expires_at > ?", session.ID, now).Count(&viewers).Error; err != nil || viewers > 0 {
if session.StopDeadline != nil {
_ = common.DB.Model(&model.LiveSession{}).Where("id = ?", session.ID).Update("stop_deadline", nil).Error
}
continue
}
if session.StopDeadline == nil {
_ = common.DB.Model(&model.LiveSession{}).Where("id = ? AND stop_deadline IS NULL", session.ID).Update("stop_deadline", now.Add(stopGraceSeconds())).Error
continue
}
if session.StopDeadline.After(now) {
continue
}
if _, busiErr := s.stopForBilling(session.DockID, session.ID); busiErr != nil {
logger.ERROR("无观看者停止直播失败: "+session.ID, busiErr)
}
}
}
// Start 兼容旧启动接口,统一复用创建或加入会话的租约流程。
func (s *LiveService) Start(userID int64, isAdmin bool, dockID string, req *vo.LiveStartReq) (*model.LiveSession, *common.BusiError) {
result, busiErr := s.Join(userID, isAdmin, dockID, &vo.LiveSessionCreateReq{MaxBitrateBps: req.MaxBitrateBps})
if busiErr != nil {
return nil, busiErr
}
return result.Session, nil
}
// Stop 停止指定机巢最新活动会话;设备必须根据 streamSessionId 防止误停新会话。
func (s *LiveService) Stop(userID int64, isAdmin bool, dockID string) *common.BusiError {
var dock model.Dock
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("dock_id = ?", dockID).First(&dock).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrDockNotFound
}
return common.ErrInternal
}
var active model.LiveSession
if err := common.DB.Where("dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", dockID).Order("id DESC").First(&active).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrLiveNotFound
}
return common.ErrInternal
}
params := map[string]any{
"streamSessionId": active.ID,
"reason": "user_request",
}
if _, err := DefaultCommandService.DispatchToDock(dockID, "video.stop_stream", params); err != nil {
logger.ERROR("下发停止推流指令失败", err)
return common.ErrInternal
}
if err := common.DB.Model(&model.LiveSession{}).Where("id = ? AND phase IN ('starting','streaming','reconnecting')", active.ID).Updates(map[string]any{"phase": "stopping", "stop_reason": "manual"}).Error; err != nil {
return common.ErrInternal
}
return nil
}
func (s *LiveService) stopForBilling(dockID, streamSessionID string) (*model.DeviceCommandLog, *common.BusiError) {
params := map[string]any{
"streamSessionId": streamSessionID,
"reason": "no_balance",
}
cmd, err := DefaultCommandService.DispatchToDock(dockID, "video.stop_stream", params)
if err != nil {
return nil, common.ErrInternal
}
if err := common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting')", streamSessionID, dockID).Updates(map[string]any{"phase": "stopping", "stop_reason": "no_balance"}).Error; err != nil {
return nil, common.ErrInternal
}
return cmd, nil
}
func (s *LiveService) GetPage(userID int64, isAdmin bool, req *vo.LivePageReq) (*common.PageResponse[model.LiveSession], *common.BusiError) {
db := common.DB.Model(&model.LiveSession{}).Scopes(withDockFilter(userID, isAdmin))
if req.Phase != "" {
db = db.Where("phase = ?", req.Phase)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, common.ErrInternal
}
var list []model.LiveSession
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// GetPlayURL only returns a URL after both edge and provider confirmation are represented by streaming.
func (s *LiveService) GetPlayURL(userID int64, isAdmin bool, dockID string) (*vo.LivePlayURLVO, *common.BusiError) {
provider, busiErr := s.adapter()
if busiErr != nil {
return nil, busiErr
}
var session model.LiveSession
if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("dock_id = ? AND phase = 'streaming'", dockID).Order("id DESC").First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrLiveNotFound
}
return nil, common.ErrInternal
}
var lease model.LiveViewerLease
if err := common.DB.Where("stream_session_id = ? AND viewer_id = ? AND released_at IS NULL AND expires_at > ?", session.ID, userID, time.Now()).First(&lease).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrLiveLeaseNotFound
}
return nil, common.ErrInternal
}
ttl := common.AppConf.Live.PlayURLTTLSeconds
if ttl <= 0 || ttl > 300 {
ttl = 300
}
urls, err := provider.CreatePlayURLs(liveprovider.PlayRequest{StreamName: session.StreamName, ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Second)})
if err != nil {
return nil, common.NewBusiError(common.LiveProviderInvalid, "生成播放地址失败")
}
return &vo.LivePlayURLVO{StreamName: session.StreamName, PlayURL: urls.HLS, ExpiresAt: urls.ExpiresAt.Unix()}, nil
}
func (s *LiveService) OnCommandAck(cmd *model.DeviceCommandLog, accepted bool, resultCode string) {
if cmd.CommandType != "video.start_stream" && cmd.CommandType != "video.stop_stream" {
return
}
var params struct {
StreamSessionID string `json:"streamSessionId"`
}
if err := json.Unmarshal([]byte(cmd.Params), &params); err != nil || params.StreamSessionID == "" {
logger.ERROR("解析直播指令参数失败", err)
return
}
if accepted {
return
}
updates := map[string]any{"error_code": resultCode}
if cmd.CommandType == "video.start_stream" {
updates["phase"] = "failed"
} else {
updates["phase"] = "streaming"
}
if err := common.DB.Model(&model.LiveSession{}).
Where("id = ? AND dock_id = ?", params.StreamSessionID, cmd.DockID).
Updates(updates).Error; err != nil {
logger.ERROR("更新直播指令失败状态失败", err)
}
}
// OnVideoState accepts only ordered state from the matching non-terminal stream session.
func (s *LiveService) OnVideoState(dockID string, streaming bool, streamSessionID, eventID string, version, updatedAt int64) bool {
if streamSessionID == "" || eventID == "" || version <= 0 || updatedAt <= 0 {
return false
}
now := time.Now()
if abs(now.UnixMilli()-updatedAt) > int64((5*time.Minute)/time.Millisecond) {
logger.WARN("忽略过期视频状态", dockID, streamSessionID)
return false
}
var session model.LiveSession
if err := common.DB.Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", streamSessionID, dockID).First(&session).Error; err != nil {
return false
}
if version <= session.DeviceStateVersion || (session.DeviceEventID != "" && session.DeviceEventID == eventID) || updatedAt < session.DeviceUpdatedAt {
return false
}
updates := map[string]any{
"device_state_version": version,
"device_event_id": eventID,
"device_updated_at": updatedAt,
"updated_at": now,
}
if streaming {
provider, busiErr := s.adapter()
if busiErr != nil {
return false
}
online, queryErr := provider.QueryOnline(streamSessionID)
if queryErr != nil || !online.Online {
updates["error_code"] = "PROVIDER_ONLINE_UNVERIFIED"
return common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND device_state_version < ?", streamSessionID, dockID, version).Updates(updates).Error == nil
}
updates["phase"] = "streaming"
updates["started_at"] = now
updates["cloud_confirmed_at"] = now
} else {
updates["phase"] = "stopped"
updates["stop_reason"] = "device"
updates["stopped_at"] = now
}
result := common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping') AND device_state_version < ?", streamSessionID, dockID, version).Updates(updates)
return result.Error == nil && result.RowsAffected == 1
}
func abs(value int64) int64 {
if value < 0 {
return -value
}
return value
}

408
service/mqtt_subscriber.go

@ -0,0 +1,408 @@
package service
import (
"encoding/json"
"fmt"
"strconv"
"strings"
paho "github.com/eclipse/paho.mqtt.golang"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/mqtt"
"laic-backend/websocket"
)
// 上行消息业务载荷结构(协议文档 §7,均位于通用外层 payload 内)
// statusOnline §7.1 工控机在线状态
type statusOnline struct {
Status string `json:"status"` // online / offline / degraded / updating
BootID string `json:"bootId"`
DockIDSource string `json:"dockIdSource"`
SoftwareVersion string `json:"softwareVersion"`
ProtocolVersion string `json:"protocolVersion"`
UptimeSec int64 `json:"uptimeSec"`
TimeSynced bool `json:"timeSynced"`
MqttConnected bool `json:"mqttConnected"`
ModbusConnected bool `json:"modbusConnected"`
MavlinkConnected bool `json:"mavlinkConnected"`
Updating bool `json:"updating"`
Name string `json:"name"`
Location string `json:"location"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
// dockState §7.2 机巢状态(仅解析业务关心字段,其余经 flatten 存 Redis)
type dockState struct {
PlcConnected bool `json:"plcConnected"`
ControlMode string `json:"controlMode"`
ChargingState string `json:"chargingState"`
EmergencyStop bool `json:"emergencyStop"`
AlarmCodes []string `json:"alarmCodes"`
DronePresent *bool `json:"dronePresent"`
}
// droneState §7.3 无人机状态
type droneState struct {
DroneSN string `json:"droneSn"`
Name string `json:"name"`
CurrentSysID int `json:"currentSysId"`
Online bool `json:"online"`
Armed bool `json:"armed"`
FlightMode string `json:"flightMode"`
FlightModeCode int `json:"flightModeCode"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude float64 `json:"altitude"`
GroundSpeed float64 `json:"groundSpeed"`
Roll float64 `json:"roll"`
Pitch float64 `json:"pitch"`
Yaw float64 `json:"yaw"`
BatteryPercent float64 `json:"batteryPercent"`
BatteryVoltage float64 `json:"batteryVoltage"`
BatteryCurrent float64 `json:"batteryCurrent"`
Satellites int `json:"satellites"`
GpsQuality string `json:"gpsQuality"`
LinkQuality float64 `json:"linkQuality"`
HomeSet bool `json:"homeSet"`
AlarmCodes []string `json:"alarmCodes"`
}
// commandAck §5.3 指令应答
type commandAck struct {
CommandID string `json:"commandId"`
Accepted bool `json:"accepted"`
ResultCode string `json:"resultCode"`
}
// otaReported §8 OTA 升级进度上报
type otaReported struct {
UpdateID string `json:"updateId"`
Component string `json:"component"`
TargetVersion string `json:"targetVersion"`
CurrentVersion string `json:"currentVersion"`
Status string `json:"status"`
Progress int `json:"progress"`
Message string `json:"message"`
ErrorCode *string `json:"errorCode"`
ReportedAt int64 `json:"reportedAt"`
}
// videoState §7.8 视频推流状态
type videoState struct {
Version int64 `json:"version"`
EventID string `json:"eventId"`
Provider string `json:"provider"`
Phase string `json:"phase"`
InputOnline bool `json:"inputOnline"`
Streaming bool `json:"streaming"`
InputCodec string `json:"inputCodec"`
UplinkProtocol string `json:"uplinkProtocol"`
StreamSessionID string `json:"streamSessionId"`
Width *int `json:"width"`
Height *int `json:"height"`
FrameRate *float64 `json:"frameRate"`
BitrateBps uint64 `json:"bitrateBps"`
RetryCount int `json:"retryCount"`
StopReason *string `json:"stopReason"`
ErrorCode *string `json:"errorCode"`
UpdatedAt int64 `json:"updatedAt"`
}
// InitMQTTSubscriber 注册 MQTT 订阅处理器
func InitMQTTSubscriber() {
mqtt.Subscribe(map[string]byte{
"dock-edge/v1/dock/+/status/online": 1,
"dock-edge/v1/dock/+/state/dock": 1,
"dock-edge/v1/dock/+/state/drone": 1,
"dock-edge/v1/dock/+/state/workflow": 1,
"dock-edge/v1/dock/+/state/video": 1,
"dock-edge/v1/dock/+/internal/original-video": 1,
"dock-edge/v1/dock/+/telemetry": 0,
"dock-edge/v1/dock/+/command/ack": 1,
"dock-edge/v1/dock/+/ota/reported": 1,
}, routeMessage)
}
// routeMessage 按 topic 分发到对应处理器
// topic 结构:dock-edge/v1/dock/{dockId}/{category}/{sub}
// 消息体为通用外层 Envelope,业务字段在 payload 内。
func routeMessage(_ paho.Client, msg paho.Message) {
topic := msg.Topic()
parts := strings.Split(topic, "/")
if len(parts) < 5 {
logger.WARN("无法解析 MQTT topic:", topic)
return
}
topicDockID := parts[3]
dockID := topicDockID
category := parts[4]
sub := ""
if len(parts) > 5 {
sub = parts[5]
}
env := mqtt.ParseEnvelope(msg.Payload())
if env.DockID != "" && env.DockID != topicDockID {
logger.WARN("MQTT envelope dockId 与 topic 不一致", topicDockID, env.DockID)
return
}
if env.DockID != "" {
dockID = env.DockID
}
switch category {
case "status":
if sub == "online" {
handleStatus(dockID, env)
}
case "state":
switch sub {
case "dock":
handleStateDock(dockID, env)
case "drone":
handleStateDrone(dockID, env)
case "workflow":
handleWorkflow(dockID, env)
case "video":
handleVideo(dockID, env)
}
case "telemetry":
handleTelemetry(dockID, env)
case "command":
if sub == "ack" {
handleCommandAck(dockID, env)
}
case "ota":
if sub == "reported" {
handleOtaReported(dockID, env)
}
case "internal":
if sub == "original-video" {
handleOriginalVideo(dockID, env)
}
default:
logger.DEBUG("未知 topic:", topic)
}
}
// handleStatus 在线/离线 → 设备自发现 + 在线集合 + 软件版本
func handleStatus(dockID string, env *mqtt.Envelope) {
var st statusOnline
if err := json.Unmarshal(env.Payload, &st); err != nil {
logger.ERROR("解析 status/online 失败", err)
return
}
DefaultDockService.EnsureFromOnline(dockID, st)
if st.Status == "online" {
refreshDeviceHeartbeat(cache.DockHeartbeatKeyOf(dockID))
}
}
// handleStateDock 机巢状态 → Redis Hash + 告警 diff
func handleStateDock(dockID string, env *mqtt.Envelope) {
var raw map[string]any
_ = json.Unmarshal(env.Payload, &raw)
_ = common.HashSetValues(cache.DockStatusKeyOf(dockID), flatten(raw))
refreshDeviceHeartbeat(cache.DockHeartbeatKeyOf(dockID))
var st dockState
if err := json.Unmarshal(env.Payload, &st); err != nil {
logger.ERROR("解析 state/dock 失败", err)
return
}
DefaultAlarmService.SyncByState(dockID, "dock", st.AlarmCodes)
}
// handleStateDrone 无人机状态 → 自动关联 + Redis 遥测 + 告警 diff
func handleStateDrone(dockID string, env *mqtt.Envelope) {
var raw map[string]any
_ = json.Unmarshal(env.Payload, &raw)
_ = common.HashSetValues(cache.DroneTelemetryKeyOf(dockID), flatten(raw))
var st droneState
if err := json.Unmarshal(env.Payload, &st); err != nil {
logger.ERROR("解析 state/drone 失败", err)
return
}
droneSN := st.DroneSN
if droneSN == "" {
droneSN = env.DroneSN
}
battery := int(st.BatteryPercent)
DefaultDroneService.EnsureFromState(dockID, droneSN, st.Name, st.Online, battery, "")
if droneSN != "" && st.Online {
refreshDeviceHeartbeat(cache.DroneHeartbeatKeyOf(droneSN))
}
DefaultAlarmService.SyncByState(dockID, "drone", st.AlarmCodes)
DefaultTelemetryStore.SetDroneSN(dockID, droneSN)
}
// handleTelemetry 高频遥测 → Redis 最新值 + TDengine 批量写入 + 轨迹采集
func handleTelemetry(dockID string, env *mqtt.Envelope) {
var raw map[string]any
if err := json.Unmarshal(env.Payload, &raw); err != nil {
logger.ERROR("解析 telemetry 失败", err)
return
}
_ = common.HashSetValues(cache.DroneTelemetryKeyOf(dockID), flatten(raw))
var p TelemetryPoint
if err := json.Unmarshal(env.Payload, &p); err == nil {
DefaultTelemetryStore.Append(dockID, p)
DefaultTrajectoryStore.Append(dockID, p)
}
if env.DroneSN != "" {
DefaultTelemetryStore.SetDroneSN(dockID, env.DroneSN)
refreshDeviceHeartbeat(cache.DroneHeartbeatKeyOf(env.DroneSN))
}
}
// handleWorkflow 工作流状态 → Redis Hash + WS 推送 + 持久化 + 终态回写任务
func handleWorkflow(dockID string, env *mqtt.Envelope) {
var raw map[string]any
_ = json.Unmarshal(env.Payload, &raw)
_ = common.HashSetValues(cache.WorkflowKeyOf(dockID), flatten(raw))
var st WorkflowStateIn
if err := json.Unmarshal(env.Payload, &st); err != nil {
logger.ERROR("解析 state/workflow 失败", err)
return
}
DefaultWorkflowService.Upsert(dockID, env.RequestID, &st)
broadcast("workflow.state", map[string]any{
"dockId": dockID,
"commandId": st.CommandID,
"taskId": st.TaskID,
"missionId": st.MissionID,
"state": st.State,
"step": st.Step,
"resultCode": st.ResultCode,
})
}
// handleVideo 视频推流状态 → Redis Hash + WS 推送 + 直播会话联动
func handleVideo(dockID string, env *mqtt.Envelope) {
var raw map[string]any
_ = json.Unmarshal(env.Payload, &raw)
var st videoState
if err := json.Unmarshal(env.Payload, &st); err != nil {
logger.ERROR("解析 state/video 失败", err)
return
}
if st.EventID == "" {
st.EventID = env.EventID
}
if st.Version == 0 {
st.Version = env.Version
}
if st.StreamSessionID == "" || st.EventID == "" || st.Version <= 0 || st.UpdatedAt <= 0 {
logger.WARN("忽略缺少顺序元数据的视频状态", dockID, st.StreamSessionID)
return
}
if !DefaultLiveService.OnVideoState(dockID, st.Streaming, st.StreamSessionID, st.EventID, st.Version, st.UpdatedAt) {
return
}
_ = common.HashSetValues(cache.VideoKeyOf(dockID), flatten(raw))
broadcast("video.state", map[string]any{
"dockId": dockID,
"streaming": st.Streaming,
"streamSessionId": st.StreamSessionID,
"bitrate": st.BitrateBps,
"phase": st.Phase,
"errorCode": st.ErrorCode,
})
}
// handleOriginalVideo 处理后台与 Mock 的内部原始视频上传事件。
func handleOriginalVideo(dockID string, env *mqtt.Envelope) {
if env.DockID != "" && env.DockID != dockID {
logger.WARN("原始视频事件机巢不匹配", dockID, env.DockID)
return
}
var event OriginalVideoEvent
if err := json.Unmarshal(env.Payload, &event); err != nil {
logger.ERROR("解析原始视频事件失败", err)
return
}
if err := DefaultVideoService.CompleteOriginalVideo(dockID, &event); err != nil {
logger.ERROR("处理原始视频事件失败", err)
return
}
broadcast("video.original", map[string]any{
"dockId": dockID, "videoId": event.VideoID, "executionId": event.ExecutionID, "eventType": event.EventType,
})
}
// handleCommandAck 指令应答 → 更新 device_command_log
func handleCommandAck(dockID string, env *mqtt.Envelope) {
var ack commandAck
if err := json.Unmarshal(env.Payload, &ack); err != nil {
logger.ERROR("解析 command/ack 失败", err)
return
}
DefaultCommandService.HandleAck(dockID, env.RequestID, ack.CommandID, ack.Accepted, ack.ResultCode)
}
// handleOtaReported OTA 升级进度 → Redis 最新值 + WS 推送
func handleOtaReported(dockID string, env *mqtt.Envelope) {
var r otaReported
if err := json.Unmarshal(env.Payload, &r); err != nil {
logger.ERROR("解析 ota/reported 失败", err)
return
}
_ = common.HashSetValues(cache.OtaStatusKeyOf(dockID), map[string]any{
"updateId": r.UpdateID,
"component": r.Component,
"targetVersion": r.TargetVersion,
"currentVersion": r.CurrentVersion,
"status": r.Status,
"progress": strconv.Itoa(r.Progress),
"message": r.Message,
})
broadcast("ota.progress", map[string]any{
"dockId": dockID,
"updateId": r.UpdateID,
"targetVersion": r.TargetVersion,
"status": r.Status,
"progress": r.Progress,
"message": r.Message,
})
}
// flatten 将 JSON map 展平为 Redis Hash 可存储的 string 值
func flatten(data map[string]any) map[string]any {
out := make(map[string]any, len(data))
for k, v := range data {
switch val := v.(type) {
case string:
out[k] = val
case float64:
out[k] = strconv.FormatFloat(val, 'f', -1, 64)
case bool:
out[k] = strconv.FormatBool(val)
case nil:
out[k] = ""
default:
if b, err := json.Marshal(val); err == nil {
out[k] = string(b)
} else {
out[k] = fmt.Sprint(val)
}
}
}
return out
}
// broadcast 向有权访问该机巢的 WS 客户端推送脱敏事件。
func broadcast(eventType string, data map[string]any) {
dockID, _ := data["dockId"].(string)
if dockID == "" {
return
}
websocket.DefaultHub.BroadcastToDockJSON(dockID, eventType, data)
}

80
service/operation_log_service.go

@ -0,0 +1,80 @@
package service
import (
"time"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type OperationLogService struct{}
var DefaultOperationLogService = &OperationLogService{}
// BootstrapOperationLog 确保基础运行时表存在(历史库未跑 DDL 时兜底)。
func BootstrapOperationLog() {
if err := common.DB.AutoMigrate(&model.OperationLog{}, &model.LiveViewerLease{}); err != nil {
logger.ERROR("迁移基础运行时表失败", err)
}
}
// Record 写入一条操作日志
func (s *OperationLogService) Record(userID int64, userName, module, action, detail, result, ip string) {
if userID == 0 {
return
}
id, err := tool.NextID()
if err != nil {
logger.ERROR("生成操作日志 ID 失败", err)
return
}
log := &model.OperationLog{
ID: id,
UserID: userID,
UserName: userName,
Module: module,
Action: action,
Detail: detail,
Result: result,
IP: ip,
CreatedAt: time.Now(),
}
if err := common.DB.Create(log).Error; err != nil {
logger.ERROR("写入操作日志失败", err)
}
}
// GetPage 操作日志分页(admin 全量,user 仅本人)
func (s *OperationLogService) GetPage(req *vo.OperationLogPageReq) (*common.PageResponse[model.OperationLog], *common.BusiError) {
db := common.DB.Model(&model.OperationLog{})
if req.UserID > 0 {
db = db.Where("user_id = ?", req.UserID)
}
if req.Module != "" {
db = db.Where("module = ?", req.Module)
}
if req.Action != "" {
db = db.Where("action = ?", req.Action)
}
if req.Result != "" {
db = db.Where("result = ?", req.Result)
}
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
db = db.Where("module LIKE ? OR action LIKE ? OR detail LIKE ?", kw, kw, kw)
}
var total int64
if err := db.Count(&total).Error; err != nil {
logger.ERROR("统计操作日志失败", err)
return nil, common.ErrInternal
}
var list []model.OperationLog
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
logger.ERROR("查询操作日志失败", err)
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}

190
service/route_service.go

@ -0,0 +1,190 @@
package service
import (
"errors"
"time"
"gorm.io/gorm"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type RouteService struct{}
var DefaultRouteService = &RouteService{}
// GetPage 航线分页列表
func (s *RouteService) GetPage(userID int64, isAdmin bool, req *vo.RoutePageReq) (*common.PageResponse[model.Route], *common.BusiError) {
db := common.DB.Model(&model.Route{}).Scopes(withUserFilter(userID, isAdmin))
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
db = db.Where("name LIKE ? OR description LIKE ?", kw, kw)
}
var total int64
if err := db.Count(&total).Error; err != nil {
logger.ERROR("统计航线失败", err)
return nil, common.ErrInternal
}
var list []model.Route
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
logger.ERROR("查询航线列表失败", err)
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// GetDetail 航线详情(含航点)
func (s *RouteService) GetDetail(userID int64, isAdmin bool, id int64) (*vo.RouteVO, *common.BusiError) {
var route model.Route
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&route, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrRouteNotFound
}
return nil, common.ErrInternal
}
waypoints, err := s.waypoints(id)
if err != nil {
logger.ERROR("查询航点失败", err)
return nil, common.ErrInternal
}
return &vo.RouteVO{Route: &route, Waypoints: waypoints}, nil
}
// Create 新增航线(含航点)
func (s *RouteService) Create(userID int64, req *vo.RouteCreateReq) (*vo.RouteVO, *common.BusiError) {
id, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
now := time.Now()
route := &model.Route{
ID: id,
UserID: userID,
Name: req.Name,
Description: req.Description,
CreatedAt: now,
UpdatedAt: now,
}
waypoints, err := buildWaypoints(id, req.Waypoints)
if err != nil {
return nil, common.ErrInternal
}
err = common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(route).Error; err != nil {
return err
}
if len(waypoints) > 0 {
return tx.Create(&waypoints).Error
}
return nil
})
if err != nil {
logger.ERROR("新增航线失败", err)
return nil, common.ErrInternal
}
return &vo.RouteVO{Route: route, Waypoints: waypoints}, nil
}
// Update 编辑航线(提供 waypoints 则整体替换航点)
func (s *RouteService) Update(userID int64, isAdmin bool, id int64, req *vo.RouteUpdateReq) (*vo.RouteVO, *common.BusiError) {
var route model.Route
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&route, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrRouteNotFound
}
return nil, common.ErrInternal
}
err := common.DB.Transaction(func(tx *gorm.DB) error {
updates := map[string]any{"updated_at": time.Now()}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Description != "" {
updates["description"] = req.Description
}
if err := tx.Model(&route).Updates(updates).Error; err != nil {
return err
}
if req.Waypoints != nil {
if err := tx.Where("route_id = ?", id).Delete(&model.RouteWaypoint{}).Error; err != nil {
return err
}
waypoints, err := buildWaypoints(id, *req.Waypoints)
if err != nil {
return err
}
if len(waypoints) > 0 {
return tx.Create(&waypoints).Error
}
}
return nil
})
if err != nil {
logger.ERROR("更新航线失败", err)
return nil, common.ErrInternal
}
common.DB.First(&route, id)
waypoints, _ := s.waypoints(id)
return &vo.RouteVO{Route: &route, Waypoints: waypoints}, nil
}
// Delete 删除航线(含航点)
func (s *RouteService) Delete(userID int64, isAdmin bool, id int64) *common.BusiError {
err := common.DB.Transaction(func(tx *gorm.DB) error {
res := tx.Scopes(withUserFilter(userID, isAdmin)).Delete(&model.Route{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return tx.Where("route_id = ?", id).Delete(&model.RouteWaypoint{}).Error
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrRouteNotFound
}
logger.ERROR("删除航线失败", err)
return common.ErrInternal
}
return nil
}
func (s *RouteService) waypoints(routeID int64) ([]model.RouteWaypoint, error) {
var waypoints []model.RouteWaypoint
err := common.DB.Where("route_id = ?", routeID).Order("seq ASC").Find(&waypoints).Error
return waypoints, err
}
// buildWaypoints 将航点请求转换为模型,Seq 从 1 递增
func buildWaypoints(routeID int64, reqs []vo.RouteWaypointReq) ([]model.RouteWaypoint, error) {
out := make([]model.RouteWaypoint, 0, len(reqs))
now := time.Now()
for i, w := range reqs {
id, err := tool.NextID()
if err != nil {
return nil, err
}
out = append(out, model.RouteWaypoint{
ID: id,
RouteID: routeID,
Seq: i + 1,
Longitude: w.Longitude,
Latitude: w.Latitude,
Altitude: w.Altitude,
Speed: w.Speed,
Yaw: w.Yaw,
HoldSec: w.HoldSec,
CreatedAt: now,
})
}
return out, nil
}

104
service/scheduler.go

@ -0,0 +1,104 @@
package service
import (
"sync"
"time"
"github.com/robfig/cron/v3"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
)
// Scheduler 后台定时任务调度器:固定周期计费任务 + 任务计划 cron 自动执行
type Scheduler struct {
cron *cron.Cron
mu sync.Mutex
entries map[string]cron.EntryID // taskID -> cron entry
}
var DefaultScheduler = &Scheduler{}
// Start 启动调度器:注册固定周期任务,加载并注册启用的 cron 任务计划
func (s *Scheduler) Start() {
s.cron = cron.New()
s.entries = make(map[string]cron.EntryID)
// 直播租约与会话对账:清理过期租约并处理超时/无人观看会话
interval := common.AppConf.Live.ReconcileIntervalSeconds
if interval <= 0 {
interval = 5
}
_, _ = s.cron.AddFunc("@every "+(time.Duration(interval)*time.Second).String(), DefaultLiveService.ReconcileLeases)
// 直播计费:每 60s 扫描进行中的 live_session 按码率扣减
_, _ = s.cron.AddFunc("@every 60s", DefaultBillingService.chargeLiveSessions)
// 余额快照:每 30min 将 Redis 权威余额回写 MySQL
_, _ = s.cron.AddFunc("@every 30m", DefaultBillingService.flushBalanceSnapshot)
// SIM 卡用量:每 1h 从运营商同步用量
_, _ = s.cron.AddFunc("@every 1h", DefaultBillingService.syncSimUsage)
// 设备心跳:清理未再收到真实 MQTT 消息的在线设备
_, _ = s.cron.AddFunc("@every "+heartbeatScanInterval().String(), expireDeviceHeartbeats)
s.reloadTaskPlans()
s.cron.Start()
logger.INFO("scheduler started")
}
// reloadTaskPlans 从 DB 加载启用中的 cron 任务计划并注册
func (s *Scheduler) reloadTaskPlans() {
var tasks []model.TaskPlan
if err := common.DB.Where("schedule_type = ? AND status = ?", "cron", "enabled").Find(&tasks).Error; err != nil {
logger.ERROR("加载 cron 任务计划失败", err)
return
}
for i := range tasks {
s.upsertTask(&tasks[i])
}
}
// upsertTask 注册或更新单个任务计划的 cron 条目
func (s *Scheduler) upsertTask(task *model.TaskPlan) {
s.mu.Lock()
defer s.mu.Unlock()
if old, ok := s.entries[task.ID]; ok {
s.cron.Remove(old)
delete(s.entries, task.ID)
}
if task.ScheduleType != "cron" || task.Status != "enabled" || task.ScheduleCron == "" {
return
}
id, err := s.cron.AddFunc(task.ScheduleCron, func() { s.executeTask(task.ID) })
if err != nil {
logger.ERROR("注册 cron 任务计划失败: "+task.ID, err)
return
}
s.entries[task.ID] = id
}
// SyncTask 任务创建/更新后同步调度(按 ID 重新加载)
func (s *Scheduler) SyncTask(taskID string) {
var task model.TaskPlan
if err := common.DB.First(&task, "id = ?", taskID).Error; err != nil {
s.RemoveTask(taskID)
return
}
s.upsertTask(&task)
}
// RemoveTask 移除任务计划的 cron 条目
func (s *Scheduler) RemoveTask(taskID string) {
s.mu.Lock()
defer s.mu.Unlock()
if old, ok := s.entries[taskID]; ok {
s.cron.Remove(old)
delete(s.entries, taskID)
}
}
// executeTask 定时触发任务执行(admin 视角,绕过用户过滤)
func (s *Scheduler) executeTask(taskID string) {
if _, busiErr := DefaultTaskService.Execute(0, true, taskID); busiErr != nil {
logger.WARN("定时执行任务失败:", taskID, busiErr.Msg)
}
}

152
service/system_service.go

@ -0,0 +1,152 @@
package service
import (
"errors"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type SystemService struct{}
var DefaultSystemService = &SystemService{}
// GetUserPage 用户分页列表(admin 全量)
func (s *SystemService) GetUserPage(req *vo.UserPageReq) (*common.PageResponse[model.User], *common.BusiError) {
db := common.DB.Model(&model.User{})
if req.Keyword != "" {
kw := "%" + req.Keyword + "%"
db = db.Where("name LIKE ? OR phone LIKE ? OR email LIKE ?", kw, kw, kw)
}
if req.Role != "" {
db = db.Where("role = ?", req.Role)
}
if req.Status != 0 {
db = db.Where("status = ?", req.Status)
}
var total int64
if err := db.Count(&total).Error; err != nil {
logger.ERROR("统计用户失败", err)
return nil, common.ErrInternal
}
var list []model.User
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
logger.ERROR("查询用户列表失败", err)
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// CreateUser 管理员创建用户
func (s *SystemService) CreateUser(req *vo.UserCreateReq) (*model.User, *common.BusiError) {
var count int64
if err := common.DB.Model(&model.User{}).Where("phone = ?", req.Phone).Count(&count).Error; err != nil {
return nil, common.ErrInternal
}
if count > 0 {
return nil, common.ErrUserPhoneExists
}
id, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, common.ErrInternal
}
role := req.Role
if role == "" {
role = common.RoleUser
}
now := time.Now()
user := &model.User{
ID: id,
Name: req.Name,
Phone: req.Phone,
Email: req.Email,
Password: string(hash),
Role: role,
Status: 1,
CreatedAt: now,
UpdatedAt: now,
}
if err := common.DB.Create(user).Error; err != nil {
if code, _ := common.ParseError(err); code == 1062 {
return nil, common.ErrUserPhoneExists
}
logger.ERROR("创建用户失败", err)
return nil, common.ErrInternal
}
_ = common.SetUserRole(id, role)
return user, nil
}
// UpdateUser 管理员编辑用户(角色/状态/密码等)
func (s *SystemService) UpdateUser(id int64, req *vo.UserUpdateReq) (*model.User, *common.BusiError) {
var user model.User
if err := common.DB.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrUserNotFound
}
return nil, common.ErrInternal
}
updates := map[string]any{"updated_at": time.Now()}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Email != "" {
updates["email"] = req.Email
}
if req.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, common.ErrInternal
}
updates["password"] = string(hash)
}
if req.Role != "" {
updates["role"] = req.Role
}
if req.Status != nil {
updates["status"] = *req.Status
}
if err := common.DB.Model(&user).Updates(updates).Error; err != nil {
logger.ERROR("更新用户失败", err)
return nil, common.ErrInternal
}
common.DB.First(&user, id)
if req.Role != "" {
_ = common.SetUserRole(id, req.Role)
}
return &user, nil
}
// DeleteUser 删除用户
func (s *SystemService) DeleteUser(id int64) *common.BusiError {
res := common.DB.Delete(&model.User{}, id)
if res.Error != nil {
logger.ERROR("删除用户失败", res.Error)
return common.ErrInternal
}
if res.RowsAffected == 0 {
return common.ErrUserNotFound
}
_ = common.SetUserRole(id, "")
return nil
}
// GetRoles 角色列表
func (s *SystemService) GetRoles() []string {
return []string{common.RoleAdmin, common.RoleUser}
}

372
service/task_service.go

@ -0,0 +1,372 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/robfig/cron/v3"
"gorm.io/gorm"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type TaskService struct{}
var DefaultTaskService = &TaskService{}
// GetPage 任务分页列表
func (s *TaskService) GetPage(userID int64, isAdmin bool, req *vo.TaskPageReq) (*common.PageResponse[vo.TaskPlanVO], *common.BusiError) {
db := common.DB.Model(&model.TaskPlan{}).Scopes(withUserFilter(userID, isAdmin))
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.ScheduleType != "" {
db = db.Where("schedule_type = ?", req.ScheduleType)
}
if req.Keyword != "" {
db = db.Where("name LIKE ?", "%"+req.Keyword+"%")
}
var total int64
if err := db.Count(&total).Error; err != nil {
logger.ERROR("统计任务失败", err)
return nil, common.ErrInternal
}
var tasks []model.TaskPlan
if err := db.Scopes(req.Paginate).Order("created_at DESC").Find(&tasks).Error; err != nil {
logger.ERROR("查询任务列表失败", err)
return nil, common.ErrInternal
}
latestByTaskID := make(map[string]*model.TaskExecution, len(tasks))
if len(tasks) > 0 {
taskIDs := make([]string, 0, len(tasks))
for _, task := range tasks {
taskIDs = append(taskIDs, task.ID)
}
var executions []model.TaskExecution
if err := common.DB.Where("task_id IN ?", taskIDs).Order("created_at DESC, id DESC").Find(&executions).Error; err != nil {
logger.ERROR("查询任务最近执行记录失败", err)
return nil, common.ErrInternal
}
for i := range executions {
exec := &executions[i]
if _, exists := latestByTaskID[exec.TaskID]; !exists {
latestByTaskID[exec.TaskID] = exec
}
}
}
list := make([]vo.TaskPlanVO, 0, len(tasks))
for _, task := range tasks {
latest := latestByTaskID[task.ID]
list = append(list, vo.TaskPlanVO{
TaskPlan: task,
PlanStatus: taskPlanStatus(task.Status, latest),
LatestExecution: latest,
})
}
return common.Page(req.Pagination, total, list), nil
}
func taskPlanStatus(status string, latest *model.TaskExecution) string {
if status == "disabled" {
return "paused"
}
if latest != nil && (latest.Status == "pending" || latest.Status == "running") {
return "running"
}
return "pending"
}
// GetDetail 任务详情
func (s *TaskService) GetDetail(userID int64, isAdmin bool, id string) (*model.TaskPlan, *common.BusiError) {
var task model.TaskPlan
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&task, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrTaskNotFound
}
return nil, common.ErrInternal
}
return &task, nil
}
func validateTaskSchedule(scheduleType, scheduleCron string) *common.BusiError {
if scheduleType == "once" {
return nil
}
if scheduleType != "cron" || scheduleCron == "" {
return common.NewBusiError(common.ParamError, "定时任务必须配置执行时间")
}
if _, err := cron.ParseStandard(scheduleCron); err != nil {
return common.NewBusiError(common.ParamError, "定时执行表达式无效")
}
return nil
}
// Create 新增任务
func (s *TaskService) Create(userID int64, req *vo.TaskCreateReq) (*model.TaskPlan, *common.BusiError) {
scheduleType := req.ScheduleType
if scheduleType == "" {
scheduleType = "once"
}
if busiErr := validateTaskSchedule(scheduleType, req.ScheduleCron); busiErr != nil {
return nil, busiErr
}
videoPolicy := req.VideoPolicy
if videoPolicy == "" {
videoPolicy = "raw"
}
now := time.Now()
task := &model.TaskPlan{
ID: strings.ReplaceAll(uuid.New().String(), "-", ""),
UserID: userID,
Name: req.Name,
DockID: req.DockID,
RouteID: req.RouteID,
ScheduleType: scheduleType,
ScheduleCron: req.ScheduleCron,
VideoPolicy: videoPolicy,
Status: "enabled",
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if err := common.DB.Create(task).Error; err != nil {
logger.ERROR("新增任务失败", err)
return nil, common.ErrInternal
}
DefaultScheduler.SyncTask(task.ID)
return task, nil
}
// Update 编辑任务
func (s *TaskService) Update(userID int64, isAdmin bool, id string, req *vo.TaskUpdateReq) *common.BusiError {
var task model.TaskPlan
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&task, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrTaskNotFound
}
return common.ErrInternal
}
scheduleType := task.ScheduleType
if req.ScheduleType != "" {
scheduleType = req.ScheduleType
}
scheduleCron := task.ScheduleCron
if req.ScheduleCron != nil {
scheduleCron = *req.ScheduleCron
}
if scheduleType == "once" {
scheduleCron = ""
}
if busiErr := validateTaskSchedule(scheduleType, scheduleCron); busiErr != nil {
return busiErr
}
if req.Status != "" && req.Status != "draft" && req.Status != "enabled" && req.Status != "disabled" {
return common.NewBusiError(common.ParamError, "任务状态无效")
}
updates := map[string]any{"updated_at": time.Now()}
if req.Name != "" {
updates["name"] = req.Name
}
if req.DockID != "" {
updates["dock_id"] = req.DockID
}
if req.RouteID != nil {
updates["route_id"] = *req.RouteID
}
if req.ScheduleType != "" {
updates["schedule_type"] = req.ScheduleType
}
if req.ScheduleCron != nil {
updates["schedule_cron"] = *req.ScheduleCron
}
if req.ScheduleType != "" && req.ScheduleType == "once" {
updates["schedule_cron"] = ""
}
if req.VideoPolicy != "" {
updates["video_policy"] = req.VideoPolicy
}
if req.Status != "" {
updates["status"] = req.Status
}
if err := common.DB.Model(&task).Updates(updates).Error; err != nil {
logger.ERROR("更新任务失败", err)
return common.ErrInternal
}
DefaultScheduler.SyncTask(id)
return nil
}
// Delete 删除任务
func (s *TaskService) Delete(userID int64, isAdmin bool, id string) *common.BusiError {
res := common.DB.Scopes(withUserFilter(userID, isAdmin)).Delete(&model.TaskPlan{}, "id = ?", id)
if res.Error != nil {
logger.ERROR("删除任务失败", res.Error)
return common.ErrInternal
}
if res.RowsAffected == 0 {
return common.ErrTaskNotFound
}
DefaultScheduler.RemoveTask(id)
return nil
}
// Execute 立即执行任务:落库执行记录 + 下发 workflow.start_task 指令
func (s *TaskService) Execute(userID int64, isAdmin bool, id string) (*vo.TaskExecuteVO, *common.BusiError) {
var task model.TaskPlan
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&task, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.WARN("执行任务失败,任务不存在:", id)
return nil, common.ErrTaskNotFound
}
logger.ERROR("查询待执行任务失败", err)
return nil, common.ErrInternal
}
if task.Status == "disabled" {
logger.WARN("执行任务失败,任务已禁用:", id)
return nil, common.ErrTaskStateErr
}
online, _ := common.SetMemberExists(cache.OnlineDockSetKey, task.DockID)
if !online {
logger.WARN("执行任务失败,机巢离线:", task.DockID, "任务:", id)
return nil, common.ErrDockOffline
}
commandID, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
execID, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
requestID := strings.ReplaceAll(uuid.New().String(), "-", "")
now := time.Now()
params := map[string]any{
"taskId": task.ID,
"missionId": strings.ReplaceAll(uuid.New().String(), "-", ""),
"executionId": strconv.FormatInt(execID, 10),
"recordOriginalVideo": task.VideoPolicy == "raw",
}
if task.RouteID != 0 {
var waypoints []model.RouteWaypoint
if err := common.DB.Where("route_id = ?", task.RouteID).Order("seq ASC").Find(&waypoints).Error; err != nil {
logger.ERROR("查询航线航点失败", err)
return nil, common.ErrInternal
}
payload := make([]map[string]any, 0, len(waypoints))
for i, wp := range waypoints {
payload = append(payload, map[string]any{
"seq": i,
"longitude": wp.Longitude,
"latitude": wp.Latitude,
"altitude": wp.Altitude,
"speed": wp.Speed,
"yaw": wp.Yaw,
"holdSec": wp.HoldSec,
})
}
params["waypoints"] = payload
}
var originalVideo *model.Video
if task.VideoPolicy == "raw" {
videoID, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
expireAt := now.Add(10 * time.Minute)
originalVideo = &model.Video{
ID: videoID,
UserID: task.UserID,
ExecutionID: execID,
FileName: fmt.Sprintf("execution-%d-original.mp4", execID),
OssKey: fmt.Sprintf("media/%d/execution-%d-original.mp4", videoID, execID),
OssBucket: common.AppConf.OSS.Bucket,
Status: "pending",
UploadExpireAt: &expireAt,
CreatedAt: now,
}
uploadURL, err := common.PresignOSS("PUT", originalVideo.OssKey, 600)
if err != nil {
logger.ERROR("生成原始视频上传地址失败", err)
return nil, common.ErrInternal
}
params["originalVideo"] = map[string]any{
"videoId": strconv.FormatInt(videoID, 10),
"executionId": strconv.FormatInt(execID, 10),
"uploadUrl": uploadURL,
"uploadExpireAt": expireAt.UnixMilli(),
"fileName": originalVideo.FileName,
"contentType": "video/mp4",
}
}
paramsJSON := ""
if b, e := json.Marshal(params); e == nil {
paramsJSON = string(b)
}
cmdLog := &model.DeviceCommandLog{
ID: commandID,
DockID: task.DockID,
CommandType: "workflow.start_task",
Params: paramsJSON,
RequestID: requestID,
TTLMs: 30000,
Status: "sent",
SentAt: &now,
CreatedAt: now,
}
exec := &model.TaskExecution{
ID: execID,
TaskID: task.ID,
CommandID: strconv.FormatInt(commandID, 10),
DockID: task.DockID,
Status: "pending",
TrajectoryJSON: json.RawMessage("[]"),
CreatedAt: now,
}
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(cmdLog).Error; err != nil {
return err
}
if err := tx.Create(exec).Error; err != nil {
return err
}
if originalVideo != nil {
return tx.Create(originalVideo).Error
}
return nil
}); err != nil {
logger.ERROR("创建执行记录失败", err)
return nil, common.ErrInternal
}
var drone model.Drone
_ = common.DB.Where("dock_id = ?", task.DockID).First(&drone).Error
DefaultTrajectoryStore.Begin(task.DockID, execID, drone.DroneSN)
if err := DefaultCommandService.publish(cmdLog, params); err != nil {
logger.ERROR("下发任务指令失败", err)
return nil, common.ErrInternal
}
return &vo.TaskExecuteVO{Execution: exec, Command: cmdLog}, nil
}

171
service/telemetry_store.go

@ -0,0 +1,171 @@
package service
import (
"fmt"
"strconv"
"strings"
"sync"
"time"
"laic-backend/common"
"laic-backend/logger"
)
// TelemetryPoint 无人机遥测点(对应 device_telemetry 超级表)
type TelemetryPoint struct {
Ts int64 `json:"ts"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
Altitude float64 `json:"altitude"`
GroundSpeed float64 `json:"groundSpeed"`
Roll float64 `json:"roll"`
Pitch float64 `json:"pitch"`
Yaw float64 `json:"yaw"`
BatteryPct int `json:"batteryPct"`
BatteryV float64 `json:"batteryV"`
Satellites int `json:"satellites"`
GpsQuality string `json:"gpsQuality"`
LinkQuality int `json:"linkQuality"`
FlightMode string `json:"flightMode"`
Armed int8 `json:"armed"`
}
const (
telemetryBatchSize = 500
telemetryFlushInterval = 10 * time.Second
)
type telemetryRecord struct {
DockID string
Point TelemetryPoint
}
// TelemetryStore 遥测缓冲:累计 500 条或 10 秒后批量写入 TDengine
type TelemetryStore struct {
mu sync.Mutex
buffer []telemetryRecord
droneSN sync.Map // dockID -> droneSN(来自 state/drone 上报)
}
var DefaultTelemetryStore = &TelemetryStore{}
// SetDroneSN 记录机巢当前无人机序列号(作为 device_telemetry 的 tag)
func (s *TelemetryStore) SetDroneSN(dockID, sn string) {
if sn != "" {
s.droneSN.Store(dockID, sn)
}
}
// Append 追加一个遥测点,达到阈值立即刷盘
func (s *TelemetryStore) Append(dockID string, p TelemetryPoint) {
s.mu.Lock()
s.buffer = append(s.buffer, telemetryRecord{DockID: dockID, Point: p})
if len(s.buffer) >= telemetryBatchSize {
s.flushLocked()
}
s.mu.Unlock()
}
// Start 启动定时刷盘协程
func (s *TelemetryStore) Start() {
go func() {
ticker := time.NewTicker(telemetryFlushInterval)
defer ticker.Stop()
for range ticker.C {
s.mu.Lock()
s.flushLocked()
s.mu.Unlock()
}
}()
}
func (s *TelemetryStore) flushLocked() {
if len(s.buffer) == 0 {
return
}
records := s.buffer
s.buffer = nil
s.write(records)
}
func (s *TelemetryStore) write(records []telemetryRecord) {
if common.TD == nil {
return
}
groups := make(map[string][]TelemetryPoint, len(records))
for _, r := range records {
groups[r.DockID] = append(groups[r.DockID], r.Point)
}
for dockID, points := range groups {
s.insert(dockID, s.droneSNValue(dockID), points)
}
}
func (s *TelemetryStore) droneSNValue(dockID string) string {
if v, ok := s.droneSN.Load(dockID); ok {
return v.(string)
}
return ""
}
// DroneSN 返回机巢当前无人机序列号(来自 state/drone 上报)
func (s *TelemetryStore) DroneSN(dockID string) string {
return s.droneSNValue(dockID)
}
func (s *TelemetryStore) insert(dockID, droneSN string, points []TelemetryPoint) {
defer func() {
if r := recover(); r != nil {
logger.ERROR("TDengine 写入 panic(已恢复,避免进程崩溃)", fmt.Errorf("%v", r))
}
}()
var b strings.Builder
b.WriteString("INSERT INTO ")
b.WriteString(sanitizeTable(dockID))
b.WriteString(" USING device_telemetry TAGS ('")
b.WriteString(escapeTD(dockID))
b.WriteString("', '")
b.WriteString(escapeTD(droneSN))
b.WriteString("') VALUES ")
for i, p := range points {
if i > 0 {
b.WriteString(", ")
}
ts := p.Ts
if ts <= 0 {
ts = time.Now().UnixMilli()
}
fmt.Fprintf(&b, "(%d, %s, %s, %s, %s, %s, %s, %s, %d, %s, %d, '%s', %d, '%s', %d)",
ts,
ftoa(p.Longitude), ftoa(p.Latitude), ftoa(p.Altitude), ftoa(p.GroundSpeed),
ftoa(p.Roll), ftoa(p.Pitch), ftoa(p.Yaw),
p.BatteryPct, ftoa(p.BatteryV), p.Satellites,
escapeTD(p.GpsQuality), p.LinkQuality, escapeTD(p.FlightMode), p.Armed,
)
}
if _, err := common.TD.Exec(b.String()); err != nil {
logger.ERROR("TDengine 批量写入遥测失败", err)
}
}
func ftoa(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
func escapeTD(s string) string {
return strings.ReplaceAll(s, "'", "\\'")
}
// sanitizeTable 将 dockID 转为 TDengine 子表名(仅保留字母数字)
func sanitizeTable(dockID string) string {
var b strings.Builder
b.WriteString("d_")
for _, r := range dockID {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}

133
service/trajectory_store.go

@ -0,0 +1,133 @@
package service
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/vo"
)
// trajectoryBuffer 单次执行的活动轨迹缓冲
type trajectoryBuffer struct {
ExecutionID int64
DockID string
DroneSN string
Points []vo.TrajectoryPoint
}
// TrajectoryStore 轨迹采集:维护每个机巢的活动执行,遥测点入缓冲,终态回写 MySQL + TDengine
type TrajectoryStore struct {
mu sync.Mutex
active map[string]*trajectoryBuffer // dockID -> 活动执行
}
var DefaultTrajectoryStore = &TrajectoryStore{active: make(map[string]*trajectoryBuffer)}
// Begin 建立活动执行关联(任务下发成功后调用)
func (s *TrajectoryStore) Begin(dockID string, executionID int64, droneSN string) {
s.mu.Lock()
defer s.mu.Unlock()
s.active[dockID] = &trajectoryBuffer{
ExecutionID: executionID,
DockID: dockID,
DroneSN: droneSN,
Points: make([]vo.TrajectoryPoint, 0, 1024),
}
}
// Append 将遥测点追加到活动执行的轨迹缓冲
func (s *TrajectoryStore) Append(dockID string, p TelemetryPoint) {
s.mu.Lock()
defer s.mu.Unlock()
buf, ok := s.active[dockID]
if !ok {
return
}
ts := p.Ts
if ts <= 0 {
ts = time.Now().UnixMilli()
}
buf.Points = append(buf.Points, vo.TrajectoryPoint{
Ts: ts,
Longitude: p.Longitude,
Latitude: p.Latitude,
Altitude: p.Altitude,
GroundSpeed: p.GroundSpeed,
Yaw: p.Yaw,
BatteryPct: p.BatteryPct,
})
}
// Finalize 终态回写:轨迹点写 MySQL trajectory_json + TDengine task_trajectory,并清除活动关联
func (s *TrajectoryStore) Finalize(dockID string, droneSN string) {
s.mu.Lock()
buf, ok := s.active[dockID]
if ok {
delete(s.active, dockID)
}
s.mu.Unlock()
if !ok || buf == nil {
return
}
if droneSN != "" {
buf.DroneSN = droneSN
}
pointsJSON, err := json.Marshal(buf.Points)
if err != nil {
pointsJSON = []byte("[]")
}
updates := map[string]any{"trajectory_json": string(pointsJSON)}
if buf.DroneSN != "" {
updates["drone_sn"] = buf.DroneSN
}
if err := common.DB.Model(&model.TaskExecution{}).
Where("id = ?", buf.ExecutionID).
Updates(updates).Error; err != nil {
logger.ERROR("回写执行轨迹 JSON 失败", err)
}
s.writeTDengine(buf)
}
// writeTDengine 轨迹点批量写入 task_trajectory 超级表(子表按 execution_id 隔离)
func (s *TrajectoryStore) writeTDengine(buf *trajectoryBuffer) {
if common.TD == nil || len(buf.Points) == 0 {
return
}
defer func() {
if r := recover(); r != nil {
logger.ERROR("TDengine 轨迹写入 panic(已恢复)", fmt.Errorf("%v", r))
}
}()
var b strings.Builder
b.WriteString("INSERT INTO ")
b.WriteString(trajectoryTable(buf.ExecutionID))
b.WriteString(" USING task_trajectory TAGS (")
fmt.Fprintf(&b, "%d, '%s', '%s') VALUES ",
buf.ExecutionID, escapeTD(buf.DockID), escapeTD(buf.DroneSN))
for i, p := range buf.Points {
if i > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "(%d, %s, %s, %s, %s, %s, %d)",
p.Ts, ftoa(p.Longitude), ftoa(p.Latitude), ftoa(p.Altitude),
ftoa(p.GroundSpeed), ftoa(p.Yaw), p.BatteryPct)
}
if _, err := common.TD.Exec(b.String()); err != nil {
logger.ERROR("TDengine 批量写入轨迹失败", err)
}
}
// trajectoryTable 按执行 ID 生成子表名
func trajectoryTable(execID int64) string {
return "tr_" + strconv.FormatInt(execID, 10)
}

207
service/user_service.go

@ -0,0 +1,207 @@
package service
import (
"errors"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"laic-backend/cache"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/token"
"laic-backend/tool"
"laic-backend/vo"
)
type UserService struct{}
var DefaultUserService = &UserService{}
// Register 注册用户(默认角色 user),成功后直接返回登录态
func (s *UserService) Register(name, phone, email, password string) (*vo.LoginResp, *common.BusiError) {
// 手机号唯一性预检(兼顾并发,唯一键兜底)
var count int64
if err := common.DB.Model(&model.User{}).Where("phone = ?", phone).Count(&count).Error; err != nil {
return nil, common.ErrInternal
}
if count > 0 {
return nil, common.ErrUserPhoneExists
}
id, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, common.ErrInternal
}
user := &model.User{
ID: id,
Name: name,
Phone: phone,
Email: email,
Password: string(hash),
Role: common.RoleUser,
Status: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := common.DB.Create(user).Error; err != nil {
if code, _ := common.ParseError(err); code == 1062 {
return nil, common.ErrUserPhoneExists
}
logger.ERROR("注册用户失败", err)
return nil, common.ErrInternal
}
_ = common.SetUserRole(id, common.RoleUser)
return s.buildLoginResp(user)
}
// Login 手机号 + 密码登录
func (s *UserService) Login(phone, password string) (*vo.LoginResp, *common.BusiError) {
var user model.User
if err := common.DB.Where("phone = ? AND status = 1", phone).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrPasswordError // 统一返回"用户名或密码错误",避免暴露账号是否存在
}
logger.ERROR("登录查询用户失败", err)
return nil, common.ErrInternal
}
if !checkPassword(password, user.Password) {
return nil, common.ErrPasswordError
}
// 更新最后登录时间(失败不阻断登录)
common.DB.Model(&user).Update("last_login", time.Now())
return s.buildLoginResp(&user)
}
// Refresh 用 refresh token 换取新的 access + refresh token(刷新令牌轮换)
func (s *UserService) Refresh(refreshToken string) (*vo.LoginResp, *common.BusiError) {
userID, err := token.ParseRefreshToken(refreshToken)
if err != nil {
return nil, common.ErrTokenInvalid
}
// 校验 refresh token 是否仍存证(登出会删除)
refreshKey := cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken))
stored, err := common.GetString(refreshKey)
if err != nil || stored != refreshToken {
return nil, common.ErrTokenInvalid
}
var user model.User
if err := common.DB.Where("id = ? AND status = 1", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrUserNotFound
}
return nil, common.ErrInternal
}
// 轮换:删除旧 refresh 存证
_ = common.Delete(refreshKey)
return s.buildLoginResp(&user)
}
// Logout 登出:删除 access 存证(及可选 refresh 存证),使 token 立即失效
func (s *UserService) Logout(userID int64, accessToken, refreshToken string) *common.BusiError {
accessKey := cache.UserTokenKeyOf(userID, token.GenerateShortID(accessToken))
_ = common.Delete(accessKey)
if refreshToken != "" {
refreshKey := cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken))
_ = common.Delete(refreshKey)
}
return nil
}
// SendSmsCode 生成并"发送"短信验证码(暂存 Redis,短信通道未接入前记日志)
func (s *UserService) SendSmsCode(phone string) (string, *common.BusiError) {
code := tool.RandDigit(6)
key := cache.SmsCodeKeyOf(phone)
if err := common.SetValueWithExpired(key, code, 5*60); err != nil {
logger.ERROR("短信验证码写 Redis 失败", err)
return "", common.ErrInternal
}
// TODO: 接入短信服务商后,此处调用真实发送接口;当前仅在日志输出验证码便于联调
logger.INFO("send sms code to", phone, "code:", code)
return code, nil
}
// ResetPassword 通过短信验证码重置密码
func (s *UserService) ResetPassword(phone, smsCode, newPassword string) *common.BusiError {
key := cache.SmsCodeKeyOf(phone)
stored, err := common.GetString(key)
if err != nil || stored == "" || stored != smsCode {
return common.ErrSmsCodeError
}
var user model.User
if err := common.DB.Where("phone = ?", phone).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.ErrUserNotFound
}
return common.ErrInternal
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return common.ErrInternal
}
if err := common.DB.Model(&user).Updates(map[string]any{
"password": string(hash),
"updated_at": time.Now(),
}).Error; err != nil {
return common.ErrInternal
}
_ = common.Delete(key)
return nil
}
// GetByID 按 ID 查询用户
func (s *UserService) GetByID(id int64) (*model.User, *common.BusiError) {
var user model.User
if err := common.DB.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrUserNotFound
}
return nil, common.ErrInternal
}
return &user, nil
}
// buildLoginResp 生成 token 并存证 Redis,返回登录响应
func (s *UserService) buildLoginResp(user *model.User) (*vo.LoginResp, *common.BusiError) {
roles := []string{user.Role}
accessToken, refreshToken, err := token.GenerateToken(user.ID, user.Phone, roles)
if err != nil {
logger.ERROR("生成 token 失败", err)
return nil, common.ErrInternal
}
accessKey := cache.UserTokenKeyOf(user.ID, token.GenerateShortID(accessToken))
refreshKey := cache.RefreshTokenKeyOf(user.ID, token.GenerateShortID(refreshToken))
if err := common.SetValueWithExpired(accessKey, "1", token.AccessExpireSeconds()); err != nil {
return nil, common.ErrInternal
}
if err := common.SetValueWithExpired(refreshKey, refreshToken, token.RefreshExpireSeconds()); err != nil {
return nil, common.ErrInternal
}
return &vo.LoginResp{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: vo.NewUserVO(user),
}, nil
}
func checkPassword(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

231
service/video_service.go

@ -0,0 +1,231 @@
package service
import (
"errors"
"fmt"
"net/http"
"time"
"gorm.io/gorm"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
"laic-backend/vo"
)
type VideoService struct{}
var DefaultVideoService = &VideoService{}
const downloadURLExpire = 300 // 预签名下载地址有效期(秒)
type OriginalVideoEvent struct {
Extension string `json:"extension"`
EventID string `json:"eventId"`
EventType string `json:"eventType"`
VideoID int64 `json:"videoId"`
ExecutionID int64 `json:"executionId"`
TaskID string `json:"taskId"`
CommandID string `json:"commandId"`
FileSize int64 `json:"fileSize"`
Duration int `json:"duration"`
ErrorCode string `json:"errorCode"`
}
func (s *VideoService) CompleteOriginalVideo(dockID string, event *OriginalVideoEvent) error {
if event.Extension != "laic.mock.original-video.v1" || event.EventID == "" || event.VideoID <= 0 || event.ExecutionID <= 0 {
return errors.New("invalid original video event")
}
var video model.Video
if err := common.DB.First(&video, event.VideoID).Error; err != nil {
return err
}
if video.ExecutionID != event.ExecutionID {
return errors.New("video execution mismatch")
}
var execution model.TaskExecution
if err := common.DB.First(&execution, event.ExecutionID).Error; err != nil {
return err
}
if execution.DockID != dockID || execution.TaskID != event.TaskID || execution.CommandID != event.CommandID {
return errors.New("execution association mismatch")
}
var task model.TaskPlan
if err := common.DB.First(&task, "id = ?", execution.TaskID).Error; err != nil {
return err
}
if task.DockID != dockID || task.VideoPolicy != "raw" {
return errors.New("task video policy mismatch")
}
if event.EventType == "failed" {
return common.DB.Model(&video).Where("id = ? AND status <> ?", video.ID, "ready").Update("status", "failed").Error
}
if event.EventType != "completed" || video.Status == "ready" {
return nil
}
if event.FileSize <= 0 || event.FileSize > 100<<20 {
return errors.New("invalid video size")
}
if err := headOSS(video.OssKey); err != nil {
return err
}
updates := map[string]any{"status": "ready", "file_size": event.FileSize}
if event.Duration > 0 {
updates["duration"] = event.Duration
}
return common.DB.Model(&video).Where("id = ? AND status IN ?", video.ID, []string{"pending", "uploading"}).Updates(updates).Error
}
func headOSS(objectKey string) error {
url, err := common.PresignOSS("HEAD", objectKey, 60)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
return err
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("oss object unavailable: %s", resp.Status)
}
return nil
}
// CreateUpload 申请视频上传:落库 + 返回 OSS 预签名 PUT URL
func (s *VideoService) CreateUpload(userID int64, req *vo.VideoUploadReq) (*vo.VideoUploadVO, *common.BusiError) {
id, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal
}
ossKey := fmt.Sprintf("media/%d/%s", id, req.FileName)
expireAt := time.Now().Add(10 * time.Minute)
video := &model.Video{
ID: id,
UserID: userID,
DroneSN: req.DroneSN,
ExecutionID: req.ExecutionID,
FileName: req.FileName,
FileSize: req.FileSize,
OssKey: ossKey,
OssBucket: common.AppConf.OSS.Bucket,
Status: "uploading",
UploadExpireAt: &expireAt,
CreatedAt: time.Now(),
}
if err := common.DB.Create(video).Error; err != nil {
logger.ERROR("创建视频记录失败", err)
return nil, common.ErrInternal
}
uploadURL, err := common.PresignOSS("PUT", ossKey, 600)
if err != nil {
logger.ERROR("生成上传预签名 URL 失败", err)
return nil, common.ErrInternal
}
return &vo.VideoUploadVO{Video: video, UploadURL: uploadURL, ExpireAt: &expireAt}, nil
}
// Complete 上传完成确认:更新视频状态为 ready
func (s *VideoService) Complete(userID int64, videoID int64, req *vo.VideoCompleteReq) (*model.Video, *common.BusiError) {
var video model.Video
if err := common.DB.Where("id = ? AND user_id = ?", videoID, userID).First(&video).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrVideoNotFound
}
return nil, common.ErrInternal
}
updates := map[string]any{"status": "ready"}
if req.FileSize > 0 {
updates["file_size"] = req.FileSize
}
if req.Duration > 0 {
updates["duration"] = req.Duration
}
if err := common.DB.Model(&video).Updates(updates).Error; err != nil {
return nil, common.ErrInternal
}
common.DB.First(&video, videoID)
return &video, nil
}
// GetPage 视频分页
func (s *VideoService) GetPage(userID int64, isAdmin bool, req *vo.VideoPageReq) (*common.PageResponse[model.Video], *common.BusiError) {
db := common.DB.Model(&model.Video{}).Scopes(withUserFilter(userID, isAdmin))
if req.Status != "" {
db = db.Where("status = ?", req.Status)
}
if req.Keyword != "" {
db = db.Where("file_name LIKE ?", "%"+req.Keyword+"%")
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, common.ErrInternal
}
var list []model.Video
if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil {
return nil, common.ErrInternal
}
return common.Page(req.Pagination, total, list), nil
}
// GetDetail 视频详情
func (s *VideoService) GetDetail(userID int64, isAdmin bool, videoID int64) (*model.Video, *common.BusiError) {
var video model.Video
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&video, videoID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrVideoNotFound
}
return nil, common.ErrInternal
}
return &video, nil
}
// Download 下载视频:校验状态 → 流量扣减 → OSS 预签名 GET → 下载日志
func (s *VideoService) Download(userID int64, isAdmin bool, videoID int64) (*vo.VideoDownloadVO, *common.BusiError) {
var video model.Video
if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&video, videoID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrVideoNotFound
}
return nil, common.ErrInternal
}
if video.Status != "ready" {
return nil, common.ErrVideoUploading
}
// 流量扣减(账本落库)
if ok, busiErr := DefaultBillingService.Deduct(userID, video.FileSize, "download", videoID); !ok {
return nil, busiErr
}
downloadURL, err := common.PresignOSS("GET", video.OssKey, downloadURLExpire)
if err != nil {
logger.ERROR("生成下载预签名 URL 失败", err)
return nil, common.ErrInternal
}
log := &model.DownloadLog{
ID: mustID(),
VideoID: videoID,
UserID: userID,
Bytes: video.FileSize,
CreatedAt: time.Now(),
}
if err := common.DB.Create(log).Error; err != nil {
logger.ERROR("下载日志落库失败", err)
}
return &vo.VideoDownloadVO{
DownloadURL: downloadURL,
ExpireAt: time.Now().Add(downloadURLExpire * time.Second).Unix(),
Bytes: video.FileSize,
}, nil
}

129
service/workflow_service.go

@ -0,0 +1,129 @@
package service
import (
"errors"
"strconv"
"time"
"gorm.io/gorm"
"laic-backend/common"
"laic-backend/logger"
"laic-backend/model"
"laic-backend/tool"
)
type WorkflowService struct{}
var DefaultWorkflowService = &WorkflowService{}
// WorkflowStateIn 工作流状态上报载荷
type WorkflowStateIn struct {
CommandID string `json:"commandId"`
Type string `json:"type"`
TaskID string `json:"taskId"`
MissionID string `json:"missionId"`
State string `json:"state"`
Step string `json:"step"`
ResultCode string `json:"resultCode"`
}
// Upsert 持久化工作流状态(按 command_id 去重更新),并在终态时回写任务执行记录
func (s *WorkflowService) Upsert(dockID, requestID string, in *WorkflowStateIn) {
now := time.Now()
if requestID != "" {
var cmd model.DeviceCommandLog
if err := common.DB.Where("dock_id = ? AND request_id = ? AND status IN ?", dockID, requestID, []string{"sent", "acked"}).First(&cmd).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
logger.ERROR("按 requestId 查询工作流指令失败", err)
}
return
}
expected := strconv.FormatInt(cmd.ID, 10)
if in.CommandID != "" && in.CommandID != expected {
logger.WARN("工作流状态 commandId 与 requestId 不匹配", dockID, requestID)
return
}
in.CommandID = expected
}
if in.CommandID != "" {
var existing model.WorkflowState
err := common.DB.Where("dock_id = ? AND command_id = ?", dockID, in.CommandID).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
id, e := tool.NextID()
if e == nil {
common.DB.Create(&model.WorkflowState{
ID: id,
DockID: dockID,
CommandID: in.CommandID,
Type: in.Type,
TaskID: in.TaskID,
MissionID: in.MissionID,
State: in.State,
Step: in.Step,
ResultCode: in.ResultCode,
UpdatedAt: now,
})
}
} else if err == nil {
common.DB.Model(&existing).Updates(map[string]any{
"dock_id": dockID,
"type": in.Type,
"task_id": in.TaskID,
"mission_id": in.MissionID,
"state": in.State,
"step": in.Step,
"result_code": in.ResultCode,
"updated_at": now,
})
}
}
// 回写 task_execution,优先按 command_id 精确关联,兼容旧设备时只回退到最新未终态记录。
if (in.CommandID != "" || in.TaskID != "") && (in.State == "running" || isTerminalWorkflowState(in.State)) {
updates := map[string]any{"status": in.State}
if in.State == "running" {
updates["start_time"] = now
} else {
updates["end_time"] = now
updates["start_time"] = gorm.Expr("COALESCE(start_time, ?)", now)
}
query := common.DB.Model(&model.TaskExecution{}).
Where("dock_id = ? AND status NOT IN ('succeeded','failed','cancelled')", dockID)
if in.CommandID != "" {
query = query.Where("command_id = ?", in.CommandID)
} else {
query = query.Where("task_id = ?", in.TaskID)
var execution model.TaskExecution
if err := query.Order("created_at DESC, id DESC").First(&execution).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
logger.ERROR("查找任务执行记录失败", err)
}
query = nil
} else {
query = common.DB.Model(&execution)
}
}
if query != nil {
if err := query.Updates(updates).Error; err != nil {
logger.ERROR("回写任务执行状态失败", err)
}
}
}
// 终态回写轨迹(MySQL trajectory_json + TDengine task_trajectory)
if isTerminalWorkflowState(in.State) {
DefaultTrajectoryStore.Finalize(dockID, DefaultTelemetryStore.DroneSN(dockID))
}
}
func isTerminalWorkflowState(state string) bool {
switch state {
case "succeeded", "failed", "cancelled":
return true
default:
return false
}
}

360
sql/001_schema.sql

@ -0,0 +1,360 @@
-- =============================================================
-- 嘉谷低空智控平台 数据库初始化脚本
-- 引擎: InnoDB / 字符集: utf8mb4
-- 说明: 所有 BIGINT 主键均为 Snowflake 雪花 ID,无 AUTO_INCREMENT
-- =============================================================
CREATE DATABASE IF NOT EXISTS laic DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE laic;
-- ---------------------------------------------------------------
-- 5.1 用户
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `user` (
id BIGINT PRIMARY KEY,
name VARCHAR(32) NOT NULL,
phone VARCHAR(32) NOT NULL,
email VARCHAR(64),
password VARCHAR(64) NOT NULL,
role VARCHAR(16) DEFAULT 'user', -- admin / user
traffic_balance BIGINT DEFAULT 0, -- 云媒体剩余流量(字节),慢快照,权威在 Redis
status TINYINT DEFAULT 1, -- 1 正常 / 0 禁用
last_login DATETIME,
created_at DATETIME,
updated_at DATETIME,
UNIQUE KEY idx_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------
-- 5.2 机巢 & 无人机
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS dock (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
dock_id VARCHAR(64) NOT NULL, -- MQTT dockId (dock-xxx)
name VARCHAR(64) DEFAULT '',
code VARCHAR(32) DEFAULT '',
sn VARCHAR(64) DEFAULT '',
iccid VARCHAR(32) DEFAULT '', -- 冗余,权威见 sim_card.iccid
longitude DOUBLE DEFAULT 0,
latitude DOUBLE DEFAULT 0,
altitude DOUBLE DEFAULT 0,
location VARCHAR(128) DEFAULT '',
status VARCHAR(32) DEFAULT 'offline',
register_status VARCHAR(16) DEFAULT 'pending', -- pending/registered
dock_id_source VARCHAR(32) DEFAULT '',
software_ver VARCHAR(16) DEFAULT '',
protocol_ver VARCHAR(8) DEFAULT '1.0',
created_at DATETIME,
updated_at DATETIME,
UNIQUE KEY idx_dock_id (dock_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS drone (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
drone_sn VARCHAR(32) NOT NULL, -- 飞控 HW_SN_NUM
dock_id VARCHAR(64) NOT NULL, -- 绑定的机巢 dock_id(1:1)
name VARCHAR(64) DEFAULT '',
code VARCHAR(32) DEFAULT '',
model VARCHAR(32) DEFAULT '',
status VARCHAR(32) DEFAULT 'offline',
battery INT DEFAULT 0,
firmware_ver VARCHAR(16) DEFAULT '',
created_at DATETIME,
updated_at DATETIME,
UNIQUE KEY idx_drone_sn (drone_sn),
UNIQUE KEY idx_drone_dock (dock_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------
-- 5.3 告警
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS alarm_code (
id BIGINT PRIMARY KEY,
code VARCHAR(64) NOT NULL,
category VARCHAR(32) NOT NULL, -- dock / drone
message_cn VARCHAR(256) NOT NULL,
level VARCHAR(16) DEFAULT 'critical',
source VARCHAR(128) DEFAULT '',
created_at DATETIME,
updated_at DATETIME,
UNIQUE KEY idx_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS alarm (
id BIGINT PRIMARY KEY,
dock_id VARCHAR(64) NOT NULL,
code VARCHAR(64) NOT NULL,
device_type VARCHAR(8) NOT NULL, -- dock / drone
message_cn VARCHAR(256) NOT NULL,
level VARCHAR(16) NOT NULL,
status VARCHAR(16) DEFAULT 'active', -- active / acknowledged / resolved
triggered_at DATETIME,
resolved_at DATETIME,
created_at DATETIME,
KEY idx_alarm_dock (dock_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------
-- 5.4 指令 & 工作流
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS device_command_log (
id BIGINT PRIMARY KEY, -- = commandId (Snowflake)
dock_id VARCHAR(64) NOT NULL,
command_type VARCHAR(64) NOT NULL, -- dock.open / drone.takeoff / workflow.start_task
params JSON,
drone_sn VARCHAR(32),
request_id VARCHAR(64),
ttl_ms INT DEFAULT 30000,
ack_accepted TINYINT,
ack_result_code VARCHAR(64),
status VARCHAR(16) DEFAULT 'sent', -- sent / acked / timeout / terminal
retry_count INT DEFAULT 0,
sent_at DATETIME,
acked_at DATETIME,
created_at DATETIME,
KEY idx_cmd_log_dock (dock_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS workflow_state (
id BIGINT PRIMARY KEY,
dock_id VARCHAR(64) NOT NULL,
command_id VARCHAR(64),
type VARCHAR(64),
task_id VARCHAR(128),
mission_id VARCHAR(128),
state VARCHAR(16) NOT NULL, -- idle / running / succeeded / failed / cancelled
step VARCHAR(64) NOT NULL,
result_code VARCHAR(64),
updated_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------
-- 5.5 固件管理
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS firmware (
id BIGINT PRIMARY KEY,
component VARCHAR(32) NOT NULL, -- 固定 dock-edge-agent
version VARCHAR(32) NOT NULL, -- 1.3.0
description VARCHAR(512) DEFAULT '',
file_url VARCHAR(512) NOT NULL,
sha256 VARCHAR(128) NOT NULL,
signature TEXT NOT NULL,
file_size BIGINT DEFAULT 0,
mandatory TINYINT DEFAULT 0,
status VARCHAR(16) DEFAULT 'draft', -- draft/released/deprecated
created_by BIGINT NOT NULL,
created_at DATETIME,
updated_at DATETIME,
UNIQUE KEY idx_component_ver (component, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------
-- 5.6 任务 & 航线
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS task_plan (
id VARCHAR(128) PRIMARY KEY,
user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL,
dock_id VARCHAR(64) NOT NULL,
route_id BIGINT,
schedule_type VARCHAR(16) DEFAULT 'once', -- once / cron
schedule_cron VARCHAR(32),
video_policy VARCHAR(16) DEFAULT 'raw',
status VARCHAR(16) DEFAULT 'draft',
created_by BIGINT,
created_at DATETIME,
updated_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS task_execution (
id BIGINT PRIMARY KEY,
task_id VARCHAR(128),
command_id VARCHAR(64),
dock_id VARCHAR(64) NOT NULL,
drone_sn VARCHAR(32),
start_time DATETIME,
end_time DATETIME,
status VARCHAR(16) DEFAULT 'pending',
trajectory_json JSON,
created_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS route (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL,
description VARCHAR(256),
created_at DATETIME,
updated_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS route_waypoint (
id BIGINT PRIMARY KEY,
route_id BIGINT NOT NULL,
seq INT NOT NULL,
longitude DOUBLE,
latitude DOUBLE,
altitude DOUBLE,
speed DOUBLE,
yaw DOUBLE,
hold_sec INT DEFAULT 0,
created_at DATETIME,
UNIQUE KEY uk_route_seq (route_id, seq)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------
-- 5.7 媒体
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS live_session (
id VARCHAR(128) PRIMARY KEY, -- streamSessionId
dock_id VARCHAR(64) NOT NULL,
provider VARCHAR(16) DEFAULT 'aliyun',
stream_name VARCHAR(128) NOT NULL, -- 直播流名(每次会话唯一)
push_url_hash VARCHAR(128),
expires_at BIGINT NOT NULL,
max_bitrate_bps BIGINT DEFAULT 1500000,
phase VARCHAR(16) DEFAULT 'idle',
stop_reason VARCHAR(32),
error_code VARCHAR(64),
requested_by BIGINT,
stop_deadline DATETIME,
device_state_version BIGINT DEFAULT 0,
device_event_id VARCHAR(128),
device_updated_at BIGINT DEFAULT 0,
cloud_confirmed_at DATETIME,
started_at DATETIME,
stopped_at DATETIME,
created_at DATETIME,
updated_at DATETIME,
active_dock_id VARCHAR(64) GENERATED ALWAYS AS (
CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END
) STORED,
KEY idx_live_session_dock_phase (dock_id, phase, created_at),
KEY idx_live_session_phase_deadline (phase, stop_deadline),
UNIQUE KEY uk_live_session_device_event (device_event_id),
UNIQUE KEY uk_live_session_active_dock (active_dock_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS live_viewer_lease (
id BIGINT PRIMARY KEY,
stream_session_id VARCHAR(128) NOT NULL,
viewer_id BIGINT NOT NULL,
expires_at DATETIME NOT NULL,
released_at DATETIME NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id),
KEY idx_live_lease_expire (stream_session_id, expires_at),
KEY idx_live_lease_active (stream_session_id, released_at, expires_at),
CONSTRAINT fk_live_lease_session FOREIGN KEY (stream_session_id) REFERENCES live_session(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS video (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
drone_sn VARCHAR(32),
execution_id BIGINT,
file_name VARCHAR(256),
file_size BIGINT,
duration INT,
oss_key VARCHAR(256),
oss_bucket VARCHAR(128), -- 预签名上传的目标 bucket
thumbnail_key VARCHAR(256),
status VARCHAR(16) DEFAULT 'uploading',
upload_expire_at DATETIME, -- 预签名 PUT URL 过期时间
created_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS download_log (
id BIGINT PRIMARY KEY,
video_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
bytes BIGINT NOT NULL,
created_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------
-- 5.8 计费
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS traffic_order (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
amount_gb INT NOT NULL, -- 购买流量(GB)
unit_price DECIMAL(6,2),
total_price DECIMAL(10,2),
pay_status VARCHAR(16) DEFAULT 'unpaid', -- unpaid/paid/cancelled
created_at DATETIME,
paid_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS traffic_usage_log (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
source_type VARCHAR(16), -- live / replay / download
source_id BIGINT,
bytes_used BIGINT,
balance_before BIGINT, -- 扣减前余额(字节)
balance_after BIGINT, -- 扣减后余额(字节)
created_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sim_card (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
dock_id VARCHAR(64),
carrier VARCHAR(16), -- 移动/联通/电信
phone VARCHAR(16),
iccid VARCHAR(32), -- 权威字段(查询/充值的入参)
plan_gb INT,
used_gb DECIMAL(10,4) DEFAULT 0,
expired_at DATE,
status VARCHAR(16) DEFAULT 'active', -- 平台业务态: active/expired
carrier_status VARCHAR(16) DEFAULT 'normal', -- 运营商侧状态: normal/suspended/arrears/cancelled
last_sync_at DATETIME,
created_at DATETIME,
updated_at DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sim_recharge_log (
id BIGINT PRIMARY KEY,
sim_card_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
amount_gb INT NOT NULL,
unit_price DECIMAL(6,2),
total_price DECIMAL(10,2),
pay_status VARCHAR(16) DEFAULT 'paid',
recharge_status VARCHAR(16) DEFAULT 'pending', -- pending/success/failed
recharge_msg VARCHAR(256) DEFAULT '',
idempotent_key VARCHAR(64) NOT NULL, -- 平台充值单号,超时重试去重
created_at DATETIME,
UNIQUE KEY uk_recharge_idem (idempotent_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sim_usage_record (
id BIGINT PRIMARY KEY,
sim_card_id BIGINT NOT NULL,
plan_gb INT,
used_gb DECIMAL(10,4),
remain_gb DECIMAL(10,4),
carrier_status VARCHAR(16),
synced_at DATETIME,
KEY idx_record_sim (sim_card_id, synced_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ---------------------------------------------------------------
-- 7.7 操作日志
-- ---------------------------------------------------------------
CREATE TABLE IF NOT EXISTS operation_log (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
user_name VARCHAR(32),
module VARCHAR(32), -- 模块(用户管理/机巢管理/任务管理...)
action VARCHAR(64), -- 动作(新增/修改/删除/下发指令...)
detail VARCHAR(256), -- 详情(请求路径)
result VARCHAR(16), -- success / failed
ip VARCHAR(64),
created_at DATETIME,
KEY idx_op_user (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

144
sql/002_alarm_codes.sql

@ -0,0 +1,144 @@
-- =============================================================
-- 告警码字典初始化数据
-- 说明: 完整枚举对齐 MQTT 协议文档 7.6 节(共 106 条)。
-- id 为固定字典 ID,非雪花 ID。按 code 唯一键 INSERT IGNORE,可重复执行。
-- category: dock(PLC/机巢/执行机构/电池/充电/空调/链路) / drone(飞控/MAVLink/定位)
-- =============================================================
USE laic;
-- ---------------------------------------------------------------
-- 7.6.2 PLC / 机巢 / 执行机构故障(dock)
-- ---------------------------------------------------------------
INSERT IGNORE INTO alarm_code (id, code, category, message_cn, level, source, created_at, updated_at) VALUES
(1001, 'DOCK_EMERGENCY_STOP', 'dock', '机巢急停已触发', 'emergency', 'dock', NOW(), NOW()),
(1002, 'DOCK_PLC_ALARM_UNKNOWN', 'dock', 'PLC 总报警已触发,但未解析到具体故障', 'critical', 'dock', NOW(), NOW()),
(1003, 'CHARGING_ROD_MOTOR_FAULT', 'dock', '充电杆电机报警', 'critical', 'dock', NOW(), NOW()),
(1004, 'CENTERING_ROD_MOTOR_FAULT', 'dock', '归中杆电机报警', 'critical', 'dock', NOW(), NOW()),
(1005, 'DRONE_POWER_OFF_TIMEOUT', 'dock', '无人机关机超时', 'critical', 'dock', NOW(), NOW()),
(1006, 'DRONE_POWER_ON_TIMEOUT', 'dock', '无人机开机超时', 'critical', 'dock', NOW(), NOW()),
(1007, 'DRONE_POSITION_DETECT_TIMEOUT', 'dock', '无人机在位检测超时', 'critical', 'dock', NOW(), NOW()),
(1008, 'LEFT_DOOR_POWER_FAULT', 'dock', '左顶门动力故障', 'critical', 'dock', NOW(), NOW()),
(1009, 'LEFT_DOOR_CLOSE_TIMEOUT', 'dock', '左顶门关门超时或关门传感器未感应', 'critical', 'dock', NOW(), NOW()),
(1010, 'LEFT_DOOR_OPEN_TIMEOUT', 'dock', '左顶门开门超时或开门传感器未感应', 'critical', 'dock', NOW(), NOW()),
(1011, 'LEFT_DOOR_RESET_TIMEOUT', 'dock', '左顶门超过时间复位未完成', 'critical', 'dock', NOW(), NOW()),
(1012, 'LEFT_DOOR_NOT_RESET_ACTION_BLOCKED', 'dock', '左顶门电机未复位完成,动作已阻止', 'critical', 'dock', NOW(), NOW()),
(1013, 'RIGHT_DOOR_POWER_FAULT', 'dock', '右顶门动力故障', 'critical', 'dock', NOW(), NOW()),
(1014, 'RIGHT_DOOR_CLOSE_TIMEOUT', 'dock', '右顶门关门超时或关门传感器未感应', 'critical', 'dock', NOW(), NOW()),
(1015, 'RIGHT_DOOR_OPEN_TIMEOUT', 'dock', '右顶门开门超时或开门传感器未感应', 'critical', 'dock', NOW(), NOW()),
(1016, 'RIGHT_DOOR_RESET_TIMEOUT', 'dock', '右顶门超过时间复位未完成', 'critical', 'dock', NOW(), NOW()),
(1017, 'RIGHT_DOOR_NOT_RESET_ACTION_BLOCKED', 'dock', '右顶门电机未复位完成,动作已阻止', 'critical', 'dock', NOW(), NOW()),
(1018, 'X_CENTER_POWER_FAULT', 'dock', '左右归中动力故障', 'critical', 'dock', NOW(), NOW()),
(1019, 'X_CENTER_OPEN_TIMEOUT', 'dock', '左右归中打开超时或传感器未感应', 'critical', 'dock', NOW(), NOW()),
(1020, 'X_CENTER_CLAMP_TIMEOUT', 'dock', '左右归中夹紧超时或传感器未感应', 'critical', 'dock', NOW(), NOW()),
(1021, 'X_CENTER_RESET_TIMEOUT', 'dock', '左右归中超过时间复位未完成', 'critical', 'dock', NOW(), NOW()),
(1022, 'X_CENTER_NOT_RESET_ACTION_BLOCKED', 'dock', '左右归中电机未复位完成,动作已阻止', 'critical', 'dock', NOW(), NOW()),
(1023, 'Y_CENTER_POWER_FAULT', 'dock', '前后归中动力故障', 'critical', 'dock', NOW(), NOW()),
(1024, 'Y_CENTER_OPEN_TIMEOUT', 'dock', '前后归中打开超时或传感器未感应', 'critical', 'dock', NOW(), NOW()),
(1025, 'Y_CENTER_CLAMP_TIMEOUT', 'dock', '前后归中夹紧超时或传感器未感应', 'critical', 'dock', NOW(), NOW()),
(1026, 'Y_CENTER_RESET_TIMEOUT', 'dock', '前后归中超过时间复位未完成', 'critical', 'dock', NOW(), NOW()),
(1027, 'Y_CENTER_NOT_RESET_ACTION_BLOCKED', 'dock', '前后归中电机未复位完成,动作已阻止', 'critical', 'dock', NOW(), NOW()),
-- ---------------------------------------------------------------
-- 7.6.3 电池保护和电池报警(dock)
-- ---------------------------------------------------------------
(1028, 'BATTERY_NOT_PRESENT', 'dock', '电池未在位,请检查系留接口连接状态', 'warning', 'dock', NOW(), NOW()),
(1029, 'BATTERY_CELL_OVER_VOLTAGE', 'dock', '电池单体过压保护', 'critical', 'dock', NOW(), NOW()),
(1030, 'BATTERY_CELL_UNDER_VOLTAGE', 'dock', '电池单体欠压保护', 'critical', 'dock', NOW(), NOW()),
(1031, 'BATTERY_PACK_OVER_VOLTAGE', 'dock', '电池整组过压保护', 'critical', 'dock', NOW(), NOW()),
(1032, 'BATTERY_PACK_UNDER_VOLTAGE', 'dock', '电池整组欠压保护', 'critical', 'dock', NOW(), NOW()),
(1033, 'BATTERY_CHARGE_OVER_TEMPERATURE', 'dock', '电池充电过温保护', 'critical', 'dock', NOW(), NOW()),
(1034, 'BATTERY_CHARGE_UNDER_TEMPERATURE', 'dock', '电池充电低温保护', 'critical', 'dock', NOW(), NOW()),
(1035, 'BATTERY_DISCHARGE_OVER_TEMPERATURE', 'dock', '电池放电过温保护', 'critical', 'dock', NOW(), NOW()),
(1036, 'BATTERY_DISCHARGE_UNDER_TEMPERATURE', 'dock', '电池放电低温保护', 'critical', 'dock', NOW(), NOW()),
(1037, 'BATTERY_CHARGE_OVER_CURRENT', 'dock', '电池充电过流保护', 'critical', 'dock', NOW(), NOW()),
(1038, 'BATTERY_DISCHARGE_OVER_CURRENT', 'dock', '电池放电过流保护', 'critical', 'dock', NOW(), NOW()),
(1039, 'BATTERY_SHORT_CIRCUIT', 'dock', '电池短路保护', 'critical', 'dock', NOW(), NOW()),
(1040, 'BATTERY_FRONT_IC_FAULT', 'dock', '电池前端检测 IC 错误', 'critical', 'dock', NOW(), NOW()),
(1041, 'BATTERY_MOS_LOCKED', 'dock', '电池保护板软件锁定 MOS', 'critical', 'dock', NOW(), NOW()),
(1042, 'BATTERY_WIRE_BREAK', 'dock', '电池断线告警', 'critical', 'dock', NOW(), NOW()),
(1043, 'BATTERY_SECONDARY_OVER_VOLTAGE', 'dock', '电池二次过压告警', 'critical', 'dock', NOW(), NOW()),
(1044, 'BATTERY_SELF_LOCK_SWITCH_OPEN', 'dock', '电池自锁开关断开', 'warning', 'dock', NOW(), NOW()),
(1045, 'BATTERY_ALARM_UNKNOWN', 'dock', '电池模块报警,具体原因未知', 'critical', 'dock', NOW(), NOW()),
-- ---------------------------------------------------------------
-- 7.6.4 充电电源故障(dock)
-- ---------------------------------------------------------------
(1046, 'CHARGER_OFFLINE', 'dock', '充电电源未在线,请检查电源和控制盒连接状态', 'critical', 'dock', NOW(), NOW()),
(1047, 'POWER_INPUT_UNDER_VOLTAGE', 'dock', '充电电源交流输入欠压', 'critical', 'dock', NOW(), NOW()),
(1048, 'POWER_INPUT_OVER_VOLTAGE', 'dock', '充电电源交流输入过压', 'critical', 'dock', NOW(), NOW()),
(1049, 'POWER_OUTPUT_OVER_CURRENT', 'dock', '充电电源直流输出过流', 'critical', 'dock', NOW(), NOW()),
(1050, 'POWER_VOLTAGE_SAMPLING_FAULT', 'dock', '充电电源电压采样异常', 'critical', 'dock', NOW(), NOW()),
(1051, 'POWER_OVER_TEMPERATURE_OR_FAN_FAULT', 'dock', '充电电源超高温或风扇故障', 'critical', 'dock', NOW(), NOW()),
(1052, 'POWER_COMMUNICATION_TIMEOUT', 'dock', '充电电源主控通信超时', 'critical', 'dock', NOW(), NOW()),
(1053, 'CHARGER_FAULT', 'dock', '充电器故障', 'critical', 'dock', NOW(), NOW()),
(1054, 'POWER_LOAD_DISCONNECTED_SHUTDOWN', 'dock', '充电电源负载断开并关机', 'critical', 'dock', NOW(), NOW()),
(1055, 'POWER_OVER_VOLTAGE_SHUTDOWN', 'dock', '充电电源过压关机', 'critical', 'dock', NOW(), NOW()),
(1056, 'BATTERY_REVERSED_OR_OTHER_POWER_FAULT', 'dock', '电池接反或充电电源其他故障', 'critical', 'dock', NOW(), NOW()),
(1057, 'POWER_NTC_FAULT', 'dock', '充电电源 NTC 故障并停机', 'critical', 'dock', NOW(), NOW()),
(1058, 'POWER_SHORT_CIRCUIT', 'dock', '充电电源输出短路', 'critical', 'dock', NOW(), NOW()),
(1059, 'CHARGING_TIMEOUT', 'dock', '充电超时', 'critical', 'dock', NOW(), NOW()),
(1060, 'POWER_ALARM_UNKNOWN', 'dock', '充电电源未知报警', 'critical', 'dock', NOW(), NOW()),
-- ---------------------------------------------------------------
-- 7.6.5 空调故障(dock)
-- ---------------------------------------------------------------
(1061, 'AC_INSIDE_TEMPERATURE_SENSOR_FAULT', 'dock', '空调柜内温度传感器故障', 'warning', 'dock', NOW(), NOW()),
(1062, 'AC_INSIDE_HIGH_TEMPERATURE', 'dock', '空调柜内高温告警', 'warning', 'dock', NOW(), NOW()),
(1063, 'AC_INSIDE_LOW_TEMPERATURE', 'dock', '空调柜内低温告警', 'warning', 'dock', NOW(), NOW()),
(1064, 'AC_OUTSIDE_TEMPERATURE_SENSOR_FAULT', 'dock', '空调柜外温度传感器故障', 'warning', 'dock', NOW(), NOW()),
(1065, 'AC_OUTSIDE_HIGH_TEMPERATURE', 'dock', '空调柜外高温告警', 'warning', 'dock', NOW(), NOW()),
(1066, 'AC_OUTSIDE_LOW_TEMPERATURE', 'dock', '空调柜外低温告警', 'warning', 'dock', NOW(), NOW()),
(1067, 'AC_INSIDE_HUMIDITY_SENSOR_FAULT', 'dock', '空调柜内湿度传感器故障', 'warning', 'dock', NOW(), NOW()),
(1068, 'AC_INSIDE_HIGH_HUMIDITY', 'dock', '空调柜内高湿告警', 'warning', 'dock', NOW(), NOW()),
(1069, 'AC_INSIDE_LOW_HUMIDITY', 'dock', '空调柜内低湿告警', 'warning', 'dock', NOW(), NOW()),
(1070, 'AC_OUTSIDE_HUMIDITY_SENSOR_FAULT', 'dock', '空调柜外湿度传感器故障', 'warning', 'dock', NOW(), NOW()),
(1071, 'AC_OUTSIDE_HIGH_HUMIDITY', 'dock', '空调柜外高湿告警', 'warning', 'dock', NOW(), NOW()),
(1072, 'AC_OUTSIDE_LOW_HUMIDITY', 'dock', '空调柜外低湿告警', 'warning', 'dock', NOW(), NOW()),
(1073, 'AC_COMPRESSOR_HIGH_PRESSURE', 'dock', '空调压缩机高压告警', 'critical', 'dock', NOW(), NOW()),
(1074, 'AC_COMPRESSOR_LOW_PRESSURE', 'dock', '空调压缩机低压告警', 'critical', 'dock', NOW(), NOW()),
(1075, 'AC_COOLING_FAILURE', 'dock', '空调制冷失效告警', 'critical', 'dock', NOW(), NOW()),
(1076, 'AC_HEATING_FAILURE', 'dock', '空调制热失效告警', 'critical', 'dock', NOW(), NOW()),
(1077, 'AC_INSIDE_COIL_TEMPERATURE_SENSOR_FAULT','dock', '空调内盘管温度传感器故障', 'warning', 'dock', NOW(), NOW()),
(1078, 'AC_INSIDE_COIL_LOW_TEMPERATURE', 'dock', '空调内盘管低温告警', 'warning', 'dock', NOW(), NOW()),
-- ---------------------------------------------------------------
-- 7.6.6 通信和边缘服务故障(dock 部分)
-- ---------------------------------------------------------------
(1079, 'DOCK_MODBUS_DISCONNECTED', 'dock', '机巢 PLC Modbus 通信中断', 'critical', 'dock', NOW(), NOW()),
(1080, 'PLC_HEARTBEAT_TIMEOUT', 'dock', '机巢 PLC 心跳超时', 'critical', 'dock', NOW(), NOW()),
(1081, 'ONE_KEY_TAKEOFF_TIMEOUT', 'dock', '一键起飞超时', 'critical', 'dock', NOW(), NOW()),
(1082, 'ONE_KEY_LANDING_TIMEOUT', 'dock', '一键降落超时', 'critical', 'dock', NOW(), NOW());
-- ---------------------------------------------------------------
-- 7.6.6 通信和边缘服务故障(drone 部分)
-- ---------------------------------------------------------------
INSERT IGNORE INTO alarm_code (id, code, category, message_cn, level, source, created_at, updated_at) VALUES
(2001, 'DRONE_MAVLINK_DISCONNECTED', 'drone', '无人机 MAVLink 通信中断', 'critical', 'drone', NOW(), NOW()),
(2002, 'DRONE_SN_UNAVAILABLE', 'drone', '无法读取无人机永久序列号', 'critical', 'drone', NOW(), NOW()),
(2003, 'MISSION_STORE_WRITE_FAILED', 'drone', '当前航线无法持久化', 'warning', 'drone', NOW(), NOW()),
(2004, 'DRONE_BATTERY_CRITICAL', 'drone', '无人机电池电量严重不足', 'critical', 'drone', NOW(), NOW()),
(2005, 'DRONE_GPS_INVALID', 'drone', '无人机定位不可用或质量不足', 'warning', 'drone', NOW(), NOW()),
-- ---------------------------------------------------------------
-- 7.6.7 无人机飞控告警(drone)
-- ---------------------------------------------------------------
(2006, 'DRONE_GCS_LINK_LOST', 'drone', '飞控与地面站通信链路中断', 'critical', 'drone', NOW(), NOW()),
(2007, 'DRONE_BATTERY_VOLTAGE_LOW', 'drone', '无人机电池电压低', 'critical', 'drone', NOW(), NOW()),
(2008, 'DRONE_SERVO_OUTPUT_IMBALANCE', 'drone', '电机或舵机输出不平衡', 'critical', 'drone', NOW(), NOW()),
(2009, 'DRONE_SERVO_OUTPUT_FAULT', 'drone', '电机或舵机动力输出故障', 'critical', 'drone', NOW(), NOW()),
(2010, 'DRONE_AUTOPILOT_OVER_TEMPERATURE', 'drone', '飞控系统温度过高', 'critical', 'drone', NOW(), NOW()),
(2011, 'DRONE_INS_POSITIONING_INVALID', 'drone', '飞控 INS 定位解算未就绪或不可用', 'critical', 'drone', NOW(), NOW()),
(2012, 'DRONE_OUTSIDE_GEOFENCE', 'drone', '无人机超出电子围栏范围', 'critical', 'drone', NOW(), NOW()),
(2013, 'DRONE_IMU_DATA_OUT_OF_RANGE', 'drone', 'IMU 数据超出范围', 'critical', 'drone', NOW(), NOW()),
(2014, 'DRONE_EXCESSIVE_TILT', 'drone', '无人机倾斜姿态过大', 'critical', 'drone', NOW(), NOW()),
(2015, 'DRONE_SPEED_OUT_OF_RANGE', 'drone', '无人机速度超出范围', 'critical', 'drone', NOW(), NOW()),
(2016, 'DRONE_RC_NOT_READY', 'drone', '遥控器数据未就绪', 'warning', 'drone', NOW(), NOW()),
(2017, 'DRONE_MAG0_INTERFERENCE', 'drone', '1 号磁力计受到磁干扰', 'warning', 'drone', NOW(), NOW()),
(2018, 'DRONE_MAG1_INTERFERENCE', 'drone', '2 号磁力计受到磁干扰', 'warning', 'drone', NOW(), NOW()),
(2019, 'DRONE_IMU0_FAULT', 'drone', '1 号 IMU 数据异常', 'critical', 'drone', NOW(), NOW()),
(2020, 'DRONE_IMU1_FAULT', 'drone', '2 号 IMU 数据异常', 'critical', 'drone', NOW(), NOW()),
(2021, 'DRONE_BAROMETER_FAULT', 'drone', '气压计数据异常', 'critical', 'drone', NOW(), NOW()),
(2022, 'DRONE_GPS0_FAULT', 'drone', '1 号普通 GPS 数据异常', 'warning', 'drone', NOW(), NOW()),
(2023, 'DRONE_GPS1_FAULT', 'drone', '2 号普通 GPS 数据异常', 'warning', 'drone', NOW(), NOW()),
(2024, 'DRONE_RTK_FAULT', 'drone', 'RTK 板卡或数据异常', 'warning', 'drone', NOW(), NOW());

73
sql/003_seed.sql

@ -0,0 +1,73 @@
-- =============================================================
-- 种子数据
-- =============================================================
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, status, 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 区', 'offline', '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 区', 'offline', '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 区', 'offline', '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, status, battery, firmware_ver, created_at, updated_at)
VALUES
(2001, @demo_user_id, 'JG-UAV-001', 'dock-1', '巡检无人机 001', 'JG-UAV-001', 'LAIC-X1', 'offline', 86, '1.0.0', NOW(), NOW()),
(2002, @demo_user_id, 'JG-UAV-002', 'dock-2', '巡检无人机 002', 'JG-UAV-002', 'LAIC-X1', 'offline', 72, '1.0.0', NOW(), NOW()),
(2003, @demo_user_id, 'JG-UAV-003', 'dock-3', '巡检无人机 003', 'JG-UAV-003', 'LAIC-X1', 'offline', 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');
-- ---------------------------------------------------------------
-- 说明: Casbin RBAC 策略(g: user_id -> role / p: role -> path -> method)
-- 由应用首次启动时通过 gorm-adapter 自动建表并引导,不在本脚本中静态写入。
-- ---------------------------------------------------------------

27
sql/004_live_control.sql

@ -0,0 +1,27 @@
-- 该迁移用于已有数据库;新数据库请直接使用 001_schema.sql 中的最终结构。
-- 执行前应确认 live_session 没有重复活动会话,再创建活动机巢唯一约束。
ALTER TABLE live_session
ADD COLUMN requested_by BIGINT NULL,
ADD COLUMN stop_deadline DATETIME NULL,
ADD COLUMN updated_at DATETIME NULL,
ADD COLUMN active_dock_id VARCHAR(64) GENERATED ALWAYS AS (
CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END
) STORED,
ADD KEY idx_live_session_dock_phase (dock_id, phase, created_at),
ADD KEY idx_live_session_phase_deadline (phase, stop_deadline),
ADD UNIQUE KEY uk_live_session_active_dock (active_dock_id);
CREATE TABLE IF NOT EXISTS live_viewer_lease (
id BIGINT PRIMARY KEY,
stream_session_id VARCHAR(128) NOT NULL,
viewer_id BIGINT NOT NULL,
expires_at DATETIME NOT NULL,
released_at DATETIME NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id),
KEY idx_live_lease_expire (stream_session_id, expires_at),
KEY idx_live_lease_active (stream_session_id, released_at, expires_at),
CONSTRAINT fk_live_lease_session FOREIGN KEY (stream_session_id) REFERENCES live_session(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

115
sql/005_live_control_upgrade.sql

@ -0,0 +1,115 @@
-- 已部署数据库的可重复执行升级脚本。
-- 执行前应先收敛同一 dock 的重复活动会话,否则活动会话唯一索引会拒绝创建。
DELIMITER $$
DROP PROCEDURE IF EXISTS upgrade_live_control $$
CREATE PROCEDURE upgrade_live_control()
BEGIN
DECLARE item_count INT DEFAULT 0;
SELECT COUNT(*) INTO item_count
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'requested_by';
IF item_count = 0 THEN
ALTER TABLE live_session ADD COLUMN requested_by BIGINT NULL;
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'stop_deadline';
IF item_count = 0 THEN
ALTER TABLE live_session ADD COLUMN stop_deadline DATETIME NULL;
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'updated_at';
IF item_count = 0 THEN
ALTER TABLE live_session ADD COLUMN updated_at DATETIME NULL;
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_state_version';
IF item_count = 0 THEN
ALTER TABLE live_session ADD COLUMN device_state_version BIGINT DEFAULT 0;
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_event_id';
IF item_count = 0 THEN
ALTER TABLE live_session ADD COLUMN device_event_id VARCHAR(128);
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_updated_at';
IF item_count = 0 THEN
ALTER TABLE live_session ADD COLUMN device_updated_at BIGINT DEFAULT 0;
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'cloud_confirmed_at';
IF item_count = 0 THEN
ALTER TABLE live_session ADD COLUMN cloud_confirmed_at DATETIME NULL;
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND index_name = 'uk_live_session_device_event';
IF item_count = 0 THEN
CREATE UNIQUE INDEX uk_live_session_device_event ON live_session (device_event_id);
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'active_dock_id';
IF item_count = 0 THEN
ALTER TABLE live_session ADD COLUMN active_dock_id VARCHAR(64) GENERATED ALWAYS AS (
CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END
) STORED;
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND index_name = 'idx_live_session_dock_phase';
IF item_count = 0 THEN
CREATE INDEX idx_live_session_dock_phase ON live_session (dock_id, phase, created_at);
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND index_name = 'idx_live_session_phase_deadline';
IF item_count = 0 THEN
CREATE INDEX idx_live_session_phase_deadline ON live_session (phase, stop_deadline);
END IF;
SELECT COUNT(*) INTO item_count
FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'live_session' AND index_name = 'uk_live_session_active_dock';
IF item_count = 0 THEN
CREATE UNIQUE INDEX uk_live_session_active_dock ON live_session (active_dock_id);
END IF;
CREATE TABLE IF NOT EXISTS live_viewer_lease (
id BIGINT PRIMARY KEY,
stream_session_id VARCHAR(128) NOT NULL,
viewer_id BIGINT NOT NULL,
expires_at DATETIME NOT NULL,
released_at DATETIME NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id),
KEY idx_live_lease_expire (stream_session_id, expires_at),
KEY idx_live_lease_active (stream_session_id, released_at, expires_at),
CONSTRAINT fk_live_lease_session FOREIGN KEY (stream_session_id) REFERENCES live_session(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
END $$
CALL upgrade_live_control() $$
DROP PROCEDURE upgrade_live_control $$
DELIMITER ;

41
sql/tdengine_schema.sql

@ -0,0 +1,41 @@
-- TDengine 时序库 schema(与 MySQL DDL 分离,需用 taos / REST 手动执行)
-- 对应 DESIGN.md §5.9
CREATE DATABASE IF NOT EXISTS laic VGROUPS 2 DURATION 10d KEEP 3650d;
-- 遥测超级表(device_telemetry):列顺序必须与 service/telemetry_store.go insert() 的 VALUES 元组一致
CREATE STABLE IF NOT EXISTS laic.device_telemetry (
ts TIMESTAMP,
longitude DOUBLE,
latitude DOUBLE,
altitude DOUBLE,
ground_speed DOUBLE,
roll DOUBLE,
pitch DOUBLE,
yaw DOUBLE,
battery_pct INT,
battery_v DOUBLE,
satellites INT,
gps_quality VARCHAR(16),
link_quality INT,
flight_mode VARCHAR(16),
armed TINYINT
) TAGS (
dock_id VARCHAR(64),
drone_sn VARCHAR(32)
);
-- 轨迹超级表(任务回放用)
CREATE STABLE IF NOT EXISTS laic.task_trajectory (
ts TIMESTAMP,
longitude DOUBLE,
latitude DOUBLE,
altitude DOUBLE,
ground_speed DOUBLE,
yaw DOUBLE,
battery_pct INT
) TAGS (
execution_id BIGINT,
dock_id VARCHAR(64),
drone_sn VARCHAR(32)
);

121
token/token.go

@ -0,0 +1,121 @@
package token
import (
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var (
jwtSecret []byte
accessExpireH = 7 * 24 // 默认 7 天
refreshExpireH = 30 * 24 // 默认 30 天
)
// Init 初始化 JWT 密钥与有效期(main 启动时从配置加载)
func Init(secret string, accessH, refreshH int) {
jwtSecret = []byte(secret)
if accessH > 0 {
accessExpireH = accessH
}
if refreshH > 0 {
refreshExpireH = refreshH
}
}
// Claims access token 载荷
type Claims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
// RefreshClaims refresh token 载荷
type RefreshClaims struct {
UserID int64 `json:"user_id"`
jwt.RegisteredClaims
}
// GenerateToken 生成 access + refresh token
func GenerateToken(userID int64, username string, roles []string) (string, string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Username: username,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(accessExpireH) * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
access, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
if err != nil {
return "", "", err
}
refreshClaims := RefreshClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(refreshExpireH) * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
refresh, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(jwtSecret)
if err != nil {
return "", "", err
}
return access, refresh, nil
}
// ParseToken 解析并校验 access token
func ParseToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, keyFunc)
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
// ParseRefreshToken 解析并校验 refresh token,返回用户 ID
func ParseRefreshToken(tokenString string) (int64, error) {
token, err := jwt.ParseWithClaims(tokenString, &RefreshClaims{}, keyFunc)
if err != nil {
return 0, err
}
if claims, ok := token.Claims.(*RefreshClaims); ok && token.Valid {
return claims.UserID, nil
}
return 0, errors.New("invalid refresh token")
}
func keyFunc(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return jwtSecret, nil
}
// GenerateShortID 生成 token 的短摘要,作为 Redis 存储 key 的一部分
func GenerateShortID(tokenString string) string {
hash := sha256.Sum256([]byte(tokenString))
return hex.EncodeToString(hash[:8])
}
// AccessExpireSeconds 返回 access token 有效期(秒),用于 Redis TTL
func AccessExpireSeconds() uint {
return uint(accessExpireH * 3600)
}
// RefreshExpireSeconds 返回 refresh token 有效期(秒),用于 Redis TTL
func RefreshExpireSeconds() uint {
return uint(refreshExpireH * 3600)
}

18
tool/geo.go

@ -0,0 +1,18 @@
package tool
import "math"
const earthRadiusM = 6371000.0
// Haversine 计算两个经纬度坐标之间的球面距离(单位:米)
func Haversine(lat1, lon1, lat2, lon2 float64) float64 {
phi1 := lat1 * math.Pi / 180
phi2 := lat2 * math.Pi / 180
dPhi := (lat2 - lat1) * math.Pi / 180
dLambda := (lon2 - lon1) * math.Pi / 180
a := math.Sin(dPhi/2)*math.Sin(dPhi/2) +
math.Cos(phi1)*math.Cos(phi2)*math.Sin(dLambda/2)*math.Sin(dLambda/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadiusM * c
}

117
tool/snowflake.go

@ -0,0 +1,117 @@
package tool
import (
"errors"
"sync"
"time"
)
const (
// 起始时间戳 (2023-01-01 00:00:00 UTC)
epoch int64 = 1672531200000
timestampBits = 28 // 时间戳位数(约17年)
workerIDBits = 5 // 工作机器ID所占位数
sequenceBits = 12 // 序列号所占位数
maxWorkerID = -1 ^ (-1 << workerIDBits)
maxSequence = -1 ^ (-1 << sequenceBits)
workerIDShift = sequenceBits
timestampShift = sequenceBits + workerIDBits
)
type Snowflake struct {
mu sync.Mutex
timestamp int64
workerID int64
sequence int64
lastTime int64
}
var DefaultSnowflake *Snowflake
func init() {
DefaultSnowflake = NewSnowflake(1)
}
func NewSnowflake(workerID int64) *Snowflake {
if workerID < 0 || workerID > maxWorkerID {
panic(errors.New("worker ID must be between 0 and 1023"))
}
return &Snowflake{
timestamp: 0,
workerID: workerID,
sequence: 0,
lastTime: -1,
}
}
// NextID 生成下一个ID
func (s *Snowflake) NextID() (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UnixMilli()
now = (now - epoch) & (-1 ^ (-1 << timestampBits))
if now <= 0 {
now = 1
}
if now < s.lastTime {
waitTime := s.lastTime - now
if waitTime > 100 {
now = s.lastTime + 1
if now > (1<<timestampBits)-1 {
return -1, errors.New("timestamp overflow due to clock moved backwards")
}
} else {
time.Sleep(time.Duration(waitTime) * time.Millisecond)
now = (time.Now().UnixMilli() - epoch) & (-1 ^ (-1 << timestampBits))
if now <= 0 {
now = 1
}
if now < s.lastTime {
now = s.lastTime + 1
if now > (1<<timestampBits)-1 {
return -1, errors.New("timestamp overflow due to clock moved backwards")
}
}
}
}
if s.lastTime == now {
s.sequence = (s.sequence + 1) & maxSequence
if s.sequence == 0 {
for now <= s.lastTime {
now = (time.Now().UnixMilli() - epoch) & (-1 ^ (-1 << timestampBits))
if now <= 0 {
now = s.lastTime + 1
break
}
}
}
} else {
s.sequence = 0
}
s.lastTime = now
id := (now << timestampShift) |
(s.workerID << workerIDShift) |
s.sequence
if id == 0 {
s.sequence = 1
id = (now << timestampShift) |
(s.workerID << workerIDShift) |
s.sequence
}
return id, nil
}
// NextID 全局函数,使用默认实例生成ID
func NextID() (int64, error) {
return DefaultSnowflake.NextID()
}

35
tool/str.go

@ -0,0 +1,35 @@
package tool
import (
"crypto/rand"
"math/big"
)
const (
letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
digits = "0123456789"
)
// RandString 生成指定长度的随机字母数字串
func RandString(n int) string {
return randFrom(letters, n)
}
// RandDigit 生成指定长度的随机数字串(短信验证码等)
func RandDigit(n int) string {
return randFrom(digits, n)
}
func randFrom(charset string, n int) string {
b := make([]byte, n)
max := big.NewInt(int64(len(charset)))
for i := range b {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
b[i] = charset[0]
continue
}
b[i] = charset[idx.Int64()]
}
return string(b)
}

28
tool/time.go

@ -0,0 +1,28 @@
package tool
import "time"
const (
TimeLayout = "2006-01-02 15:04:05"
DateLayout = "2006-01-02"
TimeLayoutMs = "2006-01-02 15:04:05.999"
)
func Now() time.Time {
return time.Now()
}
// FormatTime 格式化时间为标准字符串
func FormatTime(t time.Time) string {
return t.Format(TimeLayout)
}
// FormatTimeMs 格式化时间为毫秒精度字符串
func FormatTimeMs(t time.Time) string {
return t.Format(TimeLayoutMs)
}
// ParseTime 解析标准时间字符串
func ParseTime(s string) (time.Time, error) {
return time.ParseInLocation(TimeLayout, s, time.Local)
}

50
vo/account_vo.go

@ -0,0 +1,50 @@
package vo
import "laic-backend/common"
// ProfileUpdateReq 更新个人资料
type ProfileUpdateReq struct {
Name string `json:"name" validate:"max=32"`
Email string `json:"email" validate:"omitempty,email"`
}
// TrafficBalanceVO 云媒体流量余额(Redis 权威,字节为基准)
type TrafficBalanceVO struct {
BalanceBytes int64 `json:"balanceBytes"`
BalanceGb float64 `json:"balanceGb"`
}
// UsagePageReq 流量消费流水分页
type UsagePageReq struct {
common.Pagination
SourceType string `json:"sourceType" form:"sourceType"` // live / replay / download
}
// OrderPageReq 流量订单分页
type OrderPageReq struct {
common.Pagination
PayStatus string `json:"payStatus" form:"payStatus"` // unpaid / paid
}
// TrafficOrderCreateReq 创建流量充值订单
type TrafficOrderCreateReq struct {
AmountGb int `json:"amountGb" validate:"required,min=1"`
}
// SimCardPageReq SIM 卡分页
type SimCardPageReq struct {
common.Pagination
Status string `json:"status" form:"status"` // active / expired
}
// SimRechargeLogPageReq SIM 卡充值记录分页
type SimRechargeLogPageReq struct {
common.Pagination
SimCardID int64 `json:"simCardId" form:"simCardId"`
RechargeStatus string `json:"rechargeStatus" form:"rechargeStatus"`
}
// SimRechargeReq SIM 卡充值
type SimRechargeReq struct {
AmountGb int `json:"amountGb" validate:"required,min=1"`
}

13
vo/alarm_vo.go

@ -0,0 +1,13 @@
package vo
import "laic-backend/common"
// AlarmPageReq 告警分页查询
type AlarmPageReq struct {
common.Pagination
Status string `json:"status" form:"status"` // active / acknowledged / resolved
Level string `json:"level" form:"level"`
DeviceType string `json:"deviceType" form:"deviceType"` // dock / drone
DockID string `json:"dockId" form:"dockId"`
Keyword string `json:"keyword" form:"keyword"` // 匹配 code / message_cn
}

81
vo/auth_vo.go

@ -0,0 +1,81 @@
package vo
import (
"time"
"laic-backend/model"
)
// UserLoginReq 登录请求
type UserLoginReq struct {
Phone string `json:"phone" validate:"required,phone"`
Password string `json:"password" validate:"required"`
}
// RegisterReq 注册请求
type RegisterReq struct {
Name string `json:"name" validate:"required,max=32"`
Phone string `json:"phone" validate:"required,phone"`
Email string `json:"email" validate:"omitempty,email"`
Password string `json:"password" validate:"required,password"`
}
// SmsCodeReq 发送短信验证码请求
type SmsCodeReq struct {
Phone string `json:"phone" validate:"required,phone"`
}
// ResetPasswordReq 重置密码请求
type ResetPasswordReq struct {
Phone string `json:"phone" validate:"required,phone"`
SmsCode string `json:"smsCode" validate:"required"`
NewPassword string `json:"newPassword" validate:"required,password"`
}
// RefreshReq 刷新 token 请求
type RefreshReq struct {
RefreshToken string `json:"refreshToken" validate:"required"`
}
// LogoutReq 登出请求(refreshToken 可选,用于同时注销刷新令牌)
type LogoutReq struct {
RefreshToken string `json:"refreshToken"`
}
// UserVO 用户信息(脱敏,不含密码)
type UserVO struct {
ID int64 `json:"id"`
Name string `json:"name"`
Phone string `json:"phone"`
Email string `json:"email"`
Role string `json:"role"`
TrafficBalance int64 `json:"trafficBalance"`
Status int8 `json:"status"`
LastLogin *time.Time `json:"lastLogin"`
CreatedAt time.Time `json:"createdAt"`
}
// LoginResp 登录/刷新响应
type LoginResp struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
User *UserVO `json:"user"`
}
// NewUserVO 由 model.User 构建脱敏 VO
func NewUserVO(u *model.User) *UserVO {
if u == nil {
return nil
}
return &UserVO{
ID: u.ID,
Name: u.Name,
Phone: u.Phone,
Email: u.Email,
Role: u.Role,
TrafficBalance: u.TrafficBalance,
Status: u.Status,
LastLogin: u.LastLogin,
CreatedAt: u.CreatedAt,
}
}

20
vo/command_vo.go

@ -0,0 +1,20 @@
package vo
import "laic-backend/common"
// CommandReq 指令下发请求
type CommandReq struct {
Type string `json:"type" validate:"required,max=64"` // dock.open / drone.takeoff / workflow.start_task
Params map[string]any `json:"params"`
DroneSN string `json:"droneSn" validate:"max=32"`
TTLMs int `json:"ttlMs"`
}
// CommandPageReq 指令历史分页查询
type CommandPageReq struct {
common.Pagination
Status string `json:"status" form:"status"` // sent / acked / timeout / terminal
CommandType string `json:"commandType" form:"commandType"`
DockID string `json:"dockId" form:"dockId"`
Keyword string `json:"keyword" form:"keyword"`
}

54
vo/dock_vo.go

@ -0,0 +1,54 @@
package vo
import (
"laic-backend/common"
"laic-backend/model"
)
// DockPageReq 机巢分页查询
type DockPageReq struct {
common.Pagination
Status string `json:"status" form:"status"`
RegisterStatus string `json:"registerStatus" form:"registerStatus"`
Keyword string `json:"keyword" form:"keyword"` // 匹配 name / dockId / code
}
// DockCreateReq 手动登记机巢
type DockCreateReq struct {
DockID string `json:"dockId" validate:"required,max=64"`
Name string `json:"name" validate:"max=64"`
Code string `json:"code" validate:"max=32"`
SN string `json:"sn" validate:"max=64"`
Iccid string `json:"iccid" validate:"max=32"`
Location string `json:"location" validate:"max=128"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
Altitude float64 `json:"altitude"`
UserID int64 `json:"userId"` // 仅 admin 可指定归属用户,普通用户忽略
}
// DockUpdateReq 更新/认领机巢
type DockUpdateReq struct {
Name string `json:"name" validate:"max=64"`
Code string `json:"code" validate:"max=32"`
Location *string `json:"location" validate:"omitempty,max=128"`
Longitude *float64 `json:"longitude"`
Latitude *float64 `json:"latitude"`
Altitude *float64 `json:"altitude"`
UserID int64 `json:"userId"` // 仅 admin 可改归属
RegisterAs string `json:"registerAs"` // 空则不修改;registered 表示完成登记
}
// DockVO 机巢详情(含实时状态)
type DockVO struct {
*model.Dock
Online bool `json:"online"`
Realtime map[string]string `json:"realtime"`
}
// DockStatusVO 机巢实时状态
type DockStatusVO struct {
DockID string `json:"dockId"`
Online bool `json:"online"`
Realtime map[string]string `json:"realtime"`
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save