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) != "" }