diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..78daf36 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,1031 @@ +# 嘉谷低空智控平台 — Go 后端设计文档 + +> 版本:v1.0 +> 参考项目:pilot-train-server +> MQTT 协议版本:v1(dock-edge) + +--- + +## 1. 整体架构风格 + +**单体服务,内部分模块**,对齐 pilot-train-server 的代码风格: + +- **单一入口**:一个 `main.go`,一个 `go.mod`,内部分模块组织代码 +- **Controller → Service → Model/VO** 分层,Service 直操 `common.DB`,无独立 Repository 层 +- **包级单例**:`common.DB`、`common.RedisPool`、`service.DefaultXxxService` +- **本地 YAML 配置**:一个 `config.yaml`,Viper 加载 +- **JWT + Casbin RBAC**:`gorm-adapter` 持久化,Redis 缓存权限。admin 全量数据,user 按 `user_id` 过滤 +- **统一响应** `{code, msg, data}` + 中文验证错误翻译 +- **Redigo Redis Pool**(非 go-redis) + +--- + +## 2. 技术选型 + +| 层面 | 选型 | 说明 | +|---|---|---| +| Web 框架 | **Gin v1.10** | 轻量高性能,完全对齐 pilot-train-server | +| ORM | **GORM v1.25 + MySQL 驱动** | 完全对齐 pilot-train-server | +| 数据库 | **MySQL 8.0** | 业务数据,完全对齐 pilot-train-server | +| 时序数据库 | **TDengine 3.x** | 遥测/轨迹等时序数据,对齐 pilot-data 的用法 | +| 缓存 | **Redis 7+(Redigo Pool)** | 设备实时状态、Session、权限、流量余额 | +| MQTT Broker | **EMQX** | 设备通信,由甲方/边缘侧部署,后台只作为客户端连接 | +| 对象存储 | **MinIO / 阿里云 OSS** | S3 兼容 API,视频和升级包存储 | +| 直播 | **阿里云直播 + SRT 推流** | 后台生成签名推流地址,通过 MQTT 下发给工控机 | +| 配置 | **Viper + config.yaml** | 本地配置文件 | +| 认证 | **JWT HS256 + Casbin** | 无状态认证 + RBAC,7 天 access / 30 天 refresh | +| 日志 | **Zap + Lumberjack** | 结构化 + 日志轮转,对齐 pilot-train-server | +| 验证 | **go-playground/validator** | 中文翻译(`zh`) | +| ID 生成 | **Snowflake** | `tool/snowflake.go` | +| WebSocket | **gorilla/websocket** | 前端实时推送 | +| 定时任务 | **robfig/cron** | 任务计划调度 | + +--- + +## 3. 项目结构 + +``` +laic-backend/ +├── main.go # 入口,初始化 → MQTT + WS + HTTP +├── go.mod / go.sum +├── casbin.model +├── config.yaml # 本地配置文件 +├── Makefile +│ +├── common/ # 基础设施 +│ ├── gin.go # gin 引擎、统一响应 R、路由组 +│ ├── mysql.go # GORM + OrgPlugin 多租户、Scopes +│ ├── tdengine.go # TDengine 连接池 +│ ├── redis.go # Redigo Pool、Lua 原子操作 +│ ├── config.go # Viper 加载 config.yaml +│ ├── casbin.go # Casbin 初始化 + Redis 缓存适配器 +│ ├── busi_error.go # BusiError + 错误码常量 +│ ├── auth_util.go # admin 全量 / user 按 user_id 过滤 +│ ├── pageutil.go # 分页 +│ └── validator.go # 自定义验证器(phone, password 等) +│ +├── middleware/ # HTTP 中间件 +│ ├── auth.go # JWT 校验 + Casbin 鉴权 +│ ├── cros.go # CORS +│ ├── trace.go # X-Trace-Id +│ ├── default_log.go # 访问日志 +│ ├── rate_limit.go # 限流 +│ └── validate.go # 验证错误翻译 +│ +├── cache/ # Redis Key 管理 +│ ├── cache_key.go # 所有 Redis Key 集中定义 +│ └── cache_value.go # 缓存值 DTO +│ +├── token/ # JWT +│ └── token.go # 生成/校验/刷新 +│ +├── logger/ # 日志 +│ ├── logger.go # 自定义 logger +│ └── gorm_log.go # GORM logger 适配 +│ +├── tool/ # 工具 +│ ├── snowflake.go # 分布式 ID +│ ├── geo.go # 地理计算(Haversine 距离) +│ └── str.go / time.go # 字符串/时间工具 +│ +├── model/ # GORM 数据模型 +│ ├── user.go # User +│ ├── dock.go # Dock, Drone +│ ├── alarm.go # AlarmCode, Alarm +│ ├── command.go # DeviceCommandLog +│ ├── firmware.go # Firmware +│ ├── task.go # TaskPlan, TaskExecution, WorkflowState +│ ├── route.go # Route, RouteWaypoint +│ ├── media.go # LiveSession, Video, DownloadLog +│ └── billing.go # TrafficOrder, TrafficUsageLog, SimCard +│ +├── vo/ # 请求/响应 DTO +│ ├── user_vo.go +│ ├── dock_vo.go +│ ├── alarm_vo.go +│ ├── task_vo.go +│ ├── route_vo.go +│ └── media_vo.go +│ +├── handler/ # HTTP 处理器(Controller) +│ ├── auth_handler.go # 登录/注册/短信/刷新 +│ ├── user_handler.go # 用户 CRUD +│ ├── account_handler.go # 账户中心/流量/SIM卡 +│ ├── dock_handler.go # 机巢 CRUD/状态/指令/固件 +│ ├── drone_handler.go # 无人机 CRUD/遥测 +│ ├── alarm_handler.go # 告警列表/确认/关闭 +│ ├── firmware_handler.go # 固件管理(admin) +│ ├── task_handler.go # 任务 CRUD/执行 +│ ├── execution_handler.go # 执行记录/轨迹 +│ ├── route_handler.go # 航线 CRUD +│ ├── live_handler.go # 直播管理 +│ ├── video_handler.go # 视频/下载 +│ └── system_handler.go # 系统管理 +│ +├── service/ # 业务逻辑(struct + Default 单例) +│ ├── auth_service.go +│ ├── user_service.go +│ ├── account_service.go +│ ├── dock_service.go +│ ├── drone_service.go +│ ├── command_service.go # 指令构建/幂等/重试/超时 +│ ├── alarm_service.go # alarmCodes diff → 告警生命周期 +│ ├── firmware_service.go # 固件 CRUD + OTA 下发 +│ ├── task_service.go +│ ├── execution_service.go +│ ├── route_service.go +│ ├── live_service.go # 阿里云直播签名 + 推流会话 +│ ├── video_service.go # OSS 签名下载 +│ └── billing_service.go # 流量扣减 +│ +├── mqtt/ # MQTT 客户端 +│ ├── client.go # 连接管理(paho.mqtt.golang) +│ ├── subscriber.go # 订阅设备上报 → Redis + 告警 diff +│ └── publisher.go # 发布指令到设备 +│ +├── websocket/ # WebSocket +│ ├── hub.go # 连接池 + 广播 +│ └── client.go # 单连接读写 +│ +├── route/ # 路由注册 +│ └── route.go # InitRouter +│ +└── sql/ # 数据库 DDL + ├── 001_schema.sql # 表结构 + ├── 002_alarm_codes.sql # 告警码初始化数据 + └── 003_seed.sql # 种子数据 +``` + +--- + +## 4. MQTT 接口设计 + +### 4.1 Topic 结构(由边缘端协议定义) + +基础前缀:`dock-edge/v1/dock/{dockId}` + +**上行(边缘 → 后台):** +| Topic | QoS | Retain | 说明 | +|---|---:|---:|---| +| `status/online` | 1 | 是 | 在线/离线/软件版本/bootId | +| `state/dock` | 1 | 是 | 机巢当前完整状态 + alarmCodes | +| `state/drone` | 1 | 是 | 无人机当前完整状态 + alarmCodes | +| `state/workflow` | 1 | 是 | 任务/一键流程执行步骤 | +| `state/video` | 1 | 是 | 图传和推流状态 | +| `telemetry` | 0 | 否 | 1Hz 高频遥测(位置/姿态/电池/GPS) | +| `command/ack` | 1 | 否 | 指令应答 | +| `ota/reported` | 1 | 是 | 升级进度 | + +**下行(后台 → 边缘):** +| Topic | QoS | Retain | 说明 | +|---|---:|---:|---| +| `command` | 1 | 否 | 机巢/无人机/工作流/视频控制指令 | +| `ota/desired` | 1 | 是 | 升级任务 | + +### 4.2 后台 MQTT 客户端职责 + +``` +订阅: + dock-edge/v1/dock/+/status/online → 发现新设备 + 感知上下线 + dock-edge/v1/dock/+/state/dock → Redis 状态 + alarmCodes diff + dock-edge/v1/dock/+/state/drone → Redis 状态 + alarmCodes diff + dock-edge/v1/dock/+/state/workflow → 任务步骤跟踪 + dock-edge/v1/dock/+/state/video → 直播状态跟踪 + dock-edge/v1/dock/+/telemetry → Redis 最新遥测 + TDengine 批量写入(500条/10s) + dock-edge/v1/dock/+/command/ack → 更新 device_command_log + dock-edge/v1/dock/+/ota/reported → 升级进度 + +发布: + dock-edge/v1/dock/{dockId}/command → 设备控制指令 + dock-edge/v1/dock/{dockId}/ota/desired → 升级任务 +``` + +### 4.3 指令下发流程 + +``` +POST /v1/docks/:id/command {type:"workflow.one_key_takeoff"} + → command_service: + 1. 生成 commandId(Snowflake) + 2. 生成 requestId(UUID) + 3. 校验设备在线 + 安全条件 + 4. INSERT device_command_log(id=commandId, status='sent') + 5. MQTT Publish → EMQX → 工控机 + → 工控机: 校验 → command/ack {accepted:true, resultCode:"OK"} + → mqtt/subscriber 收到 ACK → 更新 device_command_log(status='acked') + → 工控机: 逐步骤发布 state/workflow + → mqtt/subscriber → WS 推送前端 + +超时重试(30s 未 ACK): + → command_service: 生成新 requestId,复用 commandId + → 工控机: commandId 幂等,不重复执行,返回原 ACK +``` + +### 4.4 告警处理流程 + +``` +工控机 state/dock {alarmCodes:["LEFT_DOOR_POWER_FAULT","DOCK_EMERGENCY_STOP"]} + → mqtt/subscriber: 解析 → Redis HMSET dock:{dockId}:alarm_codes + → alarm_service: + 1. 对比上次 alarmCodes(从 Redis 读取) + 2. 新增 code → INSERT alarm(status='active') + 3. 移除 code → UPDATE alarm SET status='resolved', resolved_at=now() + 4. WS 推送告警变更给前端 +``` + +--- + +## 5. 数据模型 + +### 5.1 用户 + +两个角色:**管理员(admin)** 看全部数据,**普通用户(user)** 只看自己的机巢。 + +```sql +CREATE TABLE 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, -- 云媒体剩余流量(字节) + status TINYINT DEFAULT 1, + last_login DATETIME, + created_at DATETIME, + updated_at DATETIME, + UNIQUE KEY idx_phone (phone) +); +``` + +- 用户自行注册,默认角色 `user` +- 管理员在系统初始化时手动指定,不需要组织/租户概念 +- Casbin 策略:`admin` 全量数据,`user` 按 `user_id` 过滤自己的机巢 + +### 5.2 机巢 & 无人机 + +机巢与无人机是**默认一对一绑定**的关系。工控机上报机巢状态时携带当前无人机信息,后台自动关联。 + +```sql +CREATE TABLE 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) +); + +CREATE TABLE drone ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, -- 所属用户(继承 dock 的 user_id) + 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) +); +``` + +> 机巢上报 `state/drone` 时携带 `droneSn`,后台据此自动创建或更新无人机记录。`dock_id` 上唯一约束保证一对一绑定。换机巢时新 dock 上报不同 droneSn 即建立新关系,无需人工操作。 + +### 5.3 告警 + +通过 `dock_id` 关联到用户,不单独存 `user_id`。 + +```sql +CREATE TABLE 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) +); + +CREATE TABLE alarm ( + id BIGINT PRIMARY KEY, + dock_id VARCHAR(64) NOT NULL, + code VARCHAR(64) NOT NULL, + device_type VARCHAR(8) NOT NULL, + message_cn VARCHAR(256) NOT NULL, + level VARCHAR(16) NOT NULL, + status VARCHAR(16) DEFAULT 'active', + triggered_at DATETIME, + resolved_at DATETIME, + created_at DATETIME, + KEY idx_alarm_dock (dock_id, status) +); +``` + +### 5.4 指令 & 工作流 + +```sql +CREATE TABLE 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) +); + +CREATE TABLE 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 +); +``` + +### 5.5 固件管理 + +管理员上传和管理固件版本,用户选择版本下发 OTA 升级指令。 + +```sql +CREATE TABLE 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, -- SHA-256 摘要 + signature TEXT NOT NULL, -- 签名 Base64 + file_size BIGINT DEFAULT 0, -- 文件大小(字节) + mandatory TINYINT DEFAULT 0, -- 是否强制升级 + status VARCHAR(16) DEFAULT 'draft', -- draft/released/deprecated + created_by BIGINT NOT NULL, -- 上传者(admin) + created_at DATETIME, + updated_at DATETIME, + UNIQUE KEY idx_component_ver (component, version) +); +``` + +### 5.6 任务 & 航线 + +```sql +CREATE TABLE 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', + schedule_cron VARCHAR(32), + video_policy VARCHAR(16) DEFAULT 'raw', + status VARCHAR(16) DEFAULT 'draft', + created_by BIGINT, + created_at DATETIME, + updated_at DATETIME +); + +CREATE TABLE 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 +); + +CREATE TABLE route ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + description VARCHAR(256), + created_at DATETIME, + updated_at DATETIME +); + +CREATE TABLE 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) +); +``` + +### 5.7 媒体 + +```sql +CREATE TABLE 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), + started_at DATETIME, + stopped_at DATETIME, + created_at DATETIME +); + +CREATE TABLE 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 +); + +CREATE TABLE download_log ( + id BIGINT PRIMARY KEY, + video_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + bytes BIGINT NOT NULL, + created_at DATETIME +); +``` + +### 5.8 计费 + +**商业模式:中间商赚差价。** + +- **云媒体流量**:平台自己有一个阿里云直播账号和流量包,所有用户共用。用户向平台订购流量(GB),平台在内部给该用户记账。用户消耗流量时从自己的余额扣,平台只需关注所有用户的**总消耗**,快用完时再向阿里云续购。平台赚取用户售价与阿里云成本之间的差价。 +- **4G 卡流量**:用户为机巢的 SIM 卡订购套餐,付款后平台调用运营商接口给该卡充值,差价同上。 + +两类计费相互独立,付款成功后的处理: + +- **云媒体流量**:付款 → `user.traffic_balance` 加字节数,用户即刻可用 +- **4G 卡流量**:付款 → 调运营商接口充值 → 更新 `sim_card.plan_gb` + +```sql +-- user 表流量字段 +-- traffic_balance BIGINT DEFAULT 0 -- 云媒体剩余流量(字节) + +CREATE TABLE traffic_order ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + amount_gb INT NOT NULL, -- 购买流量(GB) + unit_price DECIMAL(6,2), -- 平台售价(元/GB) + total_price DECIMAL(10,2), -- 用户实付(元) + pay_status VARCHAR(16) DEFAULT 'unpaid', -- unpaid/paid/cancelled + created_at DATETIME, + paid_at DATETIME +); + +-- 云媒体消费明细(直播/回放/下载) +CREATE TABLE 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 +); + +-- SIM 卡(用户为机巢的 4G 卡订购套餐) +CREATE TABLE 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, -- 当前套餐总流量(GB) + used_gb DECIMAL(10,4) DEFAULT 0, -- 已用流量(GB,由同步任务回填) + 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 +); + +-- SIM 卡充值记录 +CREATE TABLE 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), -- 平台售价(元/GB) + 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) +); + +-- SIM 卡用量快照(每次从运营商同步留痕,可审计/画曲线/对账) +CREATE TABLE sim_usage_record ( + id BIGINT PRIMARY KEY, + sim_card_id BIGINT NOT NULL, + plan_gb INT, -- 同步时套餐总量(GB) + used_gb DECIMAL(10,4), -- 已用(GB) + remain_gb DECIMAL(10,4), -- 剩余(GB) + carrier_status VARCHAR(16), + synced_at DATETIME, -- 同步时间 + KEY idx_record_sim (sim_card_id, synced_at) +); +``` + +> 平台采购成本(从阿里云/运营商的拿货价)不在本系统记录,由财务线下管理。系统只记录面向用户的售价和消费。 + +> **余额一致性与账本模型**:流量余额以 Redis 为唯一实时真相(source of truth),MySQL 的 `user.traffic_balance` 仅作为 30 分钟刷一次的慢快照。`traffic_usage_log` 流水是**账本**(ledger),每次扣减同步写 MySQL、不可变、可重放;余额是**派生值**(derived balance),可由 `快照 + 重放流水 + 订单` 精确重建。因此 Redis 丢失最多损失 30 分钟余额变动,但账本仍在,可对账、可审计、可重建。 + +> **SIM 卡用量来源**:工控机不上报网络用量,`used_gb` / `expired_at` / `carrier_status` 均由后台定时任务(每 1 小时)调用运营商查询接口回填。`sim_card.iccid` 为权威字段(查询与充值的入参),`dock.iccid`(§5.2)降级为冗余。运营商接口抽象为 `CarrierAPI`(`QueryUsage` / `Recharge`),暂定一家运营商,先做「接口 + 单实现」,换卡商时不改业务层。 + +### 5.9 TDengine 时序数据 + +遥测和轨迹等高频时序数据存入 TDengine,与 pilot-data 的用法保持一致。 + +**遥测超级表:** + +```sql +CREATE STABLE 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) +); +``` + +**轨迹超级表(任务回放用):** + +```sql +CREATE STABLE 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) +); +``` + +> pilot-data 使用 `TrackService` 缓冲(`sync.Map`)达到 500 条或 10 秒后批量写入 TDengine。本项目对齐此模式。 + +--- + +## 6. Redis 缓存设计 + +``` +# 设备实时状态 +device:dock:{dockId}:status → Hash (所有 state/dock 字段) +device:dock:{dockId}:alarm_codes → Set (当前告警编码集合) +device:drone:{dockId}:telemetry → Hash (所有 state/drone 字段) +device:drone:{dockId}:alarm_codes → Set (当前告警编码集合) +device:workflow:{dockId} → Hash (当前工作流状态) +device:video:{dockId} → Hash (当前视频推流状态) + +# Token & Session +laic:token:{user}:{shortId} → token 会话标记(支持踢人) + +# 权限缓存 +laic:permission:{role} → 角色权限列表 + +# 云媒体流量余额(以 Redis 为主,user.traffic_balance 为 30 分钟快照) +laic:traffic:{userId} → String (INT 值,可原子 DECRBY/INCRBY) + +# 在线设备集合 +laic:devices:online:dock → Set {dockId...} +laic:devices:online:drone → Set {droneSn...} +``` + +--- + +## 7. API 路由 + +所有端点统一注册在一个 Gin router 上,`api-port` 默认 `8080`。 + +### 7.1 认证(公开) + +``` +POST /v1/auth/login +POST /v1/auth/logout +POST /v1/auth/refresh +POST /v1/auth/sms-code +POST /v1/auth/register +POST /v1/auth/reset-password +``` + +### 7.2 账户中心(需登录) + +``` +GET /v1/account/profile +PUT /v1/account/profile +GET /v1/account/traffic/balance +GET /v1/account/traffic/usage +GET /v1/account/traffic/orders +POST /v1/account/traffic/orders +GET /v1/account/sim-cards +POST /v1/account/sim-cards/:id/recharge +``` + +### 7.3 机巢 & 无人机 + +``` +GET /v1/docks # 列表(支持筛选/分页) +POST /v1/docks # 完善待登记机巢信息 +GET /v1/docks/:id # 详情(含实时状态 from Redis) +PUT /v1/docks/:id +DELETE /v1/docks/:id +GET /v1/docks/:id/status # 纯实时状态 +POST /v1/docks/:id/command # 下发指令 +POST /v1/docks/:id/firmware/upgrade # body: {firmware_id} + +GET /v1/drones +GET /v1/drones/:id +PUT /v1/drones/:id +DELETE /v1/drones/:id +GET /v1/drones/:id/telemetry +``` + +### 7.4 告警 & 指令日志 + +``` +GET /v1/alarms # 告警列表 +PUT /v1/alarms/:id/acknowledge +PUT /v1/alarms/:id/resolve + +GET /v1/commands # 指令历史 +GET /v1/commands/:id +``` + +### 7.5 任务 & 航线 + +``` +GET /v1/tasks +POST /v1/tasks +GET /v1/tasks/:id +PUT /v1/tasks/:id +DELETE /v1/tasks/:id +POST /v1/tasks/:id/execute + +GET /v1/executions +GET /v1/executions/:id +GET /v1/executions/:id/trajectory + +GET /v1/routes +POST /v1/routes +GET /v1/routes/:id +PUT /v1/routes/:id +DELETE /v1/routes/:id +``` + +### 7.6 媒体 + +``` +GET /v1/live +POST /v1/live/:dockId/start +POST /v1/live/:dockId/stop +GET /v1/live/:dockId/play-url + +POST /v1/videos # 申请上传(返回 OSS 预签名 PUT URL) +POST /v1/videos/:id/complete # 上传完成确认(边缘侧回调) +GET /v1/videos +GET /v1/videos/:id +POST /v1/videos/:id/download +``` + +### 7.7 系统管理(需 admin) + +``` +GET /v1/users +POST /v1/users +PUT /v1/users/:id +DELETE /v1/users/:id +GET /v1/roles +GET /v1/logs/operation +``` + +### 7.8 固件管理(需 admin) + +``` +GET /v1/firmwares # 固件版本列表 +POST /v1/firmwares # 上传新固件 +PUT /v1/firmwares/:id # 编辑(状态/强制标记等) +DELETE /v1/firmwares/:id # 删除 +``` + +### 7.9 WebSocket + +``` +WS /v1/ws/monitor # 前端实时监控(设备状态 + 告警推送) +``` + +### 7.10 统一响应格式 + +```json +{ + "code": 200, + "msg": "ok", + "data": {} +} +``` + +--- + +## 8. 核心数据流 + +### 8.1 设备自发现 & 登记 + +``` +工控机首次启动 → 生成 dockId → 连接 EMQX → 发布 retained status/online + → mqtt/subscriber: + 1. 查询 dock 表 WHERE dock_id = {dockId} + 2. 不存在 → INSERT dock(dock_id, register_status='pending', user_id=0) + 3. 更新 Redis: device:dock:{dockId}:status + 4. WS 推送: 新设备上线 + → 用户在前端认领或管理员分配: PUT /v1/docks/:id {user_id, name, code, ...} +``` + +### 8.2 无人机自动关联(默认 1:1 绑定) + +``` +工控机上报 state/drone {droneSn:"23001582", online:true, ...} + → mqtt/subscriber: + 1. 查询 drone 表 WHERE drone_sn = "23001582" OR dock_id = {上报dockId} + 2. 都不存在 → INSERT drone(drone_sn, dock_id, user_id=同dock的user_id) + 3. 存在 → UPDATE drone SET drone_sn, dock_id(替换绑定) + 4. Redis 更新状态 + 5. WS 推送 +``` + +### 8.3 固件升级 + +``` +管理员 POST /v1/firmwares {version, file_url, sha256, signature, ...} + → 上传并保存固件版本 + +用户 POST /v1/docks/:id/firmware/upgrade {firmware_id} + → dock_service: + 1. 校验机巢属于当前用户 + 2. 查询 firmware 表获取版本信息 + 3. MQTT Publish → dock-edge/v1/dock/{dockId}/ota/desired + {updateId, component, version, url, sha256, signature, mandatory, issuedAt} + → 工控机: ota/reported 上报升级进度 + → mqtt/subscriber → 更新固件升级状态 → WS 推送 +``` + +### 8.4 云媒体流量计费 + +**扣减场景**:直播推流(按推流时长 × 码率估算)、回放、原始视频下载。 + +``` +POST /v1/videos/:id/download + → video_service: + 1. 查询 video → 获取 file_size(字节), user_id + 2. ensureBalance(userId): key 不存在则从 MySQL 读余额 SetNX 初始化(兜底;启动时 WarmUp 已预热) + 3. Redis Lua 脚本: GET 余额 → 校验 ≥ file_size → DECRBY + 余额不足 → 返回错误 "流量不足,请充值" + 4. 同步 INSERT traffic_usage_log(bytes_used, balance_before, balance_after) ← 账本,必须落库 + 5. 生成 OSS 签名 URL(有效期 300s) + 6. INSERT download_log + 7. 返回签名 URL + +直播扣减(后台定时任务): + → 每 60 秒扫描进行中的 live_session + → 按 elapsed × max_bitrate_bps / 8 估算流量 + → 批量扣减 Redis 余额(Lua),并同步写 usage_log + → 余额耗尽 → MQTT video.stop_stream 停止推流 + +余额快照刷新(后台定时任务,30 分钟): + → MGET 批量读 Redis 余额 + → 批量 UPDATE user.traffic_balance(CASE WHEN 逐行赋值,避免逐条 roundtrip) + → 不清 Redis(Redis 仍是唯一实时真相) + → 优雅退出时强制 flush 一次,避免丢失最后窗口 +``` + +### 8.5 充值与发放 + +``` +用户 POST /v1/account/traffic/orders {amount_gb, ...} + → 生成 traffic_order(status=unpaid) + → 用户完成付款(对接支付接口,当前先人工标记 paid) + +付款成功后 — 云媒体流量: + → account_service: + 1. Redis INCRBY user.traffic_balance (amount_gb × 1024³) ← 充值走同一 Redis 真相 + 2. UPDATE traffic_order SET pay_status='paid', paid_at=now() ← 订单实时落库,可重放 + 3. 用户立即可用 + +付款成功后 — 4G 卡: + → account_service: + 1. 生成 idempotent_key(= 平台充值单号) + 2. 调用运营商充值 API(iccid, amount_gb, idempotent_key) + 3. 运营商返回成功 → UPDATE sim_card SET plan_gb = plan_gb + amount_gb + 4. INSERT sim_recharge_log(idempotent_key, recharge_status='success') + 5. 运营商返回失败 → recharge_status='failed',告警管理员人工处理(不同步更新 plan_gb) + 6. 超时重试 → 复用同一 idempotent_key,运营商侧去重,不重复充值 +``` + +### 8.6 SIM 卡用量同步 + +``` +定时任务(robfig/cron,每 1 小时): + → 分页扫描 status='active' 的 sim_card + → 按 carrier 路由到 CarrierAPI.QueryUsage(iccid) + → 回填 sim_card: used_gb / expired_at / carrier_status / last_sync_at + → INSERT sim_usage_record(plan_gb, used_gb, remain_gb, carrier_status, synced_at) ← 留痕 + → 阈值检查: + remain_gb < plan_gb * 10% → 提醒用户充值 + expired_at 距今 < 3 天 → 提醒续费 + carrier_status = suspended/arrears → 告警管理员 + carrier_status = cancelled 或已过期 → 置 status='expired' + +限流: 串行 + 可控并发 + 失败退避,避免打爆运营商查询接口 +``` + +### 8.7 直播地址生成与按需拉流 + +**按需拉流** = 控制推流起停:用户不点播放不推流(省流量/省电),点了才开始推。 + +``` +用户点「播放」 + → GET /v1/live/:dockId/start + → live_service: + 1. 生成唯一 stream_name(= live_session.id,Snowflake/UUID) + 2. 用阿里云直播 Go SDK 在服务端计算推流地址(RTMP/SRT + 鉴权,有效期 2h) + 3. INSERT live_session(stream_name, phase='starting') + 4. MQTT 下发 video.start_stream {push_url} 给工控机 + → 工控机: 用 push_url 向阿里云推流 → MQTT 上报 state/video {streaming:true} + → live_service: 计算播放地址(HLS/FLV + URL 鉴权)→ 返回 {play_url} + → 前端: Aliplayer / hls.js / flv.js 拉 play_url 播放 + +用户点「关闭」 + → POST /v1/live/:dockId/stop + → live_service: MQTT 下发 video.stop_stream → 工控机停止推流 + → 更新 live_session(phase='stopped') +``` + +**地址格式(阿里云直播):** +- 推流:`rtmp://{推流域名}/live/{AppName}/{stream_name}?auth_key=...`(或 SRT) +- 播放:HLS `http://{播流域名}/live/{AppName}/{stream_name}.m3u8` / FLV `.flv`(低延迟) + +**关键约束:** +- 推流域名、播流域名需在阿里云控制台备案 + CNAME,提前申请 +- AccessKey 只在后台,设备只拿「这一次」的推流地址,绝不下发长期凭证 +- 网页播放推荐 HLS(兼容最好),对延迟敏感再上 HTTP-FLV +- 服务端集成用**阿里云直播 Go SDK**(视频直播 OpenAPI + 鉴权计算),前端用阿里云播放器 SDK(Aliplayer) + +--- + +## 9. 启动流程 + +```go +func main() { + // 1. 初始化日志 + logger.InitCustomLog(logger.Level_D, nil, "laic-backend") + + // 2. 加载 config.yaml + conf := &AppConfig{} + common.LoadConfig("config.yaml", conf) + + // 3. 初始化基础设施 + common.InitConnection(&conf.Mysql) // GORM + MySQL + common.InitTDengine(&conf.TDengine) // TDengine 连接池 + common.InitRedis(conf.Redis) // Redis Pool + common.InitCasbinEnforcer() // Casbin RBAC + + // 4. 预热缓存 + go cache.WarmUp() + + // 5. 连接 MQTT Broker + go mqtt.Connect(conf.MQTT) + + // 6. 启动 WebSocket Hub + go websocket.Hub.Run() + + // 7. 启动 HTTP + route.InitRouter(conf.ApiPort) + common.ServerRun(conf.ApiPort) + + // 8. 优雅退出 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit +} +``` + +`config.yaml` 示例: + +```yaml +api-port: 8080 +mysql: + url: root:password@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@tcp(127.0.0.1:6030)/laic +redis: redis://127.0.0.1:6379/0 +mqtt: + broker: tcp://127.0.0.1:1883 + client-id: laic-backend + username: laic + password: "" +log: + level: debug + path: ./logs/ +``` + +--- + +## 10. 与 pilot-train-server 的主要差异 + +| 维度 | pilot-train-server | laic-backend | +|---|---|---| +| 架构 | 多服务单体仓库(5 个 main.go) | **单服务分模块(1 个 main.go)** | +| 数据库 | MySQL | MySQL(相同) | +| 时序数据 | TDengine | TDengine(相同) | +| 设备协议 | MAVLink over TCP | **MQTT**(EMQX broker) | +| 对象存储 | 华为云 OBS | **MinIO / 阿里云 OSS**(S3 兼容) | +| 直播 | 无 | **阿里云直播 SRT 推流** | +| 计费 | 无 | **流量余额 + 订单 + 消费明细** | +| 告警 | 无 | **alarmCodes diff 引擎 + 告警生命周期** | +| 指令体系 | 简单 TCP 指令 | **commandId 幂等 + TTL + ACK + 重试** | +| 配置 | Consul | **本地 config.yaml** | +| 服务间通信 | HTTP + Consul | **内部函数调用** | + +--- + +## 11. 待确认项 + +1. **TDengine 部署**:需确认 TDengine 版本(pilot-train-server 用的 3.x),以及遥测数据保留周期 +2. **告警码初始化**:`alarm_code` 表需要根据 MQTT 文档 7.6 节全部枚举值初始化,共约 90+ 条 +3. **SIM 卡流量**:MQTT 文档明确工控机不上报网络用量,需确认运营商 API 或人工录入 +4. **视频上传通路**:MQTT 协议只定义了推流,原始视频如何从工控机到达 OSS 需确认(可能由边缘侧直接上传 OSS,通过 state/video 或独立接口通知后台) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..166f65a --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: build run clean + +build: + go build -o laic-backend . + +run: + go run . + +clean: + rm -f laic-backend diff --git a/cache/cache_key.go b/cache/cache_key.go new file mode 100644 index 0000000..9e97cd9 --- /dev/null +++ b/cache/cache_key.go @@ -0,0 +1,82 @@ +package cache + +import "fmt" + +const ( + UserTokenKey = "laic:token:%d:%s" // 用户 access token 存证(userId, shortId) + RefreshTokenKey = "laic:refresh:%d:%s" // 用户 refresh token 存证(userId, shortId) + SmsCodeKey = "laic:sms:%s" // 短信验证码(phone) + + DockStatusKey = "device:dock:%s:status" // 机巢实时状态 Hash(dockId) + DockAlarmKey = "device:dock:%s:alarm_codes" // 机巢告警码 Set(dockId) + DroneTelemetryKey = "device:drone:%s:telemetry" // 无人机遥测 Hash(dockId) + DroneAlarmKey = "device:drone:%s:alarm_codes" // 无人机告警码 Set(dockId) + OtaStatusKey = "device:dock:%s:ota" // 机巢 OTA 升级进度 Hash(dockId) + WorkflowKey = "device:workflow:%s" // 当前工作流状态 Hash(dockId) + VideoKey = "device:video:%s" // 当前视频推流状态 Hash(dockId) + OnlineDockSetKey = "laic:devices:online:dock" // 在线机巢 Set + OnlineDroneSetKey = "laic:devices:online:drone" // 在线无人机 Set + DockHeartbeatKey = "laic:devices:heartbeat:dock:%s" + DroneHeartbeatKey = "laic:devices:heartbeat:drone:%s" + + TrafficKey = "laic:traffic:%d" // 用户云媒体流量余额(字节,Redis 为权威) + + TrafficKeyPrefix = "laic:traffic:" // 流量余额 key 前缀(快照刷新扫描用) + LiveBilledKey = "laic:live:billed:%s" // 直播会话上次计费时间戳(sessionId) +) + +func UserTokenKeyOf(userID int64, shortID string) string { + return fmt.Sprintf(UserTokenKey, userID, shortID) +} + +func RefreshTokenKeyOf(userID int64, shortID string) string { + return fmt.Sprintf(RefreshTokenKey, userID, shortID) +} + +func SmsCodeKeyOf(phone string) string { + return fmt.Sprintf(SmsCodeKey, phone) +} + +func DockStatusKeyOf(dockID string) string { + return fmt.Sprintf(DockStatusKey, dockID) +} + +func DockAlarmKeyOf(dockID string) string { + return fmt.Sprintf(DockAlarmKey, dockID) +} + +func DroneTelemetryKeyOf(dockID string) string { + return fmt.Sprintf(DroneTelemetryKey, dockID) +} + +func DroneAlarmKeyOf(dockID string) string { + return fmt.Sprintf(DroneAlarmKey, dockID) +} + +func OtaStatusKeyOf(dockID string) string { + return fmt.Sprintf(OtaStatusKey, dockID) +} + +func WorkflowKeyOf(dockID string) string { + return fmt.Sprintf(WorkflowKey, dockID) +} + +func VideoKeyOf(dockID string) string { + return fmt.Sprintf(VideoKey, dockID) +} + +func DockHeartbeatKeyOf(dockID string) string { + return fmt.Sprintf(DockHeartbeatKey, dockID) +} + +func DroneHeartbeatKeyOf(droneSN string) string { + return fmt.Sprintf(DroneHeartbeatKey, droneSN) +} + +func TrafficKeyOf(userID int64) string { + return fmt.Sprintf(TrafficKey, userID) +} + +func LiveBilledKeyOf(sessionID string) string { + return fmt.Sprintf(LiveBilledKey, sessionID) +} diff --git a/cache/warmup.go b/cache/warmup.go new file mode 100644 index 0000000..ce3dcfc --- /dev/null +++ b/cache/warmup.go @@ -0,0 +1,17 @@ +package cache + +import ( + "laic-backend/common" + "laic-backend/model" +) + +// WarmUp 预热缓存:将用户流量余额从 MySQL 快照 SetNX 到 Redis(仅缺 key 时写入) +func WarmUp() { + var users []model.User + if err := common.DB.Find(&users).Error; err != nil { + return + } + for _, u := range users { + _, _ = common.SetNX(TrafficKeyOf(u.ID), u.TrafficBalance) + } +} diff --git a/casbin.model b/casbin.model new file mode 100644 index 0000000..907f24e --- /dev/null +++ b/casbin.model @@ -0,0 +1,14 @@ +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*") diff --git a/common/auth_util.go b/common/auth_util.go new file mode 100644 index 0000000..2a3b530 --- /dev/null +++ b/common/auth_util.go @@ -0,0 +1,46 @@ +package common + +import ( + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const ( + RoleAdmin = "admin" + RoleUser = "user" +) + +// IsAdmin 判断当前用户是否为 admin(全量数据) +func IsAdmin(c *gin.Context) bool { + roles := c.MustGet("roles").([]string) + for _, role := range roles { + if role == RoleAdmin { + return true + } + } + return false +} + +// GetUserId 从上下文获取当前用户 ID +func GetUserId(c *gin.Context) int64 { + return c.GetInt64("user_id") +} + +// GetAuthFilter admin 返回空 filter(全量),user 返回 user_id 过滤 +func GetAuthFilter(c *gin.Context) map[string]interface{} { + filter := make(map[string]interface{}) + if IsAdmin(c) { + return filter + } + filter["user_id"] = GetUserId(c) + return filter +} + +// WithUserFilter admin 不过滤,user 按 user_id 过滤(GORM scope) +func WithUserFilter(c *gin.Context) func(db *gorm.DB) *gorm.DB { + if IsAdmin(c) { + return func(db *gorm.DB) *gorm.DB { return db } + } + uid := GetUserId(c) + return func(db *gorm.DB) *gorm.DB { return db.Where("user_id = ?", uid) } +} diff --git a/common/busi_error.go b/common/busi_error.go new file mode 100644 index 0000000..d9d786e --- /dev/null +++ b/common/busi_error.go @@ -0,0 +1,128 @@ +package common + +// BusiError 业务错误类型 +type BusiError struct { + Code int `json:"code"` + Msg string `json:"msg"` +} + +func (e *BusiError) Error() string { + return e.Msg +} + +// 通用错误码 +const ( + Success = 200 + ParamError = 400 + Unauthorized = 401 + Forbidden = 403 + NotFound = 404 + InternalError = 500 +) + +// 业务错误码(按模块分段) +const ( + // 认证/用户 51xxx + UserPhoneExists = 51001 + UserNotFound = 51002 + PasswordError = 51003 + TokenInvalid = 51004 + TokenExpired = 51005 + SmsCodeError = 51006 + + // 机巢 52xxx + DockNotFound = 52001 + DockOffline = 52002 + DockPending = 52003 + DockExists = 52004 + CommandNotFound = 52005 + + // 无人机 53xxx + DroneNotFound = 53001 + + // 告警 54xxx + AlarmNotFound = 54001 + + // 固件 55xxx + FirmwareNotFound = 55001 + FirmwareVersionExist = 55002 + FirmwareNotReleased = 55003 + + // 任务/航线 56xxx + TaskNotFound = 56001 + RouteNotFound = 56002 + TaskStateErr = 56003 + ExecutionNotFound = 56004 + + // 媒体 57xxx + LiveNotFound = 57001 + VideoNotFound = 57002 + VideoUploading = 57003 + LiveProviderDisabled = 57004 + LiveProviderInvalid = 57005 + LiveLeaseNotFound = 57006 + + // 计费 58xxx + TrafficNotEnough = 58001 + OrderPaid = 58002 + SimCardNotFound = 58003 + OrderNotFound = 58004 +) + +// 预定义的业务错误 +var ( + ErrSuccess = &BusiError{Code: Success, Msg: "成功"} + ErrParam = &BusiError{Code: ParamError, Msg: "参数错误"} + ErrUnauthorized = &BusiError{Code: Unauthorized, Msg: "未登录"} + ErrToken = &BusiError{Code: Unauthorized, Msg: "Token异常"} + SignOutToken = &BusiError{Code: Unauthorized, Msg: "Token已失效"} + ErrForbidden = &BusiError{Code: Forbidden, Msg: "无权限访问"} + ErrNotFound = &BusiError{Code: NotFound, Msg: "资源不存在"} + ErrInternal = &BusiError{Code: InternalError, Msg: "系统内部错误"} + + ErrUserPhoneExists = &BusiError{Code: UserPhoneExists, Msg: "该手机号已注册"} + ErrUserNotFound = &BusiError{Code: UserNotFound, Msg: "用户不存在"} + ErrPasswordError = &BusiError{Code: PasswordError, Msg: "用户名或密码错误"} + ErrTokenInvalid = &BusiError{Code: TokenInvalid, Msg: "Token无效"} + ErrTokenExpired = &BusiError{Code: TokenExpired, Msg: "Token已过期"} + ErrSmsCodeError = &BusiError{Code: SmsCodeError, Msg: "短信验证码错误"} + + ErrDockNotFound = &BusiError{Code: DockNotFound, Msg: "机巢不存在"} + ErrDockOffline = &BusiError{Code: DockOffline, Msg: "机巢离线"} + ErrDockPending = &BusiError{Code: DockPending, Msg: "机巢待登记"} + ErrDockExists = &BusiError{Code: DockExists, Msg: "该机巢编号已存在"} + ErrCommandNotFound = &BusiError{Code: CommandNotFound, Msg: "指令记录不存在"} + + ErrDroneNotFound = &BusiError{Code: DroneNotFound, Msg: "无人机不存在"} + + ErrAlarmNotFound = &BusiError{Code: AlarmNotFound, Msg: "告警不存在"} + + ErrFirmwareNotFound = &BusiError{Code: FirmwareNotFound, Msg: "固件版本不存在"} + ErrFirmwareVersionExist = &BusiError{Code: FirmwareVersionExist, Msg: "该固件版本已存在"} + ErrFirmwareNotReleased = &BusiError{Code: FirmwareNotReleased, Msg: "固件未发布,无法下发升级"} + + ErrTaskNotFound = &BusiError{Code: TaskNotFound, Msg: "任务不存在"} + ErrRouteNotFound = &BusiError{Code: RouteNotFound, Msg: "航线不存在"} + ErrTaskStateErr = &BusiError{Code: TaskStateErr, Msg: "任务状态不允许此操作"} + ErrExecutionNotFound = &BusiError{Code: ExecutionNotFound, Msg: "执行记录不存在"} + + ErrLiveNotFound = &BusiError{Code: LiveNotFound, Msg: "直播会话不存在"} + ErrLiveProviderDisabled = &BusiError{Code: LiveProviderDisabled, Msg: "直播服务未配置"} + ErrLiveProviderInvalid = &BusiError{Code: LiveProviderInvalid, Msg: "直播服务配置无效"} + ErrLiveLeaseNotFound = &BusiError{Code: LiveLeaseNotFound, Msg: "直播观看租约不存在或已过期"} + ErrVideoNotFound = &BusiError{Code: VideoNotFound, Msg: "视频不存在"} + ErrVideoUploading = &BusiError{Code: VideoUploading, Msg: "视频上传中"} + + ErrTrafficNotEnough = &BusiError{Code: TrafficNotEnough, Msg: "流量不足,请充值"} + ErrOrderPaid = &BusiError{Code: OrderPaid, Msg: "该订单已支付"} + ErrSimCardNotFound = &BusiError{Code: SimCardNotFound, Msg: "SIM卡不存在"} + ErrOrderNotFound = &BusiError{Code: OrderNotFound, Msg: "订单不存在"} +) + +// NewBusiError 创建自定义业务错误 +func NewBusiError(code int, msg string) *BusiError { + return &BusiError{ + Code: code, + Msg: msg, + } +} diff --git a/common/casbin.go b/common/casbin.go new file mode 100644 index 0000000..2bb28ee --- /dev/null +++ b/common/casbin.go @@ -0,0 +1,54 @@ +package common + +import ( + "fmt" + "strconv" + + "laic-backend/logger" + + "github.com/casbin/casbin/v3" + gormadapter "github.com/casbin/gorm-adapter/v3" +) + +var CasbinEnforcer *casbin.Enforcer + +// SetUserRole 同步用户在 Casbin 中的角色映射(g 策略),role 为空则清空 +func SetUserRole(userID int64, role string) error { + if CasbinEnforcer == nil { + return nil + } + uid := strconv.FormatInt(userID, 10) + if _, err := CasbinEnforcer.DeleteRolesForUser(uid); err != nil { + return err + } + if role == "" { + return nil + } + _, err := CasbinEnforcer.AddRoleForUser(uid, role) + return err +} + +func InitCasbinEnforcer() { + e, err := mysqlCasbin() + if err != nil { + logger.ERROR("初始化Casbin失败", err) + panic(fmt.Sprintf("初始化Casbin失败: %v", err)) + } + CasbinEnforcer = e + logger.INFO("初始化Casbin完成") +} + +func mysqlCasbin() (*casbin.Enforcer, error) { + a, err := gormadapter.NewAdapterByDB(DB) + if err != nil { + return nil, err + } + e, err := casbin.NewEnforcer("casbin.model", a) + if err != nil { + return nil, err + } + if err = e.LoadPolicy(); err != nil { + return nil, err + } + return e, nil +} diff --git a/common/config.go b/common/config.go new file mode 100644 index 0000000..012cbb3 --- /dev/null +++ b/common/config.go @@ -0,0 +1,99 @@ +package common + +import ( + "github.com/spf13/viper" +) + +type AppConfig struct { + ApiPort int `mapstructure:"api-port"` + Mysql Mysql `mapstructure:"mysql"` + TDengine TDengine `mapstructure:"tdengine"` + Redis string `mapstructure:"redis"` + MQTT MQTT `mapstructure:"mqtt"` + JWT JWT `mapstructure:"jwt"` + Log Log `mapstructure:"log"` + OSS OSS `mapstructure:"oss"` + Live Live `mapstructure:"live"` + Heartbeat Heartbeat `mapstructure:"heartbeat"` +} + +// AppConf 全局配置(LoadConfig 后可用) +var AppConf *AppConfig + +type Mysql struct { + Url string `mapstructure:"url"` + Idle int `mapstructure:"idle"` + MaxConn int `mapstructure:"max-conn"` + MaxWait int `mapstructure:"max-wait"` +} + +type TDengine struct { + Dsn string `mapstructure:"dsn"` +} + +type MQTT struct { + Broker string `mapstructure:"broker"` + ClientId string `mapstructure:"client-id"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` +} + +type Log struct { + Level string `mapstructure:"level"` + Path string `mapstructure:"path"` +} + +type JWT struct { + Secret string `mapstructure:"secret"` + AccessExpireH int `mapstructure:"access-expire-hours"` + RefreshExpireH int `mapstructure:"refresh-expire-hours"` +} + +// OSS 对象存储(S3 兼容,MinIO / 阿里云 OSS) +type OSS struct { + Endpoint string `mapstructure:"endpoint"` // http://127.0.0.1:9000 或 https://oss-cn-hangzhou.aliyuncs.com + AccessKey string `mapstructure:"access-key"` + SecretKey string `mapstructure:"secret-key"` + Bucket string `mapstructure:"bucket"` + Region string `mapstructure:"region"` + UseSSL bool `mapstructure:"use-ssl"` +} + +// Live 直播(阿里云直播) +type Live struct { + Mode string `mapstructure:"mode"` // disabled / fake / aliyun + Provider string `mapstructure:"provider"` + PushDomain string `mapstructure:"push-domain"` + PlayDomain string `mapstructure:"play-domain"` + AppName string `mapstructure:"app-name"` + AuthKey string `mapstructure:"auth-key"` + PlayAuthKey string `mapstructure:"play-auth-key"` + AuthExpireSeconds int64 `mapstructure:"auth-expire-seconds"` + PlayURLTTLSeconds int64 `mapstructure:"play-url-ttl-seconds"` + Protocol string `mapstructure:"protocol"` + SRTPort int `mapstructure:"srt-port"` + MaxSessionSeconds int64 `mapstructure:"max-session-seconds"` + ViewerLeaseSeconds int `mapstructure:"viewer-lease-seconds"` + StopGraceSeconds int `mapstructure:"stop-grace-seconds"` + ReconcileIntervalSeconds int `mapstructure:"reconcile-interval-seconds"` + RecordPolicy string `mapstructure:"record-policy"` + AllowRealCloud bool `mapstructure:"allow-real-cloud"` +} + +type Heartbeat struct { + TimeoutSeconds int `mapstructure:"timeout-seconds"` + ScanSeconds int `mapstructure:"scan-seconds"` +} + +// LoadConfig 使用 Viper 加载本地 config.yaml +func LoadConfig(path string, conf *AppConfig) { + viper.SetConfigFile(path) + viper.SetConfigType("yaml") + if err := viper.ReadInConfig(); err != nil { + panic("加载配置文件失败: " + err.Error()) + } + if err := viper.Unmarshal(conf); err != nil { + panic("解析配置文件失败: " + err.Error()) + } + AppConf = conf +} diff --git a/common/gin.go b/common/gin.go new file mode 100644 index 0000000..1bd8cd4 --- /dev/null +++ b/common/gin.go @@ -0,0 +1,128 @@ +package common + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +var _engine *gin.Engine + +type R struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data any `json:"data"` +} + +func Server(debug bool) { + if !debug { + gin.SetMode(gin.ReleaseMode) + } + _engine = gin.New() + _engine.Use(gin.Recovery()) +} + +func GetEngine() *gin.Engine { + return _engine +} + +func SetWriter(w io.Writer) { + gin.DefaultWriter = w + gin.DefaultErrorWriter = w +} + +func RouteGroup(path string) *gin.RouterGroup { + return _engine.Group(path) +} + +func ServerRun(port int32) { + _engine.Run(fmt.Sprintf(":%d", port)) +} + +func OK(c *gin.Context) { + c.JSON(http.StatusOK, R{Code: http.StatusOK, Msg: "ok"}) + c.Abort() +} + +func OKNoData(c *gin.Context) { + c.JSON(http.StatusNoContent, R{Code: http.StatusOK, Msg: "ok"}) + c.Abort() +} + +func OKWithData(c *gin.Context, obj interface{}) { + c.JSON(http.StatusOK, R{Code: http.StatusOK, Msg: "ok", Data: obj}) + c.Abort() +} + +func FailWithCodeAndMsg(c *gin.Context, code int, message string) { + c.JSON(http.StatusBadRequest, R{Code: code, Msg: message, Data: nil}) + c.Abort() +} + +func FailWithBusiError(c *gin.Context, busiError *BusiError) { + c.JSON(http.StatusBadRequest, R{Code: busiError.Code, Msg: busiError.Msg, Data: nil}) + c.Abort() +} + +func FailWithBusiErrorWithHttpStatus(c *gin.Context, status int, busiError *BusiError) { + c.JSON(status, R{Code: busiError.Code, Msg: busiError.Msg, Data: nil}) + c.Abort() +} + +func FailWithMsgWithHttpStatus(c *gin.Context, status int, msg string) { + c.JSON(status, R{Code: status, Msg: msg, Data: nil}) + c.Abort() +} + +func FailWithBusiErrorByServer(c *gin.Context, busiError *BusiError) { + c.JSON(http.StatusInternalServerError, R{Code: busiError.Code, Msg: busiError.Msg, Data: nil}) + c.Abort() +} + +func FailWithMsgByServer(c *gin.Context, message string) { + c.JSON(http.StatusInternalServerError, R{Code: http.StatusInternalServerError, Msg: message, Data: nil}) + c.Abort() +} + +func FailWithMsgAndErrByServer(c *gin.Context, message string, err error) { + c.JSON(http.StatusInternalServerError, R{Code: http.StatusInternalServerError, Msg: message, Data: err}) + c.Abort() +} + +func FailWithBindError(c *gin.Context, busiError *BusiError, err error) { + var msg string + if validationErrors, ok := err.(validator.ValidationErrors); ok { + errorMessages := validationErrors.Translate(GetTrans()) + messages := make([]string, 0) + for _, m := range errorMessages { + messages = append(messages, m) + } + msg = strings.Join(messages, "; ") + } + c.JSON(http.StatusBadRequest, R{Code: busiError.Code, Msg: msg, Data: msg}) + c.Abort() +} + +func FailWithBusiErrorAndData(c *gin.Context, busiError *BusiError, data any) { + c.JSON(http.StatusBadRequest, R{Code: busiError.Code, Msg: busiError.Msg, Data: data}) + c.Abort() +} + +func FailWithError(c *gin.Context, err error) { + c.JSON(http.StatusBadRequest, R{Code: http.StatusBadRequest, Msg: err.Error(), Data: nil}) + c.Abort() +} + +func FailWithMsg(c *gin.Context, message string) { + c.JSON(http.StatusBadRequest, R{Code: http.StatusInternalServerError, Msg: message, Data: nil}) + c.Abort() +} + +func FailWithMsgAndError(c *gin.Context, msg string, err error) { + c.JSON(http.StatusBadRequest, R{Code: http.StatusBadRequest, Msg: msg, Data: nil}) + c.Abort() +} diff --git a/common/mysql.go b/common/mysql.go new file mode 100644 index 0000000..4a6ac42 --- /dev/null +++ b/common/mysql.go @@ -0,0 +1,87 @@ +package common + +import ( + "errors" + "strings" + "time" + + "laic-backend/logger" + + mysql_ "github.com/go-sql-driver/mysql" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +var DB *gorm.DB +var err error + +func InitConnection(config *Mysql) { + url := config.Url + if !strings.Contains(url, "loc=") && !strings.Contains(url, "time_zone=") { + if strings.Contains(url, "?") { + url += "&loc=Local" + } else { + url += "?loc=Local" + } + } + idle := config.Idle + maxConn := config.MaxConn + maxWait := time.Duration(config.MaxWait) + logger.DEBUG("mysql config:", config) + + gConfig := &gorm.Config{ + Logger: logger.NewGormLogger(), + } + DB, err = gorm.Open(mysql.Open(url), gConfig) + if err != nil { + logger.ERROR("mysql connect error", err) + panic(err) + } + DB = DB.Debug() + + sqlDB, err := DB.DB() + if err != nil { + logger.ERROR("init mysql data pool error", err) + panic(err) + } + sqlDB.SetMaxIdleConns(idle) + sqlDB.SetMaxOpenConns(maxConn) + sqlDB.SetConnMaxLifetime(maxWait * time.Second) + + logger.INFO("mysql ready") +} + +type Pagination struct { + PageNum int64 `json:"pageNum" form:"pageNum" binding:"required" minimum:"1"` + PageSize int64 `json:"pageSize" form:"pageSize" binding:"required" minimum:"1"` +} + +func (p *Pagination) Paginate(db *gorm.DB) *gorm.DB { + offset := int((p.PageNum - 1) * p.PageSize) + return db.Offset(offset).Limit(int(p.PageSize)) +} + +// ParseError 解析 MySQL 唯一键冲突等错误,返回错误号和约束名 +func ParseError(err error) (uint16, string) { + if err != nil { + var mysqlErr *mysql_.MySQLError + if errors.As(err, &mysqlErr) { + if mysqlErr.Number == 1062 { + return mysqlErr.Number, extractConstraintName(mysqlErr.Message) + } + return mysqlErr.Number, "" + } else { + logger.ERROR("未知错误,gorm error:", err) + } + } + return 0, "" +} + +func extractConstraintName(errMsg string) string { + start := strings.LastIndex(errMsg, "for key '") + len("for key '") + end := strings.LastIndex(errMsg, "'") + if start < end { + return errMsg[start:end] + } + return "" +} diff --git a/common/oss.go b/common/oss.go new file mode 100644 index 0000000..61497ed --- /dev/null +++ b/common/oss.go @@ -0,0 +1,101 @@ +package common + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/url" + "strconv" + "strings" + "time" +) + +// PresignOSS 生成 S3 兼容的预签名 URL(MinIO / 阿里云 OSS),method 为 HTTP 方法(GET/PUT) +// objectKey 为对象 key(不含 bucket),expires 为有效期秒数。 +func PresignOSS(method, objectKey string, expires int64) (string, error) { + oss := AppConf.OSS + if oss.Endpoint == "" { + return "", NewBusiError(InternalError, "OSS 未配置") + } + u, err := url.Parse(oss.Endpoint) + if err != nil { + return "", err + } + host := u.Host + scheme := u.Scheme + if scheme == "" { + scheme = "https" + } + region := oss.Region + if region == "" { + region = "us-east-1" + } + const service = "s3" + + now := time.Now().UTC() + amzDate := now.Format("20060102T150405Z") + dateStamp := now.Format("20060102") + credentialScope := dateStamp + "/" + region + "/" + service + "/aws4_request" + + canonicalURI := encodeObjectPath(oss.Bucket + "/" + objectKey) + + query := url.Values{} + query.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256") + query.Set("X-Amz-Credential", oss.AccessKey+"/"+credentialScope) + query.Set("X-Amz-Date", amzDate) + query.Set("X-Amz-Expires", strconv.FormatInt(expires, 10)) + query.Set("X-Amz-SignedHeaders", "host") + + canonicalHeaders := "host:" + host + "\n" + const signedHeaders = "host" + const payloadHash = "UNSIGNED-PAYLOAD" + + canonicalRequest := strings.Join([]string{ + method, + canonicalURI, + query.Encode(), + canonicalHeaders, + signedHeaders, + payloadHash, + }, "\n") + + stringToSign := strings.Join([]string{ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + sha256Hex([]byte(canonicalRequest)), + }, "\n") + + signingKey := deriveSigningKey(oss.SecretKey, dateStamp, region, service) + signature := hex.EncodeToString(hmacSHA256(signingKey, []byte(stringToSign))) + + query.Set("X-Amz-Signature", signature) + return scheme + "://" + host + canonicalURI + "?" + query.Encode(), nil +} + +// encodeObjectPath 对对象路径做分段 URL 编码(保留 "/" 分隔符),并以 "/" 开头 +func encodeObjectPath(key string) string { + segs := strings.Split(key, "/") + for i, s := range segs { + segs[i] = url.PathEscape(s) + } + return "/" + strings.Join(segs, "/") +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func hmacSHA256(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +func deriveSigningKey(secret, date, region, service string) []byte { + kDate := hmacSHA256([]byte("AWS4"+secret), []byte(date)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(service)) + return hmacSHA256(kService, []byte("aws4_request")) +} diff --git a/common/pageutil.go b/common/pageutil.go new file mode 100644 index 0000000..34e3b8d --- /dev/null +++ b/common/pageutil.go @@ -0,0 +1,28 @@ +package common + +import "math" + +type PageRequest[T any] struct { + Page Pagination `json:"page"` + OrderBy string `json:"order_by"` + Desc bool `json:"desc"` + Query T `json:"query"` +} + +type PageResponse[T any] struct { + PageNum int64 `json:"pageNum"` + PageSize int64 `json:"pageSize"` + Pages int `json:"pages"` + Total int `json:"total"` + Records []T `json:"records"` +} + +func Page[T any](page Pagination, total int64, records []T) *PageResponse[T] { + return &PageResponse[T]{ + PageNum: page.PageNum, + PageSize: page.PageSize, + Pages: int(math.Ceil(float64(total) / float64(page.PageSize))), + Total: int(total), + Records: records, + } +} diff --git a/common/redis.go b/common/redis.go new file mode 100644 index 0000000..6a0ea0b --- /dev/null +++ b/common/redis.go @@ -0,0 +1,264 @@ +package common + +import ( + "encoding/json" + "time" + + "laic-backend/logger" + + "github.com/gomodule/redigo/redis" +) + +var _redis *redis.Pool + +func InitRedis(source string) { + _redis = &redis.Pool{ + MaxIdle: 3, + IdleTimeout: 240 * time.Second, + Dial: func() (redis.Conn, error) { return redis.DialURL(source) }, + TestOnBorrow: func(c redis.Conn, t time.Time) error { + if time.Since(t) < time.Minute { + return nil + } + _, err := c.Do("PING") + return err + }, + } + logger.INFO("redis ready") +} + +func GetRedisPool() *redis.Pool { + return _redis +} + +func GetString(key string) (string, error) { + c := _redis.Get() + defer c.Close() + return redis.String(c.Do("GET", key)) +} + +func GetBytes(key string) ([]byte, error) { + c := _redis.Get() + defer c.Close() + return redis.Bytes(c.Do("GET", key)) +} + +func GetInt64(key string) (int64, error) { + c := _redis.Get() + defer c.Close() + return redis.Int64(c.Do("GET", key)) +} + +func GetJSON[T any](key string) (*T, error) { + c := _redis.Get() + defer c.Close() + r, err := redis.Bytes(c.Do("GET", key)) + if err != nil { + return nil, err + } + var v T + err = json.Unmarshal(r, &v) + return &v, err +} + +func SetValue(key string, n any) error { + c := _redis.Get() + defer c.Close() + _, err := c.Do("SET", key, n) + return err +} + +func SetJSON(key string, n any) error { + c := _redis.Get() + defer c.Close() + data, err := json.Marshal(n) + if err != nil { + return err + } + _, err = c.Do("SET", key, data) + return err +} + +func SetJSONWithExpired(key string, n any, second uint) error { + c := _redis.Get() + defer c.Close() + data, err := json.Marshal(n) + if err != nil { + return err + } + _, err = c.Do("SET", key, data, "EX", second) + return err +} + +func SetValueWithExpired(key string, n any, second uint) error { + c := _redis.Get() + defer c.Close() + _, err := c.Do("SET", key, n, "EX", second) + return err +} + +func SetRemoveIfKeyMissing(setKey, presenceKey, member string) (bool, error) { + const script = `if redis.call('EXISTS', KEYS[2]) == 0 then return redis.call('SREM', KEYS[1], ARGV[1]) end return 0` + n, err := redis.Int(exec_lua(script, []string{setKey, presenceKey}, member)) + return n == 1, err +} + +// SetNX 仅当 key 不存在时写入,返回是否写入成功 +func SetNX(key string, value any) (bool, error) { + c := _redis.Get() + defer c.Close() + n, err := redis.Int(c.Do("SET", key, value, "NX")) + return n == 1, err +} + +// IncrBy 原子自增,返回自增后的值(计费充值用) +func IncrBy(key string, delta int64) (int64, error) { + c := _redis.Get() + defer c.Close() + return redis.Int64(c.Do("INCRBY", key, delta)) +} + +// DecrBy 原子自减,返回自减后的值(计费扣减用) +func DecrBy(key string, delta int64) (int64, error) { + c := _redis.Get() + defer c.Close() + return redis.Int64(c.Do("DECRBY", key, delta)) +} + +func HasKey(key string) bool { + c := _redis.Get() + defer c.Close() + exists, err := redis.Int(c.Do("EXISTS", key)) + if err != nil { + return false + } + return exists == 1 +} + +func HashGet(key string, field string) (string, error) { + c := _redis.Get() + defer c.Close() + return redis.String(c.Do("HGET", key, field)) +} + +func HashGetAll(key string) (map[string]string, error) { + c := _redis.Get() + defer c.Close() + return redis.StringMap(c.Do("HGETALL", key)) +} + +func HashSetValue(key string, field string, n any) error { + c := _redis.Get() + defer c.Close() + _, err := c.Do("HSET", key, field, n) + return err +} + +func HashSetValues(key string, values map[string]any) error { + c := _redis.Get() + defer c.Close() + args := make([]any, 0, len(values)*2+1) + args = append(args, key) + for k, v := range values { + args = append(args, k, v) + } + _, err := c.Do("HMSET", args...) + return err +} + +func SetMembers(key string) ([]string, error) { + c := _redis.Get() + defer c.Close() + return redis.Strings(c.Do("SMEMBERS", key)) +} + +func SetAdd(key string, n any) error { + c := _redis.Get() + defer c.Close() + _, err := c.Do("SADD", key, n) + return err +} + +func SetRemove(key string, n any) error { + c := _redis.Get() + defer c.Close() + _, err := c.Do("SREM", key, n) + return err +} + +func SetMemberExists(key string, value string) (bool, error) { + c := _redis.Get() + defer c.Close() + exists, err := redis.Int(c.Do("SISMEMBER", key, value)) + if err != nil { + return false, err + } + return exists == 1, nil +} + +func exec_lua(lua string, keys []string, args ...any) (any, error) { + c := _redis.Get() + defer c.Close() + keylen := 0 + if keys != nil { + keylen = len(keys) + } + arr := make([]any, 0, keylen+len(args)+2) + arr = append(arr, lua) + arr = append(arr, keylen) + for _, k := range keys { + arr = append(arr, k) + } + arr = append(arr, args...) + return c.Do("EVAL", arr...) +} + +func ExecLua(lua string, keys []string, args ...any) error { + _, err := exec_lua(lua, keys, args...) + return err +} + +func GetLuaInt64(lua string, keys []string, args ...any) (int64, error) { + return redis.Int64(exec_lua(lua, keys, args...)) +} + +func GetLuaStrings(lua string, keys []string, args ...any) ([]string, error) { + return redis.Strings(exec_lua(lua, keys, args...)) +} + +func GetLuaInt64s(lua string, keys []string, args ...any) ([]int64, error) { + return redis.Int64s(exec_lua(lua, keys, args...)) +} + +func Delete(key ...string) error { + c := _redis.Get() + defer c.Close() + param := make([]any, len(key)) + for i, k := range key { + param[i] = k + } + _, err := c.Do("DEL", param...) + return err +} + +func GetKeysWithPrefix(prefix string) ([]string, error) { + c := _redis.Get() + defer c.Close() + cursor := 0 + var keys []string + for { + values, err := redis.Values(c.Do("SCAN", cursor, "MATCH", prefix+"*")) + if err != nil { + return nil, err + } + var items []string + if _, err := redis.Scan(values, &cursor, &items); err != nil { + return nil, err + } + keys = append(keys, items...) + if cursor == 0 { + break + } + } + return keys, nil +} diff --git a/common/tdengine.go b/common/tdengine.go new file mode 100644 index 0000000..5885e8c --- /dev/null +++ b/common/tdengine.go @@ -0,0 +1,34 @@ +package common + +import ( + "database/sql" + "time" + + "laic-backend/logger" + + _ "github.com/taosdata/driver-go/v3/taosWS" +) + +var TD *sql.DB + +// InitTDengine 初始化 TDengine 连接池。Dsn 为空时跳过;连接失败仅告警不 panic(部署期确认) +func InitTDengine(config *TDengine) { + if config == nil || config.Dsn == "" { + logger.WARN("TDengine dsn 为空,跳过初始化") + return + } + db, err := sql.Open("taosWS", config.Dsn) + if err != nil { + logger.ERROR("TDengine connect error", err) + return + } + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(time.Hour) + if err := db.Ping(); err != nil { + logger.ERROR("TDengine ping error", err) + return + } + TD = db + logger.INFO("TDengine ready") +} diff --git a/common/validator.go b/common/validator.go new file mode 100644 index 0000000..3c47e64 --- /dev/null +++ b/common/validator.go @@ -0,0 +1,81 @@ +package common + +import ( + "regexp" + + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/locales/zh" + ut "github.com/go-playground/universal-translator" + "github.com/go-playground/validator/v10" + zh_translations "github.com/go-playground/validator/v10/translations/zh" +) + +var ( + phoneRegexp = regexp.MustCompile(`^1[3456789]\d{9}$`) + emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) +) + +// IsPhoneValid 验证手机号格式 +func IsPhoneValid(fl validator.FieldLevel) bool { + return phoneRegexp.MatchString(fl.Field().String()) +} + +// IsEmailValid 验证邮箱格式 +func IsEmailValid(fl validator.FieldLevel) bool { + return emailRegexp.MatchString(fl.Field().String()) +} + +func ValidatePassword(fl validator.FieldLevel) bool { + password := fl.Field().String() + if password == "" { + return true + } + if len(password) < 8 || len(password) > 16 { + return false + } + if !regexp.MustCompile(`[0-9]`).MatchString(password) || + !regexp.MustCompile(`[a-zA-Z]`).MatchString(password) { + return false + } + return true +} + +var trans ut.Translator + +func init() { + if v, ok := binding.Validator.Engine().(*validator.Validate); ok { + v.RegisterValidation("password", ValidatePassword) + v.RegisterValidation("email", IsEmailValid) + v.RegisterValidation("phone", IsPhoneValid) + + zhCn := zh.New() + uni := ut.New(zhCn, zhCn) + trans, _ = uni.GetTranslator("zh") + zh_translations.RegisterDefaultTranslations(v, trans) + + v.RegisterTranslation("password", trans, func(ut ut.Translator) error { + return ut.Add("password", "{0}必须包含数字、字母,长度在8-16位之间", true) + }, func(ut ut.Translator, fe validator.FieldError) string { + t, _ := ut.T("password", fe.Field()) + return t + }) + + v.RegisterTranslation("phone", trans, func(ut ut.Translator) error { + return ut.Add("phone", "{0}格式不正确", true) + }, func(ut ut.Translator, fe validator.FieldError) string { + t, _ := ut.T("phone", fe.Field()) + return t + }) + + v.RegisterTranslation("email", trans, func(ut ut.Translator) error { + return ut.Add("email", "{0}格式不正确", true) + }, func(ut ut.Translator, fe validator.FieldError) string { + t, _ := ut.T("email", fe.Field()) + return t + }) + } +} + +func GetTrans() ut.Translator { + return trans +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..ffed137 --- /dev/null +++ b/config.yaml @@ -0,0 +1,47 @@ +api-port: 8080 +mysql: + url: root:123456@tcp(127.0.0.1:3306)/laic?charset=utf8mb4&parseTime=True&loc=Local + idle: 10 + max-conn: 50 + max-wait: 3600 +tdengine: + dsn: root:taosdata@ws(127.0.0.1:6041)/laic +redis: redis://127.0.0.1:6379/0 +mqtt: + broker: tcp://127.0.0.1:1883 + client-id: laic-backend + username: laic + password: "" +jwt: + secret: laic-jwt-secret-change-me + access-expire-hours: 168 # 7 天 + refresh-expire-hours: 720 # 30 天 +oss: + endpoint: http://127.0.0.1:9000 + access-key: minioadmin + secret-key: minioadmin + bucket: laic-media + region: us-east-1 + use-ssl: false +live: + mode: fake + provider: fake + push-domain: local-push + play-domain: local-play + app-name: dock-live + auth-expire-seconds: 7200 + play-url-ttl-seconds: 300 + protocol: srt + srt-port: 1105 + max-session-seconds: 7200 + viewer-lease-seconds: 30 + stop-grace-seconds: 15 + reconcile-interval-seconds: 10 + record-policy: disabled + allow-real-cloud: false +heartbeat: + timeout-seconds: 30 + scan-seconds: 5 +log: + level: debug + path: ./logs/ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2503cd1 --- /dev/null +++ b/go.mod @@ -0,0 +1,94 @@ +module laic-backend + +go 1.26.4 + +require ( + github.com/bluenviron/gomavlib/v3 v3.3.5 + github.com/casbin/casbin/v3 v3.8.1 + github.com/casbin/gorm-adapter/v3 v3.41.0 + github.com/eclipse/paho.mqtt.golang v1.5.1 + github.com/gin-gonic/gin v1.12.0 + github.com/go-playground/locales v0.14.1 + github.com/go-playground/universal-translator v0.18.1 + github.com/go-playground/validator/v10 v10.30.3 + github.com/go-sql-driver/mysql v1.10.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gomodule/redigo v1.9.3 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/robfig/cron/v3 v3.0.1 + github.com/spf13/viper v1.21.0 + github.com/taosdata/driver-go/v3 v3.3.1 + golang.org/x/crypto v0.52.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.2 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.22.0 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microsoft/go-mssqldb v1.9.5 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pion/logging v0.2.2 // indirect + github.com/pion/transport/v2 v2.2.10 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.bug.st/serial v1.7.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlserver v1.6.3 // indirect + gorm.io/plugin/dbresolver v1.6.2 // indirect + modernc.org/libc v1.67.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.42.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..533b71a --- /dev/null +++ b/go.sum @@ -0,0 +1,409 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= +github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/bluenviron/gomavlib/v3 v3.3.5 h1:4e0rxKqPH1ar1cJFkft0mjsdBDnVJKWUhDQQuuqhvyk= +github.com/bluenviron/gomavlib/v3 v3.3.5/go.mod h1:tm3CnJMWdWqDGtHwUpmM2B8VY7TF67hCrCNBrwjYJpc= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/casbin/casbin/v3 v3.8.1 h1:D4dEY4knePPR4YgNP5WZtWNaOxD0UK0LpPy9+zxtBwo= +github.com/casbin/casbin/v3 v3.8.1/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8= +github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM= +github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrHfzpBpPcNTyMGeGA= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= +github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= +github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0= +github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q= +github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= +github.com/pion/transport/v3 v3.0.6 h1:k1mQU06bmmX143qSWgXFqSH1KUJceQvIUuVH/K5ELWw= +github.com/pion/transport/v3 v3.0.6/go.mod h1:HvJr2N/JwNJAfipsRleqwFoR3t/pWyHeZUs89v3+t5s= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/taosdata/driver-go/v3 v3.3.1 h1:UXIoE+TbdbdRHtaNjIuMAQn+SjAAKUJHyeONAt5F1eI= +github.com/taosdata/driver-go/v3 v3.3.1/go.mod h1:H2vo/At+rOPY1aMzUV9P49SVX7NlXb3LAbKw+MCLrmU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc= +go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI= +gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg= +modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74= +modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/handler/account_handler.go b/handler/account_handler.go new file mode 100644 index 0000000..5887df5 --- /dev/null +++ b/handler/account_handler.go @@ -0,0 +1,166 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// GetProfile 查询个人资料 +func GetProfile(c *gin.Context) { + user, busiErr := service.DefaultAccountService.GetProfile(common.GetUserId(c)) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, user) +} + +// UpdateProfile 更新个人资料 +func UpdateProfile(c *gin.Context) { + var req vo.ProfileUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + user, busiErr := service.DefaultAccountService.UpdateProfile(common.GetUserId(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, user) +} + +// GetTrafficBalance 查询流量余额 +func GetTrafficBalance(c *gin.Context) { + bal, busiErr := service.DefaultAccountService.GetTrafficBalance(common.GetUserId(c)) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, bal) +} + +// GetUsagePage 流量消费流水分页 +func GetUsagePage(c *gin.Context) { + var req vo.UsagePageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultAccountService.GetUsagePage(common.GetUserId(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// GetOrderPage 流量订单分页 +func GetOrderPage(c *gin.Context) { + var req vo.OrderPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultAccountService.GetOrderPage(common.GetUserId(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// CreateTrafficOrder 创建流量充值订单 +func CreateTrafficOrder(c *gin.Context) { + var req vo.TrafficOrderCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + order, busiErr := service.DefaultAccountService.CreateTrafficOrder(common.GetUserId(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, order) +} + +// PayTrafficOrder 订单支付入账(admin) +func PayTrafficOrder(c *gin.Context) { + orderID, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + order, busiErr := service.DefaultAccountService.PayTrafficOrder(orderID) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, order) +} + +// ListSimCards SIM 卡列表 +func ListSimCards(c *gin.Context) { + var req vo.SimCardPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultAccountService.ListSimCards(common.GetUserId(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// GetSimRechargeLogPage SIM 卡充值记录分页 +func GetSimRechargeLogPage(c *gin.Context) { + var req vo.SimRechargeLogPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultAccountService.GetSimRechargeLogPage(common.GetUserId(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// RechargeSimCard SIM 卡充值 +func RechargeSimCard(c *gin.Context) { + simCardID, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + var req vo.SimRechargeReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + log, busiErr := service.DefaultAccountService.RechargeSimCard(common.GetUserId(c), simCardID, &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, log) +} diff --git a/handler/alarm_handler.go b/handler/alarm_handler.go new file mode 100644 index 0000000..347cecb --- /dev/null +++ b/handler/alarm_handler.go @@ -0,0 +1,55 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// GetAlarmPage 告警分页列表 +func GetAlarmPage(c *gin.Context) { + var req vo.AlarmPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultAlarmService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// AcknowledgeAlarm 确认告警 +func AcknowledgeAlarm(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + if busiErr := service.DefaultAlarmService.Acknowledge(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} + +// ResolveAlarm 关闭告警 +func ResolveAlarm(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + if busiErr := service.DefaultAlarmService.Resolve(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} diff --git a/handler/auth_handler.go b/handler/auth_handler.go new file mode 100644 index 0000000..581d7ce --- /dev/null +++ b/handler/auth_handler.go @@ -0,0 +1,98 @@ +package handler + +import ( + "strings" + + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// Register 用户注册 +func Register(c *gin.Context) { + var req vo.RegisterReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + resp, busiErr := service.DefaultUserService.Register(req.Name, req.Phone, req.Email, req.Password) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, resp) +} + +// Login 用户登录 +func Login(c *gin.Context) { + var req vo.UserLoginReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + resp, busiErr := service.DefaultUserService.Login(req.Phone, req.Password) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, resp) +} + +// Refresh 刷新令牌 +func Refresh(c *gin.Context) { + var req vo.RefreshReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + resp, busiErr := service.DefaultUserService.Refresh(req.RefreshToken) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, resp) +} + +// Logout 登出 +func Logout(c *gin.Context) { + var req vo.LogoutReq + _ = c.ShouldBindJSON(&req) // refreshToken 可选,忽略绑定错误 + + accessToken := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") + userID := common.GetUserId(c) + if busiErr := service.DefaultUserService.Logout(userID, accessToken, req.RefreshToken); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} + +// SmsCode 发送短信验证码 +func SmsCode(c *gin.Context) { + var req vo.SmsCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + if _, busiErr := service.DefaultUserService.SendSmsCode(req.Phone); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} + +// ResetPassword 重置密码 +func ResetPassword(c *gin.Context) { + var req vo.ResetPasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + if busiErr := service.DefaultUserService.ResetPassword(req.Phone, req.SmsCode, req.NewPassword); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} diff --git a/handler/command_handler.go b/handler/command_handler.go new file mode 100644 index 0000000..1d63dbd --- /dev/null +++ b/handler/command_handler.go @@ -0,0 +1,62 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// DispatchCommand 下发设备指令 +func DispatchCommand(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + var req vo.CommandReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + cmd, busiErr := service.DefaultCommandService.Dispatch(common.GetUserId(c), common.IsAdmin(c), id, &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, cmd) +} + +// GetCommandPage 指令历史分页列表 +func GetCommandPage(c *gin.Context) { + var req vo.CommandPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultCommandService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// GetCommand 指令详情 +func GetCommand(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + cmd, busiErr := service.DefaultCommandService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, cmd) +} diff --git a/handler/dock_handler.go b/handler/dock_handler.go new file mode 100644 index 0000000..6da21bc --- /dev/null +++ b/handler/dock_handler.go @@ -0,0 +1,106 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// GetDockPage 机巢分页列表 +func GetDockPage(c *gin.Context) { + var req vo.DockPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultDockService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// GetDock 机巢详情 +func GetDock(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + dock, busiErr := service.DefaultDockService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, dock) +} + +// GetDockStatus 机巢实时状态 +func GetDockStatus(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + status, busiErr := service.DefaultDockService.GetStatus(common.GetUserId(c), common.IsAdmin(c), id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, status) +} + +// CreateDock 手动登记机巢 +func CreateDock(c *gin.Context) { + var req vo.DockCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + dock, busiErr := service.DefaultDockService.Create(common.GetUserId(c), common.IsAdmin(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, dock) +} + +// UpdateDock 更新/认领机巢 +func UpdateDock(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + var req vo.DockUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + dock, busiErr := service.DefaultDockService.Update(common.GetUserId(c), common.IsAdmin(c), id, &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, dock) +} + +// DeleteDock 删除机巢 +func DeleteDock(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + if busiErr := service.DefaultDockService.Delete(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} diff --git a/handler/drone_handler.go b/handler/drone_handler.go new file mode 100644 index 0000000..44f0117 --- /dev/null +++ b/handler/drone_handler.go @@ -0,0 +1,91 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// GetDronePage 无人机分页列表 +func GetDronePage(c *gin.Context) { + var req vo.DronePageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultDroneService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// GetDrone 无人机详情 +func GetDrone(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + drone, busiErr := service.DefaultDroneService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, drone) +} + +// GetDroneTelemetry 无人机实时遥测 +func GetDroneTelemetry(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + telemetry, busiErr := service.DefaultDroneService.GetTelemetry(common.GetUserId(c), common.IsAdmin(c), id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, telemetry) +} + +// UpdateDrone 更新无人机 +func UpdateDrone(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + var req vo.DroneUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + drone, busiErr := service.DefaultDroneService.Update(common.GetUserId(c), common.IsAdmin(c), id, &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, drone) +} + +// DeleteDrone 删除无人机 +func DeleteDrone(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + if busiErr := service.DefaultDroneService.Delete(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} diff --git a/handler/execution_handler.go b/handler/execution_handler.go new file mode 100644 index 0000000..57b4a3e --- /dev/null +++ b/handler/execution_handler.go @@ -0,0 +1,57 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// GetExecutionPage 执行记录分页列表 +func GetExecutionPage(c *gin.Context) { + var req vo.ExecutionPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultExecutionService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// GetExecution 执行记录详情 +func GetExecution(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + exec, busiErr := service.DefaultExecutionService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, exec) +} + +// GetTrajectory 执行轨迹 +func GetTrajectory(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + traj, busiErr := service.DefaultExecutionService.GetTrajectory(common.GetUserId(c), common.IsAdmin(c), id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, traj) +} diff --git a/handler/firmware_handler.go b/handler/firmware_handler.go new file mode 100644 index 0000000..0aee7cf --- /dev/null +++ b/handler/firmware_handler.go @@ -0,0 +1,120 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// GetFirmwarePage 固件分页列表(admin) +func GetFirmwarePage(c *gin.Context) { + var req vo.FirmwarePageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultFirmwareService.GetPage(&req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// GetReleasedFirmwares 已发布固件列表(普通用户选择升级) +func GetReleasedFirmwares(c *gin.Context) { + list, busiErr := service.DefaultFirmwareService.GetReleasedList() + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, list) +} + +// GetFirmware 固件详情(admin) +func GetFirmware(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + fw, busiErr := service.DefaultFirmwareService.GetDetail(id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, fw) +} + +// CreateFirmware 新增固件版本(admin) +func CreateFirmware(c *gin.Context) { + var req vo.FirmwareCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + fw, busiErr := service.DefaultFirmwareService.Create(common.GetUserId(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, fw) +} + +// UpdateFirmware 编辑固件版本(admin) +func UpdateFirmware(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + var req vo.FirmwareUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + if busiErr := service.DefaultFirmwareService.Update(id, &req); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} + +// DeleteFirmware 删除固件版本(admin) +func DeleteFirmware(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + if busiErr := service.DefaultFirmwareService.Delete(id); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} + +// UpgradeFirmware 下发 OTA 升级(对所属机巢) +func UpgradeFirmware(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + var req vo.FirmwareUpgradeReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + result, busiErr := service.DefaultFirmwareService.Upgrade(common.GetUserId(c), common.IsAdmin(c), id, req.FirmwareID) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, result) +} diff --git a/handler/live_handler.go b/handler/live_handler.go new file mode 100644 index 0000000..a50d697 --- /dev/null +++ b/handler/live_handler.go @@ -0,0 +1,133 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// GetLivePage 直播会话分页列表 +func GetLivePage(c *gin.Context) { + var req vo.LivePageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultLiveService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// CreateLiveSession 创建或加入机巢的唯一活动直播会话,并创建观看租约。 +func CreateLiveSession(c *gin.Context) { + dockID := c.Param("dockId") + if dockID == "" { + common.FailWithBusiError(c, common.ErrParam) + return + } + var req vo.LiveSessionCreateReq + _ = c.ShouldBindJSON(&req) + result, busiErr := service.DefaultLiveService.Join(common.GetUserId(c), common.IsAdmin(c), dockID, &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, result) +} + +// GetLiveSession 获取指定直播会话。 +func GetLiveSession(c *gin.Context) { + dockID, sessionID := c.Param("dockId"), c.Param("streamSessionId") + if dockID == "" || sessionID == "" { + common.FailWithBusiError(c, common.ErrParam) + return + } + session, busiErr := service.DefaultLiveService.GetSession(common.GetUserId(c), common.IsAdmin(c), dockID, sessionID) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, session) +} + +// HeartbeatLiveSession 续期当前用户的观看租约。 +func HeartbeatLiveSession(c *gin.Context) { + dockID, sessionID := c.Param("dockId"), c.Param("streamSessionId") + if dockID == "" || sessionID == "" { + common.FailWithBusiError(c, common.ErrParam) + return + } + result, busiErr := service.DefaultLiveService.Heartbeat(common.GetUserId(c), common.IsAdmin(c), dockID, sessionID) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, result) +} + +// LeaveLiveSession 释放当前用户的观看租约。 +func LeaveLiveSession(c *gin.Context) { + dockID, sessionID := c.Param("dockId"), c.Param("streamSessionId") + if dockID == "" || sessionID == "" { + common.FailWithBusiError(c, common.ErrParam) + return + } + if busiErr := service.DefaultLiveService.Leave(common.GetUserId(c), common.IsAdmin(c), dockID, sessionID); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} + +// StartLive 启动直播推流 +func StartLive(c *gin.Context) { + dockID := c.Param("dockId") + if dockID == "" { + common.FailWithBusiError(c, common.ErrParam) + return + } + var req vo.LiveStartReq + _ = c.ShouldBindJSON(&req) + session, busiErr := service.DefaultLiveService.Start(common.GetUserId(c), common.IsAdmin(c), dockID, &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, session) +} + +func StopLive(c *gin.Context) { + dockID := c.Param("dockId") + if dockID == "" { + common.FailWithBusiError(c, common.ErrParam) + return + } + if busiErr := service.DefaultLiveService.Stop(common.GetUserId(c), common.IsAdmin(c), dockID); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} + +// GetLivePlayURL 获取直播播放地址 +func GetLivePlayURL(c *gin.Context) { + dockID := c.Param("dockId") + if dockID == "" { + common.FailWithBusiError(c, common.ErrParam) + return + } + play, busiErr := service.DefaultLiveService.GetPlayURL(common.GetUserId(c), common.IsAdmin(c), dockID) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, play) +} diff --git a/handler/route_handler.go b/handler/route_handler.go new file mode 100644 index 0000000..9fd3411 --- /dev/null +++ b/handler/route_handler.go @@ -0,0 +1,91 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// GetRoutePage 航线分页列表 +func GetRoutePage(c *gin.Context) { + var req vo.RoutePageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultRouteService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// GetRoute 航线详情 +func GetRoute(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + route, busiErr := service.DefaultRouteService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, route) +} + +// CreateRoute 新增航线 +func CreateRoute(c *gin.Context) { + var req vo.RouteCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + route, busiErr := service.DefaultRouteService.Create(common.GetUserId(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, route) +} + +// UpdateRoute 编辑航线 +func UpdateRoute(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + var req vo.RouteUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + route, busiErr := service.DefaultRouteService.Update(common.GetUserId(c), common.IsAdmin(c), id, &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, route) +} + +// DeleteRoute 删除航线 +func DeleteRoute(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + if busiErr := service.DefaultRouteService.Delete(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} diff --git a/handler/system_handler.go b/handler/system_handler.go new file mode 100644 index 0000000..ea8ab54 --- /dev/null +++ b/handler/system_handler.go @@ -0,0 +1,102 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// GetUserPage 用户分页列表(admin) +func GetUserPage(c *gin.Context) { + var req vo.UserPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultSystemService.GetUserPage(&req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// CreateUser 创建用户(admin) +func CreateUser(c *gin.Context) { + var req vo.UserCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + user, busiErr := service.DefaultSystemService.CreateUser(&req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, user) +} + +// UpdateUser 编辑用户(admin) +func UpdateUser(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + var req vo.UserUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + user, busiErr := service.DefaultSystemService.UpdateUser(id, &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, user) +} + +// DeleteUser 删除用户(admin) +func DeleteUser(c *gin.Context) { + id, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + if busiErr := service.DefaultSystemService.DeleteUser(id); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} + +// GetRoles 角色列表(admin) +func GetRoles(c *gin.Context) { + common.OKWithData(c, service.DefaultSystemService.GetRoles()) +} + +// GetOperationLogPage 操作日志分页(admin 全量,user 仅本人) +func GetOperationLogPage(c *gin.Context) { + var req vo.OperationLogPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + if !common.IsAdmin(c) { + req.UserID = common.GetUserId(c) + } + page, busiErr := service.DefaultOperationLogService.GetPage(&req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} diff --git a/handler/task_handler.go b/handler/task_handler.go new file mode 100644 index 0000000..1464067 --- /dev/null +++ b/handler/task_handler.go @@ -0,0 +1,105 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// GetTaskPage 任务分页列表 +func GetTaskPage(c *gin.Context) { + var req vo.TaskPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultTaskService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// GetTask 任务详情 +func GetTask(c *gin.Context) { + id, busiErr := parseStringID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + task, busiErr := service.DefaultTaskService.GetDetail(common.GetUserId(c), common.IsAdmin(c), id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, task) +} + +// CreateTask 新增任务 +func CreateTask(c *gin.Context) { + var req vo.TaskCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + task, busiErr := service.DefaultTaskService.Create(common.GetUserId(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, task) +} + +// UpdateTask 编辑任务 +func UpdateTask(c *gin.Context) { + id, busiErr := parseStringID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + var req vo.TaskUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + if busiErr := service.DefaultTaskService.Update(common.GetUserId(c), common.IsAdmin(c), id, &req); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} + +// DeleteTask 删除任务 +func DeleteTask(c *gin.Context) { + id, busiErr := parseStringID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + if busiErr := service.DefaultTaskService.Delete(common.GetUserId(c), common.IsAdmin(c), id); busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OK(c) +} + +// ExecuteTask 立即执行任务 +func ExecuteTask(c *gin.Context) { + id, busiErr := parseStringID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + result, busiErr := service.DefaultTaskService.Execute(common.GetUserId(c), common.IsAdmin(c), id) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, result) +} diff --git a/handler/util.go b/handler/util.go new file mode 100644 index 0000000..86795e3 --- /dev/null +++ b/handler/util.go @@ -0,0 +1,27 @@ +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "laic-backend/common" +) + +// parseID 解析路径参数 :id 为 int64 +func parseID(c *gin.Context) (int64, *common.BusiError) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + return 0, common.ErrParam + } + return id, nil +} + +// parseStringID 解析路径参数 :id 为字符串(如任务 ID) +func parseStringID(c *gin.Context) (string, *common.BusiError) { + id := c.Param("id") + if id == "" { + return "", common.ErrParam + } + return id, nil +} diff --git a/handler/video_handler.go b/handler/video_handler.go new file mode 100644 index 0000000..c33dce4 --- /dev/null +++ b/handler/video_handler.go @@ -0,0 +1,89 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/service" + "laic-backend/vo" +) + +// CreateVideoUpload 申请视频上传 +func CreateVideoUpload(c *gin.Context) { + var req vo.VideoUploadReq + if err := c.ShouldBindJSON(&req); err != nil { + common.FailWithBindError(c, common.ErrParam, err) + return + } + result, busiErr := service.DefaultVideoService.CreateUpload(common.GetUserId(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, result) +} + +// CompleteVideoUpload 上传完成确认 +func CompleteVideoUpload(c *gin.Context) { + videoID, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + var req vo.VideoCompleteReq + _ = c.ShouldBindJSON(&req) + video, busiErr := service.DefaultVideoService.Complete(common.GetUserId(c), videoID, &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, video) +} + +// GetVideoPage 视频分页列表 +func GetVideoPage(c *gin.Context) { + var req vo.VideoPageReq + _ = c.ShouldBindQuery(&req) + if req.PageNum <= 0 { + req.PageNum = 1 + } + if req.PageSize <= 0 { + req.PageSize = 10 + } + page, busiErr := service.DefaultVideoService.GetPage(common.GetUserId(c), common.IsAdmin(c), &req) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, page) +} + +// GetVideo 视频详情 +func GetVideo(c *gin.Context) { + videoID, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + video, busiErr := service.DefaultVideoService.GetDetail(common.GetUserId(c), common.IsAdmin(c), videoID) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, video) +} + +// DownloadVideo 下载视频 +func DownloadVideo(c *gin.Context) { + videoID, busiErr := parseID(c) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + result, busiErr := service.DefaultVideoService.Download(common.GetUserId(c), common.IsAdmin(c), videoID) + if busiErr != nil { + common.FailWithBusiError(c, busiErr) + return + } + common.OKWithData(c, result) +} diff --git a/live/adapter.go b/live/adapter.go new file mode 100644 index 0000000..e798b92 --- /dev/null +++ b/live/adapter.go @@ -0,0 +1,61 @@ +package live + +import ( + "errors" + "time" +) + +var ErrDisabled = errors.New("live provider disabled") +var ErrInvalidConfig = errors.New("live provider configuration invalid") + +type Config struct { + Mode string + Provider string + PushDomain string + PlayDomain string + AppName string + AuthKey string + PlayAuthKey string + AuthExpireSeconds int64 + PlayURLTTLSeconds int64 + Protocol string + SRTPort int + AllowRealCloud bool +} + +type StreamRequest struct { + StreamName string + ExpiresAt time.Time + MaxBitrateBps int64 +} + +type StreamCredentials struct { + Provider string + PushURL string + ExpiresAt time.Time +} + +type PlayRequest struct { + StreamName string + ExpiresAt time.Time +} + +type PlayURLs struct { + HLS string + FLV string + RTMP string + ExpiresAt time.Time +} + +type OnlineStatus struct { + Online bool + BitrateBps int64 + ClientCount int +} + +type Adapter interface { + Provider() string + CreateStream(req StreamRequest) (StreamCredentials, error) + CreatePlayURLs(req PlayRequest) (PlayURLs, error) + QueryOnline(streamName string) (OnlineStatus, error) +} diff --git a/live/factory.go b/live/factory.go new file mode 100644 index 0000000..70f6705 --- /dev/null +++ b/live/factory.go @@ -0,0 +1,43 @@ +package live + +import ( + "strings" + + "laic-backend/common" +) + +func NewAdapter(conf common.Live) (Adapter, error) { + cfg := Config{ + Mode: strings.ToLower(conf.Mode), + Provider: strings.ToLower(conf.Provider), + PushDomain: conf.PushDomain, + PlayDomain: conf.PlayDomain, + AppName: conf.AppName, + AuthKey: conf.AuthKey, + PlayAuthKey: conf.PlayAuthKey, + AuthExpireSeconds: conf.AuthExpireSeconds, + PlayURLTTLSeconds: conf.PlayURLTTLSeconds, + Protocol: strings.ToLower(conf.Protocol), + SRTPort: conf.SRTPort, + AllowRealCloud: conf.AllowRealCloud, + } + switch cfg.Mode { + case "", "disabled": + return DisabledAdapter{}, nil + case "fake": + if cfg.PushDomain == "" { + cfg.PushDomain = "local-push" + } + if cfg.PlayDomain == "" { + cfg.PlayDomain = "local-play" + } + if cfg.AppName == "" { + cfg.AppName = "dock-live" + } + return FakeAdapter{PushDomain: cfg.PushDomain, PlayDomain: cfg.PlayDomain, AppName: cfg.AppName}, nil + case "aliyun": + return NewAliyunAdapter(cfg) + default: + return nil, ErrInvalidConfig + } +} diff --git a/live/providers.go b/live/providers.go new file mode 100644 index 0000000..bf0e2d2 --- /dev/null +++ b/live/providers.go @@ -0,0 +1,90 @@ +package live + +import ( + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "strconv" + "time" +) + +type DisabledAdapter struct{} + +func (DisabledAdapter) Provider() string { return "disabled" } +func (DisabledAdapter) CreateStream(StreamRequest) (StreamCredentials, error) { + return StreamCredentials{}, ErrDisabled +} +func (DisabledAdapter) CreatePlayURLs(PlayRequest) (PlayURLs, error) { return PlayURLs{}, ErrDisabled } +func (DisabledAdapter) QueryOnline(string) (OnlineStatus, error) { return OnlineStatus{}, ErrDisabled } + +// FakeAdapter is a local provider that never contacts a cloud service. +type FakeAdapter struct { + PlayDomain string + PushDomain string + AppName string +} + +func (f FakeAdapter) Provider() string { return "fake" } +func (f FakeAdapter) CreateStream(req StreamRequest) (StreamCredentials, error) { + return StreamCredentials{ + Provider: f.Provider(), + PushURL: "fake://" + f.PushDomain + "/" + f.AppName + "/" + req.StreamName, + ExpiresAt: req.ExpiresAt, + }, nil +} +func (f FakeAdapter) CreatePlayURLs(req PlayRequest) (PlayURLs, error) { + return PlayURLs{ + HLS: "fake://" + f.PlayDomain + "/" + f.AppName + "/" + req.StreamName + ".m3u8", + ExpiresAt: req.ExpiresAt, + }, nil +} +func (f FakeAdapter) QueryOnline(string) (OnlineStatus, error) { + return OnlineStatus{Online: true}, nil +} + +type AliyunAdapter struct { + cfg Config +} + +func NewAliyunAdapter(cfg Config) (Adapter, error) { + if !cfg.AllowRealCloud || cfg.PushDomain == "" || cfg.PlayDomain == "" || cfg.AppName == "" || cfg.AuthKey == "" || cfg.PlayAuthKey == "" { + return nil, ErrInvalidConfig + } + if cfg.Protocol == "" { + cfg.Protocol = "srt" + } + if cfg.SRTPort <= 0 { + cfg.SRTPort = 1105 + } + return AliyunAdapter{cfg: cfg}, nil +} + +func (a AliyunAdapter) Provider() string { return "aliyun" } +func (a AliyunAdapter) CreateStream(req StreamRequest) (StreamCredentials, error) { + return StreamCredentials{Provider: a.Provider(), PushURL: a.pushURL(req.StreamName, req.ExpiresAt), ExpiresAt: req.ExpiresAt}, nil +} +func (a AliyunAdapter) CreatePlayURLs(req PlayRequest) (PlayURLs, error) { + expires := req.ExpiresAt + path := "/" + a.cfg.AppName + "/" + req.StreamName + signature := sign(path, expires.Unix(), a.cfg.PlayAuthKey) + base := "https://" + a.cfg.PlayDomain + "/" + a.cfg.AppName + "/" + req.StreamName + return PlayURLs{HLS: base + ".m3u8?auth_key=" + signature, FLV: base + ".flv?auth_key=" + signature, RTMP: "rtmp://" + a.cfg.PlayDomain + "/" + a.cfg.AppName + "/" + req.StreamName, ExpiresAt: expires}, nil +} +func (a AliyunAdapter) QueryOnline(string) (OnlineStatus, error) { + return OnlineStatus{}, errors.New("aliyun online query is not configured") +} +func sign(path string, expiresAt int64, key string) string { + rand := strconv.FormatInt(expiresAt%10000, 10) + sum := md5.Sum([]byte(fmt.Sprintf("%s-%d-%s-%s-%s", path, expiresAt, rand, "0", key))) + return fmt.Sprintf("%d-%s-0-%s", expiresAt, rand, hex.EncodeToString(sum[:])) +} + +func (a AliyunAdapter) pushURL(streamName string, expires time.Time) string { + path := "/" + a.cfg.AppName + "/" + streamName + auth := sign(path, expires.Unix(), a.cfg.AuthKey) + if a.cfg.Protocol == "srt" { + return "srt://" + a.cfg.PushDomain + ":" + strconv.Itoa(a.cfg.SRTPort) + "?streamid=#!::h=" + a.cfg.PushDomain + ",r=" + path + "?auth_key=" + auth + ",m=publish" + } + return "rtmps://" + a.cfg.PushDomain + "/" + a.cfg.AppName + "/" + streamName + "?auth_key=" + auth +} diff --git a/logger/gorm_log.go b/logger/gorm_log.go new file mode 100644 index 0000000..4794538 --- /dev/null +++ b/logger/gorm_log.go @@ -0,0 +1,80 @@ +package logger + +import ( + "context" + "errors" + "fmt" + "log" + "path/filepath" + "runtime" + "time" + + "gorm.io/gorm/logger" +) + +type GormLogger struct { + LogLevel logger.LogLevel + logger *log.Logger +} + +func NewGormLogger() logger.Interface { + return &GormLogger{ + LogLevel: logger.Info, + logger: log.New(DefaultWriter, "", log.LstdFlags|log.Lshortfile), + } +} + +func (gl *GormLogger) LogMode(level logger.LogLevel) logger.Interface { + newLogger := *gl + newLogger.LogLevel = level + return &newLogger +} + +func (gl *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) { + if gl.LogLevel <= logger.Info { + DEBUG(fmt.Sprintf(msg, data...)) + } +} + +func (gl *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) { + if gl.LogLevel <= logger.Warn { + WARN(fmt.Sprintf(msg, data...)) + } +} + +func (gl *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) { + if gl.LogLevel <= logger.Error { + ERROR(fmt.Sprintf(msg, data...), nil) + } +} + +func (gl *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + if gl.LogLevel > logger.Silent { + elapsed := time.Since(begin) + sql, rows := fc() + + _, file, line, _ := runtime.Caller(3) + source := fmt.Sprintf("%s:%d", filepath.Base(file), line) + + logMsg := struct { + Source string + Duration string + Rows int64 + SQL string + }{ + Source: source, + Duration: fmt.Sprintf("%.2f:ms", float64(elapsed.Nanoseconds())/1e6), + Rows: rows, + SQL: sql, + } + + switch { + case err != nil && errors.Is(err, logger.ErrRecordNotFound): + ERROR(logMsg, err) + case err != nil: + ERROR(logMsg, err) + default: + DEBUG(logMsg) + } + } +} diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..1ffaba0 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,318 @@ +package logger + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "gopkg.in/natefinch/lumberjack.v2" +) + +const ( + Level_T Level = iota + Level_D + Level_I + Level_W + Level_E +) + +const ( + Reset = "\033[0m" + Red = "\033[31m" + Green = "\033[32m" + Yellow = "\033[33m" + Blue = "\033[34m" + Magenta = "\033[35m" + Cyan = "\033[36m" + White = "\033[37m" +) + +// LogConfig 日志配置 +type LogConfig struct { + LogPath string + MaxSize int + MaxBackups int + MaxAge int + Compress bool +} + +var defaultConfig = LogConfig{ + LogPath: "./log", + MaxSize: 50, + MaxBackups: 20, + MaxAge: 30, + Compress: true, +} + +type Level int + +// TraceContext 存储跟踪信息 +type TraceContext struct { + TraceID string + SpanID string +} + +// goroutineID 到 traceID 的映射 +var traceMap sync.Map + +// 获取当前 goroutine 的 ID +func getGoroutineID() uint64 { + b := make([]byte, 64) + b = b[:runtime.Stack(b, false)] + b = bytes.TrimPrefix(b, []byte("goroutine ")) + b = b[:bytes.IndexByte(b, ' ')] + n, _ := strconv.ParseUint(string(b), 10, 64) + return n +} + +// SetTraceID 设置当前 goroutine 的 traceID +func SetTraceID(traceID string) { + traceMap.Store(getGoroutineID(), traceID) +} + +// GetTraceID 获取当前 goroutine 的 traceID +func GetTraceID() string { + if id, ok := traceMap.Load(getGoroutineID()); ok { + return id.(string) + } + return "" +} + +// CleanTraceID 清理当前 goroutine 的 traceID +func CleanTraceID() { + traceMap.Delete(getGoroutineID()) +} + +type customLogWriter struct { + prefix string + output io.Writer +} + +var DefaultWriter io.Writer + +var ( + loglevel Level + _trace *log.Logger + _debug *log.Logger + _info *log.Logger + _warn *log.Logger + _error *log.Logger +) + +func SetLevel(level Level) { + loglevel = level +} + +func GetLevel() string { + return loglevel.String() +} + +func formatLogWithTrace(data ...any) string { + logData := formatLogData(data...) + return fmt.Sprintf("===> %v", logData) +} + +func formatLogData(data ...any) string { + var formattedData []string + for _, item := range data { + switch v := item.(type) { + case string: + formattedData = append(formattedData, v) + case int, int64, float64, bool: + formattedData = append(formattedData, fmt.Sprintf("%v", v)) + case []interface{}: + for _, elem := range v { + formattedData = append(formattedData, fmt.Sprintf("%+v", elem)) + } + default: + formattedData = append(formattedData, fmt.Sprintf("%+v", v)) + } + } + return strings.Join(formattedData, " ") +} + +func TRACE(data ...any) { + if loglevel <= Level_T { + _trace.Println(formatLogWithTrace(data...)) + } +} + +func INFO(data ...any) { + if loglevel <= Level_I { + _info.Println(formatLogWithTrace(data...)) + } +} + +func DEBUG(data ...any) { + if loglevel <= Level_D { + _debug.Println(formatLogWithTrace(data...)) + } +} + +func ERROR(data any, e error) { + if loglevel <= Level_E { + _error.Println(formatLogWithTrace(data), e) + } +} + +func WARN(data ...any) { + if loglevel <= Level_W { + _warn.Println(formatLogWithTrace(data...)) + } +} + +// AsyncWriter 异步写入器 +type AsyncWriter struct { + output io.Writer + ch chan []byte + wg sync.WaitGroup +} + +func NewAsyncWriter(output io.Writer) *AsyncWriter { + w := &AsyncWriter{ + output: output, + ch: make(chan []byte, 1000), + } + w.wg.Add(1) + go w.run() + return w +} + +func NewAsyncWriterWithSize(output io.Writer, size int) *AsyncWriter { + w := &AsyncWriter{ + output: output, + ch: make(chan []byte, size), + } + w.wg.Add(1) + go w.run() + return w +} + +func (w *AsyncWriter) run() { + defer w.wg.Done() + for data := range w.ch { + w.output.Write(data) + } +} + +func (w *AsyncWriter) Write(p []byte) (n int, err error) { + w.ch <- append([]byte(nil), p...) + return len(p), nil +} + +func (w *AsyncWriter) Close() { + close(w.ch) + w.wg.Wait() +} + +func InitCustomLog(level Level, config *LogConfig, logName string) { + if config == nil { + config = &defaultConfig + } + + SetLevel(level) + + if err := os.MkdirAll(config.LogPath, 0o755); err != nil { + panic(err) + } + + infoLogger := newLevelLogger(fmt.Sprintf("%s-info", logName), config) + errorLogger := newLevelLogger(fmt.Sprintf("%s-error", logName), config) + env := os.Getenv("LAIC_ENV") + + var output io.Writer + if env != "prod" && loglevel <= Level_D { + output = io.MultiWriter(os.Stdout, infoLogger) + } else { + output = infoLogger + } + + DefaultWriter = NewAsyncWriter(output) + + var errorOutput io.Writer + if loglevel <= Level_D { + errorOutput = NewAsyncWriter(io.MultiWriter(os.Stdout, errorLogger, infoLogger)) + } else { + errorOutput = NewAsyncWriter(io.MultiWriter(errorLogger, infoLogger)) + } + + _trace = newLogger(Level_T, Magenta, DefaultWriter) + _debug = newLogger(Level_D, Blue, DefaultWriter) + _info = newLogger(Level_I, Green, DefaultWriter) + _warn = newLogger(Level_W, Yellow, DefaultWriter) + _error = newLogger(Level_E, Red, errorOutput) + + log.SetOutput(DefaultWriter) + log.SetFlags(0) +} + +func newLevelLogger(level string, config *LogConfig) io.Writer { + return &lumberjack.Logger{ + Filename: filepath.Join(config.LogPath, level+".log"), + MaxSize: config.MaxSize, + MaxBackups: config.MaxBackups, + MaxAge: config.MaxAge, + Compress: config.Compress, + } +} + +func newLogger(level Level, color string, output io.Writer) *log.Logger { + logger := log.New(output, "", 0) + logger.SetOutput(&customLogWriter{ + prefix: fmt.Sprintf("%s[%s]: %s", color, strings.ToUpper(level.String()), Reset), + output: output, + }) + return logger +} + +// String 方法 +func (l Level) String() string { + switch l { + case Level_T: + return "TRACE" + case Level_D: + return "DEBUG" + case Level_I: + return "INFO" + case Level_W: + return "WARN" + case Level_E: + return "ERROR" + default: + return "UNKNOWN" + } +} + +func (c *customLogWriter) Write(p []byte) (n int, err error) { + buf := &bytes.Buffer{} + + traceID := GetTraceID() + + _, file, line, ok := runtime.Caller(4) + if !ok { + file = "unknown" + line = 0 + } + file = file[strings.LastIndex(file, "/")+1:] + + fmt.Fprintf(buf, "[%s] ", time.Now().Format("2006-01-02 15:04:05.999")) + fmt.Fprintf(buf, "%s", c.prefix) + if traceID != "" { + fmt.Fprintf(buf, " (%s) ", traceID) + } + fmt.Fprintf(buf, "[%s:%d] %s", file, line, p) + + return c.output.Write(buf.Bytes()) +} + +func GetLogWriter() io.Writer { + return DefaultWriter +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cd7d519 --- /dev/null +++ b/main.go @@ -0,0 +1,69 @@ +package main + +import ( + "os" + "os/signal" + "syscall" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/mqtt" + "laic-backend/route" + "laic-backend/service" + "laic-backend/token" +) + +func main() { + // 1. 初始化日志(先用默认配置,便于后续报错输出) + logger.InitCustomLog(logger.Level_D, nil, "laic-backend") + + // 2. 加载 config.yaml + conf := &common.AppConfig{} + common.LoadConfig("config.yaml", conf) + if err := service.InitLiveProvider(conf.Live); err != nil { + logger.ERROR("直播提供商初始化失败", err) + panic(err) + } + setLogLevel(conf.Log.Level) + token.Init(conf.JWT.Secret, conf.JWT.AccessExpireH, conf.JWT.RefreshExpireH) + + // 3. 初始化基础设施 + common.InitConnection(&conf.Mysql) + common.InitTDengine(&conf.TDengine) + common.InitRedis(conf.Redis) + common.InitCasbinEnforcer() + + // 4. 预热缓存 + 引导 Casbin 策略 + 启动遥测批量写入 + cache.WarmUp() + service.BootstrapCasbin() + service.BootstrapOperationLog() + service.DefaultTelemetryStore.Start() + + // 5. 启动 MQTT(订阅设备上行消息)与 HTTP + mqtt.InitMQTT(&conf.MQTT) + service.InitMQTTSubscriber() + service.DefaultCommandService.StartRetryScanner() + service.DefaultScheduler.Start() + route.InitRouter(int32(conf.ApiPort)) + logger.INFO("laic-backend starting on port:", conf.ApiPort) + common.ServerRun(int32(conf.ApiPort)) + + // 5. 优雅退出 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit +} + +func setLogLevel(level string) { + switch level { + case "info": + logger.SetLevel(logger.Level_I) + case "warn": + logger.SetLevel(logger.Level_W) + case "error": + logger.SetLevel(logger.Level_E) + default: + logger.SetLevel(logger.Level_D) + } +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..654e902 --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "net/http" + "slices" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/token" +) + +// AuthMiddleware 鉴权中间件:解析 JWT + Redis 存证校验 + 注入上下文 +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + tokenString := c.GetHeader("Authorization") + if tokenString == "" { + common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.ErrUnauthorized) + return + } + tokenString = strings.TrimPrefix(tokenString, "Bearer ") + if tokenString == "" { + common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.ErrUnauthorized) + return + } + + claims, err := token.ParseToken(tokenString) + if err != nil { + common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.ErrToken) + return + } + + // Redis 存证校验:登出会删除该 key,从而让已签发 token 失效 + storeKey := cache.UserTokenKeyOf(claims.UserID, token.GenerateShortID(tokenString)) + if !common.HasKey(storeKey) { + common.FailWithBusiErrorWithHttpStatus(c, http.StatusUnauthorized, common.SignOutToken) + return + } + + roles := claims.Roles + if roles == nil { + roles = []string{} + } + isAdmin := slices.Contains(roles, common.RoleAdmin) + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + c.Set("roles", roles) + c.Set("is_admin", isAdmin) + + // Casbin 权限校验(admin 与普通用户均按 p/g 策略校验,admin 仅限平台管理职能) + if common.CasbinEnforcer != nil { + ok, err := common.CasbinEnforcer.Enforce(strconv.FormatInt(claims.UserID, 10), c.Request.URL.Path, c.Request.Method) + if err != nil || !ok { + common.FailWithBusiErrorWithHttpStatus(c, http.StatusForbidden, common.ErrForbidden) + c.Abort() + return + } + } + c.Next() + } +} + +// AdminMiddleware 仅管理员可访问 +func AdminMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if !c.GetBool("is_admin") { + common.FailWithBusiErrorWithHttpStatus(c, http.StatusForbidden, common.ErrForbidden) + return + } + c.Next() + } +} diff --git a/middleware/cros.go b/middleware/cros.go new file mode 100644 index 0000000..5c4c6d5 --- /dev/null +++ b/middleware/cros.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// CORSMiddleware 跨域中间件 +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.Request.Header.Get("Origin") + if origin == "" { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + } else { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + } + c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Length, Content-Type") + c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers") + c.Header("Access-Control-Max-Age", "172800") + c.Header("Access-Control-Allow-Credentials", "true") + + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusOK) + return + } + c.Next() + } +} diff --git a/middleware/default_log.go b/middleware/default_log.go new file mode 100644 index 0000000..f696f15 --- /dev/null +++ b/middleware/default_log.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + + "laic-backend/logger" +) + +// DefaultLogMiddleware 访问日志:方法、路径、状态码、耗时、来源 IP +func DefaultLogMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + latency := time.Since(start) + logger.INFO( + c.Request.Method, + c.Request.URL.Path, + "status", c.Writer.Status(), + "ip", c.ClientIP(), + "latency", latency.String(), + ) + } +} diff --git a/middleware/operation_log.go b/middleware/operation_log.go new file mode 100644 index 0000000..26613f0 --- /dev/null +++ b/middleware/operation_log.go @@ -0,0 +1,155 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/model" + "laic-backend/service" +) + +// OperationLogMiddleware 记录设备控制写操作(POST/PUT/DELETE)到 operation_log +func OperationLogMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if !isMutating(c.Request.Method) { + c.Next() + return + } + path := c.Request.URL.Path + if !isDeviceOperation(path) { + c.Next() + return + } + c.Next() + + userID := c.GetInt64("user_id") + if userID == 0 { + return + } + result := "success" + if c.Writer.Status() >= http.StatusBadRequest { + result = "failed" + } + userName := c.GetString("username") + if name := resolveUserName(userID); name != "" { + userName = name + } + service.DefaultOperationLogService.Record( + userID, userName, + resolveModule(path), resolveAction(c.Request.Method, path), + path, result, c.ClientIP(), + ) + } +} + +func isMutating(method string) bool { + return method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete +} + +func isDeviceOperation(path string) bool { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) < 3 || parts[0] != "v1" { + return false + } + switch parts[1] { + case "docks": + return len(parts) >= 4 && (parts[3] == "command" || parts[3] == "firmware") + case "tasks": + return len(parts) >= 4 && parts[3] == "execute" + case "alarms": + return len(parts) >= 4 && (parts[3] == "acknowledge" || parts[3] == "resolve") + case "live": + return len(parts) >= 4 && (parts[3] == "start" || parts[3] == "stop") + default: + return false + } +} + +// resolveUserName 从 DB 读取用户显示名(快照),失败回退为空(由调用方回退 phone) +func resolveUserName(userID int64) string { + var u model.User + if err := common.DB.Select("name").First(&u, userID).Error; err == nil { + return u.Name + } + return "" +} + +// resolveModule 将路径首个资源段映射为中文模块名 +func resolveModule(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) < 2 { + return "系统" + } + switch parts[1] { + case "users": + return "用户管理" + case "roles": + return "角色管理" + case "docks": + return "机巢管理" + case "drones": + return "无人机管理" + case "alarms": + return "告警管理" + case "commands": + return "指令管理" + case "routes": + return "航线管理" + case "tasks": + return "任务管理" + case "executions": + return "执行记录" + case "firmwares": + return "固件管理" + case "live": + return "直播管理" + case "videos": + return "视频管理" + case "account": + return "账户中心" + case "logs": + return "系统管理" + case "auth": + return "认证" + default: + return "系统" + } +} + +// resolveAction 将 HTTP 方法 + 路径末尾段映射为中文动作 +func resolveAction(method, path string) string { + seg := path + if i := strings.LastIndex(seg, "/"); i >= 0 { + seg = seg[i+1:] + } + special := map[string]string{ + "execute": "执行任务", + "command": "下发指令", + "upgrade": "下发升级", + "pay": "支付订单", + "download": "下载视频", + "start": "开始直播", + "stop": "停止直播", + "complete": "确认上传", + "recharge": "充值", + "resolve": "处理告警", + "acknowledge": "确认告警", + "logout": "退出登录", + } + if a, ok := special[seg]; ok { + return a + } + switch method { + case http.MethodPost: + return "新增" + case http.MethodPut: + return "修改" + case http.MethodDelete: + return "删除" + default: + return method + } +} diff --git a/middleware/rate_limit.go b/middleware/rate_limit.go new file mode 100644 index 0000000..a20aecb --- /dev/null +++ b/middleware/rate_limit.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/logger" +) + +// rateLimitLua 固定窗口计数:首次计数设置过期时间,返回当前窗口计数 +const rateLimitLua = ` +local key = KEYS[1] +local ttl = tonumber(ARGV[1]) +local n = redis.call('INCR', key) +if n == 1 then + redis.call('EXPIRE', key, ttl) +end +return n +` + +// RateLimitMiddleware 基于 Redis 固定窗口的按 IP 限流(默认 300 次/分钟) +func RateLimitMiddleware(limit int64, windowSeconds int64) gin.HandlerFunc { + if limit <= 0 { + limit = 300 + } + if windowSeconds <= 0 { + windowSeconds = 60 + } + return func(c *gin.Context) { + key := fmt.Sprintf("laic:ratelimit:%s", c.ClientIP()) + n, err := common.GetLuaInt64(rateLimitLua, []string{key}, windowSeconds) + if err != nil { + // 限流依赖 Redis,异常时放行(避免 Redis 故障拖垮全站) + logger.ERROR("限流计数失败", err) + c.Next() + return + } + if n > limit { + common.FailWithBusiErrorWithHttpStatus(c, http.StatusTooManyRequests, common.NewBusiError(http.StatusTooManyRequests, "请求过于频繁,请稍后再试")) + return + } + c.Next() + } +} diff --git a/middleware/trace.go b/middleware/trace.go new file mode 100644 index 0000000..fe3352a --- /dev/null +++ b/middleware/trace.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "laic-backend/logger" +) + +// TraceMiddleware 为每个请求生成/透传 traceID,便于链路日志追踪 +func TraceMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + traceID := c.GetHeader("X-Trace-Id") + if traceID == "" { + traceID = strings.ReplaceAll(uuid.New().String(), "-", "")[:16] + } + logger.SetTraceID(traceID) + defer logger.CleanTraceID() + + c.Header("X-Trace-Id", traceID) + c.Set("trace_id", traceID) + c.Next() + } +} diff --git a/mock/dock.go b/mock/dock.go new file mode 100644 index 0000000..de35a65 --- /dev/null +++ b/mock/dock.go @@ -0,0 +1,638 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "time" + + paho "github.com/eclipse/paho.mqtt.golang" +) + +// downlinkEnvelope 后台下发指令的通用外层(协议文档 §4) +type downlinkEnvelope struct { + RequestID string `json:"requestId"` + DockID string `json:"dockId"` + DroneSN string `json:"droneSn"` + Payload json.RawMessage `json:"payload"` +} + +// commandMsg 后台下发的指令载荷(协议文档 §5.1,位于 payload 内) +type commandMsg struct { + CommandID string `json:"commandId"` + Type string `json:"type"` + TTLMs int `json:"ttlMs"` + Params map[string]any `json:"params"` + RequestID string // 来自通用外层 + DroneSN string // 来自通用外层 +} + +// droneTelemetry 是机巢直接生成的无人机模拟状态 +type droneTelemetry struct { + armed bool + flightMode string + latitude float64 + longitude float64 + altitude float64 + groundSpeed float64 + heading float64 + roll float64 + pitch float64 + yaw float64 + batteryPct int + batteryV float64 + satellites int + gpsQuality string + batteryPctDrain int +} + +// MockDock 模拟机巢:通过 MQTT 上云并直接上报机巢、无人机状态 +type MockDock struct { + spec dockSpec + + client paho.Client + + mu sync.Mutex + doorState string + droneOnline bool + tele droneTelemetry + inMission bool + missionPaused bool + missionCancel chan struct{} + missionCmdID string + uploadedRoute []missionWaypoint + liveSessionID string + liveStreaming bool + videoVersion int64 +} + +func newMockDock(spec dockSpec) *MockDock { + d := &MockDock{ + spec: spec, + doorState: "closed", + tele: droneTelemetry{ + flightMode: "STANDBY", + latitude: spec.DroneLat, + longitude: spec.DroneLon, + batteryPct: spec.Battery, + batteryV: 25.2, + satellites: 22, + gpsQuality: "RTK_FIX", + }, + droneOnline: true, + } + if spec.Flying { + d.doorState = "open" + d.tele.armed = true + d.tele.flightMode = "AUTO" + d.tele.altitude = 58 + d.tele.groundSpeed = 4.2 + d.tele.heading = 120 + } + return d +} + +func (d *MockDock) start(broker, username, password string) error { + // 1. MQTT 连接后台 EMQX + opts := paho.NewClientOptions(). + AddBroker(broker). + SetClientID("mock-dock-" + d.spec.DockID). + SetUsername(username). + SetPassword(password). + SetAutoReconnect(true). + SetCleanSession(true) + opts.SetOnConnectHandler(func(c paho.Client) { d.onMqttConnect(c) }) + d.client = paho.NewClient(opts) + if tok := d.client.Connect(); tok.Wait() && tok.Error() != nil { + return fmt.Errorf("MQTT 连接失败: %w", tok.Error()) + } + + go d.publishLoop() + + log.Printf("[%s] 机巢启动(MQTT 直接模拟无人机数据)", d.spec.DockID) + return nil +} + +func (d *MockDock) topic(sub string) string { + return fmt.Sprintf("dock-edge/v1/dock/%s/%s", d.spec.DockID, sub) +} + +// publish 上发一条不含 droneSn 的消息(外层统一包 envelope) +func (d *MockDock) publish(topic string, qos byte, retained bool, payload any) { + d.publishEnvelope(topic, qos, retained, "", "", payload) +} + +// publishDrone 上发一条携带 droneSn 的消息 +func (d *MockDock) publishDrone(topic string, qos byte, retained bool, droneSN string, payload any) { + d.publishEnvelope(topic, qos, retained, "", droneSN, payload) +} + +func (d *MockDock) publishEnvelope(topic string, qos byte, retained bool, requestID, droneSN string, payload any) { + if d.client == nil || !d.client.IsConnected() { + return + } + b, err := json.Marshal(d.wrap(requestID, droneSN, payload)) + if err != nil { + return + } + d.client.Publish(topic, qos, retained, b) +} + +func (d *MockDock) publishVideoState(payload any, version int64) { + if d.client == nil || !d.client.IsConnected() { + return + } + b, err := json.Marshal(map[string]any{ + "requestId": nil, + "eventId": fmt.Sprintf("video-%s-%d", d.spec.DockID, version), + "version": version, + "dockId": d.spec.DockID, + "droneSn": nil, + "timestamp": time.Now().UnixMilli(), + "payload": payload, + }) + if err != nil { + return + } + d.client.Publish(d.topic("state/video"), 1, true, b) +} + +// wrap 构造通用外层;requestId / droneSn 为空时序列化为 null +func (d *MockDock) wrap(requestID, droneSN string, payload any) map[string]any { + return map[string]any{ + "requestId": nilIfEmpty(requestID), + "dockId": d.spec.DockID, + "droneSn": nilIfEmpty(droneSN), + "timestamp": time.Now().UnixMilli(), + "payload": payload, + } +} + +func (d *MockDock) onMqttConnect(c paho.Client) { + log.Printf("[%s] MQTT 已连接", d.spec.DockID) + if tok := c.Subscribe(d.topic("command"), 1, d.onCommand); tok.Wait() && tok.Error() != nil { + log.Printf("[%s] 订阅指令失败: %v", d.spec.DockID, tok.Error()) + } + d.publishStatusOnline() + d.publishStateDock() + d.publishStateDrone() + d.publishStateVideo() +} + +func (d *MockDock) publishLoop() { + tick1 := time.NewTicker(time.Second) + tick5 := time.NewTicker(5 * time.Second) + defer tick1.Stop() + defer tick5.Stop() + for { + select { + case <-tick1.C: + d.publishTelemetry() + case <-tick5.C: + d.publishStateDrone() + d.publishStateDock() + } + } +} + +// ---- 指令下发 ---- + +func (d *MockDock) onCommand(_ paho.Client, msg paho.Message) { + var env downlinkEnvelope + if err := json.Unmarshal(msg.Payload(), &env); err != nil { + log.Printf("[%s] 解析指令外层失败: %v", d.spec.DockID, err) + return + } + var cmd commandMsg + if err := json.Unmarshal(env.Payload, &cmd); err != nil { + log.Printf("[%s] 解析指令载荷失败: %v", d.spec.DockID, err) + return + } + cmd.RequestID = env.RequestID + cmd.DroneSN = env.DroneSN + log.Printf("[%s] 收到指令 %s (commandId=%s)", d.spec.DockID, cmd.Type, cmd.CommandID) + + switch { + case strings.HasPrefix(cmd.Type, "dock."): + d.handleDockCommand(cmd) + case strings.HasPrefix(cmd.Type, "drone."): + d.handleDroneCommand(cmd) + case strings.HasPrefix(cmd.Type, "workflow."): + d.handleWorkflowCommand(cmd) + case strings.HasPrefix(cmd.Type, "video."): + d.handleVideoCommand(cmd) + default: + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND") + } +} + +func (d *MockDock) handleDockCommand(cmd commandMsg) { + switch cmd.Type { + case "dock.open": + d.setDoor("open") + case "dock.close": + d.setDoor("closed") + case "dock.reset": + d.setDoor("closed") + case "dock.prepare_takeoff", "dock.complete_takeoff", "dock.prepare_landing", "dock.complete_landing", + "dock.charge_start", "dock.drone_power_on", "dock.drone_power_off", + "dock.centering_loose", "dock.centering_tight", "dock.clear_alarm", "dock.emergency_stop": + // 无状态变化,仅确认 + default: + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND") + return + } + d.publishStateDock() + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK") +} + +func (d *MockDock) handleDroneCommand(cmd commandMsg) { + switch cmd.Type { + case "drone.takeoff": + d.mu.Lock() + d.tele.armed = true + d.tele.flightMode = "AUTO" + d.tele.altitude = 50 + d.tele.groundSpeed = 4.2 + d.doorState = "open" + d.mu.Unlock() + case "drone.land", "drone.return": + if d.cancelMission() { + // 任务协程收到取消后会自行返航落地 + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK") + return + } + d.snapHomeLanded() + case "drone.mission_upload": + wps := parseWaypoints(cmd.Params) + d.mu.Lock() + d.uploadedRoute = wps + d.mu.Unlock() + log.Printf("[%s] 已缓存航线,航点数=%d", d.spec.DockID, len(wps)) + case "drone.mission_start": + d.mu.Lock() + wps := append([]missionWaypoint(nil), d.uploadedRoute...) + d.mu.Unlock() + if len(wps) == 0 { + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "NO_ROUTE") + return + } + if !d.beginMission(cmd.CommandID) { + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "MISSION_IN_PROGRESS") + return + } + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK") + go d.flyUploadedMission(wps) + return + case "drone.mission_pause": + d.setMissionPaused(true) + case "drone.mission_resume": + d.setMissionPaused(false) + case "drone.mission_cancel": + d.cancelMission() + default: + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND") + return + } + d.publishStateDrone() + d.publishStateDock() + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK") +} + +func (d *MockDock) handleVideoCommand(cmd commandMsg) { + streamSessionID, _ := cmd.Params["streamSessionId"].(string) + switch cmd.Type { + case "video.start_stream": + if streamSessionID == "" { + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "SESSION_ID_REQUIRED") + return + } + d.mu.Lock() + if d.liveStreaming && d.liveSessionID != streamSessionID { + d.mu.Unlock() + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "STREAM_IN_PROGRESS") + return + } + d.liveSessionID = streamSessionID + d.liveStreaming = true + d.mu.Unlock() + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK") + d.publishStateVideo() + case "video.stop_stream": + d.mu.Lock() + matches := streamSessionID != "" && streamSessionID == d.liveSessionID + if matches { + d.liveStreaming = false + } + d.mu.Unlock() + if !matches { + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "SESSION_NOT_FOUND") + return + } + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK") + d.publishStateVideo() + default: + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND") + } +} + +func (d *MockDock) handleWorkflowCommand(cmd commandMsg) { + switch cmd.Type { + case "workflow.cancel", "workflow.stop_task": + d.cancelMission() + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK") + return + case "workflow.one_key_return", "workflow.one_key_landing": + if !d.cancelMission() { + go d.rtlAndLand() + } + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK") + return + } + + if d.isRepeatCommand(cmd.CommandID) { + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK") + return + } + if !d.beginMission(cmd.CommandID) { + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "MISSION_IN_PROGRESS") + return + } + d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK") + + switch cmd.Type { + case "workflow.start_task": + go d.runStartTask(cmd) + case "workflow.one_key_takeoff": + go d.runOneKeyTakeoff(cmd) + default: + go d.runGenericWorkflow(cmd) + } +} + +func (d *MockDock) setDoor(state string) { + d.mu.Lock() + d.doorState = state + d.mu.Unlock() +} + +// ---- 上发各类消息 ---- + +func (d *MockDock) ack(requestID, droneSN, commandID string, accepted bool, resultCode string) { + d.publishEnvelope(d.topic("command/ack"), 1, false, requestID, droneSN, map[string]any{ + "commandId": commandID, + "accepted": accepted, + "resultCode": resultCode, + }) +} + +func (d *MockDock) publishStatusOnline() { + d.publish(d.topic("status/online"), 1, true, map[string]any{ + "status": "online", + "bootId": "boot-" + d.spec.DockID, + "dockIdSource": "dmi_product_serial", + "softwareVersion": "1.3.0", + "protocolVersion": "1.0", + "uptimeSec": 86400, + "timeSynced": true, + "mqttConnected": true, + "modbusConnected": true, + "mavlinkConnected": false, + "updating": false, + "name": d.spec.Name, + "location": d.spec.Location, + "latitude": d.spec.Latitude, + "longitude": d.spec.Longitude, + }) +} + +func (d *MockDock) publishStateDock() { + d.mu.Lock() + door := d.doorState + flying := d.tele.armed || d.tele.altitude > 0 + d.mu.Unlock() + + chargingState := "charging" + chargingVoltage := 25.2 + chargingCurrent := 8.4 + dronePresent := true + if flying { + chargingState = "idle" + chargingVoltage = 0 + chargingCurrent = 0 + dronePresent = false + door = "open" + } + + alarms := []string{} + emergency := false + if d.spec.Alarm { + alarms = []string{"DOCK_EMERGENCY_STOP"} + emergency = true + } + + d.publish(d.topic("state/dock"), 1, true, map[string]any{ + "plcConnected": true, + "controlMode": "auto", + "door": map[string]any{"left": door, "right": door}, + "centering": map[string]any{"leftRight": "loose", "frontBack": "loose"}, + "chargingState": chargingState, + "chargingVoltage": chargingVoltage, + "chargingCurrent": chargingCurrent, + "emergencyStop": emergency, + "alarmCodes": alarms, + "chargeStartComplete": chargingState == "charging", + "dronePowerOnComplete": dronePresent, + "dronePowerOffComplete": false, + "dronePresent": dronePresent, + "takeoffPreparationComplete": false, + "hangarActionComplete": false, + "landingPreparationComplete": false, + "landingExecutionComplete": false, + "doorCloseComplete": door == "closed", + "doorOpenComplete": door == "open", + "centeringLooseComplete": false, + "centeringTightComplete": false, + "resetComplete": true, + "environment": dockEnvironment(d.spec.DockID), + }) +} + +func dockEnvironment(dockID string) map[string]any { + switch dockID { + case "dock-2": + return map[string]any{ + "rain": false, "windSpeed": 3.6, "outsideTemperature": 22.4, + "outsideHumidity": 61.0, "insideTemperature": 26.1, "insideHumidity": 48.0, + } + case "dock-3": + return map[string]any{ + "rain": true, "windSpeed": 5.1, "outsideTemperature": 19.8, + "outsideHumidity": 78.0, "insideTemperature": 24.6, "insideHumidity": 62.0, + } + default: + return map[string]any{ + "rain": false, "windSpeed": 1.8, "outsideTemperature": 25.1, + "outsideHumidity": 55.0, "insideTemperature": 27.3, "insideHumidity": 50.0, + } + } +} + +func (d *MockDock) publishStateDrone() { + d.mu.Lock() + t := d.tele + online := d.droneOnline + d.mu.Unlock() + + d.publishDrone(d.topic("state/drone"), 1, true, d.spec.DroneSN, map[string]any{ + "droneSn": d.spec.DroneSN, + "name": d.spec.DroneName, + "currentSysId": 1, + "online": online, + "armed": t.armed, + "flightMode": t.flightMode, + "flightModeCode": 11, + "latitude": t.latitude, + "longitude": t.longitude, + "altitude": t.altitude, + "groundSpeed": t.groundSpeed, + "roll": t.roll, + "pitch": t.pitch, + "yaw": t.yaw, + "batteryPercent": float64(t.batteryPct), + "batteryVoltage": t.batteryV, + "batteryCurrent": 0.0, + "satellites": t.satellites, + "gpsQuality": t.gpsQuality, + "linkQuality": 98.0, + "homeSet": true, + "alarmCodes": []string{}, + }) +} + +func (d *MockDock) publishTelemetry() { + d.mu.Lock() + t := d.tele + d.mu.Unlock() + + d.publishDrone(d.topic("telemetry"), 0, false, d.spec.DroneSN, map[string]any{ + "ts": time.Now().UnixMilli(), + "longitude": t.longitude, + "latitude": t.latitude, + "altitude": t.altitude, + "groundSpeed": t.groundSpeed, + "roll": t.roll, + "pitch": t.pitch, + "yaw": t.yaw, + "batteryPct": t.batteryPct, + "batteryV": t.batteryV, + "satellites": t.satellites, + "gpsQuality": t.gpsQuality, + "linkQuality": 96, + "flightMode": t.flightMode, + "armed": boolToInt(t.armed), + }) +} + +func (d *MockDock) publishStateVideo() { + d.mu.Lock() + streamSessionID := d.liveSessionID + streaming := d.liveStreaming + d.videoVersion++ + version := d.videoVersion + d.mu.Unlock() + + phase := "idle" + if streaming { + phase = "streaming" + } + provider := "fake" + d.publishVideoState(map[string]any{ + "provider": provider, + "phase": phase, + "inputOnline": streaming, + "streaming": streaming, + "inputCodec": "h264", + "uplinkProtocol": "srt", + "streamSessionId": nilIfEmpty(streamSessionID), + "width": nil, + "height": nil, + "frameRate": nil, + "bitrateBps": liveBitrate(streaming), + "retryCount": 0, + "stopReason": nil, + "errorCode": nil, + "updatedAt": time.Now().UnixMilli(), + }, version) +} + +func liveBitrate(streaming bool) int { + if streaming { + return 1500000 + } + return 0 +} + +func (d *MockDock) publishWorkflow(cmd commandMsg, taskID, missionID, state, step string) { + resultCode := any(nil) + switch state { + case "succeeded": + resultCode = "OK" + case "cancelled": + resultCode = "CANCELLED" + case "failed": + resultCode = "TIMEOUT" + } + recordOriginalVideo := any(nil) + if cmd.Type == "workflow.start_task" { + recordOriginalVideo = asBool(cmd.Params["recordOriginalVideo"]) + } + d.publishEnvelope(d.topic("state/workflow"), 1, true, cmd.RequestID, "", map[string]any{ + "commandId": cmd.CommandID, + "type": cmd.Type, + "taskId": nilIfEmpty(taskID), + "missionId": nilIfEmpty(missionID), + "recordOriginalVideo": recordOriginalVideo, + "state": state, + "step": step, + "resultCode": resultCode, + "updatedAt": time.Now().UnixMilli(), + }) +} + +// ---- 工具函数 ---- + +func boolToInt(b bool) int8 { + if b { + return 1 + } + return 0 +} + +func nilIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} + +func flightModeFromBaseMode(base string) string { + return base +} + +func gpsQualityFromFixType(fix int) string { + switch fix { + case 6: + return "RTK_FIX" + case 5: + return "RTK_FLOAT" + case 3: + return "3D_FIX" + case 2: + return "2D_FIX" + default: + return "NO_FIX" + } +} diff --git a/mock/main.go b/mock/main.go new file mode 100644 index 0000000..2a2520c --- /dev/null +++ b/mock/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + "time" +) + +// dockSpec 描述机巢及其直接模拟上报的绑定无人机。 +type dockSpec struct { + DockID string + Name string + Location string + DroneSN string + DroneName string + Latitude float64 + Longitude float64 + DroneLat float64 + DroneLon float64 + Flying bool + Battery int + Alarm bool +} + +func main() { + broker := envOr("MOCK_MQTT_BROKER", "tcp://127.0.0.1:1883") + username := envOr("MOCK_MQTT_USERNAME", "laic") + password := envOr("MOCK_MQTT_PASSWORD", "") + + specs := []dockSpec{ + { + DockID: "dock-1", Name: "青山湖 01 号机巢", Location: "青山湖科技城 A 区", + DroneSN: "JG-UAV-001", DroneName: "巡检无人机 001", + Latitude: 30.262438, Longitude: 119.802631, DroneLat: 30.262438, DroneLon: 119.802631, + Battery: 86, + }, + { + DockID: "dock-2", Name: "青山湖 02 号机巢", Location: "青山湖科技城 B 区", + DroneSN: "JG-UAV-002", DroneName: "巡检无人机 002", + Latitude: 30.255917, Longitude: 119.818264, DroneLat: 30.256500, DroneLon: 119.818900, + Flying: true, Battery: 72, + }, + { + DockID: "dock-3", Name: "青山湖 03 号机巢", Location: "青山湖科技城 C 区", + DroneSN: "JG-UAV-003", DroneName: "巡检无人机 003", + Latitude: 30.248610, Longitude: 119.791450, DroneLat: 30.248610, DroneLon: 119.791450, + Battery: 41, Alarm: true, + }, + } + + for _, spec := range specs { + dock := newMockDock(spec) + if err := dock.start(broker, username, password); err != nil { + log.Fatalf("启动机巢 %s 失败: %v", spec.DockID, err) + } + } + + log.Println("模拟设备已启动(机巢 MQTT 直接上报机巢与无人机数据),按 Ctrl+C 退出") + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("正在退出...") + time.Sleep(200 * time.Millisecond) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/mock/mission.go b/mock/mission.go new file mode 100644 index 0000000..cd4ad6e --- /dev/null +++ b/mock/mission.go @@ -0,0 +1,705 @@ +package main + +import ( + "bytes" + "io" + "log" + "math" + "net/http" + "sort" + "strconv" + "time" +) + +const ( + missionTick = 200 * time.Millisecond + missionTimeScale = 10.0 // mock 加速倍数,便于在短时间内跑完航线 + defaultCruiseSpeed = 8.0 // m/s + minLegDuration = 2 * time.Second + maxLegDuration = 12 * time.Second + maxHoldSec = 8 + defaultTakeoffAlt = 50.0 +) + +// missionWaypoint 与后台 workflow.start_task params.waypoints 字段对齐 +type missionWaypoint struct { + Seq int + Longitude float64 + Latitude float64 + Altitude float64 + Speed float64 + Yaw float64 + HoldSec int +} + +func (d *MockDock) beginMission(commandID string) bool { + d.mu.Lock() + defer d.mu.Unlock() + if d.inMission { + return false + } + d.inMission = true + d.missionPaused = false + d.missionCmdID = commandID + d.missionCancel = make(chan struct{}) + return true +} + +func (d *MockDock) endMission() { + d.mu.Lock() + defer d.mu.Unlock() + d.inMission = false + d.missionPaused = false + d.missionCmdID = "" + d.missionCancel = nil +} + +func (d *MockDock) isRepeatCommand(commandID string) bool { + d.mu.Lock() + defer d.mu.Unlock() + return commandID != "" && d.inMission && d.missionCmdID == commandID +} + +// cancelMission 通知当前任务中止,返回是否有正在执行的任务 +func (d *MockDock) cancelMission() bool { + d.mu.Lock() + defer d.mu.Unlock() + if !d.inMission || d.missionCancel == nil { + return false + } + select { + case <-d.missionCancel: + default: + close(d.missionCancel) + } + d.missionPaused = false + return true +} + +func (d *MockDock) setMissionPaused(paused bool) { + d.mu.Lock() + defer d.mu.Unlock() + if d.inMission { + d.missionPaused = paused + } +} + +func (d *MockDock) currentCancel() <-chan struct{} { + d.mu.Lock() + defer d.mu.Unlock() + return d.missionCancel +} + +func (d *MockDock) currentLLA() (lat, lon, alt float64) { + d.mu.Lock() + defer d.mu.Unlock() + return d.tele.latitude, d.tele.longitude, d.tele.altitude +} + +func (d *MockDock) setPose(lat, lon, alt, speed, heading float64) { + d.mu.Lock() + d.tele.latitude = lat + d.tele.longitude = lon + d.tele.altitude = alt + d.tele.groundSpeed = speed + d.tele.heading = heading + d.tele.yaw = heading + if d.tele.batteryPct > 20 && speed > 0.5 { + d.tele.batteryPctDrain++ + if d.tele.batteryPctDrain >= 20 { + d.tele.batteryPctDrain = 0 + d.tele.batteryPct-- + d.tele.batteryV = 22.0 + float64(d.tele.batteryPct)*0.037 + } + } + d.mu.Unlock() +} + +func (d *MockDock) setArmedFlying(alt float64) { + d.mu.Lock() + d.tele.armed = true + d.tele.flightMode = "AUTO" + d.tele.altitude = alt + d.doorState = "open" + d.mu.Unlock() +} + +func (d *MockDock) snapHomeLanded() { + d.mu.Lock() + d.tele.armed = false + d.tele.flightMode = "STANDBY" + d.tele.groundSpeed = 0 + d.tele.altitude = 0 + d.tele.latitude = d.spec.DroneLat + d.tele.longitude = d.spec.DroneLon + d.tele.heading = 0 + d.tele.yaw = 0 + d.doorState = "closed" + d.mu.Unlock() +} + +// runStartTask 执行 workflow.start_task:按航点飞行并持续改写遥测坐标 +func (d *MockDock) runStartTask(cmd commandMsg) { + defer d.endMission() + + taskID, missionID := extractTaskIDs(cmd) + waypoints := parseWaypoints(cmd.Params) + recordOriginalVideo := asBool(cmd.Params["recordOriginalVideo"]) + originalVideo := parseOriginalVideo(cmd.Params["originalVideo"]) + d.mu.Lock() + d.uploadedRoute = waypoints + d.mu.Unlock() + + log.Printf("[%s] 开始执行任务 taskId=%s 航点数=%d", d.spec.DockID, taskID, len(waypoints)) + for _, wp := range waypoints { + log.Printf("[%s] 航点 seq=%d lon=%.6f lat=%.6f alt=%.1f speed=%.1f hold=%ds", + d.spec.DockID, wp.Seq, wp.Longitude, wp.Latitude, wp.Altitude, wp.Speed, wp.HoldSec) + } + + d.setDoor("open") + d.publishStateDock() + + prep := []string{ + "preparing_dock_takeoff", + "waiting_drone_online", + "checking_drone", + "route_uploading", + "route_ready", + } + for _, step := range prep { + d.publishWorkflow(cmd, taskID, missionID, "running", step) + if !d.sleepOrAbort(400 * time.Millisecond) { + d.finishWorkflow(cmd, taskID, missionID, "cancelled") + return + } + } + + takeoffAlt := defaultTakeoffAlt + if len(waypoints) > 0 && waypoints[0].Altitude > 0 { + takeoffAlt = waypoints[0].Altitude + } + + d.publishWorkflow(cmd, taskID, missionID, "running", "taking_off") + d.setArmedFlying(0) + d.publishStateDrone() + if !d.climbTo(takeoffAlt) { + d.returnHomeAndLand(true) + d.finishWorkflow(cmd, taskID, missionID, "cancelled") + return + } + d.publishStateDrone() + + d.publishWorkflow(cmd, taskID, missionID, "running", "finishing_takeoff") + if !d.sleepOrAbort(400 * time.Millisecond) { + d.returnHomeAndLand(true) + d.finishWorkflow(cmd, taskID, missionID, "cancelled") + return + } + + d.publishWorkflow(cmd, taskID, missionID, "running", "starting_mission") + if !d.sleepOrAbort(300 * time.Millisecond) { + d.returnHomeAndLand(true) + d.finishWorkflow(cmd, taskID, missionID, "cancelled") + return + } + + d.publishWorkflow(cmd, taskID, missionID, "running", "mission_running") + if !d.flyWaypoints(waypoints) { + d.publishWorkflow(cmd, taskID, missionID, "running", "returning_to_home") + d.returnHomeAndLand(true) + d.finishWorkflow(cmd, taskID, missionID, "cancelled") + return + } + + d.publishWorkflow(cmd, taskID, missionID, "running", "returning_to_home") + if !d.returnHomeAndLand(false) { + d.returnHomeAndLand(true) + d.finishWorkflow(cmd, taskID, missionID, "cancelled") + return + } + + d.finishWorkflow(cmd, taskID, missionID, "succeeded") + if recordOriginalVideo { + d.uploadOriginalVideo(cmd, taskID, originalVideo) + } + log.Printf("[%s] 任务执行完成 taskId=%s", d.spec.DockID, taskID) +} + +func (d *MockDock) runOneKeyTakeoff(cmd commandMsg) { + defer d.endMission() + taskID, missionID := extractTaskIDs(cmd) + d.setDoor("open") + d.publishStateDock() + d.publishWorkflow(cmd, taskID, missionID, "running", "taking_off") + d.setArmedFlying(0) + if !d.climbTo(defaultTakeoffAlt) { + d.finishWorkflow(cmd, taskID, missionID, "cancelled") + return + } + d.publishStateDrone() + d.publishWorkflow(cmd, taskID, missionID, "succeeded", "idle") +} + +func (d *MockDock) runGenericWorkflow(cmd commandMsg) { + defer d.endMission() + taskID, missionID := extractTaskIDs(cmd) + + d.setDoor("open") + d.publishStateDock() + + steps := []string{ + "preparing_dock_takeoff", + "waiting_drone_online", + "checking_drone", + "taking_off", + "finishing_takeoff", + "returning_to_home", + "preparing_dock_landing", + "returning", + "finishing_landing", + } + for _, step := range steps { + d.publishWorkflow(cmd, taskID, missionID, "running", step) + if step == "taking_off" { + d.setArmedFlying(0) + if !d.climbTo(defaultTakeoffAlt) { + d.returnHomeAndLand(true) + d.finishWorkflow(cmd, taskID, missionID, "cancelled") + return + } + } + if !d.sleepOrAbort(time.Second) { + d.returnHomeAndLand(true) + d.finishWorkflow(cmd, taskID, missionID, "cancelled") + return + } + } + + d.returnHomeAndLand(true) + d.finishWorkflow(cmd, taskID, missionID, "succeeded") +} + +func (d *MockDock) flyUploadedMission(waypoints []missionWaypoint) { + defer d.endMission() + log.Printf("[%s] 执行已上传航线,航点数=%d", d.spec.DockID, len(waypoints)) + d.setArmedFlying(math.Max(d.currentAlt(), defaultTakeoffAlt)) + if !d.flyWaypoints(waypoints) { + d.returnHomeAndLand(true) + return + } + d.returnHomeAndLand(true) +} + +func (d *MockDock) flyWaypoints(waypoints []missionWaypoint) bool { + for i, wp := range waypoints { + alt := wp.Altitude + if alt <= 0 { + _, _, alt = d.currentLLA() + if alt <= 0 { + alt = defaultTakeoffAlt + } + } + speed := wp.Speed + if speed <= 0 { + speed = defaultCruiseSpeed + } + log.Printf("[%s] 飞向航点 %d/%d (seq=%d)", d.spec.DockID, i+1, len(waypoints), wp.Seq) + if !d.flyTo(wp.Latitude, wp.Longitude, alt, speed, false) { + return false + } + if wp.HoldSec > 0 { + if !d.holdAt(wp.HoldSec, wp.Yaw) { + return false + } + } else if wp.Yaw != 0 { + d.mu.Lock() + d.tele.heading = wp.Yaw + d.tele.yaw = wp.Yaw + d.mu.Unlock() + } + d.publishStateDrone() + } + return true +} + +func (d *MockDock) climbTo(alt float64) bool { + lat, lon, cur := d.currentLLA() + if cur < 1 { + d.setPose(lat, lon, 1, 0, d.currentHeading()) + } + return d.flyTo(lat, lon, alt, 5, false) +} + +func (d *MockDock) currentAlt() float64 { + _, _, alt := d.currentLLA() + return alt +} + +func (d *MockDock) currentHeading() float64 { + d.mu.Lock() + defer d.mu.Unlock() + return d.tele.heading +} + +func (d *MockDock) returnHomeAndLand(forced bool) bool { + homeAlt := math.Max(d.currentAlt(), 20) + if !d.flyTo(d.spec.DroneLat, d.spec.DroneLon, homeAlt, defaultCruiseSpeed, forced) && !forced { + return false + } + if !d.flyTo(d.spec.DroneLat, d.spec.DroneLon, 0, 3, forced) && !forced { + return false + } + d.snapHomeLanded() + d.publishStateDrone() + d.publishStateDock() + return true +} + +func (d *MockDock) rtlAndLand() { + if !d.beginMission("rtl") { + return + } + defer d.endMission() + d.returnHomeAndLand(true) +} + +func (d *MockDock) finishWorkflow(cmd commandMsg, taskID, missionID, state string) { + if state == "cancelled" { + d.setDoor("closed") + d.publishStateDock() + } + d.publishWorkflow(cmd, taskID, missionID, state, "idle") +} + +// flyTo 按球面插值飞向目标点;forced 时忽略暂停/取消(返航落地用) +func (d *MockDock) uploadOriginalVideo(cmd commandMsg, taskID string, video originalVideoUpload) { + if video.VideoID == 0 || video.ExecutionID == 0 || video.UploadURL == "" { + d.publishOriginalVideo(cmd, taskID, video, "failed", "UPLOAD_CONFIG_INVALID", 0) + return + } + if video.UploadExpireAt > 0 && time.Now().UnixMilli() >= video.UploadExpireAt { + d.publishOriginalVideo(cmd, taskID, video, "failed", "UPLOAD_URL_EXPIRED", 0) + return + } + + body := []byte("mock original video fixture\n") + req, err := http.NewRequest(http.MethodPut, video.UploadURL, bytes.NewReader(body)) + if err != nil { + d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0) + return + } + req.Header.Set("Content-Type", "video/mp4") + resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + if err != nil { + d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0) + return + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0) + return + } + d.publishOriginalVideo(cmd, taskID, video, "completed", "", int64(len(body))) +} + +func (d *MockDock) publishOriginalVideo(cmd commandMsg, taskID string, video originalVideoUpload, eventType, errorCode string, fileSize int64) { + d.publishDrone(d.topic("internal/original-video"), 1, false, d.spec.DroneSN, map[string]any{ + "extension": "laic.mock.original-video.v1", + "eventId": "mock-original-video-" + strconv.FormatInt(video.VideoID, 10), + "eventType": eventType, + "videoId": video.VideoID, + "executionId": video.ExecutionID, + "taskId": taskID, + "commandId": cmd.CommandID, + "fileSize": fileSize, + "duration": 1, + "errorCode": nilIfEmpty(errorCode), + "uploadedAt": time.Now().UnixMilli(), + }) +} + +type originalVideoUpload struct { + VideoID int64 + ExecutionID int64 + UploadURL string + UploadExpireAt int64 +} + +func parseOriginalVideo(value any) originalVideoUpload { + data, ok := value.(map[string]any) + if !ok { + return originalVideoUpload{} + } + return originalVideoUpload{ + VideoID: asInt64(data["videoId"]), + ExecutionID: asInt64(data["executionId"]), + UploadURL: asString(data["uploadUrl"]), + UploadExpireAt: asInt64(data["uploadExpireAt"]), + } +} + +func asString(value any) string { + text, _ := value.(string) + return text +} + +func asBool(value any) bool { + valueBool, _ := value.(bool) + return valueBool +} + +func asInt64(value any) int64 { + switch v := value.(type) { + case float64: + return int64(v) + case string: + parsed, _ := strconv.ParseInt(v, 10, 64) + return parsed + default: + return 0 + } +} + +func (d *MockDock) flyTo(lat, lon, alt, speed float64, forced bool) bool { + if speed <= 0 { + speed = defaultCruiseSpeed + } + fromLat, fromLon, fromAlt := d.currentLLA() + dist := haversineM(fromLat, fromLon, lat, lon) + dAlt := math.Abs(alt - fromAlt) + heading := bearingDeg(fromLat, fromLon, lat, lon) + if dist < 0.5 { + heading = d.currentHeading() + } + + horizSec := dist / speed / missionTimeScale + vertSec := dAlt / 5.0 / missionTimeScale + sec := math.Max(horizSec, vertSec) + dur := time.Duration(sec * float64(time.Second)) + if (dist > 1 || dAlt > 1) && dur < minLegDuration { + dur = minLegDuration + } + if dur > maxLegDuration { + dur = maxLegDuration + } + if dur < missionTick { + dur = missionTick + } + + steps := int(dur / missionTick) + if steps < 1 { + steps = 1 + } + for i := 1; i <= steps; i++ { + if !forced && !d.waitMissionTick() { + return false + } + if forced { + time.Sleep(missionTick) + } + t := float64(i) / float64(steps) + d.setPose( + lerp(fromLat, lat, t), + lerp(fromLon, lon, t), + lerp(fromAlt, alt, t), + speed, + heading, + ) + } + d.setPose(lat, lon, alt, 0, heading) + return true +} + +func (d *MockDock) holdAt(seconds int, yaw float64) bool { + if seconds > maxHoldSec { + seconds = maxHoldSec + } + if yaw != 0 { + d.mu.Lock() + d.tele.heading = yaw + d.tele.yaw = yaw + d.tele.groundSpeed = 0 + d.mu.Unlock() + } else { + d.setPoseKeepLLA(0) + } + ticks := seconds * int(time.Second/missionTick) + for i := 0; i < ticks; i++ { + if !d.waitMissionTick() { + return false + } + } + return true +} + +func (d *MockDock) setPoseKeepLLA(speed float64) { + d.mu.Lock() + d.tele.groundSpeed = speed + d.mu.Unlock() +} + +func (d *MockDock) waitMissionTick() bool { + cancel := d.currentCancel() + for { + if isClosed(cancel) { + return false + } + d.mu.Lock() + paused := d.missionPaused + d.mu.Unlock() + if !paused { + if cancel == nil { + time.Sleep(missionTick) + return true + } + select { + case <-cancel: + return false + case <-time.After(missionTick): + return true + } + } + if cancel == nil { + time.Sleep(100 * time.Millisecond) + continue + } + select { + case <-cancel: + return false + case <-time.After(100 * time.Millisecond): + } + } +} + +func (d *MockDock) sleepOrAbort(wait time.Duration) bool { + cancel := d.currentCancel() + if cancel == nil { + time.Sleep(wait) + return true + } + t := time.NewTimer(wait) + defer t.Stop() + select { + case <-cancel: + return false + case <-t.C: + return true + } +} + +func extractTaskIDs(cmd commandMsg) (taskID, missionID string) { + if cmd.Params == nil { + return "", "" + } + if v, ok := cmd.Params["taskId"].(string); ok { + taskID = v + } + if v, ok := cmd.Params["missionId"].(string); ok { + missionID = v + } + return taskID, missionID +} + +func parseWaypoints(params map[string]any) []missionWaypoint { + if params == nil { + return nil + } + raw, ok := params["waypoints"] + if !ok || raw == nil { + return nil + } + arr, ok := raw.([]any) + if !ok { + return nil + } + out := make([]missionWaypoint, 0, len(arr)) + for i, item := range arr { + m, ok := item.(map[string]any) + if !ok { + continue + } + wp := missionWaypoint{ + Seq: asInt(m["seq"]), + Longitude: asFloat(m["longitude"]), + Latitude: asFloat(m["latitude"]), + Altitude: asFloat(m["altitude"]), + Speed: asFloat(m["speed"]), + Yaw: asFloat(m["yaw"]), + HoldSec: asInt(m["holdSec"]), + } + if wp.Seq == 0 { + wp.Seq = i + } + if wp.Longitude == 0 && wp.Latitude == 0 { + continue + } + out = append(out, wp) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Seq < out[j].Seq }) + return out +} + +func asFloat(v any) float64 { + switch n := v.(type) { + case float64: + return n + case float32: + return float64(n) + case int: + return float64(n) + case int32: + return float64(n) + case int64: + return float64(n) + case uint64: + return float64(n) + default: + return 0 + } +} + +func asInt(v any) int { + return int(asFloat(v)) +} + +func isClosed(ch <-chan struct{}) bool { + if ch == nil { + return false + } + select { + case <-ch: + return true + default: + return false + } +} + +func lerp(a, b, t float64) float64 { + return a + (b-a)*t +} + +func haversineM(lat1, lon1, lat2, lon2 float64) float64 { + const earthRadiusM = 6371000.0 + phi1 := lat1 * math.Pi / 180 + phi2 := lat2 * math.Pi / 180 + dPhi := (lat2 - lat1) * math.Pi / 180 + dLambda := (lon2 - lon1) * math.Pi / 180 + a := math.Sin(dPhi/2)*math.Sin(dPhi/2) + + math.Cos(phi1)*math.Cos(phi2)*math.Sin(dLambda/2)*math.Sin(dLambda/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return earthRadiusM * c +} + +func bearingDeg(lat1, lon1, lat2, lon2 float64) float64 { + phi1 := lat1 * math.Pi / 180 + phi2 := lat2 * math.Pi / 180 + dLambda := (lon2 - lon1) * math.Pi / 180 + y := math.Sin(dLambda) * math.Cos(phi2) + x := math.Cos(phi1)*math.Sin(phi2) - math.Sin(phi1)*math.Cos(phi2)*math.Cos(dLambda) + deg := math.Atan2(y, x) * 180 / math.Pi + if deg < 0 { + deg += 360 + } + return deg +} diff --git a/mock/mock.log b/mock/mock.log new file mode 100644 index 0000000..afb3a13 --- /dev/null +++ b/mock/mock.log @@ -0,0 +1,109 @@ +2026/08/14 15:49:00 [JG-UAV-001] 无人机启动(MAVLink TCP server 127.0.0.1:5760) +2026/08/14 15:49:00 [JG-UAV-002] 无人机启动(MAVLink TCP server 127.0.0.1:5761) +2026/08/14 15:49:01 [dock-1] MQTT 已连接 +2026/08/14 15:49:01 [dock-1] 机巢启动(MQTT 上云 + MAVLink→127.0.0.1:5760) +2026/08/14 15:49:01 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 15:49:01 [dock-2] MQTT 已连接 +2026/08/14 15:49:01 [dock-2] 机巢启动(MQTT 上云 + MAVLink→127.0.0.1:5761) +2026/08/14 15:49:01 模拟设备已启动(机巢=MQTT,无人机=MAVLink),按 Ctrl+C 退出 +2026/08/14 15:49:01 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:50:01 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:50:01 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:50:03 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 15:50:03 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:51:03 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:51:03 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:51:05 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 15:51:05 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:52:05 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:52:05 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:52:07 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 15:52:07 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:52:15 [dock-1] 收到指令 dock.open (commandId=10179956445184) +2026/08/14 15:52:56 [dock-1] 收到指令 drone.takeoff (commandId=10185279803392) +2026/08/14 15:52:56 [dock-1] 已向无人机下发 MAVLink 指令 drone.takeoff +2026/08/14 15:52:56 [dock-1] 收到无人机 MAVLink ACK: drone.takeoff accepted=true +2026/08/14 15:53:07 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:53:09 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:53:56 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:53:58 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 15:54:09 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:54:11 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:54:30 [dock-1] 收到指令 workflow.start_task (commandId=10197644873728) +2026/08/14 15:55:11 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:55:13 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:55:35 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:55:37 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 15:56:13 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:56:15 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:56:37 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:56:39 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 15:57:15 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:57:17 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:57:39 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:57:41 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 15:58:17 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:58:19 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:58:41 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:58:43 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 15:58:47 [dock-1] 收到指令 workflow.start_task (commandId=10231313338368) +2026/08/14 15:59:19 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:59:21 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 15:59:52 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 15:59:54 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 16:00:21 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:00:23 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 16:00:54 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:00:56 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 16:01:23 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:01:25 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 16:01:56 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:01:58 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 16:02:25 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:02:27 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 16:02:58 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:03:00 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 16:03:27 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:03:29 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 16:04:00 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:04:02 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 16:04:29 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:04:31 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 16:05:02 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:05:04 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 16:05:31 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:05:33 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 16:06:04 [dock-1] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:06:06 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 16:06:33 [dock-2] 无人机 MAVLink 通道断开: EOF +2026/08/14 16:06:35 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 16:10:58 [JG-UAV-001] 无人机启动(MAVLink TCP server 127.0.0.1:5760) +2026/08/14 16:10:58 [JG-UAV-002] 无人机启动(MAVLink TCP server 127.0.0.1:5761) +2026/08/14 16:10:58 [dock-1] MQTT 已连接 +2026/08/14 16:10:58 [dock-1] 机巢启动(MQTT 上云 + MAVLink→127.0.0.1:5760) +2026/08/14 16:10:58 [dock-1] 无人机 MAVLink 通道已建立 +2026/08/14 16:10:58 [dock-2] MQTT 已连接 +2026/08/14 16:10:58 [dock-2] 机巢启动(MQTT 上云 + MAVLink→127.0.0.1:5761) +2026/08/14 16:10:58 模拟设备已启动(机巢=MQTT,无人机=MAVLink),按 Ctrl+C 退出 +2026/08/14 16:10:58 [dock-2] 无人机 MAVLink 通道已建立 +2026/08/14 17:08:55 [dock-1] 收到指令 workflow.start_task (commandId=10782857760768) +2026/08/14 17:09:09 [dock-1] 收到指令 dock.open (commandId=10784631296000) +2026/08/14 17:09:11 [dock-1] 收到指令 workflow.start_task (commandId=10784979947520) +2026/08/14 17:09:13 [dock-1] 收到指令 dock.close (commandId=10785230032896) +2026/08/14 17:09:15 [dock-1] 收到指令 dock.self_check (commandId=10785475792896) +2026/08/14 17:09:16 [dock-1] 收到指令 dock.reset (commandId=10785655754752) +2026/08/14 17:09:22 [dock-1] 收到指令 drone.takeoff (commandId=10786424360960) +2026/08/14 17:09:22 [dock-1] 已向无人机下发 MAVLink 指令 drone.takeoff +2026/08/14 17:09:22 [dock-1] 收到无人机 MAVLink ACK: drone.takeoff accepted=true +2026/08/14 17:09:24 [dock-1] 收到指令 drone.land (commandId=10786613104640) +2026/08/14 17:09:24 [dock-1] 已向无人机下发 MAVLink 指令 drone.land +2026/08/14 17:09:24 [dock-1] 收到无人机 MAVLink ACK: drone.land accepted=true +2026/08/14 17:09:26 [dock-1] 收到指令 drone.return (commandId=10786925318144) +2026/08/14 17:09:26 [dock-1] 已向无人机下发 MAVLink 指令 drone.return +2026/08/14 17:09:26 [dock-1] 收到无人机 MAVLink ACK: drone.return accepted=true +2026/08/14 17:09:28 [dock-1] 收到指令 drone.hover (commandId=10787145781248) +2026/08/14 17:09:30 [dock-1] 收到指令 drone.emergency_stop (commandId=10787445149696) +2026/08/14 17:10:30 [dock-2] 收到指令 dock.open (commandId=10795332276224) +2026/08/14 17:10:32 [dock-2] 收到指令 dock.close (commandId=10795596517376) +2026/08/14 17:10:33 [dock-2] 收到指令 dock.reset (commandId=10795735977984) +2026/08/14 17:10:35 [dock-2] 收到指令 dock.self_check (commandId=10795919740928) diff --git a/model/alarm.go b/model/alarm.go new file mode 100644 index 0000000..9aed91b --- /dev/null +++ b/model/alarm.go @@ -0,0 +1,37 @@ +package model + +import "time" + +// AlarmCode 告警码字典 +type AlarmCode struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + Code string `gorm:"column:code;type:VARCHAR(64);not null;uniqueIndex:idx_code" json:"code"` + Category string `gorm:"column:category;type:VARCHAR(32);not null" json:"category"` // dock / drone + MessageCn string `gorm:"column:message_cn;type:VARCHAR(256);not null" json:"messageCn"` + Level string `gorm:"column:level;type:VARCHAR(16);default:critical" json:"level"` + Source string `gorm:"column:source;type:VARCHAR(128);default:''" json:"source"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (AlarmCode) TableName() string { + return "alarm_code" +} + +// Alarm 告警记录 +type Alarm struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"` + Code string `gorm:"column:code;type:VARCHAR(64);not null" json:"code"` + DeviceType string `gorm:"column:device_type;type:VARCHAR(8);not null" json:"deviceType"` // dock / drone + MessageCn string `gorm:"column:message_cn;type:VARCHAR(256);not null" json:"messageCn"` + Level string `gorm:"column:level;type:VARCHAR(16);not null" json:"level"` + Status string `gorm:"column:status;type:VARCHAR(16);default:active" json:"status"` // active / acknowledged / resolved + TriggeredAt *time.Time `gorm:"column:triggered_at" json:"triggeredAt"` + ResolvedAt *time.Time `gorm:"column:resolved_at" json:"resolvedAt"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (Alarm) TableName() string { + return "alarm" +} diff --git a/model/billing.go b/model/billing.go new file mode 100644 index 0000000..a6f1d35 --- /dev/null +++ b/model/billing.go @@ -0,0 +1,91 @@ +package model + +import "time" + +// TrafficOrder 云媒体流量订单 +type TrafficOrder struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"` + UnitPrice float64 `gorm:"column:unit_price;type:DECIMAL(6,2)" json:"unitPrice"` + TotalPrice float64 `gorm:"column:total_price;type:DECIMAL(10,2)" json:"totalPrice"` + PayStatus string `gorm:"column:pay_status;type:VARCHAR(16);default:unpaid" json:"payStatus"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + PaidAt *time.Time `gorm:"column:paid_at" json:"paidAt"` +} + +func (TrafficOrder) TableName() string { + return "traffic_order" +} + +// TrafficUsageLog 云媒体消费流水(账本,不可变) +type TrafficUsageLog struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + SourceType string `gorm:"column:source_type;type:VARCHAR(16)" json:"sourceType"` // live / replay / download + SourceID int64 `gorm:"column:source_id;type:BIGINT" json:"sourceId"` + BytesUsed int64 `gorm:"column:bytes_used;type:BIGINT" json:"bytesUsed"` + BalanceBefore int64 `gorm:"column:balance_before;type:BIGINT" json:"balanceBefore"` + BalanceAfter int64 `gorm:"column:balance_after;type:BIGINT" json:"balanceAfter"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (TrafficUsageLog) TableName() string { + return "traffic_usage_log" +} + +// SimCard SIM 卡 +type SimCard struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + DockID string `gorm:"column:dock_id;type:VARCHAR(64)" json:"dockId"` + Carrier string `gorm:"column:carrier;type:VARCHAR(16)" json:"carrier"` // 移动/联通/电信 + Phone string `gorm:"column:phone;type:VARCHAR(16)" json:"phone"` + Iccid string `gorm:"column:iccid;type:VARCHAR(32)" json:"iccid"` // 权威字段 + PlanGb int `gorm:"column:plan_gb;type:INT" json:"planGb"` + UsedGb float64 `gorm:"column:used_gb;type:DECIMAL(10,4);default:0" json:"usedGb"` + ExpiredAt *time.Time `gorm:"column:expired_at;type:DATE" json:"expiredAt"` + Status string `gorm:"column:status;type:VARCHAR(16);default:active" json:"status"` // active / expired + CarrierStatus string `gorm:"column:carrier_status;type:VARCHAR(16);default:normal" json:"carrierStatus"` // normal / suspended / arrears / cancelled + LastSyncAt *time.Time `gorm:"column:last_sync_at" json:"lastSyncAt"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (SimCard) TableName() string { + return "sim_card" +} + +// SimRechargeLog SIM 卡充值记录 +type SimRechargeLog struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + SimCardID int64 `gorm:"column:sim_card_id;type:BIGINT;not null" json:"simCardId"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + AmountGb int `gorm:"column:amount_gb;type:INT;not null" json:"amountGb"` + UnitPrice float64 `gorm:"column:unit_price;type:DECIMAL(6,2)" json:"unitPrice"` + TotalPrice float64 `gorm:"column:total_price;type:DECIMAL(10,2)" json:"totalPrice"` + PayStatus string `gorm:"column:pay_status;type:VARCHAR(16);default:paid" json:"payStatus"` + RechargeStatus string `gorm:"column:recharge_status;type:VARCHAR(16);default:pending" json:"rechargeStatus"` // pending / success / failed + RechargeMsg string `gorm:"column:recharge_msg;type:VARCHAR(256);default:''" json:"rechargeMsg"` + IdempotentKey string `gorm:"column:idempotent_key;type:VARCHAR(64);not null;uniqueIndex:uk_recharge_idem" json:"idempotentKey"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (SimRechargeLog) TableName() string { + return "sim_recharge_log" +} + +// SimUsageRecord SIM 卡用量快照 +type SimUsageRecord struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + SimCardID int64 `gorm:"column:sim_card_id;type:BIGINT;not null;index:idx_record_sim" json:"simCardId"` + PlanGb int `gorm:"column:plan_gb;type:INT" json:"planGb"` + UsedGb float64 `gorm:"column:used_gb;type:DECIMAL(10,4)" json:"usedGb"` + RemainGb float64 `gorm:"column:remain_gb;type:DECIMAL(10,4)" json:"remainGb"` + CarrierStatus string `gorm:"column:carrier_status;type:VARCHAR(16)" json:"carrierStatus"` + SyncedAt time.Time `gorm:"column:synced_at;index:idx_record_sim" json:"syncedAt"` +} + +func (SimUsageRecord) TableName() string { + return "sim_usage_record" +} diff --git a/model/command.go b/model/command.go new file mode 100644 index 0000000..00dcc66 --- /dev/null +++ b/model/command.go @@ -0,0 +1,25 @@ +package model + +import "time" + +// DeviceCommandLog 设备指令日志 +type DeviceCommandLog struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` // = commandId (Snowflake) + DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"` + CommandType string `gorm:"column:command_type;type:VARCHAR(64);not null" json:"commandType"` // dock.open / drone.takeoff / workflow.start_task + Params string `gorm:"column:params;type:JSON" json:"params"` + DroneSN string `gorm:"column:drone_sn;type:VARCHAR(32)" json:"droneSn"` + RequestID string `gorm:"column:request_id;type:VARCHAR(64)" json:"requestId"` + TTLMs int `gorm:"column:ttl_ms;type:INT;default:30000" json:"ttlMs"` + AckAccepted int8 `gorm:"column:ack_accepted;type:TINYINT" json:"ackAccepted"` + AckResultCode string `gorm:"column:ack_result_code;type:VARCHAR(64)" json:"ackResultCode"` + Status string `gorm:"column:status;type:VARCHAR(16);default:sent" json:"status"` // sent / acked / timeout / terminal + RetryCount int `gorm:"column:retry_count;type:INT;default:0" json:"retryCount"` + SentAt *time.Time `gorm:"column:sent_at" json:"sentAt"` + AckedAt *time.Time `gorm:"column:acked_at" json:"ackedAt"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (DeviceCommandLog) TableName() string { + return "device_command_log" +} diff --git a/model/dock.go b/model/dock.go new file mode 100644 index 0000000..afe8120 --- /dev/null +++ b/model/dock.go @@ -0,0 +1,49 @@ +package model + +import "time" + +// Dock 机巢 +type Dock struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null;uniqueIndex:idx_dock_id" json:"dockId"` + Name string `gorm:"column:name;type:VARCHAR(64);default:''" json:"name"` + Code string `gorm:"column:code;type:VARCHAR(32);default:''" json:"code"` + SN string `gorm:"column:sn;type:VARCHAR(64);default:''" json:"sn"` + Iccid string `gorm:"column:iccid;type:VARCHAR(32);default:''" json:"iccid"` // 冗余,权威见 sim_card.iccid + Longitude float64 `gorm:"column:longitude;type:DOUBLE;default:0" json:"longitude"` + Latitude float64 `gorm:"column:latitude;type:DOUBLE;default:0" json:"latitude"` + Altitude float64 `gorm:"column:altitude;type:DOUBLE;default:0" json:"altitude"` + Location string `gorm:"column:location;type:VARCHAR(128);default:''" json:"location"` + Status string `gorm:"column:status;type:VARCHAR(32);default:offline" json:"status"` + RegisterStatus string `gorm:"column:register_status;type:VARCHAR(16);default:pending" json:"registerStatus"` + DockIDSource string `gorm:"column:dock_id_source;type:VARCHAR(32);default:''" json:"dockIdSource"` + SoftwareVer string `gorm:"column:software_ver;type:VARCHAR(16);default:''" json:"softwareVer"` + ProtocolVer string `gorm:"column:protocol_ver;type:VARCHAR(8);default:'1.0'" json:"protocolVer"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (Dock) TableName() string { + return "dock" +} + +// Drone 无人机(与机巢默认一对一绑定) +type Drone struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + DroneSN string `gorm:"column:drone_sn;type:VARCHAR(32);not null;uniqueIndex:idx_drone_sn" json:"droneSn"` + DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null;uniqueIndex:idx_drone_dock" json:"dockId"` + Name string `gorm:"column:name;type:VARCHAR(64);default:''" json:"name"` + Code string `gorm:"column:code;type:VARCHAR(32);default:''" json:"code"` + Model string `gorm:"column:model;type:VARCHAR(32);default:''" json:"model"` + Status string `gorm:"column:status;type:VARCHAR(32);default:offline" json:"status"` + Battery int `gorm:"column:battery;type:INT;default:0" json:"battery"` + FirmwareVer string `gorm:"column:firmware_ver;type:VARCHAR(16);default:''" json:"firmwareVer"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (Drone) TableName() string { + return "drone" +} diff --git a/model/firmware.go b/model/firmware.go new file mode 100644 index 0000000..7d25e4a --- /dev/null +++ b/model/firmware.go @@ -0,0 +1,24 @@ +package model + +import "time" + +// Firmware 固件版本 +type Firmware struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + Component string `gorm:"column:component;type:VARCHAR(32);not null" json:"component"` // 固定 dock-edge-agent + Version string `gorm:"column:version;type:VARCHAR(32);not null" json:"version"` // 1.3.0 + Description string `gorm:"column:description;type:VARCHAR(512);default:''" json:"description"` + FileURL string `gorm:"column:file_url;type:VARCHAR(512);not null" json:"fileUrl"` + Sha256 string `gorm:"column:sha256;type:VARCHAR(128);not null" json:"sha256"` + Signature string `gorm:"column:signature;type:TEXT;not null" json:"signature"` + FileSize int64 `gorm:"column:file_size;type:BIGINT;default:0" json:"fileSize"` + Mandatory int8 `gorm:"column:mandatory;type:TINYINT;default:0" json:"mandatory"` + Status string `gorm:"column:status;type:VARCHAR(16);default:draft" json:"status"` // draft / released / deprecated + CreatedBy int64 `gorm:"column:created_by;type:BIGINT;not null" json:"createdBy"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (Firmware) TableName() string { + return "firmware" +} diff --git a/model/media.go b/model/media.go new file mode 100644 index 0000000..b4580e8 --- /dev/null +++ b/model/media.go @@ -0,0 +1,80 @@ +package model + +import "time" + +// LiveSession 直播会话 +type LiveSession struct { + ID string `gorm:"primaryKey;column:id;type:VARCHAR(128);not null" json:"id"` + DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"` + Provider string `gorm:"column:provider;type:VARCHAR(16);default:aliyun" json:"provider"` + StreamName string `gorm:"column:stream_name;type:VARCHAR(128);not null" json:"streamName"` + PushURLHash string `gorm:"column:push_url_hash;type:VARCHAR(128)" json:"pushUrlHash"` + ExpiresAt int64 `gorm:"column:expires_at;type:BIGINT;not null" json:"expiresAt"` + MaxBitrateBps int64 `gorm:"column:max_bitrate_bps;type:BIGINT;default:1500000" json:"maxBitrateBps"` + Phase string `gorm:"column:phase;type:VARCHAR(16);default:idle" json:"phase"` + StopReason string `gorm:"column:stop_reason;type:VARCHAR(32)" json:"stopReason"` + ErrorCode string `gorm:"column:error_code;type:VARCHAR(64)" json:"errorCode"` + RequestedBy int64 `gorm:"column:requested_by;type:BIGINT" json:"requestedBy"` + StopDeadline *time.Time `gorm:"column:stop_deadline" json:"stopDeadline"` + DeviceStateVersion int64 `gorm:"column:device_state_version;type:BIGINT" json:"deviceStateVersion"` + DeviceEventID string `gorm:"column:device_event_id;type:VARCHAR(128)" json:"deviceEventId"` + DeviceUpdatedAt int64 `gorm:"column:device_updated_at;type:BIGINT" json:"deviceUpdatedAt"` + CloudConfirmedAt *time.Time `gorm:"column:cloud_confirmed_at" json:"cloudConfirmedAt"` + StartedAt *time.Time `gorm:"column:started_at" json:"startedAt"` + StoppedAt *time.Time `gorm:"column:stopped_at" json:"stoppedAt"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (LiveSession) TableName() string { + return "live_session" +} + +// LiveViewerLease 表示用户对直播会话的短期观看租约。 +type LiveViewerLease struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + StreamSessionID string `gorm:"column:stream_session_id;type:VARCHAR(128);not null" json:"streamSessionId"` + ViewerID int64 `gorm:"column:viewer_id;type:BIGINT;not null" json:"viewerId"` + ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expiresAt"` + ReleasedAt *time.Time `gorm:"column:released_at" json:"releasedAt"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (LiveViewerLease) TableName() string { + return "live_viewer_lease" +} + +// Video 视频文件 +type Video struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + DroneSN string `gorm:"column:drone_sn;type:VARCHAR(32)" json:"droneSn"` + ExecutionID int64 `gorm:"column:execution_id;type:BIGINT" json:"executionId"` + FileName string `gorm:"column:file_name;type:VARCHAR(256)" json:"fileName"` + FileSize int64 `gorm:"column:file_size;type:BIGINT" json:"fileSize"` + Duration int `gorm:"column:duration;type:INT" json:"duration"` + OssKey string `gorm:"column:oss_key;type:VARCHAR(256)" json:"ossKey"` + OssBucket string `gorm:"column:oss_bucket;type:VARCHAR(128)" json:"ossBucket"` + ThumbnailKey string `gorm:"column:thumbnail_key;type:VARCHAR(256)" json:"thumbnailKey"` + Status string `gorm:"column:status;type:VARCHAR(16);default:uploading" json:"status"` + UploadExpireAt *time.Time `gorm:"column:upload_expire_at" json:"uploadExpireAt"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (Video) TableName() string { + return "video" +} + +// DownloadLog 下载记录 +type DownloadLog struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + VideoID int64 `gorm:"column:video_id;type:BIGINT;not null" json:"videoId"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + Bytes int64 `gorm:"column:bytes;type:BIGINT;not null" json:"bytes"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (DownloadLog) TableName() string { + return "download_log" +} diff --git a/model/operation_log.go b/model/operation_log.go new file mode 100644 index 0000000..c2ae665 --- /dev/null +++ b/model/operation_log.go @@ -0,0 +1,20 @@ +package model + +import "time" + +// OperationLog 操作日志(审计) +type OperationLog struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null;index:idx_op_user" json:"userId"` + UserName string `gorm:"column:user_name;type:VARCHAR(32)" json:"userName"` + Module string `gorm:"column:module;type:VARCHAR(32)" json:"module"` + Action string `gorm:"column:action;type:VARCHAR(64)" json:"action"` + Detail string `gorm:"column:detail;type:VARCHAR(256)" json:"detail"` + Result string `gorm:"column:result;type:VARCHAR(16)" json:"result"` + IP string `gorm:"column:ip;type:VARCHAR(64)" json:"ip"` + CreatedAt time.Time `gorm:"column:created_at;index:idx_op_user" json:"createdAt"` +} + +func (OperationLog) TableName() string { + return "operation_log" +} diff --git a/model/route.go b/model/route.go new file mode 100644 index 0000000..816a691 --- /dev/null +++ b/model/route.go @@ -0,0 +1,35 @@ +package model + +import "time" + +// Route 航线 +type Route struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + Name string `gorm:"column:name;type:VARCHAR(128);not null" json:"name"` + Description string `gorm:"column:description;type:VARCHAR(256)" json:"description"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (Route) TableName() string { + return "route" +} + +// RouteWaypoint 航线航点 +type RouteWaypoint struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + RouteID int64 `gorm:"column:route_id;type:BIGINT;not null;uniqueIndex:uk_route_seq" json:"routeId"` + Seq int `gorm:"column:seq;type:INT;not null;uniqueIndex:uk_route_seq" json:"seq"` + Longitude float64 `gorm:"column:longitude;type:DOUBLE" json:"longitude"` + Latitude float64 `gorm:"column:latitude;type:DOUBLE" json:"latitude"` + Altitude float64 `gorm:"column:altitude;type:DOUBLE" json:"altitude"` + Speed float64 `gorm:"column:speed;type:DOUBLE" json:"speed"` + Yaw float64 `gorm:"column:yaw;type:DOUBLE" json:"yaw"` + HoldSec int `gorm:"column:hold_sec;type:INT;default:0" json:"holdSec"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (RouteWaypoint) TableName() string { + return "route_waypoint" +} diff --git a/model/task.go b/model/task.go new file mode 100644 index 0000000..2922688 --- /dev/null +++ b/model/task.go @@ -0,0 +1,62 @@ +package model + +import ( + "encoding/json" + "time" +) + +// TaskPlan 任务计划 +type TaskPlan struct { + ID string `gorm:"primaryKey;column:id;type:VARCHAR(128);not null" json:"id"` + UserID int64 `gorm:"column:user_id;type:BIGINT;not null" json:"userId"` + Name string `gorm:"column:name;type:VARCHAR(128);not null" json:"name"` + DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"` + RouteID int64 `gorm:"column:route_id;type:BIGINT" json:"routeId"` + ScheduleType string `gorm:"column:schedule_type;type:VARCHAR(16);default:once" json:"scheduleType"` // once / cron + ScheduleCron string `gorm:"column:schedule_cron;type:VARCHAR(32)" json:"scheduleCron"` + VideoPolicy string `gorm:"column:video_policy;type:VARCHAR(16);default:raw" json:"videoPolicy"` + Status string `gorm:"column:status;type:VARCHAR(16);default:draft" json:"status"` + CreatedBy int64 `gorm:"column:created_by;type:BIGINT" json:"createdBy"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (TaskPlan) TableName() string { + return "task_plan" +} + +// TaskExecution 任务执行记录 +type TaskExecution struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + TaskID string `gorm:"column:task_id;type:VARCHAR(128)" json:"taskId"` + CommandID string `gorm:"column:command_id;type:VARCHAR(64)" json:"commandId"` + DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"` + DroneSN string `gorm:"column:drone_sn;type:VARCHAR(32)" json:"droneSn"` + StartTime *time.Time `gorm:"column:start_time" json:"startTime"` + EndTime *time.Time `gorm:"column:end_time" json:"endTime"` + Status string `gorm:"column:status;type:VARCHAR(16);default:pending" json:"status"` + TrajectoryJSON json.RawMessage `gorm:"column:trajectory_json;type:JSON" json:"trajectoryJson"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (TaskExecution) TableName() string { + return "task_execution" +} + +// WorkflowState 工作流状态(任务/一键流程执行步骤跟踪) +type WorkflowState struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + DockID string `gorm:"column:dock_id;type:VARCHAR(64);not null" json:"dockId"` + CommandID string `gorm:"column:command_id;type:VARCHAR(64)" json:"commandId"` + Type string `gorm:"column:type;type:VARCHAR(64)" json:"type"` + TaskID string `gorm:"column:task_id;type:VARCHAR(128)" json:"taskId"` + MissionID string `gorm:"column:mission_id;type:VARCHAR(128)" json:"missionId"` + State string `gorm:"column:state;type:VARCHAR(16);not null" json:"state"` // idle / running / succeeded / failed / cancelled + Step string `gorm:"column:step;type:VARCHAR(64);not null" json:"step"` + ResultCode string `gorm:"column:result_code;type:VARCHAR(64)" json:"resultCode"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (WorkflowState) TableName() string { + return "workflow_state" +} diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..f54dbe5 --- /dev/null +++ b/model/user.go @@ -0,0 +1,22 @@ +package model + +import "time" + +// User 用户表 +type User struct { + ID int64 `gorm:"primaryKey;column:id;type:BIGINT;not null" json:"id"` + Name string `gorm:"column:name;type:VARCHAR(32);not null" json:"name"` + Phone string `gorm:"column:phone;type:VARCHAR(32);not null;uniqueIndex:idx_phone" json:"phone"` + Email string `gorm:"column:email;type:VARCHAR(64)" json:"email"` + Password string `gorm:"column:password;type:VARCHAR(64);not null" json:"-"` + Role string `gorm:"column:role;type:VARCHAR(16);default:user" json:"role"` + TrafficBalance int64 `gorm:"column:traffic_balance;type:BIGINT;default:0" json:"trafficBalance"` + Status int8 `gorm:"column:status;type:TINYINT;default:1" json:"status"` + LastLogin *time.Time `gorm:"column:last_login" json:"lastLogin"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (User) TableName() string { + return "user" +} diff --git a/mqtt/message.go b/mqtt/message.go new file mode 100644 index 0000000..b63aac1 --- /dev/null +++ b/mqtt/message.go @@ -0,0 +1,54 @@ +package mqtt + +import ( + "encoding/json" + "time" +) + +// Envelope MQTT 消息通用外层(协议文档 §4) +// +// { +// "requestId": "req-uuid" | null, +// "dockId": "dock-...", +// "droneSn": "..." | null, +// "timestamp": 1784700000000, +// "payload": { ... } +// } +type Envelope struct { + RequestID string `json:"requestId"` + EventID string `json:"eventId"` + Version int64 `json:"version"` + DockID string `json:"dockId"` + DroneSN string `json:"droneSn"` + Timestamp int64 `json:"timestamp"` + Payload json.RawMessage `json:"payload"` +} + +// ParseEnvelope 解析通用外层;payload 缺失(无外层的历史扁平消息)时把整个消息体当作 payload。 +func ParseEnvelope(data []byte) *Envelope { + var env Envelope + if err := json.Unmarshal(data, &env); err != nil { + return &Envelope{Payload: data} + } + if len(env.Payload) == 0 { + env.Payload = data + } + return &env +} + +// NewEnvelope 构造下行外层;payload 为业务字段(内部自动 marshal),timestamp 取当前毫秒。 +func NewEnvelope(requestID, dockID, droneSN string, payload any) *Envelope { + var raw json.RawMessage + if payload != nil { + if b, err := json.Marshal(payload); err == nil { + raw = b + } + } + return &Envelope{ + RequestID: requestID, + DockID: dockID, + DroneSN: droneSN, + Timestamp: time.Now().UnixMilli(), + Payload: raw, + } +} diff --git a/mqtt/mqtt.go b/mqtt/mqtt.go new file mode 100644 index 0000000..e6d6db7 --- /dev/null +++ b/mqtt/mqtt.go @@ -0,0 +1,72 @@ +package mqtt + +import ( + "errors" + + mqtt "github.com/eclipse/paho.mqtt.golang" + + "laic-backend/common" + "laic-backend/logger" +) + +var ( + client mqtt.Client + subTopics = map[string]byte{} + subHandler mqtt.MessageHandler +) + +// InitMQTT 初始化 MQTT 客户端(不依赖 service,订阅由上层注册) +func InitMQTT(config *common.MQTT) { + if config == nil || config.Broker == "" { + logger.WARN("MQTT broker 为空,跳过初始化") + return + } + + opts := mqtt.NewClientOptions(). + AddBroker(config.Broker). + SetClientID(config.ClientId). + SetUsername(config.Username). + SetPassword(config.Password). + SetAutoReconnect(true). + SetCleanSession(true). + SetOnConnectHandler(func(c mqtt.Client) { + logger.INFO("MQTT 已连接,重新订阅") + resubscribe(c) + }) + + client = mqtt.NewClient(opts) + if token := client.Connect(); token.Wait() && token.Error() != nil { + logger.ERROR("MQTT 连接失败", token.Error()) + return + } + logger.INFO("MQTT ready") +} + +// Subscribe 记录订阅关系并立即订阅;连接重建时自动重订阅 +func Subscribe(topics map[string]byte, handler mqtt.MessageHandler) { + subHandler = handler + for topic, qos := range topics { + subTopics[topic] = qos + } + if client != nil && client.IsConnected() { + resubscribe(client) + } +} + +func resubscribe(c mqtt.Client) { + for topic, qos := range subTopics { + if token := c.Subscribe(topic, qos, subHandler); token.Wait() && token.Error() != nil { + logger.ERROR("订阅失败 topic="+topic, token.Error()) + } + } +} + +// Publish 发布消息到指定 topic +func Publish(topic string, qos byte, retained bool, payload []byte) error { + if client == nil || !client.IsConnected() { + return errors.New("mqtt client not connected") + } + token := client.Publish(topic, qos, retained, payload) + token.Wait() + return token.Error() +} diff --git a/route/route.go b/route/route.go new file mode 100644 index 0000000..c9335f8 --- /dev/null +++ b/route/route.go @@ -0,0 +1,171 @@ +package route + +import ( + "github.com/gin-gonic/gin" + + "laic-backend/common" + "laic-backend/handler" + "laic-backend/logger" + "laic-backend/middleware" + "laic-backend/websocket" +) + +// InitRouter 初始化 gin 引擎与路由注册 +func InitRouter(port int32) { + common.Server(true) + common.SetWriter(logger.GetLogWriter()) + + engine := common.GetEngine() + engine.Use(middleware.TraceMiddleware(), middleware.CORSMiddleware(), middleware.DefaultLogMiddleware(), middleware.RateLimitMiddleware(300, 60)) + + // 健康检查 + engine.GET("/health", func(c *gin.Context) { + common.OKWithData(c, gin.H{"status": "up"}) + }) + + // v1 业务路由 + v1 := common.RouteGroup("/v1") + v1.Use(middleware.OperationLogMiddleware()) + + // 实时监控 WS(设备状态 + 告警推送) + v1.GET("/ws/monitor", middleware.AuthMiddleware(), websocket.HandleMonitor) + + // 认证(公开) + auth := v1.Group("/auth") + { + auth.POST("/register", handler.Register) + auth.POST("/login", handler.Login) + auth.POST("/refresh", handler.Refresh) + auth.POST("/sms-code", handler.SmsCode) + auth.POST("/reset-password", handler.ResetPassword) + auth.POST("/logout", middleware.AuthMiddleware(), handler.Logout) + } + + // 账户中心(资料 / 流量 / SIM 卡) + account := v1.Group("/account", middleware.AuthMiddleware()) + { + account.GET("/profile", handler.GetProfile) + account.PUT("/profile", handler.UpdateProfile) + account.GET("/traffic/balance", handler.GetTrafficBalance) + account.GET("/traffic/usage", handler.GetUsagePage) + account.GET("/traffic/orders", handler.GetOrderPage) + account.POST("/traffic/orders", handler.CreateTrafficOrder) + account.POST("/traffic/orders/:id/pay", middleware.AdminMiddleware(), handler.PayTrafficOrder) + account.GET("/sim-cards", handler.ListSimCards) + account.GET("/sim-recharge-logs", handler.GetSimRechargeLogPage) + account.POST("/sim-cards/:id/recharge", handler.RechargeSimCard) + } + + // 机巢 & 无人机 + docks := v1.Group("/docks", middleware.AuthMiddleware()) + { + docks.GET("", handler.GetDockPage) + docks.POST("", handler.CreateDock) + docks.GET("/:id", handler.GetDock) + docks.PUT("/:id", handler.UpdateDock) + docks.DELETE("/:id", handler.DeleteDock) + docks.GET("/:id/status", handler.GetDockStatus) + docks.POST("/:id/command", handler.DispatchCommand) + docks.POST("/:id/firmware/upgrade", handler.UpgradeFirmware) + } + + drones := v1.Group("/drones", middleware.AuthMiddleware()) + { + drones.GET("", handler.GetDronePage) + drones.GET("/:id", handler.GetDrone) + drones.PUT("/:id", handler.UpdateDrone) + drones.DELETE("/:id", handler.DeleteDrone) + drones.GET("/:id/telemetry", handler.GetDroneTelemetry) + } + + // 告警 + alarms := v1.Group("/alarms", middleware.AuthMiddleware()) + { + alarms.GET("", handler.GetAlarmPage) + alarms.PUT("/:id/acknowledge", handler.AcknowledgeAlarm) + alarms.PUT("/:id/resolve", handler.ResolveAlarm) + } + + // 指令历史 + commands := v1.Group("/commands", middleware.AuthMiddleware()) + { + commands.GET("", handler.GetCommandPage) + commands.GET("/:id", handler.GetCommand) + } + + // 航线 + routes := v1.Group("/routes", middleware.AuthMiddleware()) + { + routes.GET("", handler.GetRoutePage) + routes.POST("", handler.CreateRoute) + routes.GET("/:id", handler.GetRoute) + routes.PUT("/:id", handler.UpdateRoute) + routes.DELETE("/:id", handler.DeleteRoute) + } + + // 任务 + tasks := v1.Group("/tasks", middleware.AuthMiddleware()) + { + tasks.GET("", handler.GetTaskPage) + tasks.POST("", handler.CreateTask) + tasks.GET("/:id", handler.GetTask) + tasks.PUT("/:id", handler.UpdateTask) + tasks.DELETE("/:id", handler.DeleteTask) + tasks.POST("/:id/execute", handler.ExecuteTask) + } + + // 执行记录 + executions := v1.Group("/executions", middleware.AuthMiddleware()) + { + executions.GET("", handler.GetExecutionPage) + executions.GET("/:id", handler.GetExecution) + executions.GET("/:id/trajectory", handler.GetTrajectory) + } + + // 已发布固件列表(普通用户可选升级,无需 admin) + v1.GET("/firmwares/released", middleware.AuthMiddleware(), handler.GetReleasedFirmwares) + + // 固件管理(admin) + firmwares := v1.Group("/firmwares", middleware.AuthMiddleware(), middleware.AdminMiddleware()) + { + firmwares.GET("", handler.GetFirmwarePage) + firmwares.GET("/:id", handler.GetFirmware) + firmwares.POST("", handler.CreateFirmware) + firmwares.PUT("/:id", handler.UpdateFirmware) + firmwares.DELETE("/:id", handler.DeleteFirmware) + } + + // 直播 + live := v1.Group("/live", middleware.AuthMiddleware()) + { + live.GET("", handler.GetLivePage) + live.POST("/:dockId/start", handler.StartLive) + live.POST("/:dockId/stop", handler.StopLive) + live.GET("/:dockId/play-url", handler.GetLivePlayURL) + live.POST("/:dockId/sessions", handler.CreateLiveSession) + live.GET("/:dockId/sessions/:streamSessionId", handler.GetLiveSession) + live.POST("/:dockId/sessions/:streamSessionId/heartbeat", handler.HeartbeatLiveSession) + live.DELETE("/:dockId/sessions/:streamSessionId/viewers/me", handler.LeaveLiveSession) + } + + // 视频(OSS 上传/下载) + videos := v1.Group("/videos", middleware.AuthMiddleware()) + { + videos.POST("", handler.CreateVideoUpload) + videos.POST("/:id/complete", handler.CompleteVideoUpload) + videos.GET("", handler.GetVideoPage) + videos.GET("/:id", handler.GetVideo) + videos.POST("/:id/download", handler.DownloadVideo) + } + + // 系统管理(admin) + users := v1.Group("/users", middleware.AuthMiddleware(), middleware.AdminMiddleware()) + { + users.GET("", handler.GetUserPage) + users.POST("", handler.CreateUser) + users.PUT("/:id", handler.UpdateUser) + users.DELETE("/:id", handler.DeleteUser) + } + v1.GET("/roles", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.GetRoles) + v1.GET("/logs/operation", middleware.AuthMiddleware(), handler.GetOperationLogPage) +} diff --git a/service/account_service.go b/service/account_service.go new file mode 100644 index 0000000..f0780f0 --- /dev/null +++ b/service/account_service.go @@ -0,0 +1,94 @@ +package service + +import ( + "errors" + "time" + + "gorm.io/gorm" + + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/vo" +) + +type AccountService struct{} + +var DefaultAccountService = &AccountService{} + +// GetProfile 查询个人资料 +func (s *AccountService) GetProfile(userID int64) (*model.User, *common.BusiError) { + var user model.User + if err := common.DB.First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrUserNotFound + } + return nil, common.ErrInternal + } + return &user, nil +} + +// UpdateProfile 更新个人资料(姓名/邮箱) +func (s *AccountService) UpdateProfile(userID int64, req *vo.ProfileUpdateReq) (*model.User, *common.BusiError) { + var user model.User + if err := common.DB.First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrUserNotFound + } + return nil, common.ErrInternal + } + + updates := map[string]any{"updated_at": time.Now()} + if req.Name != "" { + updates["name"] = req.Name + } + if req.Email != "" { + updates["email"] = req.Email + } + if err := common.DB.Model(&user).Updates(updates).Error; err != nil { + logger.ERROR("更新个人资料失败", err) + return nil, common.ErrInternal + } + common.DB.First(&user, userID) + return &user, nil +} + +// GetTrafficBalance 查询云媒体流量余额 +func (s *AccountService) GetTrafficBalance(userID int64) (*vo.TrafficBalanceVO, *common.BusiError) { + return DefaultBillingService.GetBalance(userID) +} + +// GetUsagePage 流量消费流水分页 +func (s *AccountService) GetUsagePage(userID int64, req *vo.UsagePageReq) (*common.PageResponse[model.TrafficUsageLog], *common.BusiError) { + return DefaultBillingService.GetUsagePage(userID, req) +} + +// GetOrderPage 流量订单分页 +func (s *AccountService) GetOrderPage(userID int64, req *vo.OrderPageReq) (*common.PageResponse[model.TrafficOrder], *common.BusiError) { + return DefaultBillingService.GetOrderPage(userID, req) +} + +// CreateTrafficOrder 创建流量充值订单 +func (s *AccountService) CreateTrafficOrder(userID int64, req *vo.TrafficOrderCreateReq) (*model.TrafficOrder, *common.BusiError) { + return DefaultBillingService.CreateOrder(userID, req.AmountGb) +} + +// PayTrafficOrder 订单支付入账(admin) +func (s *AccountService) PayTrafficOrder(orderID int64) (*model.TrafficOrder, *common.BusiError) { + return DefaultBillingService.PayOrder(orderID) +} + +// ListSimCards SIM 卡列表 +func (s *AccountService) ListSimCards(userID int64, req *vo.SimCardPageReq) (*common.PageResponse[model.SimCard], *common.BusiError) { + return DefaultBillingService.ListSimCards(userID, req) +} + +// GetSimRechargeLogPage SIM 卡充值记录分页 +func (s *AccountService) GetSimRechargeLogPage(userID int64, req *vo.SimRechargeLogPageReq) (*common.PageResponse[model.SimRechargeLog], *common.BusiError) { + return DefaultBillingService.GetSimRechargeLogPage(userID, req) +} + +// RechargeSimCard SIM 卡充值 +func (s *AccountService) RechargeSimCard(userID, simCardID int64, req *vo.SimRechargeReq) (*model.SimRechargeLog, *common.BusiError) { + return DefaultBillingService.RechargeSimCard(userID, simCardID, req.AmountGb) +} diff --git a/service/alarm_service.go b/service/alarm_service.go new file mode 100644 index 0000000..42147c1 --- /dev/null +++ b/service/alarm_service.go @@ -0,0 +1,200 @@ +package service + +import ( + "errors" + "time" + + "gorm.io/gorm" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +type AlarmService struct{} + +var DefaultAlarmService = &AlarmService{} + +// GetPage 告警分页列表(按所属机巢过滤) +func (s *AlarmService) GetPage(userID int64, isAdmin bool, req *vo.AlarmPageReq) (*common.PageResponse[model.Alarm], *common.BusiError) { + db := common.DB.Model(&model.Alarm{}).Scopes(withDockFilter(userID, isAdmin)) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + if req.Level != "" { + db = db.Where("level = ?", req.Level) + } + if req.DeviceType != "" { + db = db.Where("device_type = ?", req.DeviceType) + } + if req.DockID != "" { + db = db.Where("dock_id = ?", req.DockID) + } + if req.Keyword != "" { + kw := "%" + req.Keyword + "%" + db = db.Where("code LIKE ? OR message_cn LIKE ?", kw, kw) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + logger.ERROR("统计告警失败", err) + return nil, common.ErrInternal + } + var list []model.Alarm + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + logger.ERROR("查询告警列表失败", err) + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// Acknowledge 确认告警(active → acknowledged) +func (s *AlarmService) Acknowledge(userID int64, isAdmin bool, id int64) *common.BusiError { + var alarm model.Alarm + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).First(&alarm, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.ErrAlarmNotFound + } + return common.ErrInternal + } + if alarm.Status == "active" { + if err := common.DB.Model(&model.Alarm{}).Where("id = ?", id).Update("status", "acknowledged").Error; err != nil { + logger.ERROR("确认告警失败", err) + return common.ErrInternal + } + broadcast("alarm.acknowledged", map[string]any{ + "id": id, + "dockId": alarm.DockID, + "code": alarm.Code, + }) + } + return nil +} + +// Resolve 关闭告警(active/acknowledged → resolved) +func (s *AlarmService) Resolve(userID int64, isAdmin bool, id int64) *common.BusiError { + var alarm model.Alarm + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).First(&alarm, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.ErrAlarmNotFound + } + return common.ErrInternal + } + if alarm.Status != "resolved" { + now := time.Now() + if err := common.DB.Model(&model.Alarm{}).Where("id = ?", id). + Updates(map[string]any{"status": "resolved", "resolved_at": now}).Error; err != nil { + logger.ERROR("关闭告警失败", err) + return common.ErrInternal + } + broadcast("alarm.resolved", map[string]any{ + "id": id, + "dockId": alarm.DockID, + "code": alarm.Code, + "resolvedAt": now, + }) + } + return nil +} + +// SyncByState 根据设备最新 alarmCodes 做 diff,生成/关闭告警 +// deviceType: dock / drone +func (s *AlarmService) SyncByState(dockID, deviceType string, codes []string) { + key := s.alarmKey(dockID, deviceType) + + last, err := common.SetMembers(key) + if err != nil { + last = []string{} + } + lastSet := toSet(last) + newSet := toSet(codes) + + // 新增告警 → active + for code := range newSet { + if !lastSet[code] { + s.createAlarm(dockID, deviceType, code) + } + } + // 移除告警 → resolved + for code := range lastSet { + if !newSet[code] { + s.resolveAlarm(dockID, code) + } + } + + // 更新当前告警码集合 + _ = common.Delete(key) + for _, code := range codes { + _ = common.SetAdd(key, code) + } +} + +func (s *AlarmService) createAlarm(dockID, deviceType, code string) { + messageCn, level := code, "critical" + var ac model.AlarmCode + if err := common.DB.Where("code = ?", code).First(&ac).Error; err == nil { + messageCn, level = ac.MessageCn, ac.Level + } + + id, err := tool.NextID() + if err != nil { + logger.ERROR("生成告警 ID 失败", err) + return + } + now := time.Now() + alarm := model.Alarm{ + ID: id, + DockID: dockID, + Code: code, + DeviceType: deviceType, + MessageCn: messageCn, + Level: level, + Status: "active", + TriggeredAt: &now, + CreatedAt: now, + } + if err := common.DB.Create(&alarm).Error; err != nil { + logger.ERROR("写入告警失败", err) + return + } + + broadcast("alarm.created", map[string]any{ + "dockId": dockID, + "alarm": alarm, + }) +} + +func (s *AlarmService) resolveAlarm(dockID, code string) { + now := time.Now() + res := common.DB.Model(&model.Alarm{}).Where("dock_id = ? AND code = ? AND status = 'active'", dockID, code). + Updates(map[string]any{"status": "resolved", "resolved_at": now}) + if res.Error != nil { + logger.ERROR("关闭告警失败", res.Error) + return + } + if res.RowsAffected > 0 { + broadcast("alarm.resolved", map[string]any{ + "dockId": dockID, + "code": code, + "resolvedAt": now, + }) + } +} + +func (s *AlarmService) alarmKey(dockID, deviceType string) string { + if deviceType == "drone" { + return cache.DroneAlarmKeyOf(dockID) + } + return cache.DockAlarmKeyOf(dockID) +} + +func toSet(items []string) map[string]bool { + set := make(map[string]bool, len(items)) + for _, it := range items { + set[it] = true + } + return set +} diff --git a/service/billing_service.go b/service/billing_service.go new file mode 100644 index 0000000..b1d0f52 --- /dev/null +++ b/service/billing_service.go @@ -0,0 +1,478 @@ +package service + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +const ( + gbBytes = 1024 * 1024 * 1024 // 1 GB 字节数 + defaultTrafficUnitPrice = 10.0 // 云媒体流量单价(元/GB) +) + +// trafficDeductLua 原子扣减:key 不存在时用 MySQL 快照兜底初始化,再判断并扣减。 +// 返回 {status, before, after}:status=1 成功,0 余额不足。 +const trafficDeductLua = ` +local key = KEYS[1] +local delta = tonumber(ARGV[1]) +local fallback = tonumber(ARGV[2]) +local before = 0 +if redis.call('EXISTS', key) == 0 then + redis.call('SET', key, fallback) + before = fallback +else + before = tonumber(redis.call('GET', key)) +end +if before < delta then + return {0, before, before} +end +local after = redis.call('DECRBY', key, delta) +return {1, before, after} +` + +type BillingService struct{} + +var DefaultBillingService = &BillingService{} + +// CarrierAPI 运营商接口抽象(查询用量 / 充值) +type CarrierAPI interface { + QueryUsage(iccid string) (usedGb float64, carrierStatus string, err error) + Recharge(iccid string, amountGb int) (orderNo string, err error) +} + +// MockCarrierAPI 运营商 Mock(未接入真实通道前使用) +type MockCarrierAPI struct{} + +func (m *MockCarrierAPI) QueryUsage(iccid string) (float64, string, error) { + return 0.0, "normal", nil +} + +func (m *MockCarrierAPI) Recharge(iccid string, amountGb int) (string, error) { + return fmt.Sprintf("MOCK-%d", time.Now().UnixNano()), nil +} + +// carrier 当前使用的运营商通道(后续可替换为真实实现) +var carrier CarrierAPI = &MockCarrierAPI{} + +// mysqlBalance 读取用户 MySQL 流量快照 +func (b *BillingService) mysqlBalance(userID int64) (int64, error) { + var u model.User + if err := common.DB.Select("traffic_balance").First(&u, userID).Error; err != nil { + return 0, err + } + return u.TrafficBalance, nil +} + +// GetBalance 查询流量余额(Redis 权威,缺 key 时用 MySQL 快照兜底) +func (b *BillingService) GetBalance(userID int64) (*vo.TrafficBalanceVO, *common.BusiError) { + balance, err := b.currentBalance(userID) + if err != nil { + logger.ERROR("查询流量余额失败", err) + return nil, common.ErrInternal + } + return &vo.TrafficBalanceVO{ + BalanceBytes: balance, + BalanceGb: float64(balance) / float64(gbBytes), + }, nil +} + +// currentBalance 返回 Redis 权威余额;缺 key 时先用 MySQL 快照 SetNX 初始化 +func (b *BillingService) currentBalance(userID int64) (int64, error) { + key := cache.TrafficKeyOf(userID) + bal, err := common.GetInt64(key) + if err == nil { + return bal, nil + } + fallback, err := b.mysqlBalance(userID) + if err != nil { + return 0, err + } + if _, err := common.SetNX(key, fallback); err != nil { + return 0, err + } + // SetNX 失败说明并发下已有值,重读一次 + bal, err = common.GetInt64(key) + if err != nil { + return fallback, nil + } + return bal, nil +} + +// Deduct 扣减流量(直播/回放/下载计费),原子 Lua + 账本落库 +func (b *BillingService) Deduct(userID int64, bytes int64, sourceType string, sourceID int64) (bool, *common.BusiError) { + if bytes <= 0 { + return true, nil + } + fallback, err := b.mysqlBalance(userID) + if err != nil { + logger.ERROR("读取流量快照失败", err) + return false, common.ErrInternal + } + key := cache.TrafficKeyOf(userID) + res, err := common.GetLuaInt64s(trafficDeductLua, []string{key}, bytes, fallback) + if err != nil { + logger.ERROR("流量扣减 Lua 执行失败", err) + return false, common.ErrInternal + } + if len(res) < 3 { + return false, common.ErrInternal + } + status, before, after := res[0], res[1], res[2] + if status == 0 { + return false, common.ErrTrafficNotEnough + } + + log := &model.TrafficUsageLog{ + ID: mustID(), + UserID: userID, + SourceType: sourceType, + SourceID: sourceID, + BytesUsed: bytes, + BalanceBefore: before, + BalanceAfter: after, + CreatedAt: time.Now(), + } + if err := common.DB.Create(log).Error; err != nil { + logger.ERROR("流量账本落库失败", err) + } + return true, nil +} + +// credit 充值入账:Redis 权威自增 + MySQL 快照同步 +func (b *BillingService) credit(userID int64, bytes int64) error { + key := cache.TrafficKeyOf(userID) + if _, err := b.currentBalance(userID); err != nil { + return err + } + if _, err := common.IncrBy(key, bytes); err != nil { + return err + } + return common.DB.Model(&model.User{}).Where("id = ?", userID). + UpdateColumn("traffic_balance", gorm.Expr("traffic_balance + ?", bytes)).Error +} + +// GetUsagePage 流量消费流水分页 +func (b *BillingService) GetUsagePage(userID int64, req *vo.UsagePageReq) (*common.PageResponse[model.TrafficUsageLog], *common.BusiError) { + db := common.DB.Model(&model.TrafficUsageLog{}).Where("user_id = ?", userID) + if req.SourceType != "" { + db = db.Where("source_type = ?", req.SourceType) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, common.ErrInternal + } + var list []model.TrafficUsageLog + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// GetOrderPage 流量订单分页 +func (b *BillingService) GetOrderPage(userID int64, req *vo.OrderPageReq) (*common.PageResponse[model.TrafficOrder], *common.BusiError) { + db := common.DB.Model(&model.TrafficOrder{}).Where("user_id = ?", userID) + if req.PayStatus != "" { + db = db.Where("pay_status = ?", req.PayStatus) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, common.ErrInternal + } + var list []model.TrafficOrder + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// CreateOrder 创建流量充值订单(待支付) +func (b *BillingService) CreateOrder(userID int64, amountGb int) (*model.TrafficOrder, *common.BusiError) { + order := &model.TrafficOrder{ + ID: mustID(), + UserID: userID, + AmountGb: amountGb, + UnitPrice: defaultTrafficUnitPrice, + TotalPrice: float64(amountGb) * defaultTrafficUnitPrice, + PayStatus: "unpaid", + CreatedAt: time.Now(), + } + if err := common.DB.Create(order).Error; err != nil { + logger.ERROR("创建流量订单失败", err) + return nil, common.ErrInternal + } + return order, nil +} + +// PayOrder 订单支付入账(admin 确认支付) +func (b *BillingService) PayOrder(orderID int64) (*model.TrafficOrder, *common.BusiError) { + var order model.TrafficOrder + if err := common.DB.First(&order, orderID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrOrderNotFound + } + return nil, common.ErrInternal + } + if order.PayStatus == "paid" { + return nil, common.ErrOrderPaid + } + + bytes := int64(order.AmountGb) * gbBytes + now := time.Now() + if err := common.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&order).Updates(map[string]any{ + "pay_status": "paid", + "paid_at": now, + }).Error; err != nil { + return err + } + return tx.Model(&model.User{}).Where("id = ?", order.UserID). + UpdateColumn("traffic_balance", gorm.Expr("traffic_balance + ?", bytes)).Error + }); err != nil { + logger.ERROR("订单支付失败", err) + return nil, common.ErrInternal + } + + // Redis 权威余额入账(幂等:多次支付由 pay_status 校验拦截) + if err := b.credit(order.UserID, bytes); err != nil { + logger.ERROR("订单支付 Redis 入账失败", err) + return nil, common.ErrInternal + } + order.PayStatus = "paid" + order.PaidAt = &now + return &order, nil +} + +// ListSimCards SIM 卡列表 +func (b *BillingService) ListSimCards(userID int64, req *vo.SimCardPageReq) (*common.PageResponse[model.SimCard], *common.BusiError) { + db := common.DB.Model(&model.SimCard{}).Where("user_id = ?", userID) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, common.ErrInternal + } + var list []model.SimCard + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// GetSimRechargeLogPage SIM 卡充值记录分页 +func (b *BillingService) GetSimRechargeLogPage(userID int64, req *vo.SimRechargeLogPageReq) (*common.PageResponse[model.SimRechargeLog], *common.BusiError) { + db := common.DB.Model(&model.SimRechargeLog{}).Where("user_id = ?", userID) + if req.SimCardID > 0 { + db = db.Where("sim_card_id = ?", req.SimCardID) + } + if req.RechargeStatus != "" { + db = db.Where("recharge_status = ?", req.RechargeStatus) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, common.ErrInternal + } + var list []model.SimRechargeLog + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// RechargeSimCard SIM 卡充值(运营商下单 + 回写状态) +func (b *BillingService) RechargeSimCard(userID, simCardID int64, amountGb int) (*model.SimRechargeLog, *common.BusiError) { + var card model.SimCard + if err := common.DB.Where("id = ? AND user_id = ?", simCardID, userID).First(&card).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrSimCardNotFound + } + return nil, common.ErrInternal + } + + log := &model.SimRechargeLog{ + ID: mustID(), + SimCardID: simCardID, + UserID: userID, + AmountGb: amountGb, + UnitPrice: defaultTrafficUnitPrice, + TotalPrice: float64(amountGb) * defaultTrafficUnitPrice, + PayStatus: "paid", + RechargeStatus: "pending", + IdempotentKey: fmt.Sprintf("%d-%d", simCardID, time.Now().UnixNano()), + CreatedAt: time.Now(), + } + if err := common.DB.Create(log).Error; err != nil { + logger.ERROR("创建 SIM 充值记录失败", err) + return nil, common.ErrInternal + } + + orderNo, err := carrier.Recharge(card.Iccid, amountGb) + if err != nil { + log.RechargeStatus = "failed" + log.RechargeMsg = err.Error() + common.DB.Model(log).Updates(map[string]any{"recharge_status": "failed", "recharge_msg": err.Error()}) + return log, nil + } + + updates := map[string]any{ + "recharge_status": "success", + "recharge_msg": orderNo, + } + common.DB.Model(log).Updates(updates) + common.DB.Model(&card).Updates(map[string]any{ + "plan_gb": gorm.Expr("plan_gb + ?", amountGb), + "status": "active", + }) + log.RechargeStatus = "success" + log.RechargeMsg = orderNo + return log, nil +} + +// mustID 生成雪花 ID(失败返回 0,由 DB 约束兜底) +func mustID() int64 { + id, err := tool.NextID() + if err != nil { + logger.ERROR("生成雪花 ID 失败", err) + return 0 + } + return id +} + +// chargeLiveSessions 直播计费(定时任务,每 60s):扫描进行中的会话按码率估算扣减 +func (b *BillingService) chargeLiveSessions() { + var sessions []model.LiveSession + if err := common.DB.Where("phase IN ('starting','streaming')").Find(&sessions).Error; err != nil { + logger.ERROR("扫描直播会话失败", err) + return + } + now := time.Now() + for i := range sessions { + b.chargeLiveSession(&sessions[i], now) + } +} + +// chargeLiveSession 对单个直播会话按时长增量扣减;余额耗尽则停止推流 +func (b *BillingService) chargeLiveSession(session *model.LiveSession, now time.Time) { + var dock model.Dock + if err := common.DB.Select("user_id").Where("dock_id = ?", session.DockID).First(&dock).Error; err != nil { + return + } + + lastKey := cache.LiveBilledKeyOf(session.ID) + lastTs := int64(0) + if v, err := common.GetInt64(lastKey); err == nil { + lastTs = v + } else if session.StartedAt != nil { + lastTs = session.StartedAt.Unix() + } else { + lastTs = session.CreatedAt.Unix() + } + + nowTs := now.Unix() + if nowTs <= lastTs { + return + } + bytes := (nowTs - lastTs) * session.MaxBitrateBps / 8 + sourceID, _ := strconv.ParseInt(session.ID, 10, 64) + + if bytes > 0 { + ok, _ := b.Deduct(dock.UserID, bytes, "live", sourceID) + if !ok { + if _, busiErr := DefaultLiveService.stopForBilling(session.DockID, session.ID); busiErr != nil { + logger.ERROR("直播余额不足停止推流失败", busiErr) + } + logger.WARN("直播余额耗尽,停止推流:", session.DockID) + } + } + _ = common.SetValue(lastKey, nowTs) +} + +// flushBalanceSnapshot 余额快照刷新(定时任务,每 30min):Redis 权威余额回写 MySQL +func (b *BillingService) flushBalanceSnapshot() { + keys, err := common.GetKeysWithPrefix(cache.TrafficKeyPrefix) + if err != nil { + logger.ERROR("扫描流量余额 key 失败", err) + return + } + for _, key := range keys { + idStr := strings.TrimPrefix(key, cache.TrafficKeyPrefix) + userID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + continue + } + bal, err := common.GetInt64(key) + if err != nil { + continue + } + if err := common.DB.Model(&model.User{}).Where("id = ?", userID). + UpdateColumn("traffic_balance", bal).Error; err != nil { + logger.ERROR("回写余额快照失败", err) + } + } +} + +// syncSimUsage SIM 卡用量同步(定时任务,每 1h):运营商查询回填 + 用量快照 + 阈值告警 +func (b *BillingService) syncSimUsage() { + var cards []model.SimCard + if err := common.DB.Where("status = ?", "active").Find(&cards).Error; err != nil { + logger.ERROR("扫描 SIM 卡失败", err) + return + } + now := time.Now() + for i := range cards { + b.syncSimCard(&cards[i], now) + } +} + +func (b *BillingService) syncSimCard(card *model.SimCard, now time.Time) { + usedGb, carrierStatus, err := carrier.QueryUsage(card.Iccid) + if err != nil { + logger.WARN("查询 SIM 用量失败:", card.Iccid, err) + return + } + + updates := map[string]any{ + "used_gb": usedGb, + "carrier_status": carrierStatus, + "last_sync_at": now, + } + if carrierStatus == "cancelled" { + updates["status"] = "expired" + } + if err := common.DB.Model(&model.SimCard{}).Where("id = ?", card.ID).Updates(updates).Error; err != nil { + logger.ERROR("回填 SIM 用量失败", err) + } + + remainGb := float64(card.PlanGb) - usedGb + record := &model.SimUsageRecord{ + ID: mustID(), + SimCardID: card.ID, + PlanGb: card.PlanGb, + UsedGb: usedGb, + RemainGb: remainGb, + CarrierStatus: carrierStatus, + SyncedAt: now, + } + if err := common.DB.Create(record).Error; err != nil { + logger.ERROR("写入 SIM 用量快照失败", err) + } + + if remainGb < float64(card.PlanGb)*0.1 { + logger.WARN("SIM 卡流量即将耗尽:", card.Iccid, "剩余", remainGb, "GB") + } + if carrierStatus == "suspended" || carrierStatus == "arrears" { + logger.WARN("SIM 卡运营商状态异常:", card.Iccid, carrierStatus) + } +} diff --git a/service/casbin_bootstrap.go b/service/casbin_bootstrap.go new file mode 100644 index 0000000..6284a17 --- /dev/null +++ b/service/casbin_bootstrap.go @@ -0,0 +1,90 @@ +package service + +import ( + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" +) + +// BootstrapCasbin 引导默认 RBAC 策略(幂等):p 策略(角色→路径→方法)+ 用户角色映射 g 策略 +func BootstrapCasbin() { + e := common.CasbinEnforcer + if e == nil { + return + } + + // 清理旧的 admin 全量策略,防止历史 "admin /* *" 残留导致越权(幂等) + if _, err := e.RemoveFilteredPolicy(0, "admin"); err != nil { + logger.ERROR("清理 admin 旧策略失败", err) + } + + // p 策略: + // admin —— 平台管理职能:查看用户/机巢/无人机,管理固件,管理主账号云媒体流量 + // user —— 业务操作:机巢/无人机/任务/航线等(自注册普通用户) + // keyMatch2 下 "/prefix/*" 匹配所有子路径(含多级),再补 "/prefix" 精确匹配列表/新增基路径。 + adminPolicies := [][]string{ + // 查看所有用户信息(暂只读) + {"admin", "/v1/users", "GET"}, + {"admin", "/v1/users/*", "GET"}, + // 查看机巢 / 无人机(含状态与遥测) + {"admin", "/v1/docks", "GET"}, + {"admin", "/v1/docks/*", "GET"}, + {"admin", "/v1/drones", "GET"}, + {"admin", "/v1/drones/*", "GET"}, + // 固件包管理 + {"admin", "/v1/firmwares", "*"}, + {"admin", "/v1/firmwares/*", "*"}, + // 主账号云媒体流量管理(流量余额/订单/SIM 卡) + {"admin", "/v1/account/traffic", "*"}, + {"admin", "/v1/account/traffic/*", "*"}, + {"admin", "/v1/account/sim-cards", "*"}, + {"admin", "/v1/account/sim-cards/*", "*"}, + // 平台监控:角色字典 + {"admin", "/v1/roles", "GET"}, + } + + userPrefixes := []string{ + "/v1/docks", + "/v1/drones", + "/v1/alarms", + "/v1/commands", + "/v1/tasks", + "/v1/routes", + "/v1/executions", + "/v1/account", + "/v1/live", + "/v1/videos", + "/v1/ws", + } + + policies := append([][]string{}, adminPolicies...) + for _, prefix := range userPrefixes { + policies = append(policies, []string{"user", prefix, "*"}, []string{"user", prefix + "/*", "*"}) + } + // 普通用户可读取已发布固件列表(选择升级) + policies = append(policies, []string{"user", "/v1/firmwares/released", "GET"}) + // 普通用户查看自己的操作记录 + policies = append(policies, []string{"user", "/v1/logs/operation", "GET"}) + + for _, p := range policies { + args := make([]any, len(p)) + for i, v := range p { + args[i] = v + } + if _, err := e.AddPolicy(args...); err != nil { + logger.ERROR("添加 Casbin 策略失败", err) + } + } + + // g 策略:同步现有用户角色映射 + var users []model.User + if err := common.DB.Find(&users).Error; err != nil { + logger.ERROR("查询用户同步 Casbin 角色失败", err) + return + } + for _, u := range users { + if err := common.SetUserRole(u.ID, u.Role); err != nil { + logger.ERROR("同步用户角色失败", err) + } + } +} diff --git a/service/command_service.go b/service/command_service.go new file mode 100644 index 0000000..3272880 --- /dev/null +++ b/service/command_service.go @@ -0,0 +1,304 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/mqtt" + "laic-backend/tool" + "laic-backend/vo" +) + +type CommandService struct{} + +var DefaultCommandService = &CommandService{} + +const maxRetryCount = 2 + +// Dispatch 下发指令:校验在线 → 落库 → MQTT 发布 +func (s *CommandService) Dispatch(userID int64, isAdmin bool, id int64, req *vo.CommandReq) (*model.DeviceCommandLog, *common.BusiError) { + var dock model.Dock + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrDockNotFound + } + return nil, common.ErrInternal + } + dockID := dock.DockID + + online, _ := common.SetMemberExists(cache.OnlineDockSetKey, dockID) + if !online { + return nil, common.ErrDockOffline + } + + commandID, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + requestID := strings.ReplaceAll(uuid.New().String(), "-", "") + ttlMs := req.TTLMs + if ttlMs <= 0 { + ttlMs = 30000 + } + + paramsJSON := "{}" + if req.Params != nil { + if b, e := json.Marshal(req.Params); e == nil { + paramsJSON = string(b) + } + } + + now := time.Now() + cmdLog := &model.DeviceCommandLog{ + ID: commandID, + DockID: dockID, + CommandType: req.Type, + Params: paramsJSON, + DroneSN: req.DroneSN, + RequestID: requestID, + TTLMs: ttlMs, + Status: "sent", + SentAt: &now, + CreatedAt: now, + } + if err := common.DB.Create(cmdLog).Error; err != nil { + logger.ERROR("写入指令日志失败", err) + return nil, common.ErrInternal + } + + if err := s.publish(cmdLog, req.Params); err != nil { + logger.ERROR("下发指令失败", err) + return nil, common.ErrInternal + } + return cmdLog, nil +} + +// HandleAck 处理指令应答,按外层 requestId 更新一次发送尝试。 +func (s *CommandService) HandleAck(dockID, requestID, commandIDStr string, accepted bool, resultCode string) { + if requestID == "" { + logger.WARN("指令应答缺少 requestId", dockID) + return + } + + var cmd model.DeviceCommandLog + if err := common.DB.Where("dock_id = ? AND request_id = ? AND status = ?", dockID, requestID, "sent").First(&cmd).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + logger.ERROR("查询指令应答失败", err) + } + return + } + if commandIDStr != "" && commandIDStr != strconv.FormatInt(cmd.ID, 10) { + logger.WARN("指令应答 commandId 与 requestId 不匹配", dockID, requestID) + return + } + + now := time.Now() + updates := map[string]any{ + "ack_accepted": accepted, + "ack_result_code": resultCode, + "acked_at": now, + "status": "terminal", + } + if accepted { + updates["status"] = "acked" + } + result := common.DB.Model(&model.DeviceCommandLog{}). + Where("id = ? AND dock_id = ? AND request_id = ? AND status = ?", cmd.ID, dockID, requestID, "sent"). + Updates(updates) + if result.Error != nil { + logger.ERROR("更新指令应答失败", result.Error) + return + } + if result.RowsAffected == 0 { + return + } + if !accepted { + failTaskExecution(cmd, resultCode) + } + DefaultLiveService.OnCommandAck(&cmd, accepted, resultCode) +} + +func failTaskExecution(cmd model.DeviceCommandLog, reason string) { + if reason == "" { + reason = "COMMAND_REJECTED" + } + _ = common.DB.Model(&model.TaskExecution{}). + Where("dock_id = ? AND command_id = ? AND status IN ?", cmd.DockID, strconv.FormatInt(cmd.ID, 10), []string{"pending", "running"}). + Updates(map[string]any{"status": "failed", "end_time": time.Now()}) +} + +// DispatchToDock 持久化并下发已完成权限与在线校验的设备指令。 +func (s *CommandService) DispatchToDock(dockID, commandType string, params map[string]any) (*model.DeviceCommandLog, error) { + commandID, err := tool.NextID() + if err != nil { + return nil, err + } + requestID := strings.ReplaceAll(uuid.New().String(), "-", "") + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, err + } + now := time.Now() + cmd := &model.DeviceCommandLog{ + ID: commandID, + DockID: dockID, + CommandType: commandType, + Params: string(paramsJSON), + RequestID: requestID, + TTLMs: 30000, + Status: "sent", + SentAt: &now, + CreatedAt: now, + } + if err := common.DB.Create(cmd).Error; err != nil { + return nil, err + } + if err := s.publish(cmd, params); err != nil { + return nil, err + } + return cmd, nil +} + +// StartRetryScanner 启动指令超时重试扫描器 +func (s *CommandService) StartRetryScanner() { + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for range ticker.C { + s.retryTimeout() + } + }() +} + +func (s *CommandService) retryTimeout() { + var pending []model.DeviceCommandLog + if err := common.DB.Where("status = 'sent'").Find(&pending).Error; err != nil { + return + } + now := time.Now() + for _, cmd := range pending { + if cmd.SentAt == nil { + continue + } + ttl := int64(cmd.TTLMs) + if ttl <= 0 { + ttl = 30000 + } + if now.Sub(*cmd.SentAt).Milliseconds() <= ttl { + continue + } + + if cmd.RetryCount >= maxRetryCount { + result := common.DB.Model(&model.DeviceCommandLog{}).Where("id = ? AND status = ?", cmd.ID, "sent").Update("status", "timeout") + if result.Error == nil && result.RowsAffected > 0 { + failTaskExecution(cmd, "COMMAND_ACK_TIMEOUT") + } + continue + } + + // 重试:复用 commandId,换新 requestId(工控机按 commandId 幂等) + requestID := strings.ReplaceAll(uuid.New().String(), "-", "") + _ = common.DB.Model(&model.DeviceCommandLog{}).Where("id = ?", cmd.ID).Updates(map[string]any{ + "request_id": requestID, + "retry_count": cmd.RetryCount + 1, + "sent_at": now, + }) + + var params map[string]any + if cmd.Params != "" { + _ = json.Unmarshal([]byte(cmd.Params), ¶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 +} diff --git a/service/dock_service.go b/service/dock_service.go new file mode 100644 index 0000000..b1c160a --- /dev/null +++ b/service/dock_service.go @@ -0,0 +1,355 @@ +package service + +import ( + "errors" + "time" + + "gorm.io/gorm" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +type DockService struct{} + +var DefaultDockService = &DockService{} + +// withUserFilter admin 不过滤,普通用户按 user_id 过滤(GORM scope) +func withUserFilter(userID int64, isAdmin bool) func(*gorm.DB) *gorm.DB { + if isAdmin { + return func(db *gorm.DB) *gorm.DB { return db } + } + return func(db *gorm.DB) *gorm.DB { return db.Where("user_id = ?", userID) } +} + +// withDockFilter admin 不过滤,普通用户按所属机巢过滤(用于无 user_id 的 dock 关联表,如 alarm / command_log) +func withDockFilter(userID int64, isAdmin bool) func(*gorm.DB) *gorm.DB { + if isAdmin { + return func(db *gorm.DB) *gorm.DB { return db } + } + return func(db *gorm.DB) *gorm.DB { + return db.Where("dock_id IN (SELECT dock_id FROM dock WHERE user_id = ?)", userID) + } +} + +// GetPage 机巢分页列表 +func (s *DockService) GetPage(userID int64, isAdmin bool, req *vo.DockPageReq) (*common.PageResponse[model.Dock], *common.BusiError) { + db := common.DB.Model(&model.Dock{}).Scopes(withUserFilter(userID, isAdmin)) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + if req.RegisterStatus != "" { + db = db.Where("register_status = ?", req.RegisterStatus) + } + if req.Keyword != "" { + kw := "%" + req.Keyword + "%" + db = db.Where("name LIKE ? OR dock_id LIKE ? OR code LIKE ?", kw, kw, kw) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + logger.ERROR("统计机巢失败", err) + return nil, common.ErrInternal + } + var list []model.Dock + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + logger.ERROR("查询机巢列表失败", err) + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// GetDetail 机巢详情(含实时状态) +func (s *DockService) GetDetail(userID int64, isAdmin bool, id int64) (*vo.DockVO, *common.BusiError) { + var dock model.Dock + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrDockNotFound + } + return nil, common.ErrInternal + } + online, realtime := s.realtime(dock.DockID) + return &vo.DockVO{Dock: &dock, Online: online, Realtime: realtime}, nil +} + +// GetStatus 机巢纯实时状态 +func (s *DockService) GetStatus(userID int64, isAdmin bool, id int64) (*vo.DockStatusVO, *common.BusiError) { + var dock model.Dock + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).Select("dock_id").First(&dock, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrDockNotFound + } + return nil, common.ErrInternal + } + online, realtime := s.realtime(dock.DockID) + return &vo.DockStatusVO{DockID: dock.DockID, Online: online, Realtime: realtime}, nil +} + +// Create 手动登记机巢(归属当前用户;admin 可指定 userID) +func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq) (*model.Dock, *common.BusiError) { + owner := userID + if isAdmin && req.UserID != 0 { + owner = req.UserID + } + + id, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + now := time.Now() + dock := &model.Dock{ + ID: id, + UserID: owner, + DockID: req.DockID, + Name: req.Name, + Code: req.Code, + SN: req.SN, + Iccid: req.Iccid, + Location: req.Location, + Longitude: req.Longitude, + Latitude: req.Latitude, + Altitude: req.Altitude, + Status: "offline", + RegisterStatus: "registered", + DockIDSource: "manual", + ProtocolVer: "1.0", + CreatedAt: now, + UpdatedAt: now, + } + if err := common.DB.Create(dock).Error; err != nil { + if code, _ := common.ParseError(err); code == 1062 { + return nil, common.ErrDockExists + } + logger.ERROR("登记机巢失败", err) + return nil, common.ErrInternal + } + return dock, nil +} + +// Update 更新/认领机巢 +func (s *DockService) Update(userID int64, isAdmin bool, id int64, req *vo.DockUpdateReq) (*model.Dock, *common.BusiError) { + var dock model.Dock + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrDockNotFound + } + return nil, common.ErrInternal + } + + updates := map[string]any{"updated_at": time.Now()} + if req.Name != "" { + updates["name"] = req.Name + } + if req.Code != "" { + updates["code"] = req.Code + } + if req.Location != nil { + updates["location"] = *req.Location + } + if req.Longitude != nil { + updates["longitude"] = *req.Longitude + } + if req.Latitude != nil { + updates["latitude"] = *req.Latitude + } + if req.Altitude != nil { + updates["altitude"] = *req.Altitude + } + + if isAdmin && req.UserID != 0 { + updates["user_id"] = req.UserID + } + if req.RegisterAs == "registered" { + updates["register_status"] = "registered" + if isAdmin && req.UserID != 0 { + updates["user_id"] = req.UserID + } else { + updates["user_id"] = userID // 普通用户认领 → 归属自己 + } + } + + if err := common.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&dock).Updates(updates).Error; err != nil { + return err + } + if owner, ok := updates["user_id"]; ok { + if err := tx.Model(&model.Drone{}).Where("dock_id = ?", dock.DockID).Update("user_id", owner).Error; err != nil { + return err + } + } + return nil + }); err != nil { + logger.ERROR("更新机巢失败", err) + return nil, common.ErrInternal + } + common.DB.First(&dock, id) + return &dock, nil +} + +// Delete 硬删除机巢及其关联设备数据 +func (s *DockService) Delete(userID int64, isAdmin bool, id int64) *common.BusiError { + var dock model.Dock + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.ErrDockNotFound + } + logger.ERROR("查询待删除机巢失败", err) + return common.ErrInternal + } + + var taskIDs []string + var droneSNs []string + var simCardIDs []int64 + if err := common.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.TaskPlan{}).Where("dock_id = ?", dock.DockID).Pluck("id", &taskIDs).Error; err != nil { + return err + } + if err := tx.Model(&model.Drone{}).Where("dock_id = ?", dock.DockID).Pluck("drone_sn", &droneSNs).Error; err != nil { + return err + } + if err := tx.Model(&model.SimCard{}).Where("dock_id = ?", dock.DockID).Pluck("id", &simCardIDs).Error; err != nil { + return err + } + + if len(droneSNs) > 0 { + var videoIDs []int64 + if err := tx.Model(&model.Video{}).Where("drone_sn IN ?", droneSNs).Pluck("id", &videoIDs).Error; err != nil { + return err + } + if len(videoIDs) > 0 { + if err := tx.Where("video_id IN ?", videoIDs).Delete(&model.DownloadLog{}).Error; err != nil { + return err + } + if err := tx.Where("id IN ?", videoIDs).Delete(&model.Video{}).Error; err != nil { + return err + } + } + } + if len(simCardIDs) > 0 { + if err := tx.Where("sim_card_id IN ?", simCardIDs).Delete(&model.SimUsageRecord{}).Error; err != nil { + return err + } + if err := tx.Where("sim_card_id IN ?", simCardIDs).Delete(&model.SimRechargeLog{}).Error; err != nil { + return err + } + if err := tx.Where("id IN ?", simCardIDs).Delete(&model.SimCard{}).Error; err != nil { + return err + } + } + for _, item := range []any{ + &model.Alarm{}, &model.DeviceCommandLog{}, &model.WorkflowState{}, + &model.TaskExecution{}, &model.TaskPlan{}, &model.LiveSession{}, &model.Drone{}, + } { + if err := tx.Where("dock_id = ?", dock.DockID).Delete(item).Error; err != nil { + return err + } + } + return tx.Delete(&dock).Error + }); err != nil { + logger.ERROR("硬删除机巢失败", err) + return common.ErrInternal + } + + for _, taskID := range taskIDs { + DefaultScheduler.RemoveTask(taskID) + } + _ = common.SetRemove(cache.OnlineDockSetKey, dock.DockID) + _ = common.Delete(cache.DockHeartbeatKeyOf(dock.DockID)) + _ = common.Delete(cache.DockStatusKeyOf(dock.DockID)) + return nil +} + +// realtime 读取机巢实时状态(Redis Hash + 在线集合) +func (s *DockService) realtime(dockID string) (bool, map[string]string) { + realtime, err := common.HashGetAll(cache.DockStatusKeyOf(dockID)) + if err != nil { + realtime = map[string]string{} + } + online, _ := common.SetMemberExists(cache.OnlineDockSetKey, dockID) + return online, realtime +} + +// EnsureFromOnline 设备自发现:不存在则登记为 pending,存在则更新在线状态与软件版本 +func (s *DockService) EnsureFromOnline(dockID string, st statusOnline) { + online := st.Status == "online" + status := "offline" + if online { + status = "online" + } + + var dock model.Dock + err := common.DB.Where("dock_id = ?", dockID).First(&dock).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, e := tool.NextID() + if e != nil { + logger.ERROR("生成机巢 ID 失败", e) + return + } + now := time.Now() + dock = model.Dock{ + ID: id, + UserID: 0, // 待认领 + DockID: dockID, + Name: st.Name, + Code: dockID, + SN: dockID, + Location: st.Location, + Latitude: st.Latitude, + Longitude: st.Longitude, + RegisterStatus: "pending", + DockIDSource: "auto", + SoftwareVer: st.SoftwareVersion, + ProtocolVer: st.ProtocolVersion, + Status: status, + CreatedAt: now, + UpdatedAt: now, + } + if err := common.DB.Create(&dock).Error; err != nil { + logger.ERROR("登记机巢失败", err) + return + } + } else if err == nil { + updates := map[string]any{"status": status, "updated_at": time.Now()} + if st.SoftwareVersion != "" { + updates["software_ver"] = st.SoftwareVersion + } + if st.ProtocolVersion != "" { + updates["protocol_ver"] = st.ProtocolVersion + } + if dock.Name == "" && st.Name != "" { + updates["name"] = st.Name + } + if dock.Location == "" && st.Location != "" { + updates["location"] = st.Location + } + if dock.Latitude == 0 && st.Latitude != 0 { + updates["latitude"] = st.Latitude + } + if dock.Longitude == 0 && st.Longitude != 0 { + updates["longitude"] = st.Longitude + } + if err := common.DB.Model(&dock).Updates(updates).Error; err != nil { + logger.ERROR("更新机巢在线状态失败", err) + } + } + + // 在线集合 + if online { + _ = common.SetAdd(cache.OnlineDockSetKey, dockID) + refreshDeviceHeartbeat(cache.DockHeartbeatKeyOf(dockID)) + } else { + _ = common.SetRemove(cache.OnlineDockSetKey, dockID) + _ = common.Delete(cache.DockHeartbeatKeyOf(dockID)) + } + + broadcast("dock.status", map[string]any{ + "dockId": dockID, + "online": online, + "softwareVer": st.SoftwareVersion, + "protocolVer": st.ProtocolVersion, + }) +} diff --git a/service/drone_service.go b/service/drone_service.go new file mode 100644 index 0000000..c51255b --- /dev/null +++ b/service/drone_service.go @@ -0,0 +1,206 @@ +package service + +import ( + "errors" + "time" + + "gorm.io/gorm" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +type DroneService struct{} + +var DefaultDroneService = &DroneService{} + +// GetPage 无人机分页列表 +func (s *DroneService) GetPage(userID int64, isAdmin bool, req *vo.DronePageReq) (*common.PageResponse[model.Drone], *common.BusiError) { + db := common.DB.Model(&model.Drone{}).Scopes(withUserFilter(userID, isAdmin)) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + if req.Keyword != "" { + kw := "%" + req.Keyword + "%" + db = db.Where("drone_sn LIKE ? OR name LIKE ? OR code LIKE ? OR model LIKE ?", kw, kw, kw, kw) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + logger.ERROR("统计无人机失败", err) + return nil, common.ErrInternal + } + var list []model.Drone + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + logger.ERROR("查询无人机列表失败", err) + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// GetDetail 无人机详情(含实时遥测) +func (s *DroneService) GetDetail(userID int64, isAdmin bool, id int64) (*vo.DroneVO, *common.BusiError) { + var drone model.Drone + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&drone, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrDroneNotFound + } + return nil, common.ErrInternal + } + online, realtime := s.realtime(drone.DockID, drone.DroneSN) + return &vo.DroneVO{Drone: &drone, Online: online, Realtime: realtime}, nil +} + +// GetTelemetry 无人机实时遥测(当前值,历史曲线走 TDengine,见后续阶段) +func (s *DroneService) GetTelemetry(userID int64, isAdmin bool, id int64) (map[string]any, *common.BusiError) { + var drone model.Drone + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&drone, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrDroneNotFound + } + return nil, common.ErrInternal + } + online, realtime := s.realtime(drone.DockID, drone.DroneSN) + return map[string]any{ + "droneSn": drone.DroneSN, + "online": online, + "realtime": realtime, + }, nil +} + +// Update 更新无人机基础信息 +func (s *DroneService) Update(userID int64, isAdmin bool, id int64, req *vo.DroneUpdateReq) (*model.Drone, *common.BusiError) { + var drone model.Drone + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&drone, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrDroneNotFound + } + return nil, common.ErrInternal + } + + updates := map[string]any{"updated_at": time.Now()} + if req.Name != "" { + updates["name"] = req.Name + } + if req.Code != "" { + updates["code"] = req.Code + } + if req.Model != "" { + updates["model"] = req.Model + } + + if err := common.DB.Model(&drone).Updates(updates).Error; err != nil { + logger.ERROR("更新无人机失败", err) + return nil, common.ErrInternal + } + common.DB.First(&drone, id) + return &drone, nil +} + +// Delete 删除无人机 +func (s *DroneService) Delete(userID int64, isAdmin bool, id int64) *common.BusiError { + res := common.DB.Scopes(withUserFilter(userID, isAdmin)).Delete(&model.Drone{}, id) + if res.Error != nil { + logger.ERROR("删除无人机失败", res.Error) + return common.ErrInternal + } + if res.RowsAffected == 0 { + return common.ErrDroneNotFound + } + return nil +} + +// realtime 读取无人机实时遥测(Redis Hash,按 dock_id 存储)+ 在线集合 +func (s *DroneService) realtime(dockID, droneSN string) (bool, map[string]string) { + realtime, err := common.HashGetAll(cache.DroneTelemetryKeyOf(dockID)) + if err != nil { + realtime = map[string]string{} + } + online, _ := common.SetMemberExists(cache.OnlineDroneSetKey, droneSN) + return online, realtime +} + +// EnsureFromState 无人机自动关联:按 droneSn 或 dockId 创建/更新(1:1 绑定) +func (s *DroneService) EnsureFromState(dockID, droneSN, name string, online bool, battery int, firmwareVer string) { + status := "offline" + if online { + status = "online" + } + if droneSN == "" { + return + } + + var dock model.Dock + if err := common.DB.Select("user_id").Where("dock_id = ?", dockID).First(&dock).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + logger.ERROR("查询无人机关联机巢失败", err) + } + return + } + + var drone model.Drone + err := common.DB.Where("drone_sn = ? OR dock_id = ?", droneSN, dockID).First(&drone).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, e := tool.NextID() + if e != nil { + logger.ERROR("生成无人机 ID 失败", e) + return + } + now := time.Now() + drone = model.Drone{ + ID: id, + UserID: dock.UserID, + DroneSN: droneSN, + DockID: dockID, + Name: name, + Status: status, + Battery: battery, + FirmwareVer: firmwareVer, + CreatedAt: now, + UpdatedAt: now, + } + if err := common.DB.Create(&drone).Error; err != nil { + logger.ERROR("创建无人机失败", err) + return + } + } else if err == nil { + updates := map[string]any{ + "user_id": dock.UserID, + "drone_sn": droneSN, + "dock_id": dockID, + "status": status, + "battery": battery, + "updated_at": time.Now(), + } + if firmwareVer != "" { + updates["firmware_ver"] = firmwareVer + } + if name != "" { + updates["name"] = name + } + if err := common.DB.Model(&drone).Updates(updates).Error; err != nil { + logger.ERROR("更新无人机失败", err) + } + } + + // 在线集合 + if online { + _ = common.SetAdd(cache.OnlineDroneSetKey, droneSN) + refreshDeviceHeartbeat(cache.DroneHeartbeatKeyOf(droneSN)) + } else { + _ = common.SetRemove(cache.OnlineDroneSetKey, droneSN) + _ = common.Delete(cache.DroneHeartbeatKeyOf(droneSN)) + } + + broadcast("drone.status", map[string]any{ + "dockId": dockID, + "droneSn": droneSN, + "online": online, + "battery": battery, + "firmwareVer": firmwareVer, + }) +} diff --git a/service/execution_service.go b/service/execution_service.go new file mode 100644 index 0000000..0a116bc --- /dev/null +++ b/service/execution_service.go @@ -0,0 +1,75 @@ +package service + +import ( + "encoding/json" + "errors" + + "gorm.io/gorm" + + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/vo" +) + +type ExecutionService struct{} + +var DefaultExecutionService = &ExecutionService{} + +// GetPage 执行记录分页列表(按所属机巢过滤) +func (s *ExecutionService) GetPage(userID int64, isAdmin bool, req *vo.ExecutionPageReq) (*common.PageResponse[model.TaskExecution], *common.BusiError) { + db := common.DB.Model(&model.TaskExecution{}).Scopes(withDockFilter(userID, isAdmin)) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + if req.TaskID != "" { + db = db.Where("task_id = ?", req.TaskID) + } + if req.DockID != "" { + db = db.Where("dock_id = ?", req.DockID) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + logger.ERROR("统计执行记录失败", err) + return nil, common.ErrInternal + } + var list []model.TaskExecution + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + logger.ERROR("查询执行记录失败", err) + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// GetDetail 执行记录详情 +func (s *ExecutionService) GetDetail(userID int64, isAdmin bool, id int64) (*model.TaskExecution, *common.BusiError) { + var exec model.TaskExecution + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).First(&exec, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrExecutionNotFound + } + return nil, common.ErrInternal + } + return &exec, nil +} + +// GetTrajectory 执行轨迹(解析 trajectory_json) +func (s *ExecutionService) GetTrajectory(userID int64, isAdmin bool, id int64) (*vo.TrajectoryVO, *common.BusiError) { + var exec model.TaskExecution + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).First(&exec, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrExecutionNotFound + } + return nil, common.ErrInternal + } + + points := []vo.TrajectoryPoint{} + if len(exec.TrajectoryJSON) > 0 { + if err := json.Unmarshal([]byte(exec.TrajectoryJSON), &points); err != nil { + logger.ERROR("解析轨迹失败", err) + return nil, common.ErrInternal + } + } + return &vo.TrajectoryVO{ExecutionID: exec.ID, DockID: exec.DockID, Points: points}, nil +} diff --git a/service/firmware_service.go b/service/firmware_service.go new file mode 100644 index 0000000..4fd05ab --- /dev/null +++ b/service/firmware_service.go @@ -0,0 +1,220 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "gorm.io/gorm" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/mqtt" + "laic-backend/tool" + "laic-backend/vo" +) + +type FirmwareService struct{} + +var DefaultFirmwareService = &FirmwareService{} + +const defaultFirmwareComponent = "dock-edge-agent" + +// GetPage 固件分页列表(admin) +func (s *FirmwareService) GetPage(req *vo.FirmwarePageReq) (*common.PageResponse[model.Firmware], *common.BusiError) { + db := common.DB.Model(&model.Firmware{}) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + if req.Component != "" { + db = db.Where("component = ?", req.Component) + } + if req.Keyword != "" { + kw := "%" + req.Keyword + "%" + db = db.Where("version LIKE ? OR description LIKE ?", kw, kw) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + logger.ERROR("统计固件失败", err) + return nil, common.ErrInternal + } + var list []model.Firmware + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + logger.ERROR("查询固件列表失败", err) + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// GetReleasedList 已发布固件列表(供普通用户选择升级) +func (s *FirmwareService) GetReleasedList() ([]model.Firmware, *common.BusiError) { + var list []model.Firmware + if err := common.DB.Where("status = ?", "released").Order("id DESC").Find(&list).Error; err != nil { + logger.ERROR("查询已发布固件失败", err) + return nil, common.ErrInternal + } + return list, nil +} + +// GetDetail 固件详情(admin) +func (s *FirmwareService) GetDetail(id int64) (*model.Firmware, *common.BusiError) { + var fw model.Firmware + if err := common.DB.First(&fw, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrFirmwareNotFound + } + return nil, common.ErrInternal + } + return &fw, nil +} + +// Create 新增固件版本(admin) +func (s *FirmwareService) Create(adminID int64, req *vo.FirmwareCreateReq) (*model.Firmware, *common.BusiError) { + id, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + component := req.Component + if component == "" { + component = defaultFirmwareComponent + } + now := time.Now() + fw := &model.Firmware{ + ID: id, + Component: component, + Version: req.Version, + Description: req.Description, + FileURL: req.FileURL, + Sha256: req.Sha256, + Signature: req.Signature, + FileSize: req.FileSize, + Mandatory: req.Mandatory, + Status: "draft", + CreatedBy: adminID, + CreatedAt: now, + UpdatedAt: now, + } + if err := common.DB.Create(fw).Error; err != nil { + if code, _ := common.ParseError(err); code == 1062 { + return nil, common.ErrFirmwareVersionExist + } + logger.ERROR("新增固件失败", err) + return nil, common.ErrInternal + } + return fw, nil +} + +// Update 编辑固件(admin) +func (s *FirmwareService) Update(id int64, req *vo.FirmwareUpdateReq) *common.BusiError { + var fw model.Firmware + if err := common.DB.First(&fw, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.ErrFirmwareNotFound + } + return common.ErrInternal + } + + updates := map[string]any{"updated_at": time.Now()} + if req.Description != "" { + updates["description"] = req.Description + } + if req.FileURL != "" { + updates["file_url"] = req.FileURL + } + if req.Sha256 != "" { + updates["sha256"] = req.Sha256 + } + if req.Signature != "" { + updates["signature"] = req.Signature + } + if req.FileSize != 0 { + updates["file_size"] = req.FileSize + } + if req.Mandatory != nil { + updates["mandatory"] = *req.Mandatory + } + if req.Status != "" { + updates["status"] = req.Status + } + + if err := common.DB.Model(&fw).Updates(updates).Error; err != nil { + logger.ERROR("编辑固件失败", err) + return common.ErrInternal + } + return nil +} + +// Delete 删除固件(admin) +func (s *FirmwareService) Delete(id int64) *common.BusiError { + res := common.DB.Delete(&model.Firmware{}, id) + if res.Error != nil { + logger.ERROR("删除固件失败", res.Error) + return common.ErrInternal + } + if res.RowsAffected == 0 { + return common.ErrFirmwareNotFound + } + return nil +} + +// Upgrade 下发 OTA 升级:校验机巢归属+在线 → 校验固件已发布 → MQTT ota/desired +func (s *FirmwareService) Upgrade(userID int64, isAdmin bool, dockID int64, firmwareID int64) (*vo.FirmwareUpgradeVO, *common.BusiError) { + var dock model.Dock + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&dock, dockID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrDockNotFound + } + return nil, common.ErrInternal + } + + online, _ := common.SetMemberExists(cache.OnlineDockSetKey, dock.DockID) + if !online { + return nil, common.ErrDockOffline + } + + var fw model.Firmware + if err := common.DB.First(&fw, firmwareID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrFirmwareNotFound + } + return nil, common.ErrInternal + } + if fw.Status != "released" { + return nil, common.ErrFirmwareNotReleased + } + + updateID, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + payload := map[string]any{ + "updateId": strconv.FormatInt(updateID, 10), + "component": fw.Component, + "version": fw.Version, + "url": fw.FileURL, + "sha256": fw.Sha256, + "signature": fw.Signature, + "mandatory": fw.Mandatory == 1, + "issuedAt": time.Now().UnixMilli(), + } + b, err := json.Marshal(payload) + if err != nil { + return nil, common.ErrInternal + } + topic := fmt.Sprintf("dock-edge/v1/dock/%s/ota/desired", dock.DockID) + if err := mqtt.Publish(topic, 1, true, b); err != nil { + logger.ERROR("下发 OTA 升级失败", err) + return nil, common.ErrInternal + } + + return &vo.FirmwareUpgradeVO{ + UpdateID: strconv.FormatInt(updateID, 10), + DockID: dock.DockID, + Firmware: &fw, + }, nil +} diff --git a/service/heartbeat.go b/service/heartbeat.go new file mode 100644 index 0000000..78531c6 --- /dev/null +++ b/service/heartbeat.go @@ -0,0 +1,90 @@ +package service + +import ( + "time" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" +) + +func heartbeatTimeout() uint { + if common.AppConf != nil && common.AppConf.Heartbeat.TimeoutSeconds > 0 { + return uint(common.AppConf.Heartbeat.TimeoutSeconds) + } + return 30 +} + +func refreshDeviceHeartbeat(key string) { + if err := common.SetValueWithExpired(key, "1", heartbeatTimeout()); err != nil { + logger.WARN("刷新设备心跳失败:", err) + } +} + +func expireDeviceHeartbeats() { + dockMembers, err := common.SetMembers(cache.OnlineDockSetKey) + if err != nil { + logger.WARN("扫描机巢在线状态失败:", err) + } else { + for _, dockID := range dockMembers { + removed, e := common.SetRemoveIfKeyMissing(cache.OnlineDockSetKey, cache.DockHeartbeatKeyOf(dockID), dockID) + if e != nil { + logger.WARN("检查机巢心跳失败:", e) + continue + } + if removed { + markDockOffline(dockID) + } + } + } + + droneMembers, err := common.SetMembers(cache.OnlineDroneSetKey) + if err != nil { + logger.WARN("扫描无人机在线状态失败:", err) + return + } + for _, droneSN := range droneMembers { + removed, e := common.SetRemoveIfKeyMissing(cache.OnlineDroneSetKey, cache.DroneHeartbeatKeyOf(droneSN), droneSN) + if e != nil { + logger.WARN("检查无人机心跳失败:", e) + continue + } + if removed { + markDroneOffline(droneSN) + } + } +} + +func markDockOffline(dockID string) { + result := common.DB.Model(&model.Dock{}). + Where("dock_id = ? AND status = ?", dockID, "online"). + Update("status", "offline") + if result.Error != nil { + logger.WARN("更新机巢离线状态失败:", result.Error) + return + } + if result.RowsAffected > 0 { + broadcast("dock.status", map[string]any{"dockId": dockID, "online": false}) + } +} + +func markDroneOffline(droneSN string) { + result := common.DB.Model(&model.Drone{}). + Where("drone_sn = ? AND status = ?", droneSN, "online"). + Update("status", "offline") + if result.Error != nil { + logger.WARN("更新无人机离线状态失败:", result.Error) + return + } + if result.RowsAffected > 0 { + broadcast("drone.status", map[string]any{"droneSn": droneSN, "online": false}) + } +} + +func heartbeatScanInterval() time.Duration { + if common.AppConf != nil && common.AppConf.Heartbeat.ScanSeconds > 0 { + return time.Duration(common.AppConf.Heartbeat.ScanSeconds) * time.Second + } + return 5 * time.Second +} diff --git a/service/live_service.go b/service/live_service.go new file mode 100644 index 0000000..7874900 --- /dev/null +++ b/service/live_service.go @@ -0,0 +1,393 @@ +package service + +import ( + "encoding/json" + "errors" + "strconv" + "time" + + "gorm.io/gorm" + + "laic-backend/cache" + "laic-backend/common" + liveprovider "laic-backend/live" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +type LiveService struct { + provider liveprovider.Adapter +} + +var DefaultLiveService = &LiveService{} + +func InitLiveProvider(conf common.Live) error { + provider, err := liveprovider.NewAdapter(conf) + if err != nil { + return err + } + DefaultLiveService.provider = provider + return nil +} + +func (s *LiveService) adapter() (liveprovider.Adapter, *common.BusiError) { + if s.provider == nil || s.provider.Provider() == "disabled" { + return nil, common.NewBusiError(common.LiveProviderDisabled, "直播服务未配置") + } + return s.provider, nil +} + +func (s *LiveService) Join(userID int64, isAdmin bool, dockID string, req *vo.LiveSessionCreateReq) (*vo.LiveSessionVO, *common.BusiError) { + provider, busiErr := s.adapter() + if busiErr != nil { + return nil, busiErr + } + var dock model.Dock + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("dock_id = ?", dockID).First(&dock).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrDockNotFound + } + return nil, common.ErrInternal + } + online, _ := common.SetMemberExists(cache.OnlineDockSetKey, dockID) + if !online { + return nil, common.ErrDockOffline + } + + var session model.LiveSession + var lease model.LiveViewerLease + var startParams map[string]any + err := common.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("dock_id = ?", dockID).First(&model.Dock{}).Error; err != nil { + return err + } + if err := tx.Where("dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", dockID).Order("created_at DESC").First(&session).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if session.ID == "" { + maxBitrate := req.MaxBitrateBps + if maxBitrate <= 0 { + maxBitrate = 1500000 + } + streamID, err := tool.NextID() + if err != nil { + return err + } + expireSec := common.AppConf.Live.AuthExpireSeconds + if expireSec <= 0 { + expireSec = 7200 + } + expiresAt := time.Now().Add(time.Duration(expireSec) * time.Second) + streamName := strconv.FormatInt(streamID, 10) + credentials, err := provider.CreateStream(liveprovider.StreamRequest{StreamName: streamName, ExpiresAt: expiresAt, MaxBitrateBps: maxBitrate}) + if err != nil { + return err + } + now := time.Now() + session = model.LiveSession{ID: streamName, DockID: dockID, Provider: credentials.Provider, StreamName: streamName, ExpiresAt: expiresAt.Unix(), MaxBitrateBps: maxBitrate, Phase: "starting", RequestedBy: userID, CreatedAt: now, UpdatedAt: now} + if err := tx.Create(&session).Error; err != nil { + return err + } + startParams = map[string]any{"provider": credentials.Provider, "streamSessionId": session.ID, "pushUrl": credentials.PushURL, "expiresAt": credentials.ExpiresAt.UnixMilli(), "maxBitrateBps": maxBitrate} + } + now := time.Now() + leaseID, err := tool.NextID() + if err != nil { + return err + } + lease = model.LiveViewerLease{ID: leaseID, StreamSessionID: session.ID, ViewerID: userID, ExpiresAt: now.Add(time.Duration(leaseSeconds()) * time.Second), CreatedAt: now, UpdatedAt: now} + return tx.Where("stream_session_id = ? AND viewer_id = ?", session.ID, userID).Assign(map[string]any{"expires_at": lease.ExpiresAt, "released_at": nil, "updated_at": now}).FirstOrCreate(&lease).Error + }) + if err != nil { + logger.ERROR("创建直播观看租约失败", err) + return nil, common.ErrInternal + } + if startParams != nil { + if _, err := DefaultCommandService.DispatchToDock(dockID, "video.start_stream", startParams); err != nil { + logger.ERROR("下发推流指令失败", err) + _ = common.DB.Model(&model.LiveSession{}).Where("id = ?", session.ID).Updates(map[string]any{"phase": "failed", "error_code": "MQTT_PUBLISH_FAILED"}).Error + return nil, common.ErrInternal + } + } + return &vo.LiveSessionVO{Session: &session, LeaseExpiresAt: lease.ExpiresAt.Unix()}, nil +} + +func leaseSeconds() int { + seconds := common.AppConf.Live.ViewerLeaseSeconds + if seconds <= 0 { + return 30 + } + return seconds +} + +func (s *LiveService) GetSession(userID int64, isAdmin bool, dockID, streamSessionID string) (*model.LiveSession, *common.BusiError) { + var session model.LiveSession + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ?", streamSessionID, dockID).First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrLiveNotFound + } + return nil, common.ErrInternal + } + return &session, nil +} +func (s *LiveService) Heartbeat(userID int64, isAdmin bool, dockID, streamSessionID string) (*vo.LiveSessionVO, *common.BusiError) { + var session model.LiveSession + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ?", streamSessionID, dockID).First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrLiveNotFound + } + return nil, common.ErrInternal + } + expiresAt := time.Now().Add(time.Duration(leaseSeconds()) * time.Second) + result := common.DB.Model(&model.LiveViewerLease{}).Where("stream_session_id = ? AND viewer_id = ? AND released_at IS NULL", streamSessionID, userID).Updates(map[string]any{"expires_at": expiresAt, "updated_at": time.Now()}) + if result.Error != nil || result.RowsAffected == 0 { + return nil, common.ErrLiveNotFound + } + return &vo.LiveSessionVO{Session: &session, LeaseExpiresAt: expiresAt.Unix()}, nil +} + +func (s *LiveService) Leave(userID int64, isAdmin bool, dockID, streamSessionID string) *common.BusiError { + var session model.LiveSession + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("id = ? AND dock_id = ?", streamSessionID, dockID).First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.ErrLiveNotFound + } + return common.ErrInternal + } + now := time.Now() + if err := common.DB.Model(&model.LiveViewerLease{}).Where("stream_session_id = ? AND viewer_id = ? AND released_at IS NULL", streamSessionID, userID).Updates(map[string]any{"released_at": now, "updated_at": now}).Error; err != nil { + return common.ErrInternal + } + return nil +} + +func stopGraceSeconds() time.Duration { + seconds := common.AppConf.Live.StopGraceSeconds + if seconds <= 0 { + seconds = 15 + } + return time.Duration(seconds) * time.Second +} + +// ReconcileLeases expires stale viewer leases and stops unviewed sessions after the grace period. +func (s *LiveService) ReconcileLeases() { + now := time.Now() + if err := common.DB.Model(&model.LiveViewerLease{}).Where("released_at IS NULL AND expires_at <= ?", now).Updates(map[string]any{"released_at": now, "updated_at": now}).Error; err != nil { + logger.ERROR("清理过期直播租约失败", err) + return + } + + var active []model.LiveSession + if err := common.DB.Where("phase IN ('starting','streaming','reconnecting')").Find(&active).Error; err != nil { + logger.ERROR("扫描活动直播会话失败", err) + return + } + for _, session := range active { + if session.ExpiresAt > 0 && now.Unix() >= session.ExpiresAt { + if _, busiErr := s.stopForBilling(session.DockID, session.ID); busiErr != nil { + logger.ERROR("直播会话到期停止失败: "+session.ID, busiErr) + } + continue + } + var viewers int64 + if err := common.DB.Model(&model.LiveViewerLease{}).Where("stream_session_id = ? AND released_at IS NULL AND expires_at > ?", session.ID, now).Count(&viewers).Error; err != nil || viewers > 0 { + if session.StopDeadline != nil { + _ = common.DB.Model(&model.LiveSession{}).Where("id = ?", session.ID).Update("stop_deadline", nil).Error + } + continue + } + if session.StopDeadline == nil { + _ = common.DB.Model(&model.LiveSession{}).Where("id = ? AND stop_deadline IS NULL", session.ID).Update("stop_deadline", now.Add(stopGraceSeconds())).Error + continue + } + if session.StopDeadline.After(now) { + continue + } + if _, busiErr := s.stopForBilling(session.DockID, session.ID); busiErr != nil { + logger.ERROR("无观看者停止直播失败: "+session.ID, busiErr) + } + } +} + +// Start 兼容旧启动接口,统一复用创建或加入会话的租约流程。 +func (s *LiveService) Start(userID int64, isAdmin bool, dockID string, req *vo.LiveStartReq) (*model.LiveSession, *common.BusiError) { + result, busiErr := s.Join(userID, isAdmin, dockID, &vo.LiveSessionCreateReq{MaxBitrateBps: req.MaxBitrateBps}) + if busiErr != nil { + return nil, busiErr + } + return result.Session, nil +} + +// Stop 停止指定机巢最新活动会话;设备必须根据 streamSessionId 防止误停新会话。 +func (s *LiveService) Stop(userID int64, isAdmin bool, dockID string) *common.BusiError { + var dock model.Dock + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("dock_id = ?", dockID).First(&dock).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.ErrDockNotFound + } + return common.ErrInternal + } + var active model.LiveSession + if err := common.DB.Where("dock_id = ? AND phase IN ('starting','streaming','reconnecting','stopping')", dockID).Order("id DESC").First(&active).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.ErrLiveNotFound + } + return common.ErrInternal + } + params := map[string]any{ + "streamSessionId": active.ID, + "reason": "user_request", + } + if _, err := DefaultCommandService.DispatchToDock(dockID, "video.stop_stream", params); err != nil { + logger.ERROR("下发停止推流指令失败", err) + return common.ErrInternal + } + if err := common.DB.Model(&model.LiveSession{}).Where("id = ? AND phase IN ('starting','streaming','reconnecting')", active.ID).Updates(map[string]any{"phase": "stopping", "stop_reason": "manual"}).Error; err != nil { + return common.ErrInternal + } + return nil +} + +func (s *LiveService) stopForBilling(dockID, streamSessionID string) (*model.DeviceCommandLog, *common.BusiError) { + params := map[string]any{ + "streamSessionId": streamSessionID, + "reason": "no_balance", + } + cmd, err := DefaultCommandService.DispatchToDock(dockID, "video.stop_stream", params) + if err != nil { + return nil, common.ErrInternal + } + if err := common.DB.Model(&model.LiveSession{}).Where("id = ? AND dock_id = ? AND phase IN ('starting','streaming','reconnecting')", streamSessionID, dockID).Updates(map[string]any{"phase": "stopping", "stop_reason": "no_balance"}).Error; err != nil { + return nil, common.ErrInternal + } + return cmd, nil +} +func (s *LiveService) GetPage(userID int64, isAdmin bool, req *vo.LivePageReq) (*common.PageResponse[model.LiveSession], *common.BusiError) { + db := common.DB.Model(&model.LiveSession{}).Scopes(withDockFilter(userID, isAdmin)) + if req.Phase != "" { + db = db.Where("phase = ?", req.Phase) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, common.ErrInternal + } + var list []model.LiveSession + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// GetPlayURL only returns a URL after both edge and provider confirmation are represented by streaming. +func (s *LiveService) GetPlayURL(userID int64, isAdmin bool, dockID string) (*vo.LivePlayURLVO, *common.BusiError) { + provider, busiErr := s.adapter() + if busiErr != nil { + return nil, busiErr + } + var session model.LiveSession + if err := common.DB.Scopes(withDockFilter(userID, isAdmin)).Where("dock_id = ? AND phase = 'streaming'", dockID).Order("id DESC").First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrLiveNotFound + } + return nil, common.ErrInternal + } + var lease model.LiveViewerLease + if err := common.DB.Where("stream_session_id = ? AND viewer_id = ? AND released_at IS NULL AND expires_at > ?", session.ID, userID, time.Now()).First(&lease).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrLiveLeaseNotFound + } + return nil, common.ErrInternal + } + ttl := common.AppConf.Live.PlayURLTTLSeconds + if ttl <= 0 || ttl > 300 { + ttl = 300 + } + urls, err := provider.CreatePlayURLs(liveprovider.PlayRequest{StreamName: session.StreamName, ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Second)}) + if err != nil { + return nil, common.NewBusiError(common.LiveProviderInvalid, "生成播放地址失败") + } + return &vo.LivePlayURLVO{StreamName: session.StreamName, PlayURL: urls.HLS, ExpiresAt: urls.ExpiresAt.Unix()}, nil +} + +func (s *LiveService) OnCommandAck(cmd *model.DeviceCommandLog, accepted bool, resultCode string) { + if cmd.CommandType != "video.start_stream" && cmd.CommandType != "video.stop_stream" { + return + } + var params struct { + StreamSessionID string `json:"streamSessionId"` + } + if err := json.Unmarshal([]byte(cmd.Params), ¶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 +} diff --git a/service/mqtt_subscriber.go b/service/mqtt_subscriber.go new file mode 100644 index 0000000..1fde256 --- /dev/null +++ b/service/mqtt_subscriber.go @@ -0,0 +1,408 @@ +package service + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + paho "github.com/eclipse/paho.mqtt.golang" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/mqtt" + "laic-backend/websocket" +) + +// 上行消息业务载荷结构(协议文档 §7,均位于通用外层 payload 内) + +// statusOnline §7.1 工控机在线状态 +type statusOnline struct { + Status string `json:"status"` // online / offline / degraded / updating + BootID string `json:"bootId"` + DockIDSource string `json:"dockIdSource"` + SoftwareVersion string `json:"softwareVersion"` + ProtocolVersion string `json:"protocolVersion"` + UptimeSec int64 `json:"uptimeSec"` + TimeSynced bool `json:"timeSynced"` + MqttConnected bool `json:"mqttConnected"` + ModbusConnected bool `json:"modbusConnected"` + MavlinkConnected bool `json:"mavlinkConnected"` + Updating bool `json:"updating"` + Name string `json:"name"` + Location string `json:"location"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +// dockState §7.2 机巢状态(仅解析业务关心字段,其余经 flatten 存 Redis) +type dockState struct { + PlcConnected bool `json:"plcConnected"` + ControlMode string `json:"controlMode"` + ChargingState string `json:"chargingState"` + EmergencyStop bool `json:"emergencyStop"` + AlarmCodes []string `json:"alarmCodes"` + DronePresent *bool `json:"dronePresent"` +} + +// droneState §7.3 无人机状态 +type droneState struct { + DroneSN string `json:"droneSn"` + Name string `json:"name"` + CurrentSysID int `json:"currentSysId"` + Online bool `json:"online"` + Armed bool `json:"armed"` + FlightMode string `json:"flightMode"` + FlightModeCode int `json:"flightModeCode"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Altitude float64 `json:"altitude"` + GroundSpeed float64 `json:"groundSpeed"` + Roll float64 `json:"roll"` + Pitch float64 `json:"pitch"` + Yaw float64 `json:"yaw"` + BatteryPercent float64 `json:"batteryPercent"` + BatteryVoltage float64 `json:"batteryVoltage"` + BatteryCurrent float64 `json:"batteryCurrent"` + Satellites int `json:"satellites"` + GpsQuality string `json:"gpsQuality"` + LinkQuality float64 `json:"linkQuality"` + HomeSet bool `json:"homeSet"` + AlarmCodes []string `json:"alarmCodes"` +} + +// commandAck §5.3 指令应答 +type commandAck struct { + CommandID string `json:"commandId"` + Accepted bool `json:"accepted"` + ResultCode string `json:"resultCode"` +} + +// otaReported §8 OTA 升级进度上报 +type otaReported struct { + UpdateID string `json:"updateId"` + Component string `json:"component"` + TargetVersion string `json:"targetVersion"` + CurrentVersion string `json:"currentVersion"` + Status string `json:"status"` + Progress int `json:"progress"` + Message string `json:"message"` + ErrorCode *string `json:"errorCode"` + ReportedAt int64 `json:"reportedAt"` +} + +// videoState §7.8 视频推流状态 +type videoState struct { + Version int64 `json:"version"` + EventID string `json:"eventId"` + Provider string `json:"provider"` + Phase string `json:"phase"` + InputOnline bool `json:"inputOnline"` + Streaming bool `json:"streaming"` + InputCodec string `json:"inputCodec"` + UplinkProtocol string `json:"uplinkProtocol"` + StreamSessionID string `json:"streamSessionId"` + Width *int `json:"width"` + Height *int `json:"height"` + FrameRate *float64 `json:"frameRate"` + BitrateBps uint64 `json:"bitrateBps"` + RetryCount int `json:"retryCount"` + StopReason *string `json:"stopReason"` + ErrorCode *string `json:"errorCode"` + UpdatedAt int64 `json:"updatedAt"` +} + +// InitMQTTSubscriber 注册 MQTT 订阅处理器 +func InitMQTTSubscriber() { + mqtt.Subscribe(map[string]byte{ + "dock-edge/v1/dock/+/status/online": 1, + "dock-edge/v1/dock/+/state/dock": 1, + "dock-edge/v1/dock/+/state/drone": 1, + "dock-edge/v1/dock/+/state/workflow": 1, + "dock-edge/v1/dock/+/state/video": 1, + "dock-edge/v1/dock/+/internal/original-video": 1, + "dock-edge/v1/dock/+/telemetry": 0, + "dock-edge/v1/dock/+/command/ack": 1, + "dock-edge/v1/dock/+/ota/reported": 1, + }, routeMessage) +} + +// routeMessage 按 topic 分发到对应处理器 +// topic 结构:dock-edge/v1/dock/{dockId}/{category}/{sub} +// 消息体为通用外层 Envelope,业务字段在 payload 内。 +func routeMessage(_ paho.Client, msg paho.Message) { + topic := msg.Topic() + parts := strings.Split(topic, "/") + if len(parts) < 5 { + logger.WARN("无法解析 MQTT topic:", topic) + return + } + topicDockID := parts[3] + dockID := topicDockID + category := parts[4] + sub := "" + if len(parts) > 5 { + sub = parts[5] + } + + env := mqtt.ParseEnvelope(msg.Payload()) + if env.DockID != "" && env.DockID != topicDockID { + logger.WARN("MQTT envelope dockId 与 topic 不一致", topicDockID, env.DockID) + return + } + if env.DockID != "" { + dockID = env.DockID + } + + switch category { + case "status": + if sub == "online" { + handleStatus(dockID, env) + } + case "state": + switch sub { + case "dock": + handleStateDock(dockID, env) + case "drone": + handleStateDrone(dockID, env) + case "workflow": + handleWorkflow(dockID, env) + case "video": + handleVideo(dockID, env) + } + case "telemetry": + handleTelemetry(dockID, env) + case "command": + if sub == "ack" { + handleCommandAck(dockID, env) + } + case "ota": + if sub == "reported" { + handleOtaReported(dockID, env) + } + case "internal": + if sub == "original-video" { + handleOriginalVideo(dockID, env) + } + default: + logger.DEBUG("未知 topic:", topic) + } +} + +// handleStatus 在线/离线 → 设备自发现 + 在线集合 + 软件版本 +func handleStatus(dockID string, env *mqtt.Envelope) { + var st statusOnline + if err := json.Unmarshal(env.Payload, &st); err != nil { + logger.ERROR("解析 status/online 失败", err) + return + } + DefaultDockService.EnsureFromOnline(dockID, st) + if st.Status == "online" { + refreshDeviceHeartbeat(cache.DockHeartbeatKeyOf(dockID)) + } +} + +// handleStateDock 机巢状态 → Redis Hash + 告警 diff +func handleStateDock(dockID string, env *mqtt.Envelope) { + var raw map[string]any + _ = json.Unmarshal(env.Payload, &raw) + _ = common.HashSetValues(cache.DockStatusKeyOf(dockID), flatten(raw)) + refreshDeviceHeartbeat(cache.DockHeartbeatKeyOf(dockID)) + + var st dockState + if err := json.Unmarshal(env.Payload, &st); err != nil { + logger.ERROR("解析 state/dock 失败", err) + return + } + DefaultAlarmService.SyncByState(dockID, "dock", st.AlarmCodes) +} + +// handleStateDrone 无人机状态 → 自动关联 + Redis 遥测 + 告警 diff +func handleStateDrone(dockID string, env *mqtt.Envelope) { + var raw map[string]any + _ = json.Unmarshal(env.Payload, &raw) + _ = common.HashSetValues(cache.DroneTelemetryKeyOf(dockID), flatten(raw)) + + var st droneState + if err := json.Unmarshal(env.Payload, &st); err != nil { + logger.ERROR("解析 state/drone 失败", err) + return + } + droneSN := st.DroneSN + if droneSN == "" { + droneSN = env.DroneSN + } + battery := int(st.BatteryPercent) + DefaultDroneService.EnsureFromState(dockID, droneSN, st.Name, st.Online, battery, "") + if droneSN != "" && st.Online { + refreshDeviceHeartbeat(cache.DroneHeartbeatKeyOf(droneSN)) + } + DefaultAlarmService.SyncByState(dockID, "drone", st.AlarmCodes) + DefaultTelemetryStore.SetDroneSN(dockID, droneSN) +} + +// handleTelemetry 高频遥测 → Redis 最新值 + TDengine 批量写入 + 轨迹采集 +func handleTelemetry(dockID string, env *mqtt.Envelope) { + var raw map[string]any + if err := json.Unmarshal(env.Payload, &raw); err != nil { + logger.ERROR("解析 telemetry 失败", err) + return + } + _ = common.HashSetValues(cache.DroneTelemetryKeyOf(dockID), flatten(raw)) + + var p TelemetryPoint + if err := json.Unmarshal(env.Payload, &p); err == nil { + DefaultTelemetryStore.Append(dockID, p) + DefaultTrajectoryStore.Append(dockID, p) + } + if env.DroneSN != "" { + DefaultTelemetryStore.SetDroneSN(dockID, env.DroneSN) + refreshDeviceHeartbeat(cache.DroneHeartbeatKeyOf(env.DroneSN)) + } +} + +// handleWorkflow 工作流状态 → Redis Hash + WS 推送 + 持久化 + 终态回写任务 +func handleWorkflow(dockID string, env *mqtt.Envelope) { + var raw map[string]any + _ = json.Unmarshal(env.Payload, &raw) + _ = common.HashSetValues(cache.WorkflowKeyOf(dockID), flatten(raw)) + + var st WorkflowStateIn + if err := json.Unmarshal(env.Payload, &st); err != nil { + logger.ERROR("解析 state/workflow 失败", err) + return + } + DefaultWorkflowService.Upsert(dockID, env.RequestID, &st) + broadcast("workflow.state", map[string]any{ + "dockId": dockID, + "commandId": st.CommandID, + "taskId": st.TaskID, + "missionId": st.MissionID, + "state": st.State, + "step": st.Step, + "resultCode": st.ResultCode, + }) +} + +// handleVideo 视频推流状态 → Redis Hash + WS 推送 + 直播会话联动 +func handleVideo(dockID string, env *mqtt.Envelope) { + var raw map[string]any + _ = json.Unmarshal(env.Payload, &raw) + var st videoState + if err := json.Unmarshal(env.Payload, &st); err != nil { + logger.ERROR("解析 state/video 失败", err) + return + } + if st.EventID == "" { + st.EventID = env.EventID + } + if st.Version == 0 { + st.Version = env.Version + } + if st.StreamSessionID == "" || st.EventID == "" || st.Version <= 0 || st.UpdatedAt <= 0 { + logger.WARN("忽略缺少顺序元数据的视频状态", dockID, st.StreamSessionID) + return + } + if !DefaultLiveService.OnVideoState(dockID, st.Streaming, st.StreamSessionID, st.EventID, st.Version, st.UpdatedAt) { + return + } + _ = common.HashSetValues(cache.VideoKeyOf(dockID), flatten(raw)) + broadcast("video.state", map[string]any{ + "dockId": dockID, + "streaming": st.Streaming, + "streamSessionId": st.StreamSessionID, + "bitrate": st.BitrateBps, + "phase": st.Phase, + "errorCode": st.ErrorCode, + }) +} + +// handleOriginalVideo 处理后台与 Mock 的内部原始视频上传事件。 +func handleOriginalVideo(dockID string, env *mqtt.Envelope) { + if env.DockID != "" && env.DockID != dockID { + logger.WARN("原始视频事件机巢不匹配", dockID, env.DockID) + return + } + var event OriginalVideoEvent + if err := json.Unmarshal(env.Payload, &event); err != nil { + logger.ERROR("解析原始视频事件失败", err) + return + } + if err := DefaultVideoService.CompleteOriginalVideo(dockID, &event); err != nil { + logger.ERROR("处理原始视频事件失败", err) + return + } + broadcast("video.original", map[string]any{ + "dockId": dockID, "videoId": event.VideoID, "executionId": event.ExecutionID, "eventType": event.EventType, + }) +} + +// handleCommandAck 指令应答 → 更新 device_command_log +func handleCommandAck(dockID string, env *mqtt.Envelope) { + var ack commandAck + if err := json.Unmarshal(env.Payload, &ack); err != nil { + logger.ERROR("解析 command/ack 失败", err) + return + } + DefaultCommandService.HandleAck(dockID, env.RequestID, ack.CommandID, ack.Accepted, ack.ResultCode) +} + +// handleOtaReported OTA 升级进度 → Redis 最新值 + WS 推送 +func handleOtaReported(dockID string, env *mqtt.Envelope) { + var r otaReported + if err := json.Unmarshal(env.Payload, &r); err != nil { + logger.ERROR("解析 ota/reported 失败", err) + return + } + _ = common.HashSetValues(cache.OtaStatusKeyOf(dockID), map[string]any{ + "updateId": r.UpdateID, + "component": r.Component, + "targetVersion": r.TargetVersion, + "currentVersion": r.CurrentVersion, + "status": r.Status, + "progress": strconv.Itoa(r.Progress), + "message": r.Message, + }) + broadcast("ota.progress", map[string]any{ + "dockId": dockID, + "updateId": r.UpdateID, + "targetVersion": r.TargetVersion, + "status": r.Status, + "progress": r.Progress, + "message": r.Message, + }) +} + +// flatten 将 JSON map 展平为 Redis Hash 可存储的 string 值 +func flatten(data map[string]any) map[string]any { + out := make(map[string]any, len(data)) + for k, v := range data { + switch val := v.(type) { + case string: + out[k] = val + case float64: + out[k] = strconv.FormatFloat(val, 'f', -1, 64) + case bool: + out[k] = strconv.FormatBool(val) + case nil: + out[k] = "" + default: + if b, err := json.Marshal(val); err == nil { + out[k] = string(b) + } else { + out[k] = fmt.Sprint(val) + } + } + } + return out +} + +// broadcast 向有权访问该机巢的 WS 客户端推送脱敏事件。 +func broadcast(eventType string, data map[string]any) { + dockID, _ := data["dockId"].(string) + if dockID == "" { + return + } + websocket.DefaultHub.BroadcastToDockJSON(dockID, eventType, data) +} diff --git a/service/operation_log_service.go b/service/operation_log_service.go new file mode 100644 index 0000000..f971247 --- /dev/null +++ b/service/operation_log_service.go @@ -0,0 +1,80 @@ +package service + +import ( + "time" + + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +type OperationLogService struct{} + +var DefaultOperationLogService = &OperationLogService{} + +// BootstrapOperationLog 确保基础运行时表存在(历史库未跑 DDL 时兜底)。 +func BootstrapOperationLog() { + if err := common.DB.AutoMigrate(&model.OperationLog{}, &model.LiveViewerLease{}); err != nil { + logger.ERROR("迁移基础运行时表失败", err) + } +} + +// Record 写入一条操作日志 +func (s *OperationLogService) Record(userID int64, userName, module, action, detail, result, ip string) { + if userID == 0 { + return + } + id, err := tool.NextID() + if err != nil { + logger.ERROR("生成操作日志 ID 失败", err) + return + } + log := &model.OperationLog{ + ID: id, + UserID: userID, + UserName: userName, + Module: module, + Action: action, + Detail: detail, + Result: result, + IP: ip, + CreatedAt: time.Now(), + } + if err := common.DB.Create(log).Error; err != nil { + logger.ERROR("写入操作日志失败", err) + } +} + +// GetPage 操作日志分页(admin 全量,user 仅本人) +func (s *OperationLogService) GetPage(req *vo.OperationLogPageReq) (*common.PageResponse[model.OperationLog], *common.BusiError) { + db := common.DB.Model(&model.OperationLog{}) + if req.UserID > 0 { + db = db.Where("user_id = ?", req.UserID) + } + if req.Module != "" { + db = db.Where("module = ?", req.Module) + } + if req.Action != "" { + db = db.Where("action = ?", req.Action) + } + if req.Result != "" { + db = db.Where("result = ?", req.Result) + } + if req.Keyword != "" { + kw := "%" + req.Keyword + "%" + db = db.Where("module LIKE ? OR action LIKE ? OR detail LIKE ?", kw, kw, kw) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + logger.ERROR("统计操作日志失败", err) + return nil, common.ErrInternal + } + var list []model.OperationLog + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + logger.ERROR("查询操作日志失败", err) + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} diff --git a/service/route_service.go b/service/route_service.go new file mode 100644 index 0000000..fdb3f56 --- /dev/null +++ b/service/route_service.go @@ -0,0 +1,190 @@ +package service + +import ( + "errors" + "time" + + "gorm.io/gorm" + + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +type RouteService struct{} + +var DefaultRouteService = &RouteService{} + +// GetPage 航线分页列表 +func (s *RouteService) GetPage(userID int64, isAdmin bool, req *vo.RoutePageReq) (*common.PageResponse[model.Route], *common.BusiError) { + db := common.DB.Model(&model.Route{}).Scopes(withUserFilter(userID, isAdmin)) + if req.Keyword != "" { + kw := "%" + req.Keyword + "%" + db = db.Where("name LIKE ? OR description LIKE ?", kw, kw) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + logger.ERROR("统计航线失败", err) + return nil, common.ErrInternal + } + var list []model.Route + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + logger.ERROR("查询航线列表失败", err) + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// GetDetail 航线详情(含航点) +func (s *RouteService) GetDetail(userID int64, isAdmin bool, id int64) (*vo.RouteVO, *common.BusiError) { + var route model.Route + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&route, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrRouteNotFound + } + return nil, common.ErrInternal + } + waypoints, err := s.waypoints(id) + if err != nil { + logger.ERROR("查询航点失败", err) + return nil, common.ErrInternal + } + return &vo.RouteVO{Route: &route, Waypoints: waypoints}, nil +} + +// Create 新增航线(含航点) +func (s *RouteService) Create(userID int64, req *vo.RouteCreateReq) (*vo.RouteVO, *common.BusiError) { + id, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + now := time.Now() + route := &model.Route{ + ID: id, + UserID: userID, + Name: req.Name, + Description: req.Description, + CreatedAt: now, + UpdatedAt: now, + } + waypoints, err := buildWaypoints(id, req.Waypoints) + if err != nil { + return nil, common.ErrInternal + } + + err = common.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(route).Error; err != nil { + return err + } + if len(waypoints) > 0 { + return tx.Create(&waypoints).Error + } + return nil + }) + if err != nil { + logger.ERROR("新增航线失败", err) + return nil, common.ErrInternal + } + return &vo.RouteVO{Route: route, Waypoints: waypoints}, nil +} + +// Update 编辑航线(提供 waypoints 则整体替换航点) +func (s *RouteService) Update(userID int64, isAdmin bool, id int64, req *vo.RouteUpdateReq) (*vo.RouteVO, *common.BusiError) { + var route model.Route + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&route, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrRouteNotFound + } + return nil, common.ErrInternal + } + + err := common.DB.Transaction(func(tx *gorm.DB) error { + updates := map[string]any{"updated_at": time.Now()} + if req.Name != "" { + updates["name"] = req.Name + } + if req.Description != "" { + updates["description"] = req.Description + } + if err := tx.Model(&route).Updates(updates).Error; err != nil { + return err + } + if req.Waypoints != nil { + if err := tx.Where("route_id = ?", id).Delete(&model.RouteWaypoint{}).Error; err != nil { + return err + } + waypoints, err := buildWaypoints(id, *req.Waypoints) + if err != nil { + return err + } + if len(waypoints) > 0 { + return tx.Create(&waypoints).Error + } + } + return nil + }) + if err != nil { + logger.ERROR("更新航线失败", err) + return nil, common.ErrInternal + } + + common.DB.First(&route, id) + waypoints, _ := s.waypoints(id) + return &vo.RouteVO{Route: &route, Waypoints: waypoints}, nil +} + +// Delete 删除航线(含航点) +func (s *RouteService) Delete(userID int64, isAdmin bool, id int64) *common.BusiError { + err := common.DB.Transaction(func(tx *gorm.DB) error { + res := tx.Scopes(withUserFilter(userID, isAdmin)).Delete(&model.Route{}, id) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return tx.Where("route_id = ?", id).Delete(&model.RouteWaypoint{}).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.ErrRouteNotFound + } + logger.ERROR("删除航线失败", err) + return common.ErrInternal + } + return nil +} + +func (s *RouteService) waypoints(routeID int64) ([]model.RouteWaypoint, error) { + var waypoints []model.RouteWaypoint + err := common.DB.Where("route_id = ?", routeID).Order("seq ASC").Find(&waypoints).Error + return waypoints, err +} + +// buildWaypoints 将航点请求转换为模型,Seq 从 1 递增 +func buildWaypoints(routeID int64, reqs []vo.RouteWaypointReq) ([]model.RouteWaypoint, error) { + out := make([]model.RouteWaypoint, 0, len(reqs)) + now := time.Now() + for i, w := range reqs { + id, err := tool.NextID() + if err != nil { + return nil, err + } + out = append(out, model.RouteWaypoint{ + ID: id, + RouteID: routeID, + Seq: i + 1, + Longitude: w.Longitude, + Latitude: w.Latitude, + Altitude: w.Altitude, + Speed: w.Speed, + Yaw: w.Yaw, + HoldSec: w.HoldSec, + CreatedAt: now, + }) + } + return out, nil +} diff --git a/service/scheduler.go b/service/scheduler.go new file mode 100644 index 0000000..c848d54 --- /dev/null +++ b/service/scheduler.go @@ -0,0 +1,104 @@ +package service + +import ( + "sync" + "time" + + "github.com/robfig/cron/v3" + + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" +) + +// Scheduler 后台定时任务调度器:固定周期计费任务 + 任务计划 cron 自动执行 +type Scheduler struct { + cron *cron.Cron + mu sync.Mutex + entries map[string]cron.EntryID // taskID -> cron entry +} + +var DefaultScheduler = &Scheduler{} + +// Start 启动调度器:注册固定周期任务,加载并注册启用的 cron 任务计划 +func (s *Scheduler) Start() { + s.cron = cron.New() + s.entries = make(map[string]cron.EntryID) + + // 直播租约与会话对账:清理过期租约并处理超时/无人观看会话 + interval := common.AppConf.Live.ReconcileIntervalSeconds + if interval <= 0 { + interval = 5 + } + _, _ = s.cron.AddFunc("@every "+(time.Duration(interval)*time.Second).String(), DefaultLiveService.ReconcileLeases) + // 直播计费:每 60s 扫描进行中的 live_session 按码率扣减 + _, _ = s.cron.AddFunc("@every 60s", DefaultBillingService.chargeLiveSessions) + // 余额快照:每 30min 将 Redis 权威余额回写 MySQL + _, _ = s.cron.AddFunc("@every 30m", DefaultBillingService.flushBalanceSnapshot) + // SIM 卡用量:每 1h 从运营商同步用量 + _, _ = s.cron.AddFunc("@every 1h", DefaultBillingService.syncSimUsage) + // 设备心跳:清理未再收到真实 MQTT 消息的在线设备 + _, _ = s.cron.AddFunc("@every "+heartbeatScanInterval().String(), expireDeviceHeartbeats) + + s.reloadTaskPlans() + s.cron.Start() + logger.INFO("scheduler started") +} + +// reloadTaskPlans 从 DB 加载启用中的 cron 任务计划并注册 +func (s *Scheduler) reloadTaskPlans() { + var tasks []model.TaskPlan + if err := common.DB.Where("schedule_type = ? AND status = ?", "cron", "enabled").Find(&tasks).Error; err != nil { + logger.ERROR("加载 cron 任务计划失败", err) + return + } + for i := range tasks { + s.upsertTask(&tasks[i]) + } +} + +// upsertTask 注册或更新单个任务计划的 cron 条目 +func (s *Scheduler) upsertTask(task *model.TaskPlan) { + s.mu.Lock() + defer s.mu.Unlock() + if old, ok := s.entries[task.ID]; ok { + s.cron.Remove(old) + delete(s.entries, task.ID) + } + if task.ScheduleType != "cron" || task.Status != "enabled" || task.ScheduleCron == "" { + return + } + id, err := s.cron.AddFunc(task.ScheduleCron, func() { s.executeTask(task.ID) }) + if err != nil { + logger.ERROR("注册 cron 任务计划失败: "+task.ID, err) + return + } + s.entries[task.ID] = id +} + +// SyncTask 任务创建/更新后同步调度(按 ID 重新加载) +func (s *Scheduler) SyncTask(taskID string) { + var task model.TaskPlan + if err := common.DB.First(&task, "id = ?", taskID).Error; err != nil { + s.RemoveTask(taskID) + return + } + s.upsertTask(&task) +} + +// RemoveTask 移除任务计划的 cron 条目 +func (s *Scheduler) RemoveTask(taskID string) { + s.mu.Lock() + defer s.mu.Unlock() + if old, ok := s.entries[taskID]; ok { + s.cron.Remove(old) + delete(s.entries, taskID) + } +} + +// executeTask 定时触发任务执行(admin 视角,绕过用户过滤) +func (s *Scheduler) executeTask(taskID string) { + if _, busiErr := DefaultTaskService.Execute(0, true, taskID); busiErr != nil { + logger.WARN("定时执行任务失败:", taskID, busiErr.Msg) + } +} diff --git a/service/system_service.go b/service/system_service.go new file mode 100644 index 0000000..5a5b662 --- /dev/null +++ b/service/system_service.go @@ -0,0 +1,152 @@ +package service + +import ( + "errors" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +type SystemService struct{} + +var DefaultSystemService = &SystemService{} + +// GetUserPage 用户分页列表(admin 全量) +func (s *SystemService) GetUserPage(req *vo.UserPageReq) (*common.PageResponse[model.User], *common.BusiError) { + db := common.DB.Model(&model.User{}) + if req.Keyword != "" { + kw := "%" + req.Keyword + "%" + db = db.Where("name LIKE ? OR phone LIKE ? OR email LIKE ?", kw, kw, kw) + } + if req.Role != "" { + db = db.Where("role = ?", req.Role) + } + if req.Status != 0 { + db = db.Where("status = ?", req.Status) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + logger.ERROR("统计用户失败", err) + return nil, common.ErrInternal + } + var list []model.User + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + logger.ERROR("查询用户列表失败", err) + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// CreateUser 管理员创建用户 +func (s *SystemService) CreateUser(req *vo.UserCreateReq) (*model.User, *common.BusiError) { + var count int64 + if err := common.DB.Model(&model.User{}).Where("phone = ?", req.Phone).Count(&count).Error; err != nil { + return nil, common.ErrInternal + } + if count > 0 { + return nil, common.ErrUserPhoneExists + } + + id, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, common.ErrInternal + } + role := req.Role + if role == "" { + role = common.RoleUser + } + now := time.Now() + user := &model.User{ + ID: id, + Name: req.Name, + Phone: req.Phone, + Email: req.Email, + Password: string(hash), + Role: role, + Status: 1, + CreatedAt: now, + UpdatedAt: now, + } + if err := common.DB.Create(user).Error; err != nil { + if code, _ := common.ParseError(err); code == 1062 { + return nil, common.ErrUserPhoneExists + } + logger.ERROR("创建用户失败", err) + return nil, common.ErrInternal + } + _ = common.SetUserRole(id, role) + return user, nil +} + +// UpdateUser 管理员编辑用户(角色/状态/密码等) +func (s *SystemService) UpdateUser(id int64, req *vo.UserUpdateReq) (*model.User, *common.BusiError) { + var user model.User + if err := common.DB.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrUserNotFound + } + return nil, common.ErrInternal + } + + updates := map[string]any{"updated_at": time.Now()} + if req.Name != "" { + updates["name"] = req.Name + } + if req.Email != "" { + updates["email"] = req.Email + } + if req.Password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, common.ErrInternal + } + updates["password"] = string(hash) + } + if req.Role != "" { + updates["role"] = req.Role + } + if req.Status != nil { + updates["status"] = *req.Status + } + + if err := common.DB.Model(&user).Updates(updates).Error; err != nil { + logger.ERROR("更新用户失败", err) + return nil, common.ErrInternal + } + common.DB.First(&user, id) + if req.Role != "" { + _ = common.SetUserRole(id, req.Role) + } + return &user, nil +} + +// DeleteUser 删除用户 +func (s *SystemService) DeleteUser(id int64) *common.BusiError { + res := common.DB.Delete(&model.User{}, id) + if res.Error != nil { + logger.ERROR("删除用户失败", res.Error) + return common.ErrInternal + } + if res.RowsAffected == 0 { + return common.ErrUserNotFound + } + _ = common.SetUserRole(id, "") + return nil +} + +// GetRoles 角色列表 +func (s *SystemService) GetRoles() []string { + return []string{common.RoleAdmin, common.RoleUser} +} diff --git a/service/task_service.go b/service/task_service.go new file mode 100644 index 0000000..171fa51 --- /dev/null +++ b/service/task_service.go @@ -0,0 +1,372 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/robfig/cron/v3" + "gorm.io/gorm" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +type TaskService struct{} + +var DefaultTaskService = &TaskService{} + +// GetPage 任务分页列表 +func (s *TaskService) GetPage(userID int64, isAdmin bool, req *vo.TaskPageReq) (*common.PageResponse[vo.TaskPlanVO], *common.BusiError) { + db := common.DB.Model(&model.TaskPlan{}).Scopes(withUserFilter(userID, isAdmin)) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + if req.ScheduleType != "" { + db = db.Where("schedule_type = ?", req.ScheduleType) + } + if req.Keyword != "" { + db = db.Where("name LIKE ?", "%"+req.Keyword+"%") + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + logger.ERROR("统计任务失败", err) + return nil, common.ErrInternal + } + var tasks []model.TaskPlan + if err := db.Scopes(req.Paginate).Order("created_at DESC").Find(&tasks).Error; err != nil { + logger.ERROR("查询任务列表失败", err) + return nil, common.ErrInternal + } + + latestByTaskID := make(map[string]*model.TaskExecution, len(tasks)) + if len(tasks) > 0 { + taskIDs := make([]string, 0, len(tasks)) + for _, task := range tasks { + taskIDs = append(taskIDs, task.ID) + } + var executions []model.TaskExecution + if err := common.DB.Where("task_id IN ?", taskIDs).Order("created_at DESC, id DESC").Find(&executions).Error; err != nil { + logger.ERROR("查询任务最近执行记录失败", err) + return nil, common.ErrInternal + } + for i := range executions { + exec := &executions[i] + if _, exists := latestByTaskID[exec.TaskID]; !exists { + latestByTaskID[exec.TaskID] = exec + } + } + } + + list := make([]vo.TaskPlanVO, 0, len(tasks)) + for _, task := range tasks { + latest := latestByTaskID[task.ID] + list = append(list, vo.TaskPlanVO{ + TaskPlan: task, + PlanStatus: taskPlanStatus(task.Status, latest), + LatestExecution: latest, + }) + } + return common.Page(req.Pagination, total, list), nil +} + +func taskPlanStatus(status string, latest *model.TaskExecution) string { + if status == "disabled" { + return "paused" + } + if latest != nil && (latest.Status == "pending" || latest.Status == "running") { + return "running" + } + return "pending" +} + +// GetDetail 任务详情 +func (s *TaskService) GetDetail(userID int64, isAdmin bool, id string) (*model.TaskPlan, *common.BusiError) { + var task model.TaskPlan + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&task, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrTaskNotFound + } + return nil, common.ErrInternal + } + return &task, nil +} + +func validateTaskSchedule(scheduleType, scheduleCron string) *common.BusiError { + if scheduleType == "once" { + return nil + } + if scheduleType != "cron" || scheduleCron == "" { + return common.NewBusiError(common.ParamError, "定时任务必须配置执行时间") + } + if _, err := cron.ParseStandard(scheduleCron); err != nil { + return common.NewBusiError(common.ParamError, "定时执行表达式无效") + } + return nil +} + +// Create 新增任务 +func (s *TaskService) Create(userID int64, req *vo.TaskCreateReq) (*model.TaskPlan, *common.BusiError) { + scheduleType := req.ScheduleType + if scheduleType == "" { + scheduleType = "once" + } + if busiErr := validateTaskSchedule(scheduleType, req.ScheduleCron); busiErr != nil { + return nil, busiErr + } + videoPolicy := req.VideoPolicy + if videoPolicy == "" { + videoPolicy = "raw" + } + now := time.Now() + task := &model.TaskPlan{ + ID: strings.ReplaceAll(uuid.New().String(), "-", ""), + UserID: userID, + Name: req.Name, + DockID: req.DockID, + RouteID: req.RouteID, + ScheduleType: scheduleType, + ScheduleCron: req.ScheduleCron, + VideoPolicy: videoPolicy, + Status: "enabled", + CreatedBy: userID, + CreatedAt: now, + UpdatedAt: now, + } + if err := common.DB.Create(task).Error; err != nil { + logger.ERROR("新增任务失败", err) + return nil, common.ErrInternal + } + DefaultScheduler.SyncTask(task.ID) + return task, nil +} + +// Update 编辑任务 +func (s *TaskService) Update(userID int64, isAdmin bool, id string, req *vo.TaskUpdateReq) *common.BusiError { + var task model.TaskPlan + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&task, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.ErrTaskNotFound + } + return common.ErrInternal + } + + scheduleType := task.ScheduleType + if req.ScheduleType != "" { + scheduleType = req.ScheduleType + } + scheduleCron := task.ScheduleCron + if req.ScheduleCron != nil { + scheduleCron = *req.ScheduleCron + } + if scheduleType == "once" { + scheduleCron = "" + } + if busiErr := validateTaskSchedule(scheduleType, scheduleCron); busiErr != nil { + return busiErr + } + if req.Status != "" && req.Status != "draft" && req.Status != "enabled" && req.Status != "disabled" { + return common.NewBusiError(common.ParamError, "任务状态无效") + } + + updates := map[string]any{"updated_at": time.Now()} + if req.Name != "" { + updates["name"] = req.Name + } + if req.DockID != "" { + updates["dock_id"] = req.DockID + } + if req.RouteID != nil { + updates["route_id"] = *req.RouteID + } + if req.ScheduleType != "" { + updates["schedule_type"] = req.ScheduleType + } + if req.ScheduleCron != nil { + updates["schedule_cron"] = *req.ScheduleCron + } + if req.ScheduleType != "" && req.ScheduleType == "once" { + updates["schedule_cron"] = "" + } + if req.VideoPolicy != "" { + updates["video_policy"] = req.VideoPolicy + } + if req.Status != "" { + updates["status"] = req.Status + } + + if err := common.DB.Model(&task).Updates(updates).Error; err != nil { + logger.ERROR("更新任务失败", err) + return common.ErrInternal + } + DefaultScheduler.SyncTask(id) + return nil +} + +// Delete 删除任务 +func (s *TaskService) Delete(userID int64, isAdmin bool, id string) *common.BusiError { + res := common.DB.Scopes(withUserFilter(userID, isAdmin)).Delete(&model.TaskPlan{}, "id = ?", id) + if res.Error != nil { + logger.ERROR("删除任务失败", res.Error) + return common.ErrInternal + } + if res.RowsAffected == 0 { + return common.ErrTaskNotFound + } + DefaultScheduler.RemoveTask(id) + return nil +} + +// Execute 立即执行任务:落库执行记录 + 下发 workflow.start_task 指令 +func (s *TaskService) Execute(userID int64, isAdmin bool, id string) (*vo.TaskExecuteVO, *common.BusiError) { + var task model.TaskPlan + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&task, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + logger.WARN("执行任务失败,任务不存在:", id) + return nil, common.ErrTaskNotFound + } + logger.ERROR("查询待执行任务失败", err) + return nil, common.ErrInternal + } + if task.Status == "disabled" { + logger.WARN("执行任务失败,任务已禁用:", id) + return nil, common.ErrTaskStateErr + } + + online, _ := common.SetMemberExists(cache.OnlineDockSetKey, task.DockID) + if !online { + logger.WARN("执行任务失败,机巢离线:", task.DockID, "任务:", id) + return nil, common.ErrDockOffline + } + + commandID, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + execID, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + requestID := strings.ReplaceAll(uuid.New().String(), "-", "") + now := time.Now() + params := map[string]any{ + "taskId": task.ID, + "missionId": strings.ReplaceAll(uuid.New().String(), "-", ""), + "executionId": strconv.FormatInt(execID, 10), + "recordOriginalVideo": task.VideoPolicy == "raw", + } + if task.RouteID != 0 { + var waypoints []model.RouteWaypoint + if err := common.DB.Where("route_id = ?", task.RouteID).Order("seq ASC").Find(&waypoints).Error; err != nil { + logger.ERROR("查询航线航点失败", err) + return nil, common.ErrInternal + } + payload := make([]map[string]any, 0, len(waypoints)) + for i, wp := range waypoints { + payload = append(payload, map[string]any{ + "seq": i, + "longitude": wp.Longitude, + "latitude": wp.Latitude, + "altitude": wp.Altitude, + "speed": wp.Speed, + "yaw": wp.Yaw, + "holdSec": wp.HoldSec, + }) + } + params["waypoints"] = payload + } + + var originalVideo *model.Video + if task.VideoPolicy == "raw" { + videoID, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + expireAt := now.Add(10 * time.Minute) + originalVideo = &model.Video{ + ID: videoID, + UserID: task.UserID, + ExecutionID: execID, + FileName: fmt.Sprintf("execution-%d-original.mp4", execID), + OssKey: fmt.Sprintf("media/%d/execution-%d-original.mp4", videoID, execID), + OssBucket: common.AppConf.OSS.Bucket, + Status: "pending", + UploadExpireAt: &expireAt, + CreatedAt: now, + } + uploadURL, err := common.PresignOSS("PUT", originalVideo.OssKey, 600) + if err != nil { + logger.ERROR("生成原始视频上传地址失败", err) + return nil, common.ErrInternal + } + params["originalVideo"] = map[string]any{ + "videoId": strconv.FormatInt(videoID, 10), + "executionId": strconv.FormatInt(execID, 10), + "uploadUrl": uploadURL, + "uploadExpireAt": expireAt.UnixMilli(), + "fileName": originalVideo.FileName, + "contentType": "video/mp4", + } + } + + paramsJSON := "" + if b, e := json.Marshal(params); e == nil { + paramsJSON = string(b) + } + cmdLog := &model.DeviceCommandLog{ + ID: commandID, + DockID: task.DockID, + CommandType: "workflow.start_task", + Params: paramsJSON, + RequestID: requestID, + TTLMs: 30000, + Status: "sent", + SentAt: &now, + CreatedAt: now, + } + exec := &model.TaskExecution{ + ID: execID, + TaskID: task.ID, + CommandID: strconv.FormatInt(commandID, 10), + DockID: task.DockID, + Status: "pending", + TrajectoryJSON: json.RawMessage("[]"), + CreatedAt: now, + } + + if err := common.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(cmdLog).Error; err != nil { + return err + } + if err := tx.Create(exec).Error; err != nil { + return err + } + if originalVideo != nil { + return tx.Create(originalVideo).Error + } + return nil + }); err != nil { + logger.ERROR("创建执行记录失败", err) + return nil, common.ErrInternal + } + + var drone model.Drone + _ = common.DB.Where("dock_id = ?", task.DockID).First(&drone).Error + DefaultTrajectoryStore.Begin(task.DockID, execID, drone.DroneSN) + + if err := DefaultCommandService.publish(cmdLog, params); err != nil { + logger.ERROR("下发任务指令失败", err) + return nil, common.ErrInternal + } + + return &vo.TaskExecuteVO{Execution: exec, Command: cmdLog}, nil +} diff --git a/service/telemetry_store.go b/service/telemetry_store.go new file mode 100644 index 0000000..512cf18 --- /dev/null +++ b/service/telemetry_store.go @@ -0,0 +1,171 @@ +package service + +import ( + "fmt" + "strconv" + "strings" + "sync" + "time" + + "laic-backend/common" + "laic-backend/logger" +) + +// TelemetryPoint 无人机遥测点(对应 device_telemetry 超级表) +type TelemetryPoint struct { + Ts int64 `json:"ts"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + Altitude float64 `json:"altitude"` + GroundSpeed float64 `json:"groundSpeed"` + Roll float64 `json:"roll"` + Pitch float64 `json:"pitch"` + Yaw float64 `json:"yaw"` + BatteryPct int `json:"batteryPct"` + BatteryV float64 `json:"batteryV"` + Satellites int `json:"satellites"` + GpsQuality string `json:"gpsQuality"` + LinkQuality int `json:"linkQuality"` + FlightMode string `json:"flightMode"` + Armed int8 `json:"armed"` +} + +const ( + telemetryBatchSize = 500 + telemetryFlushInterval = 10 * time.Second +) + +type telemetryRecord struct { + DockID string + Point TelemetryPoint +} + +// TelemetryStore 遥测缓冲:累计 500 条或 10 秒后批量写入 TDengine +type TelemetryStore struct { + mu sync.Mutex + buffer []telemetryRecord + droneSN sync.Map // dockID -> droneSN(来自 state/drone 上报) +} + +var DefaultTelemetryStore = &TelemetryStore{} + +// SetDroneSN 记录机巢当前无人机序列号(作为 device_telemetry 的 tag) +func (s *TelemetryStore) SetDroneSN(dockID, sn string) { + if sn != "" { + s.droneSN.Store(dockID, sn) + } +} + +// Append 追加一个遥测点,达到阈值立即刷盘 +func (s *TelemetryStore) Append(dockID string, p TelemetryPoint) { + s.mu.Lock() + s.buffer = append(s.buffer, telemetryRecord{DockID: dockID, Point: p}) + if len(s.buffer) >= telemetryBatchSize { + s.flushLocked() + } + s.mu.Unlock() +} + +// Start 启动定时刷盘协程 +func (s *TelemetryStore) Start() { + go func() { + ticker := time.NewTicker(telemetryFlushInterval) + defer ticker.Stop() + for range ticker.C { + s.mu.Lock() + s.flushLocked() + s.mu.Unlock() + } + }() +} + +func (s *TelemetryStore) flushLocked() { + if len(s.buffer) == 0 { + return + } + records := s.buffer + s.buffer = nil + s.write(records) +} + +func (s *TelemetryStore) write(records []telemetryRecord) { + if common.TD == nil { + return + } + groups := make(map[string][]TelemetryPoint, len(records)) + for _, r := range records { + groups[r.DockID] = append(groups[r.DockID], r.Point) + } + for dockID, points := range groups { + s.insert(dockID, s.droneSNValue(dockID), points) + } +} + +func (s *TelemetryStore) droneSNValue(dockID string) string { + if v, ok := s.droneSN.Load(dockID); ok { + return v.(string) + } + return "" +} + +// DroneSN 返回机巢当前无人机序列号(来自 state/drone 上报) +func (s *TelemetryStore) DroneSN(dockID string) string { + return s.droneSNValue(dockID) +} + +func (s *TelemetryStore) insert(dockID, droneSN string, points []TelemetryPoint) { + defer func() { + if r := recover(); r != nil { + logger.ERROR("TDengine 写入 panic(已恢复,避免进程崩溃)", fmt.Errorf("%v", r)) + } + }() + var b strings.Builder + b.WriteString("INSERT INTO ") + b.WriteString(sanitizeTable(dockID)) + b.WriteString(" USING device_telemetry TAGS ('") + b.WriteString(escapeTD(dockID)) + b.WriteString("', '") + b.WriteString(escapeTD(droneSN)) + b.WriteString("') VALUES ") + for i, p := range points { + if i > 0 { + b.WriteString(", ") + } + ts := p.Ts + if ts <= 0 { + ts = time.Now().UnixMilli() + } + fmt.Fprintf(&b, "(%d, %s, %s, %s, %s, %s, %s, %s, %d, %s, %d, '%s', %d, '%s', %d)", + ts, + ftoa(p.Longitude), ftoa(p.Latitude), ftoa(p.Altitude), ftoa(p.GroundSpeed), + ftoa(p.Roll), ftoa(p.Pitch), ftoa(p.Yaw), + p.BatteryPct, ftoa(p.BatteryV), p.Satellites, + escapeTD(p.GpsQuality), p.LinkQuality, escapeTD(p.FlightMode), p.Armed, + ) + } + if _, err := common.TD.Exec(b.String()); err != nil { + logger.ERROR("TDengine 批量写入遥测失败", err) + } +} + +func ftoa(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} + +func escapeTD(s string) string { + return strings.ReplaceAll(s, "'", "\\'") +} + +// sanitizeTable 将 dockID 转为 TDengine 子表名(仅保留字母数字) +func sanitizeTable(dockID string) string { + var b strings.Builder + b.WriteString("d_") + for _, r := range dockID { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} diff --git a/service/trajectory_store.go b/service/trajectory_store.go new file mode 100644 index 0000000..d33aa2f --- /dev/null +++ b/service/trajectory_store.go @@ -0,0 +1,133 @@ +package service + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/vo" +) + +// trajectoryBuffer 单次执行的活动轨迹缓冲 +type trajectoryBuffer struct { + ExecutionID int64 + DockID string + DroneSN string + Points []vo.TrajectoryPoint +} + +// TrajectoryStore 轨迹采集:维护每个机巢的活动执行,遥测点入缓冲,终态回写 MySQL + TDengine +type TrajectoryStore struct { + mu sync.Mutex + active map[string]*trajectoryBuffer // dockID -> 活动执行 +} + +var DefaultTrajectoryStore = &TrajectoryStore{active: make(map[string]*trajectoryBuffer)} + +// Begin 建立活动执行关联(任务下发成功后调用) +func (s *TrajectoryStore) Begin(dockID string, executionID int64, droneSN string) { + s.mu.Lock() + defer s.mu.Unlock() + s.active[dockID] = &trajectoryBuffer{ + ExecutionID: executionID, + DockID: dockID, + DroneSN: droneSN, + Points: make([]vo.TrajectoryPoint, 0, 1024), + } +} + +// Append 将遥测点追加到活动执行的轨迹缓冲 +func (s *TrajectoryStore) Append(dockID string, p TelemetryPoint) { + s.mu.Lock() + defer s.mu.Unlock() + buf, ok := s.active[dockID] + if !ok { + return + } + ts := p.Ts + if ts <= 0 { + ts = time.Now().UnixMilli() + } + buf.Points = append(buf.Points, vo.TrajectoryPoint{ + Ts: ts, + Longitude: p.Longitude, + Latitude: p.Latitude, + Altitude: p.Altitude, + GroundSpeed: p.GroundSpeed, + Yaw: p.Yaw, + BatteryPct: p.BatteryPct, + }) +} + +// Finalize 终态回写:轨迹点写 MySQL trajectory_json + TDengine task_trajectory,并清除活动关联 +func (s *TrajectoryStore) Finalize(dockID string, droneSN string) { + s.mu.Lock() + buf, ok := s.active[dockID] + if ok { + delete(s.active, dockID) + } + s.mu.Unlock() + if !ok || buf == nil { + return + } + if droneSN != "" { + buf.DroneSN = droneSN + } + + pointsJSON, err := json.Marshal(buf.Points) + if err != nil { + pointsJSON = []byte("[]") + } + updates := map[string]any{"trajectory_json": string(pointsJSON)} + if buf.DroneSN != "" { + updates["drone_sn"] = buf.DroneSN + } + if err := common.DB.Model(&model.TaskExecution{}). + Where("id = ?", buf.ExecutionID). + Updates(updates).Error; err != nil { + logger.ERROR("回写执行轨迹 JSON 失败", err) + } + + s.writeTDengine(buf) +} + +// writeTDengine 轨迹点批量写入 task_trajectory 超级表(子表按 execution_id 隔离) +func (s *TrajectoryStore) writeTDengine(buf *trajectoryBuffer) { + if common.TD == nil || len(buf.Points) == 0 { + return + } + defer func() { + if r := recover(); r != nil { + logger.ERROR("TDengine 轨迹写入 panic(已恢复)", fmt.Errorf("%v", r)) + } + }() + + var b strings.Builder + b.WriteString("INSERT INTO ") + b.WriteString(trajectoryTable(buf.ExecutionID)) + b.WriteString(" USING task_trajectory TAGS (") + fmt.Fprintf(&b, "%d, '%s', '%s') VALUES ", + buf.ExecutionID, escapeTD(buf.DockID), escapeTD(buf.DroneSN)) + for i, p := range buf.Points { + if i > 0 { + b.WriteString(", ") + } + fmt.Fprintf(&b, "(%d, %s, %s, %s, %s, %s, %d)", + p.Ts, ftoa(p.Longitude), ftoa(p.Latitude), ftoa(p.Altitude), + ftoa(p.GroundSpeed), ftoa(p.Yaw), p.BatteryPct) + } + if _, err := common.TD.Exec(b.String()); err != nil { + logger.ERROR("TDengine 批量写入轨迹失败", err) + } +} + +// trajectoryTable 按执行 ID 生成子表名 +func trajectoryTable(execID int64) string { + return "tr_" + strconv.FormatInt(execID, 10) +} diff --git a/service/user_service.go b/service/user_service.go new file mode 100644 index 0000000..b20b150 --- /dev/null +++ b/service/user_service.go @@ -0,0 +1,207 @@ +package service + +import ( + "errors" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "laic-backend/cache" + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/token" + "laic-backend/tool" + "laic-backend/vo" +) + +type UserService struct{} + +var DefaultUserService = &UserService{} + +// Register 注册用户(默认角色 user),成功后直接返回登录态 +func (s *UserService) Register(name, phone, email, password string) (*vo.LoginResp, *common.BusiError) { + // 手机号唯一性预检(兼顾并发,唯一键兜底) + var count int64 + if err := common.DB.Model(&model.User{}).Where("phone = ?", phone).Count(&count).Error; err != nil { + return nil, common.ErrInternal + } + if count > 0 { + return nil, common.ErrUserPhoneExists + } + + id, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, common.ErrInternal + } + + user := &model.User{ + ID: id, + Name: name, + Phone: phone, + Email: email, + Password: string(hash), + Role: common.RoleUser, + Status: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := common.DB.Create(user).Error; err != nil { + if code, _ := common.ParseError(err); code == 1062 { + return nil, common.ErrUserPhoneExists + } + logger.ERROR("注册用户失败", err) + return nil, common.ErrInternal + } + _ = common.SetUserRole(id, common.RoleUser) + + return s.buildLoginResp(user) +} + +// Login 手机号 + 密码登录 +func (s *UserService) Login(phone, password string) (*vo.LoginResp, *common.BusiError) { + var user model.User + if err := common.DB.Where("phone = ? AND status = 1", phone).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrPasswordError // 统一返回"用户名或密码错误",避免暴露账号是否存在 + } + logger.ERROR("登录查询用户失败", err) + return nil, common.ErrInternal + } + if !checkPassword(password, user.Password) { + return nil, common.ErrPasswordError + } + + // 更新最后登录时间(失败不阻断登录) + common.DB.Model(&user).Update("last_login", time.Now()) + + return s.buildLoginResp(&user) +} + +// Refresh 用 refresh token 换取新的 access + refresh token(刷新令牌轮换) +func (s *UserService) Refresh(refreshToken string) (*vo.LoginResp, *common.BusiError) { + userID, err := token.ParseRefreshToken(refreshToken) + if err != nil { + return nil, common.ErrTokenInvalid + } + + // 校验 refresh token 是否仍存证(登出会删除) + refreshKey := cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken)) + stored, err := common.GetString(refreshKey) + if err != nil || stored != refreshToken { + return nil, common.ErrTokenInvalid + } + + var user model.User + if err := common.DB.Where("id = ? AND status = 1", userID).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrUserNotFound + } + return nil, common.ErrInternal + } + + // 轮换:删除旧 refresh 存证 + _ = common.Delete(refreshKey) + + return s.buildLoginResp(&user) +} + +// Logout 登出:删除 access 存证(及可选 refresh 存证),使 token 立即失效 +func (s *UserService) Logout(userID int64, accessToken, refreshToken string) *common.BusiError { + accessKey := cache.UserTokenKeyOf(userID, token.GenerateShortID(accessToken)) + _ = common.Delete(accessKey) + if refreshToken != "" { + refreshKey := cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken)) + _ = common.Delete(refreshKey) + } + return nil +} + +// SendSmsCode 生成并"发送"短信验证码(暂存 Redis,短信通道未接入前记日志) +func (s *UserService) SendSmsCode(phone string) (string, *common.BusiError) { + code := tool.RandDigit(6) + key := cache.SmsCodeKeyOf(phone) + if err := common.SetValueWithExpired(key, code, 5*60); err != nil { + logger.ERROR("短信验证码写 Redis 失败", err) + return "", common.ErrInternal + } + // TODO: 接入短信服务商后,此处调用真实发送接口;当前仅在日志输出验证码便于联调 + logger.INFO("send sms code to", phone, "code:", code) + return code, nil +} + +// ResetPassword 通过短信验证码重置密码 +func (s *UserService) ResetPassword(phone, smsCode, newPassword string) *common.BusiError { + key := cache.SmsCodeKeyOf(phone) + stored, err := common.GetString(key) + if err != nil || stored == "" || stored != smsCode { + return common.ErrSmsCodeError + } + + var user model.User + if err := common.DB.Where("phone = ?", phone).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return common.ErrUserNotFound + } + return common.ErrInternal + } + + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return common.ErrInternal + } + if err := common.DB.Model(&user).Updates(map[string]any{ + "password": string(hash), + "updated_at": time.Now(), + }).Error; err != nil { + return common.ErrInternal + } + _ = common.Delete(key) + return nil +} + +// GetByID 按 ID 查询用户 +func (s *UserService) GetByID(id int64) (*model.User, *common.BusiError) { + var user model.User + if err := common.DB.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrUserNotFound + } + return nil, common.ErrInternal + } + return &user, nil +} + +// buildLoginResp 生成 token 并存证 Redis,返回登录响应 +func (s *UserService) buildLoginResp(user *model.User) (*vo.LoginResp, *common.BusiError) { + roles := []string{user.Role} + accessToken, refreshToken, err := token.GenerateToken(user.ID, user.Phone, roles) + if err != nil { + logger.ERROR("生成 token 失败", err) + return nil, common.ErrInternal + } + + accessKey := cache.UserTokenKeyOf(user.ID, token.GenerateShortID(accessToken)) + refreshKey := cache.RefreshTokenKeyOf(user.ID, token.GenerateShortID(refreshToken)) + if err := common.SetValueWithExpired(accessKey, "1", token.AccessExpireSeconds()); err != nil { + return nil, common.ErrInternal + } + if err := common.SetValueWithExpired(refreshKey, refreshToken, token.RefreshExpireSeconds()); err != nil { + return nil, common.ErrInternal + } + + return &vo.LoginResp{ + AccessToken: accessToken, + RefreshToken: refreshToken, + User: vo.NewUserVO(user), + }, nil +} + +func checkPassword(password, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/service/video_service.go b/service/video_service.go new file mode 100644 index 0000000..2249935 --- /dev/null +++ b/service/video_service.go @@ -0,0 +1,231 @@ +package service + +import ( + "errors" + "fmt" + "net/http" + "time" + + "gorm.io/gorm" + + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" + "laic-backend/vo" +) + +type VideoService struct{} + +var DefaultVideoService = &VideoService{} + +const downloadURLExpire = 300 // 预签名下载地址有效期(秒) + +type OriginalVideoEvent struct { + Extension string `json:"extension"` + EventID string `json:"eventId"` + EventType string `json:"eventType"` + VideoID int64 `json:"videoId"` + ExecutionID int64 `json:"executionId"` + TaskID string `json:"taskId"` + CommandID string `json:"commandId"` + FileSize int64 `json:"fileSize"` + Duration int `json:"duration"` + ErrorCode string `json:"errorCode"` +} + +func (s *VideoService) CompleteOriginalVideo(dockID string, event *OriginalVideoEvent) error { + if event.Extension != "laic.mock.original-video.v1" || event.EventID == "" || event.VideoID <= 0 || event.ExecutionID <= 0 { + return errors.New("invalid original video event") + } + var video model.Video + if err := common.DB.First(&video, event.VideoID).Error; err != nil { + return err + } + if video.ExecutionID != event.ExecutionID { + return errors.New("video execution mismatch") + } + var execution model.TaskExecution + if err := common.DB.First(&execution, event.ExecutionID).Error; err != nil { + return err + } + if execution.DockID != dockID || execution.TaskID != event.TaskID || execution.CommandID != event.CommandID { + return errors.New("execution association mismatch") + } + var task model.TaskPlan + if err := common.DB.First(&task, "id = ?", execution.TaskID).Error; err != nil { + return err + } + if task.DockID != dockID || task.VideoPolicy != "raw" { + return errors.New("task video policy mismatch") + } + if event.EventType == "failed" { + return common.DB.Model(&video).Where("id = ? AND status <> ?", video.ID, "ready").Update("status", "failed").Error + } + if event.EventType != "completed" || video.Status == "ready" { + return nil + } + if event.FileSize <= 0 || event.FileSize > 100<<20 { + return errors.New("invalid video size") + } + if err := headOSS(video.OssKey); err != nil { + return err + } + updates := map[string]any{"status": "ready", "file_size": event.FileSize} + if event.Duration > 0 { + updates["duration"] = event.Duration + } + return common.DB.Model(&video).Where("id = ? AND status IN ?", video.ID, []string{"pending", "uploading"}).Updates(updates).Error +} + +func headOSS(objectKey string) error { + url, err := common.PresignOSS("HEAD", objectKey, 60) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodHead, url, nil) + if err != nil { + return err + } + resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("oss object unavailable: %s", resp.Status) + } + return nil +} + +// CreateUpload 申请视频上传:落库 + 返回 OSS 预签名 PUT URL +func (s *VideoService) CreateUpload(userID int64, req *vo.VideoUploadReq) (*vo.VideoUploadVO, *common.BusiError) { + id, err := tool.NextID() + if err != nil { + return nil, common.ErrInternal + } + ossKey := fmt.Sprintf("media/%d/%s", id, req.FileName) + expireAt := time.Now().Add(10 * time.Minute) + + video := &model.Video{ + ID: id, + UserID: userID, + DroneSN: req.DroneSN, + ExecutionID: req.ExecutionID, + FileName: req.FileName, + FileSize: req.FileSize, + OssKey: ossKey, + OssBucket: common.AppConf.OSS.Bucket, + Status: "uploading", + UploadExpireAt: &expireAt, + CreatedAt: time.Now(), + } + if err := common.DB.Create(video).Error; err != nil { + logger.ERROR("创建视频记录失败", err) + return nil, common.ErrInternal + } + + uploadURL, err := common.PresignOSS("PUT", ossKey, 600) + if err != nil { + logger.ERROR("生成上传预签名 URL 失败", err) + return nil, common.ErrInternal + } + return &vo.VideoUploadVO{Video: video, UploadURL: uploadURL, ExpireAt: &expireAt}, nil +} + +// Complete 上传完成确认:更新视频状态为 ready +func (s *VideoService) Complete(userID int64, videoID int64, req *vo.VideoCompleteReq) (*model.Video, *common.BusiError) { + var video model.Video + if err := common.DB.Where("id = ? AND user_id = ?", videoID, userID).First(&video).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrVideoNotFound + } + return nil, common.ErrInternal + } + updates := map[string]any{"status": "ready"} + if req.FileSize > 0 { + updates["file_size"] = req.FileSize + } + if req.Duration > 0 { + updates["duration"] = req.Duration + } + if err := common.DB.Model(&video).Updates(updates).Error; err != nil { + return nil, common.ErrInternal + } + common.DB.First(&video, videoID) + return &video, nil +} + +// GetPage 视频分页 +func (s *VideoService) GetPage(userID int64, isAdmin bool, req *vo.VideoPageReq) (*common.PageResponse[model.Video], *common.BusiError) { + db := common.DB.Model(&model.Video{}).Scopes(withUserFilter(userID, isAdmin)) + if req.Status != "" { + db = db.Where("status = ?", req.Status) + } + if req.Keyword != "" { + db = db.Where("file_name LIKE ?", "%"+req.Keyword+"%") + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, common.ErrInternal + } + var list []model.Video + if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { + return nil, common.ErrInternal + } + return common.Page(req.Pagination, total, list), nil +} + +// GetDetail 视频详情 +func (s *VideoService) GetDetail(userID int64, isAdmin bool, videoID int64) (*model.Video, *common.BusiError) { + var video model.Video + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&video, videoID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrVideoNotFound + } + return nil, common.ErrInternal + } + return &video, nil +} + +// Download 下载视频:校验状态 → 流量扣减 → OSS 预签名 GET → 下载日志 +func (s *VideoService) Download(userID int64, isAdmin bool, videoID int64) (*vo.VideoDownloadVO, *common.BusiError) { + var video model.Video + if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&video, videoID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrVideoNotFound + } + return nil, common.ErrInternal + } + if video.Status != "ready" { + return nil, common.ErrVideoUploading + } + + // 流量扣减(账本落库) + if ok, busiErr := DefaultBillingService.Deduct(userID, video.FileSize, "download", videoID); !ok { + return nil, busiErr + } + + downloadURL, err := common.PresignOSS("GET", video.OssKey, downloadURLExpire) + if err != nil { + logger.ERROR("生成下载预签名 URL 失败", err) + return nil, common.ErrInternal + } + + log := &model.DownloadLog{ + ID: mustID(), + VideoID: videoID, + UserID: userID, + Bytes: video.FileSize, + CreatedAt: time.Now(), + } + if err := common.DB.Create(log).Error; err != nil { + logger.ERROR("下载日志落库失败", err) + } + + return &vo.VideoDownloadVO{ + DownloadURL: downloadURL, + ExpireAt: time.Now().Add(downloadURLExpire * time.Second).Unix(), + Bytes: video.FileSize, + }, nil +} diff --git a/service/workflow_service.go b/service/workflow_service.go new file mode 100644 index 0000000..c93080f --- /dev/null +++ b/service/workflow_service.go @@ -0,0 +1,129 @@ +package service + +import ( + "errors" + "strconv" + "time" + + "gorm.io/gorm" + + "laic-backend/common" + "laic-backend/logger" + "laic-backend/model" + "laic-backend/tool" +) + +type WorkflowService struct{} + +var DefaultWorkflowService = &WorkflowService{} + +// WorkflowStateIn 工作流状态上报载荷 +type WorkflowStateIn struct { + CommandID string `json:"commandId"` + Type string `json:"type"` + TaskID string `json:"taskId"` + MissionID string `json:"missionId"` + State string `json:"state"` + Step string `json:"step"` + ResultCode string `json:"resultCode"` +} + +// Upsert 持久化工作流状态(按 command_id 去重更新),并在终态时回写任务执行记录 +func (s *WorkflowService) Upsert(dockID, requestID string, in *WorkflowStateIn) { + now := time.Now() + if requestID != "" { + var cmd model.DeviceCommandLog + if err := common.DB.Where("dock_id = ? AND request_id = ? AND status IN ?", dockID, requestID, []string{"sent", "acked"}).First(&cmd).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + logger.ERROR("按 requestId 查询工作流指令失败", err) + } + return + } + expected := strconv.FormatInt(cmd.ID, 10) + if in.CommandID != "" && in.CommandID != expected { + logger.WARN("工作流状态 commandId 与 requestId 不匹配", dockID, requestID) + return + } + in.CommandID = expected + } + + if in.CommandID != "" { + var existing model.WorkflowState + err := common.DB.Where("dock_id = ? AND command_id = ?", dockID, in.CommandID).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + id, e := tool.NextID() + if e == nil { + common.DB.Create(&model.WorkflowState{ + ID: id, + DockID: dockID, + CommandID: in.CommandID, + Type: in.Type, + TaskID: in.TaskID, + MissionID: in.MissionID, + State: in.State, + Step: in.Step, + ResultCode: in.ResultCode, + UpdatedAt: now, + }) + } + } else if err == nil { + common.DB.Model(&existing).Updates(map[string]any{ + "dock_id": dockID, + "type": in.Type, + "task_id": in.TaskID, + "mission_id": in.MissionID, + "state": in.State, + "step": in.Step, + "result_code": in.ResultCode, + "updated_at": now, + }) + } + } + + // 回写 task_execution,优先按 command_id 精确关联,兼容旧设备时只回退到最新未终态记录。 + if (in.CommandID != "" || in.TaskID != "") && (in.State == "running" || isTerminalWorkflowState(in.State)) { + updates := map[string]any{"status": in.State} + if in.State == "running" { + updates["start_time"] = now + } else { + updates["end_time"] = now + updates["start_time"] = gorm.Expr("COALESCE(start_time, ?)", now) + } + + query := common.DB.Model(&model.TaskExecution{}). + Where("dock_id = ? AND status NOT IN ('succeeded','failed','cancelled')", dockID) + if in.CommandID != "" { + query = query.Where("command_id = ?", in.CommandID) + } else { + query = query.Where("task_id = ?", in.TaskID) + var execution model.TaskExecution + if err := query.Order("created_at DESC, id DESC").First(&execution).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + logger.ERROR("查找任务执行记录失败", err) + } + query = nil + } else { + query = common.DB.Model(&execution) + } + } + if query != nil { + if err := query.Updates(updates).Error; err != nil { + logger.ERROR("回写任务执行状态失败", err) + } + } + } + + // 终态回写轨迹(MySQL trajectory_json + TDengine task_trajectory) + if isTerminalWorkflowState(in.State) { + DefaultTrajectoryStore.Finalize(dockID, DefaultTelemetryStore.DroneSN(dockID)) + } +} + +func isTerminalWorkflowState(state string) bool { + switch state { + case "succeeded", "failed", "cancelled": + return true + default: + return false + } +} diff --git a/sql/001_schema.sql b/sql/001_schema.sql new file mode 100644 index 0000000..4d246c0 --- /dev/null +++ b/sql/001_schema.sql @@ -0,0 +1,360 @@ +-- ============================================================= +-- 嘉谷低空智控平台 数据库初始化脚本 +-- 引擎: InnoDB / 字符集: utf8mb4 +-- 说明: 所有 BIGINT 主键均为 Snowflake 雪花 ID,无 AUTO_INCREMENT +-- ============================================================= + +CREATE DATABASE IF NOT EXISTS laic DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +USE laic; + +-- --------------------------------------------------------------- +-- 5.1 用户 +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `user` ( + id BIGINT PRIMARY KEY, + name VARCHAR(32) NOT NULL, + phone VARCHAR(32) NOT NULL, + email VARCHAR(64), + password VARCHAR(64) NOT NULL, + role VARCHAR(16) DEFAULT 'user', -- admin / user + traffic_balance BIGINT DEFAULT 0, -- 云媒体剩余流量(字节),慢快照,权威在 Redis + status TINYINT DEFAULT 1, -- 1 正常 / 0 禁用 + last_login DATETIME, + created_at DATETIME, + updated_at DATETIME, + UNIQUE KEY idx_phone (phone) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------- +-- 5.2 机巢 & 无人机 +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS dock ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + dock_id VARCHAR(64) NOT NULL, -- MQTT dockId (dock-xxx) + name VARCHAR(64) DEFAULT '', + code VARCHAR(32) DEFAULT '', + sn VARCHAR(64) DEFAULT '', + iccid VARCHAR(32) DEFAULT '', -- 冗余,权威见 sim_card.iccid + longitude DOUBLE DEFAULT 0, + latitude DOUBLE DEFAULT 0, + altitude DOUBLE DEFAULT 0, + location VARCHAR(128) DEFAULT '', + status VARCHAR(32) DEFAULT 'offline', + register_status VARCHAR(16) DEFAULT 'pending', -- pending/registered + dock_id_source VARCHAR(32) DEFAULT '', + software_ver VARCHAR(16) DEFAULT '', + protocol_ver VARCHAR(8) DEFAULT '1.0', + created_at DATETIME, + updated_at DATETIME, + UNIQUE KEY idx_dock_id (dock_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS drone ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + drone_sn VARCHAR(32) NOT NULL, -- 飞控 HW_SN_NUM + dock_id VARCHAR(64) NOT NULL, -- 绑定的机巢 dock_id(1:1) + name VARCHAR(64) DEFAULT '', + code VARCHAR(32) DEFAULT '', + model VARCHAR(32) DEFAULT '', + status VARCHAR(32) DEFAULT 'offline', + battery INT DEFAULT 0, + firmware_ver VARCHAR(16) DEFAULT '', + created_at DATETIME, + updated_at DATETIME, + UNIQUE KEY idx_drone_sn (drone_sn), + UNIQUE KEY idx_drone_dock (dock_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------- +-- 5.3 告警 +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS alarm_code ( + id BIGINT PRIMARY KEY, + code VARCHAR(64) NOT NULL, + category VARCHAR(32) NOT NULL, -- dock / drone + message_cn VARCHAR(256) NOT NULL, + level VARCHAR(16) DEFAULT 'critical', + source VARCHAR(128) DEFAULT '', + created_at DATETIME, + updated_at DATETIME, + UNIQUE KEY idx_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS alarm ( + id BIGINT PRIMARY KEY, + dock_id VARCHAR(64) NOT NULL, + code VARCHAR(64) NOT NULL, + device_type VARCHAR(8) NOT NULL, -- dock / drone + message_cn VARCHAR(256) NOT NULL, + level VARCHAR(16) NOT NULL, + status VARCHAR(16) DEFAULT 'active', -- active / acknowledged / resolved + triggered_at DATETIME, + resolved_at DATETIME, + created_at DATETIME, + KEY idx_alarm_dock (dock_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------- +-- 5.4 指令 & 工作流 +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS device_command_log ( + id BIGINT PRIMARY KEY, -- = commandId (Snowflake) + dock_id VARCHAR(64) NOT NULL, + command_type VARCHAR(64) NOT NULL, -- dock.open / drone.takeoff / workflow.start_task + params JSON, + drone_sn VARCHAR(32), + request_id VARCHAR(64), + ttl_ms INT DEFAULT 30000, + ack_accepted TINYINT, + ack_result_code VARCHAR(64), + status VARCHAR(16) DEFAULT 'sent', -- sent / acked / timeout / terminal + retry_count INT DEFAULT 0, + sent_at DATETIME, + acked_at DATETIME, + created_at DATETIME, + KEY idx_cmd_log_dock (dock_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS workflow_state ( + id BIGINT PRIMARY KEY, + dock_id VARCHAR(64) NOT NULL, + command_id VARCHAR(64), + type VARCHAR(64), + task_id VARCHAR(128), + mission_id VARCHAR(128), + state VARCHAR(16) NOT NULL, -- idle / running / succeeded / failed / cancelled + step VARCHAR(64) NOT NULL, + result_code VARCHAR(64), + updated_at DATETIME +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------- +-- 5.5 固件管理 +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS firmware ( + id BIGINT PRIMARY KEY, + component VARCHAR(32) NOT NULL, -- 固定 dock-edge-agent + version VARCHAR(32) NOT NULL, -- 1.3.0 + description VARCHAR(512) DEFAULT '', + file_url VARCHAR(512) NOT NULL, + sha256 VARCHAR(128) NOT NULL, + signature TEXT NOT NULL, + file_size BIGINT DEFAULT 0, + mandatory TINYINT DEFAULT 0, + status VARCHAR(16) DEFAULT 'draft', -- draft/released/deprecated + created_by BIGINT NOT NULL, + created_at DATETIME, + updated_at DATETIME, + UNIQUE KEY idx_component_ver (component, version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------- +-- 5.6 任务 & 航线 +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS task_plan ( + id VARCHAR(128) PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + dock_id VARCHAR(64) NOT NULL, + route_id BIGINT, + schedule_type VARCHAR(16) DEFAULT 'once', -- once / cron + schedule_cron VARCHAR(32), + video_policy VARCHAR(16) DEFAULT 'raw', + status VARCHAR(16) DEFAULT 'draft', + created_by BIGINT, + created_at DATETIME, + updated_at DATETIME +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS task_execution ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(128), + command_id VARCHAR(64), + dock_id VARCHAR(64) NOT NULL, + drone_sn VARCHAR(32), + start_time DATETIME, + end_time DATETIME, + status VARCHAR(16) DEFAULT 'pending', + trajectory_json JSON, + created_at DATETIME +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS route ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + description VARCHAR(256), + created_at DATETIME, + updated_at DATETIME +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS route_waypoint ( + id BIGINT PRIMARY KEY, + route_id BIGINT NOT NULL, + seq INT NOT NULL, + longitude DOUBLE, + latitude DOUBLE, + altitude DOUBLE, + speed DOUBLE, + yaw DOUBLE, + hold_sec INT DEFAULT 0, + created_at DATETIME, + UNIQUE KEY uk_route_seq (route_id, seq) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------- +-- 5.7 媒体 +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS live_session ( + id VARCHAR(128) PRIMARY KEY, -- streamSessionId + dock_id VARCHAR(64) NOT NULL, + provider VARCHAR(16) DEFAULT 'aliyun', + stream_name VARCHAR(128) NOT NULL, -- 直播流名(每次会话唯一) + push_url_hash VARCHAR(128), + expires_at BIGINT NOT NULL, + max_bitrate_bps BIGINT DEFAULT 1500000, + phase VARCHAR(16) DEFAULT 'idle', + stop_reason VARCHAR(32), + error_code VARCHAR(64), + requested_by BIGINT, + stop_deadline DATETIME, + device_state_version BIGINT DEFAULT 0, + device_event_id VARCHAR(128), + device_updated_at BIGINT DEFAULT 0, + cloud_confirmed_at DATETIME, + started_at DATETIME, + stopped_at DATETIME, + created_at DATETIME, + updated_at DATETIME, + active_dock_id VARCHAR(64) GENERATED ALWAYS AS ( + CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END + ) STORED, + KEY idx_live_session_dock_phase (dock_id, phase, created_at), + KEY idx_live_session_phase_deadline (phase, stop_deadline), + UNIQUE KEY uk_live_session_device_event (device_event_id), + UNIQUE KEY uk_live_session_active_dock (active_dock_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS live_viewer_lease ( + id BIGINT PRIMARY KEY, + stream_session_id VARCHAR(128) NOT NULL, + viewer_id BIGINT NOT NULL, + expires_at DATETIME NOT NULL, + released_at DATETIME NULL, + created_at DATETIME NULL, + updated_at DATETIME NULL, + UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id), + KEY idx_live_lease_expire (stream_session_id, expires_at), + KEY idx_live_lease_active (stream_session_id, released_at, expires_at), + CONSTRAINT fk_live_lease_session FOREIGN KEY (stream_session_id) REFERENCES live_session(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS video ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + drone_sn VARCHAR(32), + execution_id BIGINT, + file_name VARCHAR(256), + file_size BIGINT, + duration INT, + oss_key VARCHAR(256), + oss_bucket VARCHAR(128), -- 预签名上传的目标 bucket + thumbnail_key VARCHAR(256), + status VARCHAR(16) DEFAULT 'uploading', + upload_expire_at DATETIME, -- 预签名 PUT URL 过期时间 + created_at DATETIME +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS download_log ( + id BIGINT PRIMARY KEY, + video_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + bytes BIGINT NOT NULL, + created_at DATETIME +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------- +-- 5.8 计费 +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS traffic_order ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + amount_gb INT NOT NULL, -- 购买流量(GB) + unit_price DECIMAL(6,2), + total_price DECIMAL(10,2), + pay_status VARCHAR(16) DEFAULT 'unpaid', -- unpaid/paid/cancelled + created_at DATETIME, + paid_at DATETIME +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS traffic_usage_log ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + source_type VARCHAR(16), -- live / replay / download + source_id BIGINT, + bytes_used BIGINT, + balance_before BIGINT, -- 扣减前余额(字节) + balance_after BIGINT, -- 扣减后余额(字节) + created_at DATETIME +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS sim_card ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + dock_id VARCHAR(64), + carrier VARCHAR(16), -- 移动/联通/电信 + phone VARCHAR(16), + iccid VARCHAR(32), -- 权威字段(查询/充值的入参) + plan_gb INT, + used_gb DECIMAL(10,4) DEFAULT 0, + expired_at DATE, + status VARCHAR(16) DEFAULT 'active', -- 平台业务态: active/expired + carrier_status VARCHAR(16) DEFAULT 'normal', -- 运营商侧状态: normal/suspended/arrears/cancelled + last_sync_at DATETIME, + created_at DATETIME, + updated_at DATETIME +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS sim_recharge_log ( + id BIGINT PRIMARY KEY, + sim_card_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + amount_gb INT NOT NULL, + unit_price DECIMAL(6,2), + total_price DECIMAL(10,2), + pay_status VARCHAR(16) DEFAULT 'paid', + recharge_status VARCHAR(16) DEFAULT 'pending', -- pending/success/failed + recharge_msg VARCHAR(256) DEFAULT '', + idempotent_key VARCHAR(64) NOT NULL, -- 平台充值单号,超时重试去重 + created_at DATETIME, + UNIQUE KEY uk_recharge_idem (idempotent_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS sim_usage_record ( + id BIGINT PRIMARY KEY, + sim_card_id BIGINT NOT NULL, + plan_gb INT, + used_gb DECIMAL(10,4), + remain_gb DECIMAL(10,4), + carrier_status VARCHAR(16), + synced_at DATETIME, + KEY idx_record_sim (sim_card_id, synced_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------- +-- 7.7 操作日志 +-- --------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS operation_log ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + user_name VARCHAR(32), + module VARCHAR(32), -- 模块(用户管理/机巢管理/任务管理...) + action VARCHAR(64), -- 动作(新增/修改/删除/下发指令...) + detail VARCHAR(256), -- 详情(请求路径) + result VARCHAR(16), -- success / failed + ip VARCHAR(64), + created_at DATETIME, + KEY idx_op_user (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/sql/002_alarm_codes.sql b/sql/002_alarm_codes.sql new file mode 100644 index 0000000..553b435 --- /dev/null +++ b/sql/002_alarm_codes.sql @@ -0,0 +1,144 @@ +-- ============================================================= +-- 告警码字典初始化数据 +-- 说明: 完整枚举对齐 MQTT 协议文档 7.6 节(共 106 条)。 +-- id 为固定字典 ID,非雪花 ID。按 code 唯一键 INSERT IGNORE,可重复执行。 +-- category: dock(PLC/机巢/执行机构/电池/充电/空调/链路) / drone(飞控/MAVLink/定位) +-- ============================================================= + +USE laic; + +-- --------------------------------------------------------------- +-- 7.6.2 PLC / 机巢 / 执行机构故障(dock) +-- --------------------------------------------------------------- +INSERT IGNORE INTO alarm_code (id, code, category, message_cn, level, source, created_at, updated_at) VALUES +(1001, 'DOCK_EMERGENCY_STOP', 'dock', '机巢急停已触发', 'emergency', 'dock', NOW(), NOW()), +(1002, 'DOCK_PLC_ALARM_UNKNOWN', 'dock', 'PLC 总报警已触发,但未解析到具体故障', 'critical', 'dock', NOW(), NOW()), +(1003, 'CHARGING_ROD_MOTOR_FAULT', 'dock', '充电杆电机报警', 'critical', 'dock', NOW(), NOW()), +(1004, 'CENTERING_ROD_MOTOR_FAULT', 'dock', '归中杆电机报警', 'critical', 'dock', NOW(), NOW()), +(1005, 'DRONE_POWER_OFF_TIMEOUT', 'dock', '无人机关机超时', 'critical', 'dock', NOW(), NOW()), +(1006, 'DRONE_POWER_ON_TIMEOUT', 'dock', '无人机开机超时', 'critical', 'dock', NOW(), NOW()), +(1007, 'DRONE_POSITION_DETECT_TIMEOUT', 'dock', '无人机在位检测超时', 'critical', 'dock', NOW(), NOW()), +(1008, 'LEFT_DOOR_POWER_FAULT', 'dock', '左顶门动力故障', 'critical', 'dock', NOW(), NOW()), +(1009, 'LEFT_DOOR_CLOSE_TIMEOUT', 'dock', '左顶门关门超时或关门传感器未感应', 'critical', 'dock', NOW(), NOW()), +(1010, 'LEFT_DOOR_OPEN_TIMEOUT', 'dock', '左顶门开门超时或开门传感器未感应', 'critical', 'dock', NOW(), NOW()), +(1011, 'LEFT_DOOR_RESET_TIMEOUT', 'dock', '左顶门超过时间复位未完成', 'critical', 'dock', NOW(), NOW()), +(1012, 'LEFT_DOOR_NOT_RESET_ACTION_BLOCKED', 'dock', '左顶门电机未复位完成,动作已阻止', 'critical', 'dock', NOW(), NOW()), +(1013, 'RIGHT_DOOR_POWER_FAULT', 'dock', '右顶门动力故障', 'critical', 'dock', NOW(), NOW()), +(1014, 'RIGHT_DOOR_CLOSE_TIMEOUT', 'dock', '右顶门关门超时或关门传感器未感应', 'critical', 'dock', NOW(), NOW()), +(1015, 'RIGHT_DOOR_OPEN_TIMEOUT', 'dock', '右顶门开门超时或开门传感器未感应', 'critical', 'dock', NOW(), NOW()), +(1016, 'RIGHT_DOOR_RESET_TIMEOUT', 'dock', '右顶门超过时间复位未完成', 'critical', 'dock', NOW(), NOW()), +(1017, 'RIGHT_DOOR_NOT_RESET_ACTION_BLOCKED', 'dock', '右顶门电机未复位完成,动作已阻止', 'critical', 'dock', NOW(), NOW()), +(1018, 'X_CENTER_POWER_FAULT', 'dock', '左右归中动力故障', 'critical', 'dock', NOW(), NOW()), +(1019, 'X_CENTER_OPEN_TIMEOUT', 'dock', '左右归中打开超时或传感器未感应', 'critical', 'dock', NOW(), NOW()), +(1020, 'X_CENTER_CLAMP_TIMEOUT', 'dock', '左右归中夹紧超时或传感器未感应', 'critical', 'dock', NOW(), NOW()), +(1021, 'X_CENTER_RESET_TIMEOUT', 'dock', '左右归中超过时间复位未完成', 'critical', 'dock', NOW(), NOW()), +(1022, 'X_CENTER_NOT_RESET_ACTION_BLOCKED', 'dock', '左右归中电机未复位完成,动作已阻止', 'critical', 'dock', NOW(), NOW()), +(1023, 'Y_CENTER_POWER_FAULT', 'dock', '前后归中动力故障', 'critical', 'dock', NOW(), NOW()), +(1024, 'Y_CENTER_OPEN_TIMEOUT', 'dock', '前后归中打开超时或传感器未感应', 'critical', 'dock', NOW(), NOW()), +(1025, 'Y_CENTER_CLAMP_TIMEOUT', 'dock', '前后归中夹紧超时或传感器未感应', 'critical', 'dock', NOW(), NOW()), +(1026, 'Y_CENTER_RESET_TIMEOUT', 'dock', '前后归中超过时间复位未完成', 'critical', 'dock', NOW(), NOW()), +(1027, 'Y_CENTER_NOT_RESET_ACTION_BLOCKED', 'dock', '前后归中电机未复位完成,动作已阻止', 'critical', 'dock', NOW(), NOW()), + +-- --------------------------------------------------------------- +-- 7.6.3 电池保护和电池报警(dock) +-- --------------------------------------------------------------- +(1028, 'BATTERY_NOT_PRESENT', 'dock', '电池未在位,请检查系留接口连接状态', 'warning', 'dock', NOW(), NOW()), +(1029, 'BATTERY_CELL_OVER_VOLTAGE', 'dock', '电池单体过压保护', 'critical', 'dock', NOW(), NOW()), +(1030, 'BATTERY_CELL_UNDER_VOLTAGE', 'dock', '电池单体欠压保护', 'critical', 'dock', NOW(), NOW()), +(1031, 'BATTERY_PACK_OVER_VOLTAGE', 'dock', '电池整组过压保护', 'critical', 'dock', NOW(), NOW()), +(1032, 'BATTERY_PACK_UNDER_VOLTAGE', 'dock', '电池整组欠压保护', 'critical', 'dock', NOW(), NOW()), +(1033, 'BATTERY_CHARGE_OVER_TEMPERATURE', 'dock', '电池充电过温保护', 'critical', 'dock', NOW(), NOW()), +(1034, 'BATTERY_CHARGE_UNDER_TEMPERATURE', 'dock', '电池充电低温保护', 'critical', 'dock', NOW(), NOW()), +(1035, 'BATTERY_DISCHARGE_OVER_TEMPERATURE', 'dock', '电池放电过温保护', 'critical', 'dock', NOW(), NOW()), +(1036, 'BATTERY_DISCHARGE_UNDER_TEMPERATURE', 'dock', '电池放电低温保护', 'critical', 'dock', NOW(), NOW()), +(1037, 'BATTERY_CHARGE_OVER_CURRENT', 'dock', '电池充电过流保护', 'critical', 'dock', NOW(), NOW()), +(1038, 'BATTERY_DISCHARGE_OVER_CURRENT', 'dock', '电池放电过流保护', 'critical', 'dock', NOW(), NOW()), +(1039, 'BATTERY_SHORT_CIRCUIT', 'dock', '电池短路保护', 'critical', 'dock', NOW(), NOW()), +(1040, 'BATTERY_FRONT_IC_FAULT', 'dock', '电池前端检测 IC 错误', 'critical', 'dock', NOW(), NOW()), +(1041, 'BATTERY_MOS_LOCKED', 'dock', '电池保护板软件锁定 MOS', 'critical', 'dock', NOW(), NOW()), +(1042, 'BATTERY_WIRE_BREAK', 'dock', '电池断线告警', 'critical', 'dock', NOW(), NOW()), +(1043, 'BATTERY_SECONDARY_OVER_VOLTAGE', 'dock', '电池二次过压告警', 'critical', 'dock', NOW(), NOW()), +(1044, 'BATTERY_SELF_LOCK_SWITCH_OPEN', 'dock', '电池自锁开关断开', 'warning', 'dock', NOW(), NOW()), +(1045, 'BATTERY_ALARM_UNKNOWN', 'dock', '电池模块报警,具体原因未知', 'critical', 'dock', NOW(), NOW()), + +-- --------------------------------------------------------------- +-- 7.6.4 充电电源故障(dock) +-- --------------------------------------------------------------- +(1046, 'CHARGER_OFFLINE', 'dock', '充电电源未在线,请检查电源和控制盒连接状态', 'critical', 'dock', NOW(), NOW()), +(1047, 'POWER_INPUT_UNDER_VOLTAGE', 'dock', '充电电源交流输入欠压', 'critical', 'dock', NOW(), NOW()), +(1048, 'POWER_INPUT_OVER_VOLTAGE', 'dock', '充电电源交流输入过压', 'critical', 'dock', NOW(), NOW()), +(1049, 'POWER_OUTPUT_OVER_CURRENT', 'dock', '充电电源直流输出过流', 'critical', 'dock', NOW(), NOW()), +(1050, 'POWER_VOLTAGE_SAMPLING_FAULT', 'dock', '充电电源电压采样异常', 'critical', 'dock', NOW(), NOW()), +(1051, 'POWER_OVER_TEMPERATURE_OR_FAN_FAULT', 'dock', '充电电源超高温或风扇故障', 'critical', 'dock', NOW(), NOW()), +(1052, 'POWER_COMMUNICATION_TIMEOUT', 'dock', '充电电源主控通信超时', 'critical', 'dock', NOW(), NOW()), +(1053, 'CHARGER_FAULT', 'dock', '充电器故障', 'critical', 'dock', NOW(), NOW()), +(1054, 'POWER_LOAD_DISCONNECTED_SHUTDOWN', 'dock', '充电电源负载断开并关机', 'critical', 'dock', NOW(), NOW()), +(1055, 'POWER_OVER_VOLTAGE_SHUTDOWN', 'dock', '充电电源过压关机', 'critical', 'dock', NOW(), NOW()), +(1056, 'BATTERY_REVERSED_OR_OTHER_POWER_FAULT', 'dock', '电池接反或充电电源其他故障', 'critical', 'dock', NOW(), NOW()), +(1057, 'POWER_NTC_FAULT', 'dock', '充电电源 NTC 故障并停机', 'critical', 'dock', NOW(), NOW()), +(1058, 'POWER_SHORT_CIRCUIT', 'dock', '充电电源输出短路', 'critical', 'dock', NOW(), NOW()), +(1059, 'CHARGING_TIMEOUT', 'dock', '充电超时', 'critical', 'dock', NOW(), NOW()), +(1060, 'POWER_ALARM_UNKNOWN', 'dock', '充电电源未知报警', 'critical', 'dock', NOW(), NOW()), + +-- --------------------------------------------------------------- +-- 7.6.5 空调故障(dock) +-- --------------------------------------------------------------- +(1061, 'AC_INSIDE_TEMPERATURE_SENSOR_FAULT', 'dock', '空调柜内温度传感器故障', 'warning', 'dock', NOW(), NOW()), +(1062, 'AC_INSIDE_HIGH_TEMPERATURE', 'dock', '空调柜内高温告警', 'warning', 'dock', NOW(), NOW()), +(1063, 'AC_INSIDE_LOW_TEMPERATURE', 'dock', '空调柜内低温告警', 'warning', 'dock', NOW(), NOW()), +(1064, 'AC_OUTSIDE_TEMPERATURE_SENSOR_FAULT', 'dock', '空调柜外温度传感器故障', 'warning', 'dock', NOW(), NOW()), +(1065, 'AC_OUTSIDE_HIGH_TEMPERATURE', 'dock', '空调柜外高温告警', 'warning', 'dock', NOW(), NOW()), +(1066, 'AC_OUTSIDE_LOW_TEMPERATURE', 'dock', '空调柜外低温告警', 'warning', 'dock', NOW(), NOW()), +(1067, 'AC_INSIDE_HUMIDITY_SENSOR_FAULT', 'dock', '空调柜内湿度传感器故障', 'warning', 'dock', NOW(), NOW()), +(1068, 'AC_INSIDE_HIGH_HUMIDITY', 'dock', '空调柜内高湿告警', 'warning', 'dock', NOW(), NOW()), +(1069, 'AC_INSIDE_LOW_HUMIDITY', 'dock', '空调柜内低湿告警', 'warning', 'dock', NOW(), NOW()), +(1070, 'AC_OUTSIDE_HUMIDITY_SENSOR_FAULT', 'dock', '空调柜外湿度传感器故障', 'warning', 'dock', NOW(), NOW()), +(1071, 'AC_OUTSIDE_HIGH_HUMIDITY', 'dock', '空调柜外高湿告警', 'warning', 'dock', NOW(), NOW()), +(1072, 'AC_OUTSIDE_LOW_HUMIDITY', 'dock', '空调柜外低湿告警', 'warning', 'dock', NOW(), NOW()), +(1073, 'AC_COMPRESSOR_HIGH_PRESSURE', 'dock', '空调压缩机高压告警', 'critical', 'dock', NOW(), NOW()), +(1074, 'AC_COMPRESSOR_LOW_PRESSURE', 'dock', '空调压缩机低压告警', 'critical', 'dock', NOW(), NOW()), +(1075, 'AC_COOLING_FAILURE', 'dock', '空调制冷失效告警', 'critical', 'dock', NOW(), NOW()), +(1076, 'AC_HEATING_FAILURE', 'dock', '空调制热失效告警', 'critical', 'dock', NOW(), NOW()), +(1077, 'AC_INSIDE_COIL_TEMPERATURE_SENSOR_FAULT','dock', '空调内盘管温度传感器故障', 'warning', 'dock', NOW(), NOW()), +(1078, 'AC_INSIDE_COIL_LOW_TEMPERATURE', 'dock', '空调内盘管低温告警', 'warning', 'dock', NOW(), NOW()), + +-- --------------------------------------------------------------- +-- 7.6.6 通信和边缘服务故障(dock 部分) +-- --------------------------------------------------------------- +(1079, 'DOCK_MODBUS_DISCONNECTED', 'dock', '机巢 PLC Modbus 通信中断', 'critical', 'dock', NOW(), NOW()), +(1080, 'PLC_HEARTBEAT_TIMEOUT', 'dock', '机巢 PLC 心跳超时', 'critical', 'dock', NOW(), NOW()), +(1081, 'ONE_KEY_TAKEOFF_TIMEOUT', 'dock', '一键起飞超时', 'critical', 'dock', NOW(), NOW()), +(1082, 'ONE_KEY_LANDING_TIMEOUT', 'dock', '一键降落超时', 'critical', 'dock', NOW(), NOW()); + +-- --------------------------------------------------------------- +-- 7.6.6 通信和边缘服务故障(drone 部分) +-- --------------------------------------------------------------- +INSERT IGNORE INTO alarm_code (id, code, category, message_cn, level, source, created_at, updated_at) VALUES +(2001, 'DRONE_MAVLINK_DISCONNECTED', 'drone', '无人机 MAVLink 通信中断', 'critical', 'drone', NOW(), NOW()), +(2002, 'DRONE_SN_UNAVAILABLE', 'drone', '无法读取无人机永久序列号', 'critical', 'drone', NOW(), NOW()), +(2003, 'MISSION_STORE_WRITE_FAILED', 'drone', '当前航线无法持久化', 'warning', 'drone', NOW(), NOW()), +(2004, 'DRONE_BATTERY_CRITICAL', 'drone', '无人机电池电量严重不足', 'critical', 'drone', NOW(), NOW()), +(2005, 'DRONE_GPS_INVALID', 'drone', '无人机定位不可用或质量不足', 'warning', 'drone', NOW(), NOW()), + +-- --------------------------------------------------------------- +-- 7.6.7 无人机飞控告警(drone) +-- --------------------------------------------------------------- +(2006, 'DRONE_GCS_LINK_LOST', 'drone', '飞控与地面站通信链路中断', 'critical', 'drone', NOW(), NOW()), +(2007, 'DRONE_BATTERY_VOLTAGE_LOW', 'drone', '无人机电池电压低', 'critical', 'drone', NOW(), NOW()), +(2008, 'DRONE_SERVO_OUTPUT_IMBALANCE', 'drone', '电机或舵机输出不平衡', 'critical', 'drone', NOW(), NOW()), +(2009, 'DRONE_SERVO_OUTPUT_FAULT', 'drone', '电机或舵机动力输出故障', 'critical', 'drone', NOW(), NOW()), +(2010, 'DRONE_AUTOPILOT_OVER_TEMPERATURE', 'drone', '飞控系统温度过高', 'critical', 'drone', NOW(), NOW()), +(2011, 'DRONE_INS_POSITIONING_INVALID', 'drone', '飞控 INS 定位解算未就绪或不可用', 'critical', 'drone', NOW(), NOW()), +(2012, 'DRONE_OUTSIDE_GEOFENCE', 'drone', '无人机超出电子围栏范围', 'critical', 'drone', NOW(), NOW()), +(2013, 'DRONE_IMU_DATA_OUT_OF_RANGE', 'drone', 'IMU 数据超出范围', 'critical', 'drone', NOW(), NOW()), +(2014, 'DRONE_EXCESSIVE_TILT', 'drone', '无人机倾斜姿态过大', 'critical', 'drone', NOW(), NOW()), +(2015, 'DRONE_SPEED_OUT_OF_RANGE', 'drone', '无人机速度超出范围', 'critical', 'drone', NOW(), NOW()), +(2016, 'DRONE_RC_NOT_READY', 'drone', '遥控器数据未就绪', 'warning', 'drone', NOW(), NOW()), +(2017, 'DRONE_MAG0_INTERFERENCE', 'drone', '1 号磁力计受到磁干扰', 'warning', 'drone', NOW(), NOW()), +(2018, 'DRONE_MAG1_INTERFERENCE', 'drone', '2 号磁力计受到磁干扰', 'warning', 'drone', NOW(), NOW()), +(2019, 'DRONE_IMU0_FAULT', 'drone', '1 号 IMU 数据异常', 'critical', 'drone', NOW(), NOW()), +(2020, 'DRONE_IMU1_FAULT', 'drone', '2 号 IMU 数据异常', 'critical', 'drone', NOW(), NOW()), +(2021, 'DRONE_BAROMETER_FAULT', 'drone', '气压计数据异常', 'critical', 'drone', NOW(), NOW()), +(2022, 'DRONE_GPS0_FAULT', 'drone', '1 号普通 GPS 数据异常', 'warning', 'drone', NOW(), NOW()), +(2023, 'DRONE_GPS1_FAULT', 'drone', '2 号普通 GPS 数据异常', 'warning', 'drone', NOW(), NOW()), +(2024, 'DRONE_RTK_FAULT', 'drone', 'RTK 板卡或数据异常', 'warning', 'drone', NOW(), NOW()); diff --git a/sql/003_seed.sql b/sql/003_seed.sql new file mode 100644 index 0000000..31de681 --- /dev/null +++ b/sql/003_seed.sql @@ -0,0 +1,73 @@ +-- ============================================================= +-- 种子数据 +-- ============================================================= + +USE laic; + +-- --------------------------------------------------------------- +-- 默认管理员账号 +-- 手机号: 13800000000 +-- 密码: admin123456 (bcrypt 加密) +-- ⚠ 上线前务必修改默认密码 +-- --------------------------------------------------------------- +INSERT INTO `user` (id, name, phone, email, password, role, traffic_balance, status, created_at, updated_at) +VALUES (1, '管理员', '13800000000', 'admin@laic.local', + '$2a$10$vpTDi8nUl/Lyb4KBjtHLBevsCQ08FB46bLgJG3JxVACzqYOWD3YSq', + 'admin', 0, 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE role = 'admin'; + +-- --------------------------------------------------------------- +-- 演示业务账号(远程监控 / 设备控制走普通用户) +-- 手机号: 13900000000 +-- 密码: admin123456 +-- --------------------------------------------------------------- +INSERT INTO `user` (id, name, phone, email, password, role, traffic_balance, status, created_at, updated_at) +VALUES (2, '演示用户', '13900000000', 'demo@laic.local', + '$2a$10$vpTDi8nUl/Lyb4KBjtHLBevsCQ08FB46bLgJG3JxVACzqYOWD3YSq', + 'user', 10737418240, 1, NOW(), NOW()) +ON DUPLICATE KEY UPDATE + password = VALUES(password), + role = 'user', + status = 1; + +SET @demo_user_id = (SELECT id FROM `user` WHERE phone = '13900000000'); + +-- --------------------------------------------------------------- +-- 演示机巢 / 无人机(与 mock 设备 dock-1/2/3 对应) +-- 若 MQTT 已自发现同 dock_id 的 pending 记录,则认领并补全名称坐标。 +-- --------------------------------------------------------------- +INSERT INTO dock (id, user_id, dock_id, name, code, sn, iccid, longitude, latitude, altitude, location, status, register_status, dock_id_source, software_ver, protocol_ver, created_at, updated_at) +VALUES + (1001, @demo_user_id, 'dock-1', '青山湖 01 号机巢', 'JG-DOCK-001', 'dock-1', '8986000000000000001', 119.802631, 30.262438, 15, '青山湖科技城 A 区', 'offline', 'registered', 'seed', '1.3.0', '1.0', NOW(), NOW()), + (1002, @demo_user_id, 'dock-2', '青山湖 02 号机巢', 'JG-DOCK-002', 'dock-2', '8986000000000000002', 119.818264, 30.255917, 18, '青山湖科技城 B 区', 'offline', 'registered', 'seed', '1.3.0', '1.0', NOW(), NOW()), + (1003, @demo_user_id, 'dock-3', '青山湖 03 号机巢', 'JG-DOCK-003', 'dock-3', '8986000000000000003', 119.791450, 30.248610, 12, '青山湖科技城 C 区', 'offline', 'registered', 'seed', '1.3.0', '1.0', NOW(), NOW()) +ON DUPLICATE KEY UPDATE + user_id = @demo_user_id, + name = VALUES(name), + code = VALUES(code), + sn = VALUES(sn), + iccid = VALUES(iccid), + longitude = VALUES(longitude), + latitude = VALUES(latitude), + location = VALUES(location), + register_status = 'registered'; + +INSERT INTO drone (id, user_id, drone_sn, dock_id, name, code, model, status, battery, firmware_ver, created_at, updated_at) +VALUES + (2001, @demo_user_id, 'JG-UAV-001', 'dock-1', '巡检无人机 001', 'JG-UAV-001', 'LAIC-X1', 'offline', 86, '1.0.0', NOW(), NOW()), + (2002, @demo_user_id, 'JG-UAV-002', 'dock-2', '巡检无人机 002', 'JG-UAV-002', 'LAIC-X1', 'offline', 72, '1.0.0', NOW(), NOW()), + (2003, @demo_user_id, 'JG-UAV-003', 'dock-3', '巡检无人机 003', 'JG-UAV-003', 'LAIC-X1', 'offline', 41, '1.0.0', NOW(), NOW()) +ON DUPLICATE KEY UPDATE + user_id = @demo_user_id, + dock_id = VALUES(dock_id), + name = VALUES(name), + code = VALUES(code), + model = VALUES(model); + +DELETE FROM drone WHERE drone_sn = 'JG-UAV-MOCK01'; +DELETE FROM dock WHERE dock_id IN ('JG-DK-MOCK01', '333'); + +-- --------------------------------------------------------------- +-- 说明: Casbin RBAC 策略(g: user_id -> role / p: role -> path -> method) +-- 由应用首次启动时通过 gorm-adapter 自动建表并引导,不在本脚本中静态写入。 +-- --------------------------------------------------------------- diff --git a/sql/004_live_control.sql b/sql/004_live_control.sql new file mode 100644 index 0000000..88cd29d --- /dev/null +++ b/sql/004_live_control.sql @@ -0,0 +1,27 @@ +-- 该迁移用于已有数据库;新数据库请直接使用 001_schema.sql 中的最终结构。 +-- 执行前应确认 live_session 没有重复活动会话,再创建活动机巢唯一约束。 + +ALTER TABLE live_session + ADD COLUMN requested_by BIGINT NULL, + ADD COLUMN stop_deadline DATETIME NULL, + ADD COLUMN updated_at DATETIME NULL, + ADD COLUMN active_dock_id VARCHAR(64) GENERATED ALWAYS AS ( + CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END + ) STORED, + ADD KEY idx_live_session_dock_phase (dock_id, phase, created_at), + ADD KEY idx_live_session_phase_deadline (phase, stop_deadline), + ADD UNIQUE KEY uk_live_session_active_dock (active_dock_id); + +CREATE TABLE IF NOT EXISTS live_viewer_lease ( + id BIGINT PRIMARY KEY, + stream_session_id VARCHAR(128) NOT NULL, + viewer_id BIGINT NOT NULL, + expires_at DATETIME NOT NULL, + released_at DATETIME NULL, + created_at DATETIME NULL, + updated_at DATETIME NULL, + UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id), + KEY idx_live_lease_expire (stream_session_id, expires_at), + KEY idx_live_lease_active (stream_session_id, released_at, expires_at), + CONSTRAINT fk_live_lease_session FOREIGN KEY (stream_session_id) REFERENCES live_session(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/sql/005_live_control_upgrade.sql b/sql/005_live_control_upgrade.sql new file mode 100644 index 0000000..cbd5e61 --- /dev/null +++ b/sql/005_live_control_upgrade.sql @@ -0,0 +1,115 @@ +-- 已部署数据库的可重复执行升级脚本。 +-- 执行前应先收敛同一 dock 的重复活动会话,否则活动会话唯一索引会拒绝创建。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS upgrade_live_control $$ +CREATE PROCEDURE upgrade_live_control() +BEGIN + DECLARE item_count INT DEFAULT 0; + + SELECT COUNT(*) INTO item_count + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'requested_by'; + IF item_count = 0 THEN + ALTER TABLE live_session ADD COLUMN requested_by BIGINT NULL; + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'stop_deadline'; + IF item_count = 0 THEN + ALTER TABLE live_session ADD COLUMN stop_deadline DATETIME NULL; + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'updated_at'; + IF item_count = 0 THEN + ALTER TABLE live_session ADD COLUMN updated_at DATETIME NULL; + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_state_version'; + IF item_count = 0 THEN + ALTER TABLE live_session ADD COLUMN device_state_version BIGINT DEFAULT 0; + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_event_id'; + IF item_count = 0 THEN + ALTER TABLE live_session ADD COLUMN device_event_id VARCHAR(128); + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'device_updated_at'; + IF item_count = 0 THEN + ALTER TABLE live_session ADD COLUMN device_updated_at BIGINT DEFAULT 0; + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'cloud_confirmed_at'; + IF item_count = 0 THEN + ALTER TABLE live_session ADD COLUMN cloud_confirmed_at DATETIME NULL; + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND index_name = 'uk_live_session_device_event'; + IF item_count = 0 THEN + CREATE UNIQUE INDEX uk_live_session_device_event ON live_session (device_event_id); + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND column_name = 'active_dock_id'; + IF item_count = 0 THEN + ALTER TABLE live_session ADD COLUMN active_dock_id VARCHAR(64) GENERATED ALWAYS AS ( + CASE WHEN phase IN ('starting', 'streaming', 'reconnecting', 'stopping') THEN dock_id ELSE NULL END + ) STORED; + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND index_name = 'idx_live_session_dock_phase'; + IF item_count = 0 THEN + CREATE INDEX idx_live_session_dock_phase ON live_session (dock_id, phase, created_at); + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND index_name = 'idx_live_session_phase_deadline'; + IF item_count = 0 THEN + CREATE INDEX idx_live_session_phase_deadline ON live_session (phase, stop_deadline); + END IF; + + SELECT COUNT(*) INTO item_count + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'live_session' AND index_name = 'uk_live_session_active_dock'; + IF item_count = 0 THEN + CREATE UNIQUE INDEX uk_live_session_active_dock ON live_session (active_dock_id); + END IF; + + CREATE TABLE IF NOT EXISTS live_viewer_lease ( + id BIGINT PRIMARY KEY, + stream_session_id VARCHAR(128) NOT NULL, + viewer_id BIGINT NOT NULL, + expires_at DATETIME NOT NULL, + released_at DATETIME NULL, + created_at DATETIME NULL, + updated_at DATETIME NULL, + UNIQUE KEY uk_live_viewer (stream_session_id, viewer_id), + KEY idx_live_lease_expire (stream_session_id, expires_at), + KEY idx_live_lease_active (stream_session_id, released_at, expires_at), + CONSTRAINT fk_live_lease_session FOREIGN KEY (stream_session_id) REFERENCES live_session(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +END $$ + +CALL upgrade_live_control() $$ +DROP PROCEDURE upgrade_live_control $$ + +DELIMITER ; diff --git a/sql/tdengine_schema.sql b/sql/tdengine_schema.sql new file mode 100644 index 0000000..34147f6 --- /dev/null +++ b/sql/tdengine_schema.sql @@ -0,0 +1,41 @@ +-- TDengine 时序库 schema(与 MySQL DDL 分离,需用 taos / REST 手动执行) +-- 对应 DESIGN.md §5.9 + +CREATE DATABASE IF NOT EXISTS laic VGROUPS 2 DURATION 10d KEEP 3650d; + +-- 遥测超级表(device_telemetry):列顺序必须与 service/telemetry_store.go insert() 的 VALUES 元组一致 +CREATE STABLE IF NOT EXISTS laic.device_telemetry ( + ts TIMESTAMP, + longitude DOUBLE, + latitude DOUBLE, + altitude DOUBLE, + ground_speed DOUBLE, + roll DOUBLE, + pitch DOUBLE, + yaw DOUBLE, + battery_pct INT, + battery_v DOUBLE, + satellites INT, + gps_quality VARCHAR(16), + link_quality INT, + flight_mode VARCHAR(16), + armed TINYINT +) TAGS ( + dock_id VARCHAR(64), + drone_sn VARCHAR(32) +); + +-- 轨迹超级表(任务回放用) +CREATE STABLE IF NOT EXISTS laic.task_trajectory ( + ts TIMESTAMP, + longitude DOUBLE, + latitude DOUBLE, + altitude DOUBLE, + ground_speed DOUBLE, + yaw DOUBLE, + battery_pct INT +) TAGS ( + execution_id BIGINT, + dock_id VARCHAR(64), + drone_sn VARCHAR(32) +); diff --git a/token/token.go b/token/token.go new file mode 100644 index 0000000..379411b --- /dev/null +++ b/token/token.go @@ -0,0 +1,121 @@ +package token + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + jwtSecret []byte + accessExpireH = 7 * 24 // 默认 7 天 + refreshExpireH = 30 * 24 // 默认 30 天 +) + +// Init 初始化 JWT 密钥与有效期(main 启动时从配置加载) +func Init(secret string, accessH, refreshH int) { + jwtSecret = []byte(secret) + if accessH > 0 { + accessExpireH = accessH + } + if refreshH > 0 { + refreshExpireH = refreshH + } +} + +// Claims access token 载荷 +type Claims struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + Roles []string `json:"roles"` + jwt.RegisteredClaims +} + +// RefreshClaims refresh token 载荷 +type RefreshClaims struct { + UserID int64 `json:"user_id"` + jwt.RegisteredClaims +} + +// GenerateToken 生成 access + refresh token +func GenerateToken(userID int64, username string, roles []string) (string, string, error) { + now := time.Now() + claims := Claims{ + UserID: userID, + Username: username, + Roles: roles, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(accessExpireH) * time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + } + access, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret) + if err != nil { + return "", "", err + } + + refreshClaims := RefreshClaims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(refreshExpireH) * time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + } + refresh, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(jwtSecret) + if err != nil { + return "", "", err + } + return access, refresh, nil +} + +// ParseToken 解析并校验 access token +func ParseToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, keyFunc) + if err != nil { + return nil, err + } + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + return nil, errors.New("invalid token") +} + +// ParseRefreshToken 解析并校验 refresh token,返回用户 ID +func ParseRefreshToken(tokenString string) (int64, error) { + token, err := jwt.ParseWithClaims(tokenString, &RefreshClaims{}, keyFunc) + if err != nil { + return 0, err + } + if claims, ok := token.Claims.(*RefreshClaims); ok && token.Valid { + return claims.UserID, nil + } + return 0, errors.New("invalid refresh token") +} + +func keyFunc(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return jwtSecret, nil +} + +// GenerateShortID 生成 token 的短摘要,作为 Redis 存储 key 的一部分 +func GenerateShortID(tokenString string) string { + hash := sha256.Sum256([]byte(tokenString)) + return hex.EncodeToString(hash[:8]) +} + +// AccessExpireSeconds 返回 access token 有效期(秒),用于 Redis TTL +func AccessExpireSeconds() uint { + return uint(accessExpireH * 3600) +} + +// RefreshExpireSeconds 返回 refresh token 有效期(秒),用于 Redis TTL +func RefreshExpireSeconds() uint { + return uint(refreshExpireH * 3600) +} diff --git a/tool/geo.go b/tool/geo.go new file mode 100644 index 0000000..0f7f209 --- /dev/null +++ b/tool/geo.go @@ -0,0 +1,18 @@ +package tool + +import "math" + +const earthRadiusM = 6371000.0 + +// Haversine 计算两个经纬度坐标之间的球面距离(单位:米) +func Haversine(lat1, lon1, lat2, lon2 float64) float64 { + phi1 := lat1 * math.Pi / 180 + phi2 := lat2 * math.Pi / 180 + dPhi := (lat2 - lat1) * math.Pi / 180 + dLambda := (lon2 - lon1) * math.Pi / 180 + + a := math.Sin(dPhi/2)*math.Sin(dPhi/2) + + math.Cos(phi1)*math.Cos(phi2)*math.Sin(dLambda/2)*math.Sin(dLambda/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return earthRadiusM * c +} diff --git a/tool/snowflake.go b/tool/snowflake.go new file mode 100644 index 0000000..8ca4de2 --- /dev/null +++ b/tool/snowflake.go @@ -0,0 +1,117 @@ +package tool + +import ( + "errors" + "sync" + "time" +) + +const ( + // 起始时间戳 (2023-01-01 00:00:00 UTC) + epoch int64 = 1672531200000 + + timestampBits = 28 // 时间戳位数(约17年) + workerIDBits = 5 // 工作机器ID所占位数 + sequenceBits = 12 // 序列号所占位数 + + maxWorkerID = -1 ^ (-1 << workerIDBits) + maxSequence = -1 ^ (-1 << sequenceBits) + + workerIDShift = sequenceBits + timestampShift = sequenceBits + workerIDBits +) + +type Snowflake struct { + mu sync.Mutex + timestamp int64 + workerID int64 + sequence int64 + lastTime int64 +} + +var DefaultSnowflake *Snowflake + +func init() { + DefaultSnowflake = NewSnowflake(1) +} + +func NewSnowflake(workerID int64) *Snowflake { + if workerID < 0 || workerID > maxWorkerID { + panic(errors.New("worker ID must be between 0 and 1023")) + } + return &Snowflake{ + timestamp: 0, + workerID: workerID, + sequence: 0, + lastTime: -1, + } +} + +// NextID 生成下一个ID +func (s *Snowflake) NextID() (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UnixMilli() + now = (now - epoch) & (-1 ^ (-1 << timestampBits)) + if now <= 0 { + now = 1 + } + + if now < s.lastTime { + waitTime := s.lastTime - now + if waitTime > 100 { + now = s.lastTime + 1 + if now > (1< (1<