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