Browse Source

feat:init

master
刘浩东 4 weeks ago
parent
commit
602c1a4015
  1. 70
      README.md
  2. 1
      common/config.go
  3. 48
      config-prod.yaml
  4. 13
      config.yaml
  5. 12
      config/laic-backend-prod.conf
  6. 4
      go.mod
  7. 26
      go.sum
  8. 7
      main.go
  9. 6
      mock/dock.go
  10. 8
      mqtt/message.go
  11. 133
      publish-prod.sh
  12. 44
      service/dock_service.go
  13. 10
      service/mqtt_subscriber.go
  14. 7
      service/operation_log_service.go
  15. 86
      service/sms_service.go
  16. 205
      service/smsrpc/sms.pb.go
  17. 147
      service/smsrpc/sms_grpc.pb.go
  18. 4
      service/user_service.go
  19. 8
      sql/tdengine_schema.sql

70
README.md

@ -1,3 +1,71 @@
# laic-backend
低空智控平台 后端go
低空智控平台 后端 Go 服务。
## 生产部署
部署脚本参考 `D:/GoProject/roller-service/roller-manage/publish-prod.sh`,使用 Supervisor 管理服务。
### 服务器准备
在生产服务器安装 Supervisor。生产配置保存在仓库根目录的 `config-prod.yaml`,部署脚本会将它上传为服务器部署目录中的 `config.yaml`
```bash
sudo apt update
sudo apt install supervisor -y
sudo systemctl enable --now supervisor
```
生产配置至少包含:
```yaml
oss:
endpoint: https://obs.cn-east-2.myhuaweicloud.com
access-key: 实际的华为云 OBS AK
secret-key: 实际的华为云 OBS SK
bucket: gcs-edu
region: cn-east-2
use-ssl: true
```
后端启动时直接读取部署目录下的 `config.yaml`。本地开发不设置 `LAIC_CONFIG_FILE` 时读取本地 `config.yaml`;Supervisor 显式设置 `LAIC_CONFIG_FILE=config.yaml`
### 执行部署
修改 `publish-prod.sh` 顶部的 `REMOTE`、`REMOTE_DIR` 和服务参数,然后执行:
```bash
chmod +x publish-prod.sh
./publish-prod.sh
```
脚本会完成:
1. 交叉编译 Linux amd64 二进制。
2. 创建远程部署目录、日志目录和备份目录。
3. 备份旧二进制和 `config.yaml`(首次部署没有旧文件时跳过)。
4. 将本地 `config-prod.yaml` 上传为远程 `config.yaml`
5. 更新 Supervisor 配置、重启服务并检查状态。
6. 启动失败时自动恢复最近的旧二进制。
首次部署前确认远程服务器已安装并运行 Supervisor:
```bash
sudo apt update
sudo apt install supervisor -y
sudo systemctl enable --now supervisor
```
查看状态和日志:
```bash
sudo supervisorctl status laic-backend-prod
sudo tail -f /usr/local/laic-backend-prod/log/info.log
sudo tail -f /usr/local/laic-backend-prod/log/err.log
```
部署目录和服务名可以在 `publish-prod.sh` 顶部调整。回滚时,脚本会使用远程 `backup/` 目录中最近的二进制备份;也可以手工恢复后执行:
```bash
sudo supervisorctl restart laic-backend-prod
```

1
common/config.go

