Files
story/internal/saas/readiness_service.go

184 lines
8.7 KiB
Go

package saas
import (
"context"
"fmt"
"strings"
"time"
"data-terminal-cloud/internal/firmwareversion"
"data-terminal-cloud/internal/identity"
)
const deviceRecentWindow = 24 * time.Hour
type ConversationReadinessCheck struct {
Code string `json:"code"`
Status string `json:"status"`
Blocking bool `json:"blocking"`
Title string `json:"title"`
Detail string `json:"detail"`
Action string `json:"action,omitempty"`
}
type ConversationReadinessDevice struct {
ID string `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
FirmwareVersion *string `json:"firmware_version,omitempty"`
MinimumFirmwareVersion *string `json:"minimum_firmware_version,omitempty"`
DesiredRevision int64 `json:"desired_revision"`
ReportedRevision *int64 `json:"reported_revision,omitempty"`
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
}
type ConversationReadiness struct {
SubjectID string `json:"subject_id"`
Ready bool `json:"ready"`
EvaluatedAt time.Time `json:"evaluated_at"`
Checks []ConversationReadinessCheck `json:"checks"`
Devices []ConversationReadinessDevice `json:"devices"`
StartTimeChecks []string `json:"start_time_checks"`
}
// GetConversationReadiness evaluates the household-manageable prerequisites for
// a subject's first call. Quota, routing and concurrent-call checks deliberately
// remain start-time checks because their state can change immediately after this
// response is returned.
func (s *Service) GetConversationReadiness(ctx context.Context, principal identity.Principal, subjectPublicID string) (ConversationReadiness, error) {
access, subject, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return ConversationReadiness{}, err
}
result := ConversationReadiness{
SubjectID: subjectPublicID,
Ready: true,
EvaluatedAt: time.Now().UTC(),
Checks: make([]ConversationReadinessCheck, 0, 8),
Devices: make([]ConversationReadinessDevice, 0),
StartTimeChecks: []string{"usage_quota", "runtime_routing", "no_active_conversation"},
}
add := func(check ConversationReadinessCheck) {
result.Checks = append(result.Checks, check)
if check.Blocking && check.Status != "ready" {
result.Ready = false
}
}
if subject.Status == "active" {
add(readinessCheck("SUBJECT_ACTIVE", "ready", true, "长者档案已启用", "当前档案允许创建新通话。", ""))
} else {
add(readinessCheck("SUBJECT_ACTIVE", "blocked", true, "长者档案未启用", "请先把长者档案状态恢复为启用。", "EDIT_PROFILE"))
}
if nonEmptyProfileString(subject.ProfileData, "background") {
add(readinessCheck("PROFILE_BACKGROUND", "ready", true, "长者背景已填写", "授权背景会按档案版本冻结到新通话。", ""))
} else {
add(readinessCheck("PROFILE_BACKGROUND", "blocked", true, "缺少长者背景", "请填写简要家庭背景,避免首次交流缺少上下文。", "EDIT_PROFILE"))
}
if nonEmptyProfileString(subject.ConversationPreferences, "conversation_goal") {
add(readinessCheck("CONVERSATION_GOAL", "ready", true, "交流目标已填写", "新通话将使用当前档案版本中的交流目标。", ""))
} else {
add(readinessCheck("CONVERSATION_GOAL", "blocked", true, "缺少交流目标", "请填写本阶段希望达成的交流目标。", "EDIT_PROFILE"))
}
var hasVoiceConsent bool
err = s.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM consent_snapshot
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid
AND effective_consents @> '{"voice_chat":true}'::jsonb
)`, access.tenantID, access.subjectID).Scan(&hasVoiceConsent)
if err != nil {
return ConversationReadiness{}, fmt.Errorf("check voice consent readiness: %w", err)
}
if hasVoiceConsent {
add(readinessCheck("VOICE_CONSENT", "ready", true, "语音通话授权已具备", "服务端已存在可冻结到新通话的有效授权快照。", ""))
} else {
add(readinessCheck("VOICE_CONSENT", "blocked", true, "缺少语音通话授权", "请完成至少一项明确分项授权并保留授权凭证。", "MANAGE_CONSENTS"))
}
rows, err := s.pool.Query(ctx, `
SELECT d.public_id::text,d.model,d.status,dt.firmware_version,d.minimum_firmware_version,
dt.desired_revision,dt.reported_revision,dt.last_reported_at
FROM device_binding b
JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id
JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
WHERE b.tenant_id=$1::uuid AND b.subject_id=$2::uuid AND b.unbound_at IS NULL
ORDER BY b.bound_at DESC,d.id`, access.tenantID, access.subjectID)
if err != nil {
return ConversationReadiness{}, fmt.Errorf("list readiness devices: %w", err)
}
defer rows.Close()
for rows.Next() {
var item ConversationReadinessDevice
if err := rows.Scan(&item.ID, &item.Model, &item.Status, &item.FirmwareVersion, &item.MinimumFirmwareVersion,
&item.DesiredRevision, &item.ReportedRevision, &item.LastReportedAt); err != nil {
return ConversationReadiness{}, fmt.Errorf("scan readiness device: %w", err)
}
result.Devices = append(result.Devices, item)
}
if err := rows.Err(); err != nil {
return ConversationReadiness{}, fmt.Errorf("iterate readiness devices: %w", err)
}
if len(result.Devices) == 0 {
add(readinessCheck("DEVICE_BOUND", "blocked", true, "尚未绑定设备", "请使用设备屏幕显示的一次性绑定码完成绑定。", "BIND_DEVICE"))
add(readinessCheck("DEVICE_RUNTIME", "blocked", true, "没有可用于通话的设备", "绑定并启动设备后才能发起首次通话。", "BIND_DEVICE"))
return result, nil
}
add(readinessCheck("DEVICE_BOUND", "ready", true, "设备已绑定", fmt.Sprintf("当前长者已绑定%d台设备。", len(result.Devices)), ""))
registered, reported, compatible, synced, recent := 0, 0, 0, 0, 0
for _, device := range result.Devices {
if device.Status != "registered" {
continue
}
registered++
if device.FirmwareVersion != nil && strings.TrimSpace(*device.FirmwareVersion) != "" {
reported++
if device.MinimumFirmwareVersion == nil || firmwareversion.AtLeast(*device.FirmwareVersion, *device.MinimumFirmwareVersion) {
compatible++
}
}
if device.ReportedRevision != nil && *device.ReportedRevision == device.DesiredRevision {
synced++
}
if device.LastReportedAt != nil && result.EvaluatedAt.Sub(*device.LastReportedAt) <= deviceRecentWindow {
recent++
}
}
if registered > 0 && reported > 0 {
add(readinessCheck("DEVICE_RUNTIME", "ready", true, "设备运行信息完整", fmt.Sprintf("%d台已注册设备已报告固件版本。", reported), ""))
} else if registered == 0 {
add(readinessCheck("DEVICE_RUNTIME", "blocked", true, "绑定设备不可用", "已绑定设备均不是注册状态,请联系运维处理设备状态。", "OPEN_DEVICES"))
} else {
add(readinessCheck("DEVICE_RUNTIME", "blocked", true, "设备尚未完成启动上报", "请启动设备并等待其完成引导和固件信息上报。", "OPEN_DEVICES"))
}
if compatible > 0 {
add(readinessCheck("FIRMWARE_POLICY", "ready", true, "设备固件满足最低版本", fmt.Sprintf("%d台设备可通过新通话固件门禁。", compatible), ""))
} else {
add(readinessCheck("FIRMWARE_POLICY", "blocked", true, "设备固件需要升级", "当前已上报设备均未达到最低固件版本,请先完成签名固件升级。", "OPEN_DEVICES"))
}
if synced > 0 {
add(readinessCheck("CONFIG_SYNC", "ready", false, "设备配置已同步", fmt.Sprintf("%d台设备已应用当前配置版本。", synced), ""))
} else {
add(readinessCheck("CONFIG_SYNC", "attention", false, "设备配置待同步", "这不会阻止创建通话,但设备应尽快拉取并应用当前配置。", "OPEN_DEVICES"))
}
if recent > 0 {
add(readinessCheck("RECENT_CONTACT", "ready", false, "设备近期已联系云端", fmt.Sprintf("%d台设备在24小时内完成过状态上报。", recent), ""))
} else {
add(readinessCheck("RECENT_CONTACT", "attention", false, "设备近期未联系云端", "通话开始前请确认设备已联网;在线状态会在通话心跳后更新。", "OPEN_DEVICES"))
}
return result, nil
}
func readinessCheck(code, status string, blocking bool, title, detail, action string) ConversationReadinessCheck {
return ConversationReadinessCheck{Code: code, Status: status, Blocking: blocking, Title: title, Detail: detail, Action: action}
}
func nonEmptyProfileString(values map[string]any, key string) bool {
value, ok := values[key].(string)
return ok && strings.TrimSpace(value) != ""
}