153 lines
7.6 KiB
Go
153 lines
7.6 KiB
Go
package devicefleet
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Start authorizes under the caller's device-twin lock, shared with binding changes.
|
||
|
|
func (s *Service) Start(ctx context.Context, request string, code int64, authorize func(pgx.Tx) (string, error)) (Task, error) {
|
||
|
|
var result Task
|
||
|
|
if s.Publish == nil {
|
||
|
|
return result, conflict("MQTT_UNAVAILABLE", "MQTT服务未启用")
|
||
|
|
}
|
||
|
|
if _, err := uuid.Parse(request); err != nil {
|
||
|
|
return result, conflict("REQUEST_ID_REQUIRED", "请求号必须为UUID")
|
||
|
|
}
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
defer tx.Rollback(ctx)
|
||
|
|
device, err := authorize(tx)
|
||
|
|
if err != nil {
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
err = tx.QueryRow(ctx, `SELECT o.id::text,o.status,f.version,f.version_code,o.message,o.created_at FROM fleet_ota o JOIN fleet_firmware f ON f.id=o.release_id WHERE o.request_id=$1 AND o.device_id=$2`, request, device).Scan(&result.ID, &result.Status, &result.Version, &result.Code, &result.Message, &result.Created)
|
||
|
|
if err == nil {
|
||
|
|
if result.Code != code {
|
||
|
|
return result, conflict("REQUEST_CONFLICT", "同一请求号不能更换目标版本")
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
var tenant, model, factory string
|
||
|
|
var online, supported, busy bool
|
||
|
|
var current int64
|
||
|
|
err = tx.QueryRow(ctx, `SELECT d.tenant_id::text,d.model,d.factory_device_id,COALESCE(p.connected AND p.last_seen_at>now()-interval '90 seconds',false),COALESCE(p.ota_supported,false),COALESCE(p.version_code,0),EXISTS(SELECT 1 FROM call_session WHERE device_id=d.id AND call_state IN ('PREPARING','STARTING','TALKING','STOPPING')) OR EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=d.id AND status IN ('pending','accepted')) FROM device d LEFT JOIN fleet_presence p ON p.device_id=d.id WHERE d.id=$1 AND d.status='registered' AND d.activation_state NOT IN ('LOST','REVOKED')`, device).Scan(&tenant, &model, &factory, &online, &supported, ¤t, &busy)
|
||
|
|
if err != nil {
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
if !online {
|
||
|
|
return result, conflict("DEVICE_OFFLINE", "设备离线,请开机联网后重试")
|
||
|
|
}
|
||
|
|
var otherBusy bool
|
||
|
|
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM conversation WHERE device_id=$1 AND status NOT IN ('COMPLETED','FAILED','EXPIRED')) OR EXISTS(SELECT 1 FROM device_command WHERE device_id=$1 AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()) OR EXISTS(SELECT 1 FROM firmware_assignment WHERE device_id=$1 AND status IN ('ASSIGNED','DOWNLOADING','INSTALLING'))`, device).Scan(&otherBusy)
|
||
|
|
if err != nil {
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
busy = busy || otherBusy
|
||
|
|
if !supported {
|
||
|
|
return result, conflict("OTA_UNSUPPORTED", "当前设备固件或分区不支持OTA,请先由嵌入式端适配")
|
||
|
|
}
|
||
|
|
if busy {
|
||
|
|
return result, conflict("DEVICE_BUSY", "设备正在通话或升级,请稍后重试")
|
||
|
|
}
|
||
|
|
release, err := scanRelease(tx.QueryRow(ctx, `SELECT `+releaseFields+` FROM fleet_firmware WHERE tenant_id=$1 AND model=$2 AND status='READY' ORDER BY version_code DESC LIMIT 1`, tenant, model))
|
||
|
|
if err != nil {
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
if release.Code != code || code <= current {
|
||
|
|
return result, conflict("VERSION_CHANGED", "版本已变化或已经是最新版本,请刷新")
|
||
|
|
}
|
||
|
|
_, tokenHash, downloadURL, downloadExpiresAt, err := newDownloadTicket(s.DownloadBaseURL)
|
||
|
|
if err != nil {
|
||
|
|
return result, conflict("DOWNLOAD_UNAVAILABLE", "固件短下载地址未正确配置")
|
||
|
|
}
|
||
|
|
result = Task{ID: uuid.NewString(), Status: "pending", Version: release.Version, Code: code, Created: time.Now().UTC()}
|
||
|
|
_, err = tx.Exec(ctx, `INSERT INTO fleet_ota(id,device_id,release_id,request_id) VALUES($1,$2,$3,$4)`, result.ID, device, release.ID, request)
|
||
|
|
if err != nil {
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
_, err = tx.Exec(ctx, `INSERT INTO fleet_firmware_download_ticket(token_hash,release_id,ota_id,expires_at) VALUES($1,$2,$3,$4)`, tokenHash, release.ID, result.ID, downloadExpiresAt)
|
||
|
|
if err != nil {
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
if err = tx.Commit(ctx); err != nil {
|
||
|
|
return result, err
|
||
|
|
}
|
||
|
|
command, _ := json.Marshal(map[string]any{"task_id": result.ID, "model": model, "version": release.Version, "version_code": code, "download_url": downloadURL, "sha256": release.SHA256, "size": release.Size, "expires_at_ms": result.Created.Add(30 * time.Second).UnixMilli()})
|
||
|
|
// No retained commands: a device reconnecting later must not begin an obsolete OTA.
|
||
|
|
if err = s.Publish(OTATopic(factory), command); err != nil {
|
||
|
|
_, _ = s.Pool.Exec(ctx, `UPDATE fleet_ota SET status='failed',message='升级指令发送失败',updated_at=now() WHERE id=$1 AND status='pending'`, result.ID)
|
||
|
|
_, _ = s.Pool.Exec(ctx, `UPDATE fleet_firmware_download_ticket SET revoked_at=now() WHERE ota_id=$1 AND revoked_at IS NULL`, result.ID)
|
||
|
|
return result, conflict("MQTT_PUBLISH_FAILED", "升级指令发送失败,请刷新后重试")
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
func (s *Service) Expire(ctx context.Context) error {
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer tx.Rollback(ctx)
|
||
|
|
_, err = tx.Exec(ctx, `UPDATE fleet_ota SET status='timeout',message=CASE WHEN status='pending' THEN '设备未确认接收指令' ELSE '升级结果超时,请检查设备' END,updated_at=now() WHERE (status='pending' AND created_at<now()-interval '30 seconds') OR (status='accepted' AND created_at<now()-interval '20 minutes')`)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
_, err = tx.Exec(ctx, `UPDATE fleet_firmware_download_ticket t SET revoked_at=now() FROM fleet_ota o WHERE o.id=t.ota_id AND o.status='timeout' AND t.revoked_at IS NULL`)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return tx.Commit(ctx)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Manifest resolves the MQTT wakeup over authenticated HTTPS. The device must
|
||
|
|
// not install a URL or digest taken solely from the plaintext MQTT transport.
|
||
|
|
func (s *Service) Manifest(ctx context.Context, deviceID, taskID string) (map[string]any, error) {
|
||
|
|
if _, err := uuid.Parse(taskID); err != nil {
|
||
|
|
return nil, conflict("INVALID_OTA_TASK", "升级任务编号不正确")
|
||
|
|
}
|
||
|
|
tx, err := s.Pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer tx.Rollback(ctx)
|
||
|
|
var releaseID, model, version, digest string
|
||
|
|
var size, code int64
|
||
|
|
var created time.Time
|
||
|
|
err = tx.QueryRow(ctx, `SELECT f.id::text,f.model,f.version,f.version_code,f.size,f.sha256,o.created_at
|
||
|
|
FROM fleet_ota o JOIN fleet_firmware f ON f.id=o.release_id
|
||
|
|
JOIN device d ON d.id=o.device_id AND d.tenant_id=f.tenant_id
|
||
|
|
WHERE o.id=$1 AND o.device_id=$2 AND d.status='registered' AND d.activation_state NOT IN ('LOST','REVOKED')
|
||
|
|
AND f.status='READY' AND f.model=d.model AND
|
||
|
|
((o.status='pending' AND o.created_at>now()-interval '30 seconds') OR
|
||
|
|
(o.status='accepted' AND o.created_at>now()-interval '20 minutes')) FOR UPDATE OF o`, taskID, deviceID).
|
||
|
|
Scan(&releaseID, &model, &version, &code, &size, &digest, &created)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return nil, conflict("OTA_TASK_UNAVAILABLE", "升级任务不可用或已过期")
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
_, hash, url, expires, err := newDownloadTicket(s.DownloadBaseURL)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
_, err = tx.Exec(ctx, `INSERT INTO fleet_firmware_download_ticket(token_hash,release_id,ota_id,expires_at) VALUES($1,$2,$3,$4)
|
||
|
|
ON CONFLICT (ota_id) WHERE ota_id IS NOT NULL DO UPDATE SET token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at,revoked_at=NULL`, hash, releaseID, taskID, expires)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err = tx.Commit(ctx); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return map[string]any{"task_id": taskID, "model": model, "version": version, "version_code": code, "size": size, "sha256": digest, "download_url": url, "expires_at_ms": created.Add(30 * time.Second).UnixMilli()}, nil
|
||
|
|
}
|