@ -8,6 +8,7 @@ import (
type AppConfig struct {
ApiPort int `mapstructure:"api-port"`
SmsURL string `mapstructure:"sms-url"`
Mysql Mysql `mapstructure:"mysql"`
TDengine TDengine `mapstructure:"tdengine"`
Redis string `mapstructure:"redis"`

48
config-prod.yaml

@ -0,0 +1,48 @@
api-port: 9913
sms-url: 192.168.1.195:21001
mysql:
url: "root:jiagu^2058@tcp(192.168.0.111:3306)/laic?charset=utf8&parseTime=true&loc=Asia%2FShanghai"
idle: 10
max-conn: 100
max-wait: 30
tdengine:
dsn: "root:taosdata@ws(172.16.0.216:6041)/laic_track"
redis: "redis://:Test123@192.168.1.13:6379/7"
mqtt:
broker: "tcp://192.168.0.6:1883"
client-id: laic-backend-prod
username: laic
password: ""
jwt:
secret: laic-backend-production-secret-change-me
access-expire-hours: 168
refresh-expire-hours: 720
oss:
endpoint: https://obs.cn-east-2.myhuaweicloud.com
access-key: 7TGDNNPBKVOPQ66JQNRU
secret-key: MMW9OSFChH7haJmTXoN4m8roCfIc22ffG5zrt2zT
bucket: gcs-edu
region: cn-east-2
use-ssl: true
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: info
path: ./logs/

13
config.yaml

@ -1,21 +1,22 @@
api-port: 8080
sms-url: 192.168.1.195:21001
mysql:
url: root:123456@tcp(127.0.0.1:3306)/laic?charset=utf8mb4&parseTime=True&loc=Local
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
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
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 天
access-expire-hours: 168
refresh-expire-hours: 720
oss:
endpoint: https://obs.cn-east-2.myhuaweicloud.com
access-key: ""

12
config/laic-backend-prod.conf

@ -0,0 +1,12 @@
[program:laic-backend-prod]
command=/usr/local/laic-backend-prod/laic-backend-prod.srv
directory=/usr/local/laic-backend-prod/
autostart=true
autorestart=true
startsecs=3
environment=LAIC_CONFIG_FILE="config.yaml"
stopasgroup=true
killasgroup=true
stderr_logfile=/usr/local/laic-backend-prod/log/err.log
stdout_logfile=/usr/local/laic-backend-prod/log/info.log
user=root

4
go.mod

@ -17,6 +17,8 @@ require (
github.com/spf13/viper v1.21.0
github.com/taosdata/driver-go/v3 v3.3.1
golang.org/x/crypto v0.52.0
google.golang.org/grpc v1.79.2
google.golang.org/protobuf v1.36.10
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.2
@ -61,5 +63,5 @@ require (
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
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)

26
go.sum

@ -6,6 +6,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -23,6 +25,10 @@ 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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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=
@ -41,6 +47,8 @@ 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.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@ -121,6 +129,18 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
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=
@ -138,6 +158,12 @@ 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/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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=

7
main.go

@ -19,8 +19,12 @@ func main() {
logger.InitCustomLog(logger.Level_D, nil, "laic-backend")
// 2. 加载 config.yaml
configPath := os.Getenv("LAIC_CONFIG_FILE")
if configPath == "" {
configPath = "config.yaml"
}
conf := &common.AppConfig{}
common.LoadConfig("config.yaml", conf)
common.LoadConfig(configPath, conf)
if err := service.InitLiveProvider(conf.Live); err != nil {
logger.ERROR("直播提供商初始化失败", err)
panic(err)
@ -35,7 +39,6 @@ func main() {
// 4. 预热缓存 + 启动遥测批量写入
cache.WarmUp()
service.BootstrapOperationLog()
service.DefaultTelemetryStore.Start()
// 5. 启动 MQTT(订阅设备上行消息)与 HTTP

6
mock/dock.go

@ -15,7 +15,7 @@ import (
type downlinkEnvelope struct {
RequestID string `json:"requestId"`
DockID string `json:"dockId"`
DroneSN string `json:"droneSn"`
DroneSN *string `json:"droneSn"`
Payload json.RawMessage `json:"payload"`
}
@ -211,7 +211,9 @@ func (d *MockDock) onCommand(_ paho.Client, msg paho.Message) {
return
}
cmd.RequestID = env.RequestID
cmd.DroneSN = env.DroneSN
if env.DroneSN != nil {
cmd.DroneSN = *env.DroneSN
}
log.Printf("[%s] 收到指令 %s (commandId=%s)", d.spec.DockID, cmd.Type, cmd.CommandID)
switch {

8
mqtt/message.go

@ -19,7 +19,7 @@ type Envelope struct {
EventID string `json:"eventId"`
Version int64 `json:"version"`
DockID string `json:"dockId"`
DroneSN string `json:"droneSn"`
DroneSN *string `json:"droneSn"`
Timestamp int64 `json:"timestamp"`
Payload json.RawMessage `json:"payload"`
}
@ -44,10 +44,14 @@ func NewEnvelope(requestID, dockID, droneSN string, payload any) *Envelope {
raw = b
}
}
var droneSNValue *string
if droneSN != "" {
droneSNValue = &droneSN
}
return &Envelope{
RequestID: requestID,
DockID: dockID,
DroneSN: droneSN,
DroneSN: droneSNValue,
Timestamp: time.Now().UnixMilli(),
Payload: raw,
}

133
publish-prod.sh

@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -Eeuo pipefail
TARGET="laic-backend-prod.srv"
REMOTE="root@jg-serv1"
REMOTE_DIR="/usr/local/laic-backend-prod"
SERVICE_NAME="laic-backend-prod"
SUPERVISOR_CONF_DIR="/etc/supervisor/conf.d"
WAIT_TIME=3
BACKUP_DIR="$REMOTE_DIR/backup"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
LOCAL_CONF="$SCRIPT_DIR/config/$SERVICE_NAME.conf"
LOCAL_CONFIG="$SCRIPT_DIR/config-prod.yaml"
LOCAL_ARTIFACT="$SCRIPT_DIR/$TARGET"
cleanup() {
rm -f -- "$LOCAL_ARTIFACT"
}
trap cleanup EXIT
remote_cmd() {
ssh "$REMOTE" "$@" </dev/null
}
require_command() {
command -v "$1" >/dev/null 2>&1 || {
printf 'Missing required command: %s\n' "$1" >&2
exit 1
}
}
check_inputs() {
require_command go
require_command ssh
require_command scp
require_command diff
[[ -f "$LOCAL_CONF" ]] || { printf 'Missing Supervisor config: %s\n' "$LOCAL_CONF" >&2; exit 1; }
[[ -f "$LOCAL_CONFIG" ]] || { printf 'Missing production config: %s\n' "$LOCAL_CONFIG" >&2; exit 1; }
if [[ "$REMOTE" == *your-production-host* ]]; then
printf 'Set REMOTE in publish-prod.sh before deploying.\n' >&2
exit 1
fi
}
build_local() {
printf 'Building Linux amd64 binary...\n'
(cd "$SCRIPT_DIR" && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$LOCAL_ARTIFACT" .)
}
ensure_remote_dirs() {
remote_cmd "mkdir -p '$REMOTE_DIR' '$REMOTE_DIR/config' '$REMOTE_DIR/log' '$BACKUP_DIR'"
}
check_remote_config() {
if ! remote_cmd "test -s '$REMOTE_DIR/config.yaml'"; then
printf 'Remote config.yaml is missing or empty: %s/config.yaml\n' "$REMOTE_DIR" >&2
printf 'Upload config.yaml with OBS credentials before deploying.\n' >&2
exit 1
fi
}
backup_old() {
remote_cmd "
if [ -f '$REMOTE_DIR/$TARGET' ]; then
timestamp=\$(date +'%Y%m%d%H%M%S')
cp '$REMOTE_DIR/$TARGET' '$BACKUP_DIR/$TARGET.'\$timestamp
echo 'Previous binary backed up.'
fi
if [ -f '$REMOTE_DIR/config.yaml' ]; then
timestamp=\$(date +'%Y%m%d%H%M%S')
cp '$REMOTE_DIR/config.yaml' '$BACKUP_DIR/config.yaml.'\$timestamp
fi
"
}
upload_files() {
scp "$LOCAL_ARTIFACT" "$REMOTE:$REMOTE_DIR/$TARGET.new"
remote_cmd "mv -f '$REMOTE_DIR/$TARGET.new' '$REMOTE_DIR/$TARGET' && chmod 755 '$REMOTE_DIR/$TARGET'"
scp "$LOCAL_CONFIG" "$REMOTE:$REMOTE_DIR/config.yaml.new"
remote_cmd "mv -f '$REMOTE_DIR/config.yaml.new' '$REMOTE_DIR/config.yaml' && chmod 640 '$REMOTE_DIR/config.yaml'"
}
upload_supervisor_conf() {
local remote_conf="$SUPERVISOR_CONF_DIR/$SERVICE_NAME.conf"
if remote_cmd "test -f '$remote_conf'"; then
if diff -q "$LOCAL_CONF" <(ssh "$REMOTE" "cat '$remote_conf'") >/dev/null; then
printf 'Supervisor config unchanged.\n'
return
fi
fi
scp "$LOCAL_CONF" "$REMOTE:$remote_conf"
remote_cmd "supervisorctl reread && supervisorctl update"
}
restart_service() {
local status
status="$(remote_cmd "supervisorctl status '$SERVICE_NAME' 2>/dev/null | awk '{print \$2}'" || true)"
if [[ "$status" == RUNNING ]]; then
remote_cmd "supervisorctl restart '$SERVICE_NAME'"
else
remote_cmd "supervisorctl start '$SERVICE_NAME'"
fi
sleep "$WAIT_TIME"
if remote_cmd "supervisorctl status '$SERVICE_NAME' | grep -q RUNNING"; then
printf 'Service is running.\n'
remote_cmd "tail -n 20 '$REMOTE_DIR/log/info.log'" || true
return
fi
printf 'Service failed to start; rolling back binary.\n' >&2
rollback
exit 1
}
rollback() {
local latest_backup
latest_backup="$(remote_cmd "ls -t '$BACKUP_DIR/$TARGET.'* 2>/dev/null | head -n 1" || true)"
[[ -n "$latest_backup" ]] || { printf 'No binary backup available.\n' >&2; return 1; }
remote_cmd "cp '$latest_backup' '$REMOTE_DIR/$TARGET' && chmod 755 '$REMOTE_DIR/$TARGET' && supervisorctl restart '$SERVICE_NAME'"
}
main() {
check_inputs
build_local
ensure_remote_dirs
backup_old
upload_files
upload_supervisor_conf
restart_service
printf 'Deployment complete.\n'
}
main "$@"

44
service/dock_service.go

@ -93,6 +93,50 @@ func (s *DockService) Create(userID int64, isAdmin bool, req *vo.DockCreateReq)
owner = req.UserID
}
var existing model.Dock
if err := common.DB.Where("dock_id = ?", req.DockID).First(&existing).Error; err == nil {
if existing.UserID != 0 {
return nil, common.ErrDockExists
}
updates := map[string]any{
"user_id": owner,
"register_status": "registered",
"updated_at": time.Now(),
}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Code != "" {
updates["code"] = req.Code
}
if req.SN != "" {
updates["sn"] = req.SN
}
if req.Iccid != "" {
updates["iccid"] = req.Iccid
}
if req.Location != "" {
updates["location"] = req.Location
}
updates["longitude"] = req.Longitude
updates["latitude"] = req.Latitude
updates["altitude"] = req.Altitude
if err := common.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&existing).Updates(updates).Error; err != nil {
return err
}
return tx.Model(&model.Drone{}).Where("dock_id = ?", existing.DockID).Update("user_id", owner).Error
}); err != nil {
logger.ERROR("认领机巢失败", err)
return nil, common.ErrInternal
}
common.DB.First(&existing, existing.ID)
return &existing, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
logger.ERROR("查询机巢失败", err)
return nil, common.ErrInternal
}
id, err := tool.NextID()
if err != nil {
return nil, common.ErrInternal

10
service/mqtt_subscriber.go

@ -230,8 +230,8 @@ func handleStateDrone(dockID string, env *mqtt.Envelope) {
return
}
droneSN := st.DroneSN
if droneSN == "" {
droneSN = env.DroneSN
if droneSN == "" && env.DroneSN != nil {
droneSN = *env.DroneSN
}
battery := int(st.BatteryPercent)
DefaultDroneService.EnsureFromState(dockID, droneSN, st.Name, st.Online, battery, "")
@ -256,9 +256,9 @@ func handleTelemetry(dockID string, env *mqtt.Envelope) {
DefaultTelemetryStore.Append(dockID, p)
DefaultTrajectoryStore.Append(dockID, p)
}
if env.DroneSN != "" {
DefaultTelemetryStore.SetDroneSN(dockID, env.DroneSN)
refreshDeviceHeartbeat(cache.DroneHeartbeatKeyOf(env.DroneSN))
if env.DroneSN != nil && *env.DroneSN != "" {
DefaultTelemetryStore.SetDroneSN(dockID, *env.DroneSN)
refreshDeviceHeartbeat(cache.DroneHeartbeatKeyOf(*env.DroneSN))
}
}

7
service/operation_log_service.go

@ -14,13 +14,6 @@ type OperationLogService struct{}
var DefaultOperationLogService = &OperationLogService{}
// BootstrapOperationLog 确保基础运行时表存在(历史库未跑 DDL 时兜底)。
func BootstrapOperationLog() {
if err := common.DB.AutoMigrate(&model.User{}, &model.OperationLog{}, &model.LiveViewerLease{}, &model.UserLoginSession{}, &model.InvoiceProfile{}, &model.InvoiceRequest{}); err != nil {
logger.ERROR("迁移基础运行时表失败", err)
}
}
// Record 写入一条操作日志
func (s *OperationLogService) Record(userID int64, userName, module, action, detail, result, ip string) {
if userID == 0 {

86
service/sms_service.go

@ -0,0 +1,86 @@
package service
import (
"context"
"errors"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"laic-backend/service/smsrpc"
)
const (
smsRequestTimeout = time.Second
smsLoginTemplateID = "SMS_73670083"
smsRegisterTemplate = "SMS_73670081"
)
type smsTemplate struct {
verifyType int32
id string
}
var smsTemplates = map[string]smsTemplate{
"login": {verifyType: 1, id: smsLoginTemplateID},
"register": {verifyType: 2, id: smsRegisterTemplate},
"reset": {verifyType: 1, id: smsLoginTemplateID},
}
type smsRPCClient struct {
mu sync.Mutex
url string
conn *grpc.ClientConn
close func() error
}
var defaultSMSRPCClient smsRPCClient
func sendSMS(url, scene, phone, code string) error {
template, ok := smsTemplates[scene]
if !ok || url == "" {
return errors.New("短信服务配置错误")
}
client, err := defaultSMSRPCClient.client(url)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), smsRequestTimeout)
defer cancel()
result, err := client.SendSms(ctx, &message.SmsRequest{
VerifyCode: code,
VerifyType: template.verifyType,
SmsId: template.id,
Phone: phone,
})
if err != nil || result == nil || !result.Value {
return errors.New("短信发送失败")
}
return nil
}
func (c *smsRPCClient) client(url string) (message.SmsClient, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil && c.url == url {
return message.NewSmsClient(c.conn), nil
}
if c.close != nil {
_ = c.close()
}
conn, err := grpc.NewClient(url, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
c.url = url
c.conn = conn
c.close = conn.Close
return message.NewSmsClient(conn), nil
}

205
service/smsrpc/sms.pb.go

@ -0,0 +1,205 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc v4.25.3
// source: sms.proto
package message
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
wrapperspb "google.golang.org/protobuf/types/known/wrapperspb"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type SmsRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
VerifyCode string `protobuf:"bytes,1,opt,name=verify_code,json=verifyCode,proto3" json:"verify_code,omitempty"`
VerifyType int32 `protobuf:"varint,2,opt,name=verify_type,json=verifyType,proto3" json:"verify_type,omitempty"`
SmsId string `protobuf:"bytes,3,opt,name=sms_id,json=smsId,proto3" json:"sms_id,omitempty"`
Phone string `protobuf:"bytes,4,opt,name=phone,proto3" json:"phone,omitempty"`
Sender string `protobuf:"bytes,5,opt,name=sender,proto3" json:"sender,omitempty"`
Email string `protobuf:"bytes,6,opt,name=email,proto3" json:"email,omitempty"`
}
func (x *SmsRequest) Reset() {
*x = SmsRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_sms_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *SmsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SmsRequest) ProtoMessage() {}
func (x *SmsRequest) ProtoReflect() protoreflect.Message {
mi := &file_sms_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SmsRequest.ProtoReflect.Descriptor instead.
func (*SmsRequest) Descriptor() ([]byte, []int) {
return file_sms_proto_rawDescGZIP(), []int{0}
}
func (x *SmsRequest) GetVerifyCode() string {
if x != nil {
return x.VerifyCode
}
return ""
}
func (x *SmsRequest) GetVerifyType() int32 {
if x != nil {
return x.VerifyType
}
return 0
}
func (x *SmsRequest) GetSmsId() string {
if x != nil {
return x.SmsId
}
return ""
}
func (x *SmsRequest) GetPhone() string {
if x != nil {
return x.Phone
}
return ""
}
func (x *SmsRequest) GetSender() string {
if x != nil {
return x.Sender
}
return ""
}
func (x *SmsRequest) GetEmail() string {
if x != nil {
return x.Email
}
return ""
}
var File_sms_proto protoreflect.FileDescriptor
var file_sms_proto_rawDesc = []byte{
0x0a, 0x09, 0x73, 0x6d, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f,
0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x77, 0x72, 0x61,
0x70, 0x70, 0x65, 0x72, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xa9, 0x01, 0x0a, 0x0a,
0x53, 0x6d, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x76, 0x65,
0x72, 0x69, 0x66, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0a, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x76,
0x65, 0x72, 0x69, 0x66, 0x79, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05,
0x52, 0x0a, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12, 0x15, 0x0a, 0x06,
0x73, 0x6d, 0x73, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x6d,
0x73, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x04, 0x20, 0x01,
0x28, 0x09, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x6e,
0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x6e, 0x64, 0x65,
0x72, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09,
0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x32, 0x73, 0x0a, 0x03, 0x73, 0x6d, 0x73, 0x12, 0x34,
0x0a, 0x07, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x6d, 0x73, 0x12, 0x0b, 0x2e, 0x53, 0x6d, 0x73, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x42, 0x6f, 0x6f, 0x6c, 0x56, 0x61, 0x6c,
0x75, 0x65, 0x22, 0x00, 0x12, 0x36, 0x0a, 0x09, 0x53, 0x65, 0x6e, 0x64, 0x45, 0x6d, 0x61, 0x69,
0x6c, 0x12, 0x0b, 0x2e, 0x53, 0x6d, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a,
0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
0x2e, 0x42, 0x6f, 0x6f, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x00, 0x42, 0x0b, 0x5a, 0x09,
0x2e, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x33,
}
var (
file_sms_proto_rawDescOnce sync.Once
file_sms_proto_rawDescData = file_sms_proto_rawDesc
)
func file_sms_proto_rawDescGZIP() []byte {
file_sms_proto_rawDescOnce.Do(func() {
file_sms_proto_rawDescData = protoimpl.X.CompressGZIP(file_sms_proto_rawDescData)
})
return file_sms_proto_rawDescData
}
var file_sms_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
var file_sms_proto_goTypes = []interface{}{
(*SmsRequest)(nil), // 0: SmsRequest
(*wrapperspb.BoolValue)(nil), // 1: google.protobuf.BoolValue
}
var file_sms_proto_depIdxs = []int32{
0, // 0: sms.SendSms:input_type -> SmsRequest
0, // 1: sms.SendEmail:input_type -> SmsRequest
1, // 2: sms.SendSms:output_type -> google.protobuf.BoolValue
1, // 3: sms.SendEmail:output_type -> google.protobuf.BoolValue
2, // [2:4] is the sub-list for method output_type
0, // [0:2] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_sms_proto_init() }
func file_sms_proto_init() {
if File_sms_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_sms_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*SmsRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_sms_proto_rawDesc,
NumEnums: 0,
NumMessages: 1,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_sms_proto_goTypes,
DependencyIndexes: file_sms_proto_depIdxs,
MessageInfos: file_sms_proto_msgTypes,
}.Build()
File_sms_proto = out.File
file_sms_proto_rawDesc = nil
file_sms_proto_goTypes = nil
file_sms_proto_depIdxs = nil
}

147
service/smsrpc/sms_grpc.pb.go

@ -0,0 +1,147 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.3.0
// - protoc v4.25.3
// source: sms.proto
package message
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
wrapperspb "google.golang.org/protobuf/types/known/wrapperspb"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.32.0 or later.
const _ = grpc.SupportPackageIsVersion7
const (
Sms_SendSms_FullMethodName = "/sms/SendSms"
Sms_SendEmail_FullMethodName = "/sms/SendEmail"
)
// SmsClient is the client API for Sms service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type SmsClient interface {
SendSms(ctx context.Context, in *SmsRequest, opts ...grpc.CallOption) (*wrapperspb.BoolValue, error)
SendEmail(ctx context.Context, in *SmsRequest, opts ...grpc.CallOption) (*wrapperspb.BoolValue, error)
}
type smsClient struct {
cc grpc.ClientConnInterface
}
func NewSmsClient(cc grpc.ClientConnInterface) SmsClient {
return &smsClient{cc}
}
func (c *smsClient) SendSms(ctx context.Context, in *SmsRequest, opts ...grpc.CallOption) (*wrapperspb.BoolValue, error) {
out := new(wrapperspb.BoolValue)
err := c.cc.Invoke(ctx, Sms_SendSms_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *smsClient) SendEmail(ctx context.Context, in *SmsRequest, opts ...grpc.CallOption) (*wrapperspb.BoolValue, error) {
out := new(wrapperspb.BoolValue)
err := c.cc.Invoke(ctx, Sms_SendEmail_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// SmsServer is the server API for Sms service.
// All implementations must embed UnimplementedSmsServer
// for forward compatibility
type SmsServer interface {
SendSms(context.Context, *SmsRequest) (*wrapperspb.BoolValue, error)
SendEmail(context.Context, *SmsRequest) (*wrapperspb.BoolValue, error)
mustEmbedUnimplementedSmsServer()
}
// UnimplementedSmsServer must be embedded to have forward compatible implementations.
type UnimplementedSmsServer struct {
}
func (UnimplementedSmsServer) SendSms(context.Context, *SmsRequest) (*wrapperspb.BoolValue, error) {
return nil, status.Errorf(codes.Unimplemented, "method SendSms not implemented")
}
func (UnimplementedSmsServer) SendEmail(context.Context, *SmsRequest) (*wrapperspb.BoolValue, error) {
return nil, status.Errorf(codes.Unimplemented, "method SendEmail not implemented")
}
func (UnimplementedSmsServer) mustEmbedUnimplementedSmsServer() {}
// UnsafeSmsServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to SmsServer will
// result in compilation errors.
type UnsafeSmsServer interface {
mustEmbedUnimplementedSmsServer()
}
func RegisterSmsServer(s grpc.ServiceRegistrar, srv SmsServer) {
s.RegisterService(&Sms_ServiceDesc, srv)
}
func _Sms_SendSms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SmsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SmsServer).SendSms(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Sms_SendSms_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SmsServer).SendSms(ctx, req.(*SmsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Sms_SendEmail_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SmsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SmsServer).SendEmail(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Sms_SendEmail_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SmsServer).SendEmail(ctx, req.(*SmsRequest))
}
return interceptor(ctx, in, info, handler)
}
// Sms_ServiceDesc is the grpc.ServiceDesc for Sms service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var Sms_ServiceDesc = grpc.ServiceDesc{
ServiceName: "sms",
HandlerType: (*SmsServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "SendSms",
Handler: _Sms_SendSms_Handler,
},
{
MethodName: "SendEmail",
Handler: _Sms_SendEmail_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "sms.proto",
}

4
service/user_service.go

@ -204,13 +204,15 @@ func (s *UserService) SendSmsCode(phone, scene string) (string, *common.BusiErro
return "", common.NewBusiError(common.ParamError, "验证码发送过于频繁,请稍后再试")
}
code := tool.RandDigit(6)
if err := sendSMS(common.AppConf.SmsURL, scene, phone, code); err != nil {
return "", common.ErrInternal
}
if err := common.SetValueWithExpired(cache.SmsCodeKeyOf(phone), code, 5*60); err != nil {
return "", common.ErrInternal
}
if err := common.SetValueWithExpired(cache.SmsRateKeyOf(phone), "1", 60); err != nil {
return "", common.ErrInternal
}
logger.INFO("send sms code to", phone, "code:", code)
return code, nil
}

8
sql/tdengine_schema.sql

@ -1,10 +1,12 @@
-- TDengine 时序库 schema(与 MySQL DDL 分离,需用 taos / REST 手动执行)
-- 对应 DESIGN.md §5.9
CREATE DATABASE IF NOT EXISTS laic VGROUPS 2 DURATION 10d KEEP 3650d;
CREATE DATABASE IF NOT EXISTS laic_track VGROUPS 2 DURATION 10d KEEP 3650d;
USE laic_track;
-- 遥测超级表(device_telemetry):列顺序必须与 service/telemetry_store.go insert() 的 VALUES 元组一致
CREATE STABLE IF NOT EXISTS laic.device_telemetry (
CREATE STABLE IF NOT EXISTS device_telemetry (
ts TIMESTAMP,
longitude DOUBLE,
latitude DOUBLE,
@ -26,7 +28,7 @@ CREATE STABLE IF NOT EXISTS laic.device_telemetry (
);
-- 轨迹超级表(任务回放用)
CREATE STABLE IF NOT EXISTS laic.task_trajectory (
CREATE STABLE IF NOT EXISTS task_trajectory (
ts TIMESTAMP,
longitude DOUBLE,
latitude DOUBLE,

Loading…
Cancel
Save