Files

361 lines
14 KiB
Go

package provisioning
import (
"context"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
deviceDTO "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/quota"
)
var (
ErrInvalidRequest = errors.New("批量开通请求不符合要求")
ErrBatchNotFound = errors.New("批量开通批次不存在")
ErrItemNotFound = errors.New("批量开通条目不存在")
)
type Activator func(context.Context, *Service, *Item, CredentialInput, int) (string, string, string, error)
type Service struct {
coreService.Service
Quota int
Activator Activator
}
func (s *Service) quotaLimit() (int, error) {
if s.Quota > 0 {
return s.Quota, nil
}
return quota.ReadLimit(s.Orm)
}
func (s *Service) CreateBatch(request CreateBatchRequest) (BatchResponse, error) {
request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey)
if request.IdempotencyKey == "" || len(request.IdempotencyKey) > 128 || len(request.Rows) == 0 || len(request.Rows) > 1000 {
return BatchResponse{}, ErrInvalidRequest
}
var existing Batch
if err := s.Orm.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("line_number") }).First(&existing, "idempotency_key = ?", request.IdempotencyKey).Error; err == nil {
return batchResponse(existing), nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return BatchResponse{}, fmt.Errorf("read provisioning idempotency key: %w", err)
}
var existingDevices int64
if err := s.Orm.Table("sense_devices").Where("status <> ?", "disabled").Count(&existingDevices).Error; err != nil {
return BatchResponse{}, fmt.Errorf("count provisioned devices: %w", err)
}
quotaLimit, err := s.quotaLimit()
if err != nil {
return BatchResponse{}, err
}
available := quotaLimit - int(existingDevices)
if available < 0 {
available = 0
}
batch := Batch{ID: uuid.NewString(), IdempotencyKey: request.IdempotencyKey, Status: BatchReady, QuotaLimit: quotaLimit, ExistingCount: int(existingDevices), TotalCount: len(request.Rows)}
batch.CreateBy, batch.UpdateBy = request.CreateBy, request.CreateBy
seenLines := map[int]bool{}
seenAddresses := map[string]bool{}
ready := 0
for _, row := range request.Rows {
item := Item{ID: uuid.NewString(), BatchID: batch.ID, LineNumber: row.LineNumber, Name: strings.TrimSpace(row.Name), Location: strings.TrimSpace(row.Location), Address: strings.TrimSpace(row.Address), Status: ItemReady}
item.CreateBy, item.UpdateBy = request.CreateBy, request.CreateBy
code, detail := validateImportRow(item, seenLines, seenAddresses)
if code != "" {
item.Status, item.FailureCode, item.Detail = ItemInvalid, code, detail
} else if ready >= available {
item.Status, item.FailureCode, item.Detail = ItemQuotaExceeded, "quota_exceeded", "超出当前可用配额,请调整配额或批次后重试"
} else {
ready++
}
batch.Items = append(batch.Items, item)
}
batch.ReadyCount = ready
if ready == 0 {
batch.Status = BatchFailed
}
if err := s.Orm.Transaction(func(tx *gorm.DB) error {
if err := tx.Omit("Items").Create(&batch).Error; err != nil {
return err
}
return tx.Create(&batch.Items).Error
}); err != nil {
if isDuplicateKey(err) {
return s.GetBatchByKey(request.IdempotencyKey)
}
return BatchResponse{}, fmt.Errorf("create provisioning batch: %w", err)
}
return s.GetBatch(batch.ID)
}
func validateImportRow(item Item, seenLines map[int]bool, seenAddresses map[string]bool) (string, string) {
if item.LineNumber < 1 || seenLines[item.LineNumber] {
return "invalid_line_number", "行号必须为正整数且批次内唯一"
}
seenLines[item.LineNumber] = true
if item.Name == "" || len([]rune(item.Name)) > 128 || len([]rune(item.Location)) > 255 {
return "invalid_device", "设备名称不能为空,名称或安装位置长度不能超过限制"
}
parsed, err := url.Parse(item.Address)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "invalid_address", "设备地址必须是无账号、查询参数和片段的 HTTP(S) 地址"
}
key := strings.ToLower(parsed.String())
if seenAddresses[key] {
return "duplicate_address", "同一批次中设备地址不能重复"
}
seenAddresses[key] = true
return "", ""
}
func (s *Service) ListBatches(request *BatchPageRequest) ([]BatchResponse, int64, error) {
query := s.Orm.Model(&Batch{})
if request.Status != "" {
query = query.Where("status = ?", request.Status)
}
var count int64
if err := query.Count(&count).Error; err != nil {
return nil, 0, err
}
pageSize := request.GetPageSize()
if pageSize > 100 {
pageSize = 100
}
var batches []Batch
if err := query.Order("created_at DESC").Limit(pageSize).Offset((request.GetPageIndex() - 1) * pageSize).Find(&batches).Error; err != nil {
return nil, 0, err
}
result := make([]BatchResponse, 0, len(batches))
for _, batch := range batches {
result = append(result, batchResponse(batch))
}
return result, count, nil
}
func (s *Service) GetBatch(id string) (BatchResponse, error) {
var batch Batch
if err := s.Orm.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("line_number") }).First(&batch, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return BatchResponse{}, ErrBatchNotFound
}
return BatchResponse{}, err
}
return batchResponse(batch), nil
}
func (s *Service) GetBatchByKey(key string) (BatchResponse, error) {
var batch Batch
if err := s.Orm.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("line_number") }).First(&batch, "idempotency_key = ?", key).Error; err != nil {
return BatchResponse{}, err
}
return batchResponse(batch), nil
}
func (s *Service) Execute(ctx context.Context, batchID string, itemID string, retryFailed bool, request ExecuteRequest) (BatchResponse, error) {
credentialByItem := make(map[string]CredentialInput, len(request.Credentials))
for _, input := range request.Credentials {
if input.ItemID == "" || credentialByItem[input.ItemID].ItemID != "" {
return BatchResponse{}, ErrInvalidRequest
}
credentialByItem[input.ItemID] = input
}
var batch Batch
if err := s.Orm.First(&batch, "id = ?", batchID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return BatchResponse{}, ErrBatchNotFound
}
return BatchResponse{}, err
}
query := s.Orm.Where("batch_id = ?", batchID)
if itemID != "" {
query = query.Where("id = ?", itemID)
}
if retryFailed {
query = query.Where("status = ?", ItemFailed)
} else {
query = query.Where("status = ?", ItemReady)
}
var items []Item
if err := query.Order("line_number").Find(&items).Error; err != nil {
return BatchResponse{}, err
}
if itemID != "" && len(items) == 0 {
return BatchResponse{}, ErrItemNotFound
}
if len(items) == 0 {
return s.GetBatch(batchID)
}
if err := s.Orm.Model(&Batch{}).Where("id = ?", batchID).Updates(map[string]any{"status": BatchRunning, "update_by": request.UpdateBy}).Error; err != nil {
return BatchResponse{}, fmt.Errorf("mark provisioning batch running: %w", err)
}
activate := s.Activator
if activate == nil {
activate = defaultActivate
}
for index := range items {
item := &items[index]
now := time.Now().UTC()
expectedStatus := ItemReady
if retryFailed {
expectedStatus = ItemFailed
}
claim := s.Orm.Model(&Item{}).Where("id = ? AND status = ?", item.ID, expectedStatus).Updates(map[string]any{"status": ItemRunning, "attempts": gorm.Expr("attempts + 1"), "last_tried_at": now, "update_by": request.UpdateBy})
if claim.Error != nil {
return BatchResponse{}, fmt.Errorf("mark provisioning item running: %w", claim.Error)
}
if claim.RowsAffected == 0 {
continue
}
input, ok := credentialByItem[item.ID]
if !ok || strings.TrimSpace(input.ONVIFUsername) == "" || input.ONVIFPassword == "" {
if err := s.failItem(item.ID, request.UpdateBy, "credentials_required", "请安全填写该设备的账号密码后重试"); err != nil {
return BatchResponse{}, err
}
continue
}
deviceID, status, detail, err := activate(ctx, s, item, input, request.UpdateBy)
if deviceID != "" && deviceID != item.DeviceID {
item.DeviceID = deviceID
if err := s.Orm.Model(&Item{}).Where("id = ?", item.ID).Update("device_id", deviceID).Error; err != nil {
return BatchResponse{}, fmt.Errorf("link provisioning item to device: %w", err)
}
}
if err != nil {
code, safeDetail := safeFailure(err)
if updateErr := s.failItem(item.ID, request.UpdateBy, code, safeDetail); updateErr != nil {
return BatchResponse{}, updateErr
}
continue
}
if status != "ready" {
if detail == "" {
detail = "设备或视频流尚未通过验证"
}
if err := s.failItem(item.ID, request.UpdateBy, status, detail); err != nil {
return BatchResponse{}, err
}
continue
}
if err := s.Orm.Model(&Item{}).Where("id = ?", item.ID).Updates(map[string]any{"status": ItemSucceeded, "failure_code": "", "detail": "设备已开通并通过视频验证", "update_by": request.UpdateBy}).Error; err != nil {
return BatchResponse{}, fmt.Errorf("mark provisioning item succeeded: %w", err)
}
}
if err := s.refreshBatch(batchID, request.UpdateBy); err != nil {
return BatchResponse{}, err
}
return s.GetBatch(batchID)
}
func isDuplicateKey(err error) bool {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
var postgresError *pgconn.PgError
return errors.As(err, &postgresError) && postgresError.Code == "23505"
}
func (s *Service) failItem(id string, updateBy int, code, detail string) error {
if err := s.Orm.Model(&Item{}).Where("id = ?", id).Updates(map[string]any{"status": ItemFailed, "failure_code": code, "detail": detail, "update_by": updateBy}).Error; err != nil {
return fmt.Errorf("mark provisioning item failed: %w", err)
}
return nil
}
func (s *Service) refreshBatch(batchID string, updateBy int) error {
var items []Item
if err := s.Orm.Where("batch_id = ?", batchID).Find(&items).Error; err != nil {
return err
}
ready, success, failure := 0, 0, 0
for _, item := range items {
switch item.Status {
case ItemReady, ItemRunning:
ready++
case ItemSucceeded:
success++
case ItemFailed:
failure++
}
}
status := BatchFailed
if success == len(items) {
status = BatchSucceeded
} else if success > 0 {
status = BatchPartial
} else if ready > 0 {
status = BatchReady
}
return s.Orm.Model(&Batch{}).Where("id = ?", batchID).Updates(map[string]any{"status": status, "ready_count": ready, "success_count": success, "failure_count": failure, "update_by": updateBy}).Error
}
func defaultActivate(ctx context.Context, service *Service, item *Item, input CredentialInput, updateBy int) (string, string, string, error) {
device := deviceService.Device{Service: service.Service}
deviceID := item.DeviceID
var response deviceDTO.DeviceResponse
if deviceID == "" {
if err := device.Insert(&deviceDTO.CreateReq{Name: item.Name, Location: item.Location, Modality: "video", Capabilities: []string{"video"}, CreateBy: updateBy}, &response); err != nil {
return "", "", "", err
}
deviceID = response.ID
if err := service.Orm.Model(&Item{}).Where("id = ?", item.ID).Update("device_id", deviceID).Error; err != nil {
return deviceID, "", "", err
}
} else if err := device.Get(deviceID, &response); err != nil {
return deviceID, "", "", err
}
if err := device.UpdateCredentials(&deviceDTO.CredentialUpdateReq{ID: deviceID, ONVIFUsername: input.ONVIFUsername, ONVIFPassword: input.ONVIFPassword, RTSPSameAsONVIF: input.RTSPSameAsONVIF, RTSPUsername: input.RTSPUsername, RTSPPassword: input.RTSPPassword, Version: response.Version, UpdateBy: updateBy}, &response); err != nil {
return deviceID, "", "", err
}
probeService, err := admission.NewRuntime(service.Service)
if err != nil {
return deviceID, "", "", err
}
result, err := probeService.Probe(ctx, admission.ProbeRequest{DeviceID: deviceID, Address: item.Address, Version: response.Version, UpdateBy: updateBy})
if err != nil {
return deviceID, "", "", err
}
return deviceID, result.Status, result.Detail, nil
}
func safeFailure(err error) (string, string) {
switch {
case errors.Is(err, credential.ErrKeyUnavailable):
return "credential_key_unavailable", "摄像头凭据安全配置不可用"
case errors.Is(err, credential.ErrCredentialNotConfigured):
return "credentials_required", "请安全填写摄像头账号密码后重试"
case errors.Is(err, deviceService.ErrInvalidDevice), errors.Is(err, admission.ErrInvalid):
return "invalid_device", "设备信息或凭据不符合要求"
case errors.Is(err, deviceService.ErrVersionConflict), errors.Is(err, admission.ErrConflict):
return "version_conflict", "设备已被其他操作更新,请重试"
case errors.Is(err, quota.ErrExceeded):
return "quota_exceeded", "当前配额已满,请调整配额或停用其他设备后重试"
case errors.Is(err, quota.ErrUnavailable):
return "quota_unavailable", "配额配置不可读取,已拒绝新增或启用设备"
default:
return "activation_failed", "设备开通失败,请检查网络、地址和凭据后重试"
}
}
func batchResponse(batch Batch) BatchResponse {
response := BatchResponse{ID: batch.ID, IdempotencyKey: batch.IdempotencyKey, Status: batch.Status, QuotaLimit: batch.QuotaLimit, ExistingCount: batch.ExistingCount, TotalCount: batch.TotalCount, ReadyCount: batch.ReadyCount, SuccessCount: batch.SuccessCount, FailureCount: batch.FailureCount, CreatedAt: batch.CreatedAt, UpdatedAt: batch.UpdatedAt, Items: make([]ItemResponse, 0, len(batch.Items))}
sort.Slice(batch.Items, func(i, j int) bool { return batch.Items[i].LineNumber < batch.Items[j].LineNumber })
for _, item := range batch.Items {
response.Items = append(response.Items, ItemResponse{ID: item.ID, LineNumber: item.LineNumber, Name: item.Name, Location: item.Location, Address: item.Address, Status: item.Status, FailureCode: item.FailureCode, Detail: item.Detail, DeviceID: item.DeviceID, Attempts: item.Attempts, LastTriedAt: item.LastTriedAt})
}
return response
}