Browse Source
Add the backend application, MQTT device lifecycle, task execution tracking, media control, and supporting persistence.master
110 changed files with 13797 additions and 0 deletions
File diff suppressed because it is too large
@ -0,0 +1,10 @@ |
|||
.PHONY: build run clean |
|||
|
|||
build: |
|||
go build -o laic-backend . |
|||
|
|||
run: |
|||
go run . |
|||
|
|||
clean: |
|||
rm -f laic-backend |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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 == "*") |
|||
@ -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) } |
|||
} |
|||
@ -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, |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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() |
|||
} |
|||
@ -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 "" |
|||
} |
|||
@ -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")) |
|||
} |
|||
@ -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, |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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") |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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/ |
|||
@ -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 |
|||
) |
|||
@ -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= |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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(), |
|||
) |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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" |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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" |
|||
} |
|||
@ -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, |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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), ¶ms) |
|||
} |
|||
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 |
|||
} |
|||
@ -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, |
|||
}) |
|||
} |
|||
@ -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, |
|||
}) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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), ¶ms); 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 |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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() |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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; |
|||
@ -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()); |
|||
@ -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 自动建表并引导,不在本脚本中静态写入。 |
|||
-- --------------------------------------------------------------- |
|||
@ -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; |
|||
@ -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 ; |
|||
@ -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) |
|||
); |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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() |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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"` |
|||
} |
|||
@ -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
|
|||
} |
|||
@ -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, |
|||
} |
|||
} |
|||
@ -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"` |
|||
} |
|||
@ -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…
Reference in new issue