chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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) != ""
|
||||
}
|
||||
Reference in New Issue
Block a user