184 lines
8.7 KiB
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) != ""
|
|
}
|