feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+121
View File
@@ -0,0 +1,121 @@
package saas
import (
"context"
"errors"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceactivation"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
const accountDeviceSelect = `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,d.id::text,
d.public_id::text,ab.id::text,d.model,d.hardware_revision,d.status,d.minimum_security_version,d.minimum_firmware_version,
dt.firmware_version,dt.desired_revision,dt.reported_revision,dt.last_reported_at,ab.bound_at,d.registered_at,d.version
FROM device_account_binding ab JOIN device d ON d.id=ab.device_id AND d.tenant_id=ab.tenant_id
JOIN tenant t ON t.id=d.tenant_id AND t.status='active'
JOIN user_account ua ON ua.id=ab.user_id AND ua.tenant_id=ab.tenant_id AND ua.status='active' AND ua.account_kind='customer'
JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
WHERE ab.released_at IS NULL AND ab.generation=d.binding_generation AND ua.oidc_issuer=$1 AND ua.oidc_subject=$2
AND d.status='registered' AND d.activation_state NOT IN ('REVOKED','LOST')
AND NOT EXISTS(SELECT 1 FROM device_binding b WHERE b.device_id=d.id AND b.unbound_at IS NULL)`
func scanAccountDevice(row interface{ Scan(...any) error }) (deviceAccess, ManagedDevice, error) {
var a deviceAccess
var d ManagedDevice
err := row.Scan(&a.tenantID, &a.tenantPublicID, &a.userID, &a.userPublicID, &a.deviceID, &d.ID, &d.AccountBindingID, &d.Model, &d.HardwareRevision, &d.Status, &d.MinimumSecurityVersion, &d.MinimumFirmwareVersion, &d.FirmwareVersion, &d.DesiredRevision, &d.ReportedRevision, &d.LastReportedAt, &d.BoundAt, &d.RegisteredAt, &d.Version)
a.role = identity.Role("owner")
a.desiredRevision = d.DesiredRevision
d.BindingID = d.AccountBindingID
return a, d, err
}
func (s *Service) listAccountDevices(ctx context.Context, p identity.Principal) ([]ManagedDevice, error) {
rows, err := s.pool.Query(ctx, accountDeviceSelect+` ORDER BY ab.bound_at DESC,d.public_id`, p.Issuer, p.Subject)
if err != nil {
return nil, err
}
defer rows.Close()
result := []ManagedDevice{}
for rows.Next() {
_, d, e := scanAccountDevice(rows)
if e != nil {
return nil, e
}
result = append(result, d)
}
return result, rows.Err()
}
func resolveAccountDevice(ctx context.Context, q queryRow, p identity.Principal, public string, capability identity.Capability, lock bool) (deviceAccess, ManagedDevice, error) {
// Only device capabilities may be granted without a household.
if capability != identity.CapabilityHouseholdRead && capability != identity.CapabilityDeviceManage && capability != identity.CapabilityFirmwareManage {
return deviceAccess{}, ManagedDevice{}, ErrAccessDenied
}
sql := accountDeviceSelect + ` AND d.public_id=$3::uuid`
if lock {
sql += ` FOR UPDATE OF d,dt`
}
a, d, err := scanAccountDevice(q.QueryRow(ctx, sql, p.Issuer, p.Subject, public))
if errors.Is(err, pgx.ErrNoRows) {
err = ErrAccessDenied
}
return a, d, err
}
func (s *Service) releaseAccountDevice(ctx context.Context, tx pgx.Tx, p identity.Principal, a deviceAccess, d ManagedDevice, requestID string, in ChangeDeviceBindingInput) (DeviceBindingChangeResult, error) {
if in.Action != DeviceBindingActionUnbind || in.ExpectedBindingID != d.AccountBindingID {
return DeviceBindingChangeResult{}, ErrDeviceBindingStale
}
impact, err := s.readDeviceBindingImpact(ctx, tx, p, a, d, in.Action, "")
if err != nil {
return DeviceBindingChangeResult{}, err
}
if !impact.Allowed {
return DeviceBindingChangeResult{}, ErrDeviceBindingBlocked
}
var lifecycle string
if err = tx.QueryRow(ctx, `SELECT activation_state FROM device WHERE id=$1::uuid FOR UPDATE`, a.deviceID).Scan(&lifecycle); err != nil {
return DeviceBindingChangeResult{}, err
}
now := time.Now().UTC()
if _, err = tx.Exec(ctx, `UPDATE device_account_binding SET released_at=$2 WHERE id=$1::uuid AND released_at IS NULL`, d.AccountBindingID, now); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE device SET binding_generation=binding_generation+1,onboarding_revision=onboarding_revision+1 WHERE id=$1::uuid`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE device_ble_binding SET status='CANCELLED',completed_at=now() WHERE device_id=$1::uuid AND status='PENDING'`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE device_activation_job SET config_ack_at=NULL,engine_ready_at=NULL,config_ciphertext=NULL,config_digest=NULL,status=CASE WHEN status IN ('CONTACT_POSSIBLE','RECONCILE_REQUIRED') THEN 'RECONCILE_REQUIRED' ELSE 'BLOCKED' END,last_error_code='BINDING_CHANGED',version=version+1,updated_at=now() WHERE device_id=$1::uuid`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE device_setup_session SET closed_at=COALESCE(closed_at,now()),close_reason='BINDING_CHANGED' WHERE device_id=$1::uuid`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
if _, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=COALESCE(revoked_at,now()) WHERE setup_id IN (SELECT id FROM device_setup_session WHERE device_id=$1::uuid)`, a.deviceID); err != nil {
return DeviceBindingChangeResult{}, err
}
transition := deviceactivation.Input{DeviceID: a.deviceID, TenantID: a.tenantID, Target: deviceactivation.Claimable, Reason: "用户移除账号设备", Source: "onboarding", ActorType: "user", ActorPublicID: &a.userPublicID, RequestID: requestID}
if lifecycle == "PROVISIONING" {
transition.Target = deviceactivation.Bound
if _, err = deviceactivation.Apply(ctx, tx, transition); err != nil {
return DeviceBindingChangeResult{}, err
}
transition.Target = deviceactivation.Claimable
}
if _, err = deviceactivation.Apply(ctx, tx, transition); err != nil {
return DeviceBindingChangeResult{}, err
}
err = s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "device.account.unbind", ResourceType: "device", ResourceID: &d.ID, RequestID: requestID, Result: "success", Detail: map[string]any{"account_binding_id": d.AccountBindingID}})
return DeviceBindingChangeResult{DeviceID: d.ID, Action: in.Action, PreviousBindingID: d.AccountBindingID, ChangedAt: now}, err
}
+129 -18
View File
@@ -11,6 +11,7 @@ import (
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
@@ -56,12 +57,17 @@ type ConversationContentSummary struct {
}
type StorySource struct {
TurnID string `json:"turn_id"`
SequenceNo int64 `json:"sequence_no"`
Speaker string `json:"speaker"`
Text string `json:"text"`
StartOffset *int `json:"start_offset,omitempty"`
EndOffset *int `json:"end_offset,omitempty"`
TurnID string `json:"turn_id"`
CallSessionID string `json:"call_session_id,omitempty"`
ImportID string `json:"import_id,omitempty"`
PrivateSources bool `json:"-"`
UtteranceID string `json:"utterance_id,omitempty"`
SourceRevision *int64 `json:"source_revision,omitempty"`
SequenceNo int64 `json:"sequence_no"`
Speaker string `json:"speaker"`
Text string `json:"text"`
StartOffset *int `json:"start_offset,omitempty"`
EndOffset *int `json:"end_offset,omitempty"`
}
type FactCandidate struct {
@@ -88,7 +94,10 @@ type UpdateFactCandidateInput struct {
type Story struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
ConversationID string `json:"conversation_id"`
ConversationID string `json:"conversation_id,omitempty"`
CallSessionID string `json:"call_session_id,omitempty"`
ImportID string `json:"import_id,omitempty"`
PrivateSources bool `json:"-"`
Title string `json:"title"`
Content string `json:"content"`
Status string `json:"status"`
@@ -251,7 +260,9 @@ func (s *Service) ListSubjectStories(ctx context.Context, principal identity.Pri
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, storySelectSQL+` WHERE sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid AND (NULLIF($3,'') IS NULL OR sd.status=$3) ORDER BY sd.created_at DESC,sd.public_id`, access.tenantID, access.subjectID, status)
callAccess, callAccessErr := callmemory.ResolveAccess(ctx, s.pool, principal, subjectPublicID, false)
canReadCallSources := callAccessErr == nil && callAccess.Sensitive
rows, err := s.pool.Query(ctx, storySelectSQL+` WHERE sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid AND call_story_available(sd.id) AND (NULLIF($3,'') IS NULL OR sd.status=$3) ORDER BY sd.created_at DESC,sd.public_id`, access.tenantID, access.subjectID, status)
if err != nil {
return nil, fmt.Errorf("list subject stories: %w", err)
}
@@ -262,6 +273,9 @@ func (s *Service) ListSubjectStories(ctx context.Context, principal identity.Pri
if err != nil {
return nil, err
}
if item.PrivateSources && !canReadCallSources {
continue
}
item.Sources = []StorySource{}
item.Facts = []FactCandidate{}
items = append(items, item)
@@ -384,7 +398,7 @@ func (s *Service) ReviseStory(ctx context.Context, principal identity.Principal,
if err := tx.QueryRow(ctx, `INSERT INTO story_revision(story_id,revision_no,content,author_type,author_id,edit_reason,base_revision_id,error_type) SELECT id,$2,$3,'user',$4::uuid,$5,$6::uuid,NULLIF($7,'') FROM story_draft WHERE public_id=$1::uuid RETURNING id::text`, storyPublicID, current.RevisionNo+1, content, access.userID, reason, currentRevisionID, errorType).Scan(&newRevisionID); err != nil {
return fmt.Errorf("insert story revision: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO story_source(story_revision_id,conversation_turn_id,source_start_offset,source_end_offset,source_role) SELECT $1::uuid,conversation_turn_id,source_start_offset,source_end_offset,source_role FROM story_source WHERE story_revision_id=$2::uuid`, newRevisionID, currentRevisionID); err != nil {
if _, err := tx.Exec(ctx, `INSERT INTO story_source(story_revision_id,conversation_turn_id,call_session_id,call_utterance_id,call_source_revision,import_turn_id,source_start_offset,source_end_offset,source_role) SELECT $1::uuid,conversation_turn_id,call_session_id,call_utterance_id,call_source_revision,import_turn_id,source_start_offset,source_end_offset,source_role FROM story_source WHERE story_revision_id=$2::uuid`, newRevisionID, currentRevisionID); err != nil {
return err
}
row := tx.QueryRow(ctx, storyUpdateReturningSQL, `IN_REVIEW`, newRevisionID, access.userID, reason, storyPublicID)
@@ -452,6 +466,10 @@ func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal
if err != nil {
return err
}
grant, e := callmemory.ResolveAccess(ctx, tx, principal, current.SubjectID, true)
if e != nil || (!grant.Sensitive && current.Sensitivity != "normal") {
return ErrAccessDenied
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
@@ -478,6 +496,43 @@ func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal
return err
}
sourceRefs := []map[string]any{{"type": "story", "story_id": storyPublicID, "revision_no": story.RevisionNo}}
if story.PrivateSources {
rows, err := tx.Query(ctx, `SELECT ss.call_session_id::text,ss.call_utterance_id,ss.call_source_revision FROM story_source ss JOIN story_draft sd ON sd.current_revision_id=ss.story_revision_id WHERE sd.public_id=$1::uuid AND ss.call_session_id IS NOT NULL ORDER BY ss.call_utterance_id`, storyPublicID)
if err != nil {
return err
}
for rows.Next() {
var sessionID, utteranceID string
var sourceRevision int64
if err := rows.Scan(&sessionID, &utteranceID, &sourceRevision); err != nil {
rows.Close()
return err
}
sourceRefs = append(sourceRefs, map[string]any{"type": "call_utterance", "session_id": sessionID, "utterance_id": utteranceID, "source_revision": sourceRevision})
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
}
importRows, err := tx.Query(ctx, `SELECT it.import_id::text,it.id::text FROM story_source ss JOIN story_draft sd ON sd.current_revision_id=ss.story_revision_id JOIN memoir_import_turn it ON it.id=ss.import_turn_id WHERE sd.public_id=$1::uuid ORDER BY it.sequence_no`, storyPublicID)
if err != nil {
return err
}
for importRows.Next() {
var importID, turnID string
if err := importRows.Scan(&importID, &turnID); err != nil {
importRows.Close()
return err
}
sourceRefs = append(sourceRefs, map[string]any{"type": "import_turn", "import_id": importID, "turn_id": turnID})
}
err = importRows.Err()
importRows.Close()
if err != nil {
return err
}
sourceJSON, _ := json.Marshal(sourceRefs)
digest := sha256.Sum256([]byte(story.Content))
var memoryID, memoryVersionID string
@@ -534,6 +589,10 @@ func (s *Service) ConfirmStory(ctx context.Context, principal identity.Principal
}
func (s *Service) ListSubjectMemories(ctx context.Context, principal identity.Principal, subjectPublicID, query string) ([]CuratedMemory, error) {
grant, grantErr := callmemory.ResolveAccess(ctx, s.pool, principal, subjectPublicID, false)
if grantErr != nil {
return nil, ErrAccessDenied
}
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
@@ -550,11 +609,17 @@ func (s *Service) ListSubjectMemories(ctx context.Context, principal identity.Pr
return nil, err
}
items = append(items, item)
if !grant.Sensitive && item.Sensitivity != "normal" {
items = items[:len(items)-1]
}
}
return items, rows.Err()
}
func (s *Service) GetMemory(ctx context.Context, principal identity.Principal, memoryPublicID string) (CuratedMemory, error) {
if err := s.checkMemoryGrant(ctx, s.pool, principal, memoryPublicID, false); err != nil {
return CuratedMemory{}, err
}
result, err := scanMemory(s.pool.QueryRow(ctx, memorySelectSQL+` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE cm.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`, memoryPublicID, principal.Issuer, principal.Subject))
if errors.Is(err, pgx.ErrNoRows) {
return CuratedMemory{}, ErrAccessDenied
@@ -570,6 +635,9 @@ func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal
return CuratedMemory{}, ErrReviewStateConflict
}
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
if err := s.checkMemoryGrant(ctx, tx, principal, memoryPublicID, true); err != nil {
return err
}
var internalID, tenantID, subjectID, userID, userPublicID, role string
var currentVersionID, currentContent, currentStatus string
var currentVersionNo, resourceVersion int64
@@ -701,6 +769,9 @@ func (s *Service) UpdateMemory(ctx context.Context, principal identity.Principal
func (s *Service) RequestMemoryDeletion(ctx context.Context, principal identity.Principal, memoryPublicID, reason, requestID string, expectedVersion int64) (CuratedMemory, error) {
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
if err := s.checkMemoryGrant(ctx, tx, principal, memoryPublicID, true); err != nil {
return err
}
var internalID, tenantID, userPublicID, role, status string
var version int64
err := tx.QueryRow(ctx, `
@@ -799,7 +870,21 @@ func (s *Service) resolveConversationContent(ctx context.Context, q queryRow, p
return a, conversationID, nil
}
func (s *Service) resolveStory(ctx context.Context, q queryRow, p identity.Principal, publicID string, cap identity.Capability, lock bool) (contentAccess, Story, error) {
sql := storySelectSQL + ` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE sd.public_id=$1::uuid AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`
if lock {
access, current, err := s.resolveStory(ctx, q, p, publicID, cap, false)
if err != nil {
return contentAccess{}, Story{}, err
}
if current.PrivateSources {
// Serialize all draft edits with extraction and source deletion, using
// the same subject-before-story lock order as memory mutations.
var epoch int64
if err := q.QueryRow(ctx, `SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, access.subjectID, access.tenantID).Scan(&epoch); err != nil {
return contentAccess{}, Story{}, err
}
}
}
sql := storySelectSQL + ` JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id JOIN household_member hm ON hm.household_id=h.id AND hm.status='active' JOIN user_account member_account ON member_account.id=hm.user_id AND member_account.tenant_id=h.tenant_id AND member_account.status='active' WHERE sd.public_id=$1::uuid AND call_story_available(sd.id) AND sp.status IN ('active','suspended') AND member_account.oidc_issuer=$2 AND member_account.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF sd`
}
@@ -822,15 +907,31 @@ func (s *Service) resolveStory(ctx context.Context, q queryRow, p identity.Princ
if !identity.Can(a.role, cap) {
return contentAccess{}, Story{}, ErrAccessDenied
}
if story.PrivateSources {
if lock {
var available bool
if err := q.QueryRow(ctx, `SELECT call_story_available(id) FROM story_draft WHERE public_id=$1::uuid`, publicID).Scan(&available); err != nil {
return contentAccess{}, Story{}, err
}
if !available {
return contentAccess{}, Story{}, ErrAccessDenied
}
}
grant, err := callmemory.ResolveAccess(ctx, q, p, story.SubjectID, cap == identity.CapabilityStoryReview)
if err != nil || !grant.Sensitive {
return contentAccess{}, Story{}, ErrAccessDenied
}
}
return a, story, nil
}
const storySelectSQL = `SELECT sd.public_id::text AS story_public_id,sp.public_id::text AS subject_public_id,c.public_id::text AS conversation_public_id,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text AS tenant_internal_id,sd.subject_id::text AS subject_internal_id FROM story_draft sd JOIN subject_profile sp ON sp.id=sd.subject_id AND sp.tenant_id=sd.tenant_id JOIN conversation c ON c.id=sd.conversation_id AND c.tenant_id=sd.tenant_id JOIN story_revision sr ON sr.id=sd.current_revision_id`
const storyStatusReturningSQL = `UPDATE story_draft sd SET status=$1,reviewed_by=$2::uuid,reviewed_at=now(),review_reason=$3,version=sd.version+1,updated_at=now() FROM subject_profile sp,conversation c,story_revision sr WHERE sd.public_id=$4::uuid AND sp.id=sd.subject_id AND c.id=sd.conversation_id AND sr.id=sd.current_revision_id RETURNING sd.public_id::text,sp.public_id::text,c.public_id::text,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text`
const storyUpdateReturningSQL = `UPDATE story_draft sd SET status=$1,current_revision_id=$2::uuid,reviewed_by=$3::uuid,reviewed_at=now(),review_reason=$4,version=sd.version+1,updated_at=now() FROM subject_profile sp,conversation c,story_revision sr WHERE sd.public_id=$5::uuid AND sp.id=sd.subject_id AND c.id=sd.conversation_id AND sr.id=$2::uuid RETURNING sd.public_id::text,sp.public_id::text,c.public_id::text,sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text`
const storySelectSQL = `SELECT sd.public_id::text AS story_public_id,sp.public_id::text AS subject_public_id,COALESCE(c.public_id::text,'') AS conversation_public_id,COALESCE(sd.call_session_id::text,''),COALESCE(sd.import_id::text,''),EXISTS(SELECT 1 FROM story_source ps WHERE ps.story_revision_id=sd.current_revision_id AND (ps.call_session_id IS NOT NULL OR ps.import_turn_id IS NOT NULL)),sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text AS tenant_internal_id,sd.subject_id::text AS subject_internal_id FROM story_draft sd JOIN subject_profile sp ON sp.id=sd.subject_id AND sp.tenant_id=sd.tenant_id LEFT JOIN conversation c ON c.id=sd.conversation_id AND c.tenant_id=sd.tenant_id JOIN story_revision sr ON sr.id=sd.current_revision_id`
const storyReturningColumns = `sd.public_id::text,sp.public_id::text,COALESCE((SELECT c.public_id::text FROM conversation c WHERE c.id=sd.conversation_id),''),COALESCE(sd.call_session_id::text,''),COALESCE(sd.import_id::text,''),EXISTS(SELECT 1 FROM story_source ps WHERE ps.story_revision_id=sd.current_revision_id AND (ps.call_session_id IS NOT NULL OR ps.import_turn_id IS NOT NULL)),sd.title,sr.content,sd.status,sd.confidence,sd.sensitivity,sd.generation_version,sr.revision_no,sr.author_type,COALESCE(sd.review_reason,''),sd.version,sd.created_at,sd.updated_at,sd.tenant_id::text,sd.subject_id::text`
const storyStatusReturningSQL = `UPDATE story_draft sd SET status=$1,reviewed_by=$2::uuid,reviewed_at=now(),review_reason=$3,version=sd.version+1,updated_at=now() FROM subject_profile sp,story_revision sr WHERE sd.public_id=$4::uuid AND sp.id=sd.subject_id AND sr.id=sd.current_revision_id AND call_story_available(sd.id) RETURNING ` + storyReturningColumns
const storyUpdateReturningSQL = `UPDATE story_draft sd SET status=$1,current_revision_id=$2::uuid,reviewed_by=$3::uuid,reviewed_at=now(),review_reason=$4,version=sd.version+1,updated_at=now() FROM subject_profile sp,story_revision sr WHERE sd.public_id=$5::uuid AND sp.id=sd.subject_id AND sr.id=$2::uuid AND call_story_available(sd.id) RETURNING ` + storyReturningColumns
func storyScanTargets(s *Story, tenantID, subjectInternal *string) []any {
return []any{&s.ID, &s.SubjectID, &s.ConversationID, &s.Title, &s.Content, &s.Status, &s.Confidence, &s.Sensitivity, &s.GenerationVersion, &s.RevisionNo, &s.AuthorType, &s.ReviewReason, &s.Version, &s.CreatedAt, &s.UpdatedAt, tenantID, subjectInternal}
return []any{&s.ID, &s.SubjectID, &s.ConversationID, &s.CallSessionID, &s.ImportID, &s.PrivateSources, &s.Title, &s.Content, &s.Status, &s.Confidence, &s.Sensitivity, &s.GenerationVersion, &s.RevisionNo, &s.AuthorType, &s.ReviewReason, &s.Version, &s.CreatedAt, &s.UpdatedAt, tenantID, subjectInternal}
}
func scanStory(row rowScanner) (Story, error) {
var s Story
@@ -839,21 +940,31 @@ func scanStory(row rowScanner) (Story, error) {
return s, err
}
func (s *Service) loadStoryChildren(ctx context.Context, story *Story) error {
rows, err := s.pool.Query(ctx, `SELECT ct.public_id::text,ct.sequence_no,ct.speaker,ct.final_text,ss.source_start_offset,ss.source_end_offset FROM story_draft sd JOIN story_source ss ON ss.story_revision_id=sd.current_revision_id JOIN conversation_turn ct ON ct.id=ss.conversation_turn_id WHERE sd.public_id=$1::uuid ORDER BY ct.sequence_no`, story.ID)
rows, err := s.pool.Query(ctx, `SELECT COALESCE(ct.public_id::text,u.utterance_id,it.id::text),COALESCE(ss.call_session_id::text,''),COALESCE(ss.call_utterance_id,''),ss.call_source_revision,
COALESCE(ct.sequence_no,it.sequence_no,row_number() OVER(ORDER BY u.source_at,u.utterance_id)),ss.source_role,COALESCE(ct.final_text,u.text_content,it.content),ss.source_start_offset,ss.source_end_offset
FROM story_draft sd JOIN story_source ss ON ss.story_revision_id=sd.current_revision_id
LEFT JOIN conversation_turn ct ON ct.id=ss.conversation_turn_id
LEFT JOIN call_utterance u ON u.session_id=ss.call_session_id AND u.utterance_id=ss.call_utterance_id AND u.source_revision=ss.call_source_revision
LEFT JOIN memoir_import_turn it ON it.id=ss.import_turn_id
WHERE sd.public_id=$1::uuid AND call_story_available(sd.id) ORDER BY ct.sequence_no,u.source_at,u.utterance_id`, story.ID)
if err != nil {
return err
}
story.Sources = []StorySource{}
for rows.Next() {
var x StorySource
if err := rows.Scan(&x.TurnID, &x.SequenceNo, &x.Speaker, &x.Text, &x.StartOffset, &x.EndOffset); err != nil {
if err := rows.Scan(&x.TurnID, &x.CallSessionID, &x.UtteranceID, &x.SourceRevision, &x.SequenceNo, &x.Speaker, &x.Text, &x.StartOffset, &x.EndOffset); err != nil {
rows.Close()
return err
}
story.Sources = append(story.Sources, x)
}
err = rows.Err()
rows.Close()
rows, err = s.pool.Query(ctx, `SELECT fc.public_id::text,fc.fact_type,fc.content,fc.statement_basis,fc.confidence,fc.sensitivity,fc.source_turn_ids,fc.status,fc.version FROM fact_candidate fc JOIN story_draft sd ON sd.id=fc.story_id WHERE sd.public_id=$1::uuid ORDER BY fc.created_at,fc.public_id`, story.ID)
if err != nil {
return err
}
rows, err = s.pool.Query(ctx, `SELECT fc.public_id::text,fc.fact_type,fc.content,fc.statement_basis,fc.confidence,fc.sensitivity,fc.source_turn_ids,fc.status,fc.version FROM fact_candidate fc JOIN story_draft sd ON sd.id=fc.story_id WHERE sd.public_id=$1::uuid AND call_story_available(sd.id) ORDER BY fc.created_at,fc.public_id`, story.ID)
if err != nil {
return err
}
@@ -877,7 +988,7 @@ func scanFactCandidate(row rowScanner, result *FactCandidate) error {
return json.Unmarshal(source, &result.SourceTurnIDs)
}
const memorySelectSQL = `SELECT cm.public_id::text,sp.public_id::text,mv.content,mv.source_refs,cm.status,cm.sensitivity,mv.version_no,cm.version,ua.public_id::text,mv.confirmed_at,COALESCE(mv.change_reason,''),COALESCE(mi.status,'PENDING'),COALESCE(mi.last_error,''),COALESCE(provider_mi.status,'DISABLED'),COALESCE(provider_mi.last_error,''),cm.created_at,cm.updated_at FROM curated_memory cm JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id JOIN memory_version mv ON mv.id=cm.current_version_id JOIN user_account ua ON ua.id=mv.confirmed_by LEFT JOIN memory_index_entry mi ON mi.memory_version_id=mv.id AND mi.index_type='pgvector' LEFT JOIN memory_index_entry provider_mi ON provider_mi.memory_version_id=mv.id AND provider_mi.index_type='provider'`
const memorySelectSQL = `SELECT cm.public_id::text,sp.public_id::text,mv.content,mv.source_refs,cm.status,cm.sensitivity,mv.version_no,cm.version,COALESCE(ua.public_id::text,''),mv.confirmed_at,COALESCE(mv.change_reason,''),COALESCE(mi.status,'PENDING'),COALESCE(mi.last_error,''),COALESCE(provider_mi.status,'DISABLED'),COALESCE(provider_mi.last_error,''),cm.created_at,cm.updated_at FROM curated_memory cm JOIN subject_profile sp ON sp.id=cm.subject_id AND sp.tenant_id=cm.tenant_id JOIN memory_version mv ON mv.id=cm.current_version_id LEFT JOIN user_account ua ON ua.id=mv.confirmed_by LEFT JOIN memory_index_entry mi ON mi.memory_version_id=mv.id AND mi.index_type='pgvector' LEFT JOIN memory_index_entry provider_mi ON provider_mi.memory_version_id=mv.id AND provider_mi.index_type='provider'`
func scanMemory(row rowScanner) (CuratedMemory, error) {
var m CuratedMemory
+43 -15
View File
@@ -22,21 +22,22 @@ const (
)
type DeviceBindingImpact struct {
DeviceID string `json:"device_id"`
BindingID string `json:"binding_id"`
Action string `json:"action"`
SourceHouseholdID string `json:"source_household_id"`
SourceSubjectID string `json:"source_subject_id"`
SourceSubjectName string `json:"source_subject_name"`
TargetHouseholdID *string `json:"target_household_id,omitempty"`
TargetSubjectID *string `json:"target_subject_id,omitempty"`
TargetSubjectName *string `json:"target_subject_name,omitempty"`
ActiveConversationID *string `json:"active_conversation_id,omitempty"`
PendingCommandCount int `json:"pending_command_count"`
ActiveFirmwareTaskCount int `json:"active_firmware_task_count"`
Allowed bool `json:"allowed"`
Blockers []string `json:"blockers"`
EvaluatedAt time.Time `json:"evaluated_at"`
DeviceID string `json:"device_id"`
BindingID string `json:"binding_id"`
Action string `json:"action"`
SourceHouseholdID string `json:"source_household_id"`
SourceSubjectID string `json:"source_subject_id"`
SourceSubjectName string `json:"source_subject_name"`
TargetHouseholdID *string `json:"target_household_id,omitempty"`
TargetSubjectID *string `json:"target_subject_id,omitempty"`
TargetSubjectName *string `json:"target_subject_name,omitempty"`
ActiveConversationID *string `json:"active_conversation_id,omitempty"`
PendingCommandCount int `json:"pending_command_count"`
ActiveFirmwareTaskCount int `json:"active_firmware_task_count"`
Allowed bool `json:"allowed"`
RequiresPhysicalConfirmation bool `json:"requires_physical_confirmation,omitempty"`
Blockers []string `json:"blockers"`
EvaluatedAt time.Time `json:"evaluated_at"`
}
type ChangeDeviceBindingInput struct {
@@ -72,6 +73,10 @@ func (s *Service) ChangeDeviceBinding(ctx context.Context, principal identity.Pr
if err != nil {
return err
}
if device.AccountBindingID != "" {
result, err = s.releaseAccountDevice(ctx, tx, principal, access, device, requestID, input)
return err
}
var bindingID string
if input.Action == DeviceBindingActionTransfer {
if err = devicebinding.RequireLegacy(ctx, tx, access.deviceID); err != nil {
@@ -146,12 +151,18 @@ func (s *Service) readDeviceBindingImpact(ctx context.Context, q queryRow, princ
if action != DeviceBindingActionUnbind && action != DeviceBindingActionTransfer {
return DeviceBindingImpact{}, ErrNotFound
}
if device.AccountBindingID != "" && action != DeviceBindingActionUnbind {
return DeviceBindingImpact{}, ErrNotFound
}
impact := DeviceBindingImpact{
DeviceID: device.ID, BindingID: device.BindingID, Action: action,
SourceHouseholdID: device.HouseholdID, SourceSubjectID: device.SubjectID, SourceSubjectName: device.SubjectName,
Blockers: make([]string, 0), EvaluatedAt: time.Now().UTC(),
}
if action == DeviceBindingActionTransfer {
if err := q.QueryRow(ctx, `SELECT enrollment_mode='physical_confirm' FROM device WHERE id=$1::uuid`, access.deviceID).Scan(&impact.RequiresPhysicalConfirmation); err != nil {
return DeviceBindingImpact{}, err
}
if targetSubjectPublicID == "" || targetSubjectPublicID == device.SubjectID {
impact.Blockers = append(impact.Blockers, "target_subject_required")
} else {
@@ -172,6 +183,13 @@ func (s *Service) readDeviceBindingImpact(ctx context.Context, q queryRow, princ
} else if !errors.Is(err, pgx.ErrNoRows) {
return DeviceBindingImpact{}, fmt.Errorf("check active device conversation: %w", err)
}
var activeCall bool
if err := q.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM call_session WHERE device_id=$1::uuid AND call_state IN ('PREPARING','STARTING','TALKING','STOPPING'))`, access.deviceID).Scan(&activeCall); err != nil {
return DeviceBindingImpact{}, err
}
if activeCall {
impact.Blockers = append(impact.Blockers, "active_call")
}
if err := q.QueryRow(ctx, `SELECT count(*) FROM device_command WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()`, access.deviceID, access.tenantID).Scan(&impact.PendingCommandCount); err != nil {
return DeviceBindingImpact{}, fmt.Errorf("count pending device commands: %w", err)
}
@@ -184,6 +202,16 @@ func (s *Service) readDeviceBindingImpact(ctx context.Context, q queryRow, princ
if impact.ActiveFirmwareTaskCount > 0 {
impact.Blockers = append(impact.Blockers, "active_firmware_task")
}
if s.Fleet != nil {
var active int
if err := q.QueryRow(ctx, `SELECT count(*) FROM fleet_ota WHERE device_id=$1 AND status IN ('pending','accepted')`, access.deviceID).Scan(&active); err != nil {
return DeviceBindingImpact{}, err
}
impact.ActiveFirmwareTaskCount += active
if active > 0 {
impact.Blockers = append(impact.Blockers, "active_mqtt_ota")
}
}
impact.Allowed = len(impact.Blockers) == 0
return impact, nil
}
+35 -2
View File
@@ -17,6 +17,7 @@ import (
)
type ManagedDevice struct {
AccountBindingID string `json:"account_binding_id,omitempty"`
ID string `json:"id"`
BindingID string `json:"binding_id"`
HouseholdID string `json:"household_id"`
@@ -65,6 +66,8 @@ type DevicePresence struct {
Source string `json:"source"`
}
var ErrDevicePowerUnsupported = errors.New("device has not reported power control support")
func (s *Service) ListDevices(ctx context.Context, principal identity.Principal) ([]ManagedDevice, error) {
rows, err := s.pool.Query(ctx, `
SELECT d.public_id::text,b.public_id::text,h.public_id::text,sp.public_id::text,sp.preferred_name,
@@ -99,7 +102,9 @@ func (s *Service) ListDevices(ctx context.Context, principal identity.Principal)
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate managed devices: %w", err)
}
return result, nil
rows.Close()
owned, err := s.listAccountDevices(ctx, principal)
return append(result, owned...), err
}
func (s *Service) GetDevice(ctx context.Context, principal identity.Principal, devicePublicID string) (ManagedDevice, error) {
@@ -126,6 +131,16 @@ func (s *Service) PatchDeviceTwin(ctx context.Context, principal identity.Princi
if err := concurrency.RequireVersion(expectedRevision, access.desiredRevision); err != nil {
return ErrVersionConflict
}
if _, changing := desired["power"]; changing {
twin, err := readDeviceTwin(ctx, tx, access)
if err != nil {
return err
}
power, ok := twin.ReportedConfig["power"].(map[string]any)
if !ok || (power["mode"] != "active" && power["mode"] != "standby" && power["mode"] != "transition") {
return ErrDevicePowerUnsupported
}
}
desiredJSON, _ := json.Marshal(desired)
if _, err := tx.Exec(ctx, `UPDATE device_twin SET desired_config=desired_config || $1::jsonb,desired_revision=desired_revision+1,updated_at=now() WHERE device_id=$2::uuid AND tenant_id=$3::uuid`, desiredJSON, access.deviceID, access.tenantID); err != nil {
return fmt.Errorf("update desired device configuration: %w", err)
@@ -149,6 +164,24 @@ func (s *Service) GetDevicePresence(ctx context.Context, principal identity.Prin
return DevicePresence{}, err
}
result := DevicePresence{DeviceID: devicePublicID, Online: false, Status: "offline", LastSeenAt: device.LastReportedAt, Firmware: device.FirmwareVersion, Source: "database"}
// Factory-authenticated hardware reports presence through MQTT, not the
// legacy conversation telemetry Redis key. Use the same source as mini OTA.
var fleetSeen *time.Time
var fleetOnline bool
err = s.pool.QueryRow(ctx, `SELECT COALESCE(connected AND last_seen_at>now()-interval '90 seconds',false),last_seen_at
FROM fleet_presence WHERE device_id=$1`, access.deviceID).Scan(&fleetOnline, &fleetSeen)
if err == nil {
result.Online = fleetOnline
if fleetOnline {
result.Status = "online"
}
result.LastSeenAt = fleetSeen
result.Source = "mqtt"
return result, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return DevicePresence{}, err
}
if s.redis == nil {
return result, nil
}
@@ -214,7 +247,7 @@ func resolveDevice(ctx context.Context, q queryRow, principal identity.Principal
&device.Status, &device.MinimumSecurityVersion, &device.MinimumFirmwareVersion, &device.FirmwareVersion, &device.DesiredRevision, &device.ReportedRevision,
&device.LastReportedAt, &device.BoundAt, &device.RegisteredAt, &device.Version)
if errors.Is(err, pgx.ErrNoRows) {
return deviceAccess{}, ManagedDevice{}, ErrAccessDenied
return resolveAccountDevice(ctx, q, principal, publicID, capability, lock)
}
if err != nil {
return deviceAccess{}, ManagedDevice{}, fmt.Errorf("resolve device access: %w", err)
@@ -0,0 +1,113 @@
package saas
import (
"context"
"errors"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/sharing"
"github.com/google/uuid"
)
func directStoryReviewFixture(t *testing.T) (homeFixture, string, string) {
t.Helper()
f := newHomeFixture(t, "Asia/Shanghai")
f.service.audit = audit.NewRepository()
session := f.direct(t, time.Now().UTC(), time.Minute, "ENDED")
f.exec(t, `UPDATE call_session SET budget_snapshot=budget_snapshot || '{"story_extraction_allowed":true}'::jsonb WHERE id=$1::uuid`, session)
f.exec(t, `INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES($1::uuid,'native-user-turn',1,'USER','小时候我和爷爷在河边种了一棵树,爷爷教我每天浇水。',false,now())`, session)
story, public, revision := uuid.NewString(), uuid.NewString(), uuid.NewString()
f.exec(t, `INSERT INTO story_draft(id,public_id,tenant_id,subject_id,call_session_id,call_privacy_epoch,title,generation_version) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',$3::uuid,$4::uuid,0,'和爷爷种树',1)`, story, public, f.subject, session)
f.exec(t, `INSERT INTO story_revision(id,story_id,revision_no,content,author_type,model_ref,prompt_version) VALUES($1::uuid,$2::uuid,1,'小时候和爷爷在河边种树。','model','test','test')`, revision, story)
f.exec(t, `INSERT INTO story_source(story_revision_id,call_session_id,call_utterance_id,call_source_revision,source_start_offset,source_end_offset,source_role) VALUES($1::uuid,$2::uuid,'native-user-turn',1,0,10,'subject')`, revision, session)
f.exec(t, `UPDATE story_draft SET current_revision_id=$2::uuid WHERE id=$1::uuid`, story, revision)
return f, public, session
}
func TestDirectStoryReviewRequiresSourceAccessAndCurrentEvidence(t *testing.T) {
f, public, session := directStoryReviewFixture(t)
ctx := context.Background()
story, err := f.service.GetStory(ctx, f.owner, public)
if err != nil || story.ConversationID != "" || story.CallSessionID != session || len(story.Sources) != 1 {
t.Fatalf("native story=%+v err=%v", story, err)
}
source := story.Sources[0]
if source.TurnID != "native-user-turn" || source.UtteranceID != source.TurnID || source.CallSessionID != session || source.SourceRevision == nil || *source.SourceRevision != 1 {
t.Fatalf("native source=%+v", source)
}
editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject}
outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject}
for _, principal := range []identity.Principal{editor, outsider} {
if _, err := f.service.GetStory(ctx, principal, public); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("ungranted source read error=%v", err)
}
}
f.exec(t, `INSERT INTO memory_access_grant(subject_id,user_id,can_read,can_write,sensitive_read) VALUES($1::uuid,'10000000-0000-0000-0000-000000000102',true,false,false)`, f.subject)
items, err := f.service.ListSubjectStories(ctx, editor, f.public, "")
if err != nil || len(items) != 0 {
t.Fatalf("source access required for native list: items=%+v err=%v", items, err)
}
f.exec(t, `UPDATE memory_access_grant SET sensitive_read=true WHERE subject_id=$1::uuid`, f.subject)
if _, err = f.service.GetStory(ctx, editor, public); err != nil {
t.Fatalf("granted source read: %v", err)
}
if _, err = f.service.ReviseStory(ctx, editor, public, "修改", "核对", "native-story-denied", 1, ""); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("read-only grant allowed editing: %v", err)
}
f.exec(t, `UPDATE call_utterance SET source_revision=2,text_content='更正后的原话' WHERE session_id=$1::uuid`, session)
if _, err = f.service.GetStory(ctx, f.owner, public); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("stale source read: %v", err)
}
if _, _, err = f.service.ConfirmStory(ctx, f.owner, public, "核对", "native-story-stale", 1); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("stale source confirmation: %v", err)
}
items, err = f.service.ListSubjectStories(ctx, f.owner, f.public, "")
if err != nil || len(items) != 0 {
t.Fatalf("stale source list: items=%+v err=%v", items, err)
}
}
type directStorySharingStore struct{}
func (directStorySharingStore) PresignMediaDownload(context.Context, string, time.Duration, string, string, bool) (string, error) {
return "https://objects.invalid/unused", nil
}
func TestDirectStorySharedEditPreservesNativeSourcesAndWithdrawalHidesIt(t *testing.T) {
f, public, session := directStoryReviewFixture(t)
ctx := context.Background()
service, err := sharing.NewService(f.pool, audit.NewRepository(), directStorySharingStore{}, []byte(strings.Repeat("s", 32)), time.Minute)
if err != nil {
t.Fatal(err)
}
var recipient string
if err := f.pool.QueryRow(ctx, `SELECT public_id::text FROM user_account WHERE id='10000000-0000-0000-0000-000000000102'`).Scan(&recipient); err != nil {
t.Fatal(err)
}
grant, _, err := service.Create(ctx, f.owner, uuid.NewString(), "native-sharing-create", sharing.CreateInput{ResourceType: "STORY", ResourceID: public, GranteeUserID: recipient, Permission: "EDIT", Purpose: "核对回忆", ExpiresAt: time.Now().Add(time.Hour), LoginRequired: true})
if err != nil {
t.Fatal(err)
}
editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject}
if _, err = service.EditStory(ctx, editor, grant.ID, "小时候我和爷爷一起在河边种树。", "核对原话", "native-sharing-edit", 1); err != nil {
t.Fatal(err)
}
story, err := f.service.GetStory(ctx, f.owner, public)
if err != nil || story.RevisionNo != 2 || len(story.Sources) != 1 || story.Sources[0].CallSessionID != session || story.Sources[0].UtteranceID != "native-user-turn" {
t.Fatalf("shared revision lost evidence: story=%+v err=%v", story, err)
}
if _, err = service.Access(ctx, editor, grant.ID, "native-sharing-read"); err != nil {
t.Fatal(err)
}
f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='story_extraction'`, f.subject)
if _, err = service.Access(ctx, editor, grant.ID, "native-sharing-withdrawn-read"); err == nil {
t.Fatal("shared story remained readable after withdrawal")
}
if _, err = service.EditStory(ctx, editor, grant.ID, "撤回后修改", "应拒绝", "native-sharing-withdrawn-edit", 2); err == nil {
t.Fatal("shared story remained editable after withdrawal")
}
}
+38
View File
@@ -0,0 +1,38 @@
package saas
import (
"context"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/devicefleet"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5/pgxpool"
"os"
"testing"
)
func TestFleetTenantAndHouseholdAccess(t *testing.T) {
if os.Getenv("TEST_DATABASE_URL") == "" {
t.Skip("integration database required")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
defer pool.Close()
s := NewService(pool, audit.NewRepository(), nil, nil)
s.Fleet = &devicefleet.Service{Pool: pool}
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
if _, err = s.FleetState(ctx, owner, "10000000-0000-0000-0000-000000000511", ""); err != nil {
t.Fatal(err)
}
if _, err = s.FleetState(ctx, owner, "10000000-0000-0000-0000-000000000512", ""); err == nil {
t.Fatal("unbound device accessible")
}
if _, err = s.FleetTenant(ctx, owner, testTenantPublicID, true); err == nil {
t.Fatal("customer uploaded firmware")
}
if _, err = s.FleetState(ctx, owner, "10000000-0000-0000-0000-000000000511", testTenantPublicID); err == nil {
t.Fatal("customer spoofed operator tenant")
}
}
+51
View File
@@ -0,0 +1,51 @@
package saas
import (
"context"
"data-terminal-cloud/internal/devicefleet"
"data-terminal-cloud/internal/identity"
"errors"
"github.com/jackc/pgx/v5"
)
func (s *Service) FleetTenant(ctx context.Context, p identity.Principal, public string, mutate bool) (string, error) {
if s.Fleet == nil {
return "", errors.New("fleet unavailable")
}
a, err := resolveOpsFirmwareAccess(ctx, s.pool, p, public, mutate)
return a.tenantID, err
}
func (s *Service) FleetState(ctx context.Context, p identity.Principal, public, tenant string) (devicefleet.State, error) {
if s.Fleet == nil {
return devicefleet.State{}, errors.New("fleet unavailable")
}
var id string
if tenant != "" {
a, err := resolveOpsFirmwareAccess(ctx, s.pool, p, tenant, false)
if err != nil {
return devicefleet.State{}, err
}
if err = s.pool.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1 AND tenant_id=$2`, public, a.tenantID).Scan(&id); err != nil {
return devicefleet.State{}, ErrAccessDenied
}
} else {
a, _, err := resolveDevice(ctx, s.pool, p, public, identity.CapabilityHouseholdRead, false)
if err != nil {
return devicefleet.State{}, err
}
id = a.deviceID
}
return s.Fleet.State(ctx, id)
}
func (s *Service) FleetOTA(ctx context.Context, p identity.Principal, public, request string, code int64) (devicefleet.Task, error) {
if s.Fleet == nil {
return devicefleet.Task{}, errors.New("fleet unavailable")
}
return s.Fleet.Start(ctx, request, code, func(tx pgx.Tx) (string, error) {
a, _, err := resolveDevice(ctx, tx, p, public, identity.CapabilityDeviceManage, true)
if err != nil {
return "", err
}
return a.deviceID, nil
})
}
+32
View File
@@ -0,0 +1,32 @@
package saas
import (
"testing"
"time"
)
func TestHomeCalendarWeekUsesBeijingAcrossHostZonesAndBoundaries(t *testing.T) {
cases := []struct{ instant, start, end string }{
{"2026-10-04T15:59:59Z", "2026-09-28", "2026-10-05"},
{"2026-10-04T16:00:00Z", "2026-10-05", "2026-10-12"},
{"2026-10-05T16:00:00Z", "2026-10-05", "2026-10-12"},
{"2025-12-31T16:00:00Z", "2025-12-29", "2026-01-05"},
{"2026-03-08T16:00:00Z", "2026-03-09", "2026-03-16"},
}
for _, tc := range cases {
at, err := time.Parse(time.RFC3339, tc.instant)
if err != nil {
t.Fatal(err)
}
for _, offset := range []int{-7 * 3600, 0, 8 * 3600, 9 * 3600} {
start, end, err := homeCalendarWeek(at.In(time.FixedZone("host", offset)))
if err != nil {
t.Fatal(err)
}
_, gotOffset := start.Zone()
if start.Location().String() != "Asia/Shanghai" || gotOffset != 8*3600 || start.Format("2006-01-02") != tc.start || end.Format("2006-01-02") != tc.end || start.Hour() != 0 || end.Sub(start) != 7*24*time.Hour {
t.Fatalf("instant=%s hostOffset=%d start=%s end=%s", tc.instant, offset, start, end)
}
}
}
}
+251
View File
@@ -0,0 +1,251 @@
package saas
import (
"context"
"errors"
"time"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type HomeOverviewDay struct {
Date string `json:"date"`
ConversationCount int `json:"conversation_count"`
DurationMS int64 `json:"duration_ms"`
}
type HomeOverviewConversation struct {
ID string `json:"id"`
Source string `json:"source"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
DurationMS int64 `json:"duration_ms"`
Summary string `json:"summary,omitempty"`
SummaryState string `json:"summary_state"`
SummaryKind string `json:"summary_kind,omitempty"`
}
type HomeOverview struct {
SubjectID string `json:"subject_id"`
Timezone string `json:"timezone"`
WeekStart string `json:"week_start"`
WeekEnd string `json:"week_end"`
ConversationCount int `json:"conversation_count"`
TotalDurationMS int64 `json:"total_duration_ms"`
Daily []HomeOverviewDay `json:"daily"`
LatestConversation *HomeOverviewConversation `json:"latest_conversation"`
}
// These are separate session stores, not joins against turns or memories: a
// session contributes once regardless of how many utterances or memories it has.
// DIRECT has no reliable connected_at. Its existing created_at -> ended_at
// duration is retained, but a finalized USER/ASSISTANT utterance must establish
// actual interaction so failed preparations and abandoned leases do not count.
const homeConversationsSQL = `WITH eligible AS (
SELECT c.public_id AS id,c.id AS internal_id,'conversation'::text AS source,c.status,c.started_at,c.ended_at
FROM conversation c
WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid
AND c.status IN ('COMPLETED','FAILED','EXPIRED') AND c.started_at IS NOT NULL
AND c.ended_at>c.started_at AND c.ended_at<=$3
AND NOT EXISTS(SELECT 1 FROM memory_source_suppression ss WHERE ss.subject_id=c.subject_id AND ss.scope='SOURCE_AND_DERIVED'
AND (ss.source_refs @> jsonb_build_array(jsonb_build_object('conversation_id',c.public_id::text))
OR ss.source_refs @> jsonb_build_array(jsonb_build_object('conversation_id',c.id::text))
OR EXISTS(SELECT 1 FROM story_draft sd WHERE sd.conversation_id=c.id AND sd.subject_id=c.subject_id
AND ss.source_refs @> jsonb_build_array(jsonb_build_object('story_id',sd.public_id::text)))))
UNION ALL
SELECT c.id,c.id,'call_memory',c.call_state,c.created_at,c.ended_at
FROM call_session c
WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid AND c.budget_snapshot->>'protocol'='DIRECT-CHAT-1'
AND c.call_state IN ('ENDED','FAILED','CANCELLED') AND c.ended_at>c.created_at AND c.ended_at<=$3
AND EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker IN ('USER','ASSISTANT')
AND regexp_replace(u.text_content,'[[:space:]]','','g') NOT IN ('','[deleted]'))
AND NOT EXISTS(SELECT 1 FROM memory_source_suppression ss WHERE ss.subject_id=c.subject_id AND ss.scope='SOURCE_AND_DERIVED'
AND ss.source_refs @> jsonb_build_array(jsonb_build_object('session_id',c.id::text)))
), latest AS (SELECT source,id FROM eligible ORDER BY started_at DESC,id DESC,source DESC LIMIT 1)
SELECT e.id::text,e.internal_id::text,e.source,e.status,e.started_at,e.ended_at
FROM eligible e WHERE (e.started_at >= $4 AND e.started_at < $5)
OR EXISTS(SELECT 1 FROM latest l WHERE l.source=e.source AND l.id=e.id)
ORDER BY e.started_at DESC,e.id DESC,e.source DESC`
// Home calendars use the product timezone even for historical profiles that
// store another timezone. This changes presentation boundaries, not the profile.
func homeCalendarWeek(now time.Time) (time.Time, time.Time, error) {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.Time{}, time.Time{}, err
}
local := now.In(location)
start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
start = start.AddDate(0, 0, -(int(start.Weekday())+6)%7)
return start, start.AddDate(0, 0, 7), nil
}
func (s *Service) SubjectHomeOverview(ctx context.Context, principal identity.Principal, subjectPublicID string) (HomeOverview, error) {
return s.subjectHomeOverviewAt(ctx, principal, subjectPublicID, time.Now())
}
func (s *Service) subjectHomeOverviewAt(ctx context.Context, principal identity.Principal, subjectPublicID string, now time.Time) (HomeOverview, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return HomeOverview{}, err
}
defer tx.Rollback(context.Background())
a, subject, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilityConversationRead, false)
if err != nil {
return HomeOverview{}, err
}
start, end, err := homeCalendarWeek(now)
if err != nil {
return HomeOverview{}, err
}
location := start.Location()
result := HomeOverview{SubjectID: subject.ID, Timezone: location.String(), WeekStart: start.Format("2006-01-02"), WeekEnd: end.Format("2006-01-02"), Daily: make([]HomeOverviewDay, 7)}
dayIndexes := map[string]int{}
for i := range result.Daily {
day := start.AddDate(0, 0, i).Format("2006-01-02")
result.Daily[i].Date = day
dayIndexes[day] = i
}
rows, err := tx.Query(ctx, homeConversationsSQL, a.tenantID, a.subjectID, now, start, end)
if err != nil {
return HomeOverview{}, err
}
var latestInternalID string
for rows.Next() {
var item HomeOverviewConversation
var internalID string
if err = rows.Scan(&item.ID, &internalID, &item.Source, &item.Status, &item.StartedAt, &item.EndedAt); err != nil {
rows.Close()
return HomeOverview{}, err
}
item.DurationMS = item.EndedAt.Sub(item.StartedAt).Milliseconds()
item.SummaryState = "UNAVAILABLE"
if result.LatestConversation == nil {
result.LatestConversation = &item
latestInternalID = internalID
}
// Entire duration belongs to the local start date, including midnight
// crossings. AddDate above preserves calendar weeks through DST.
if i, ok := dayIndexes[item.StartedAt.In(location).Format("2006-01-02")]; ok {
result.Daily[i].ConversationCount++
result.Daily[i].DurationMS += item.DurationMS
result.ConversationCount++
result.TotalDurationMS += item.DurationMS
}
}
err = rows.Err()
rows.Close()
if err != nil {
return HomeOverview{}, err
}
if result.LatestConversation != nil {
grant, grantErr := callmemory.ResolveAccess(ctx, tx, principal, subjectPublicID, false)
if grantErr == nil {
err = s.homeConversationSummary(ctx, tx, a.subjectID, latestInternalID, grant, result.LatestConversation)
if err != nil {
return HomeOverview{}, err
}
} else {
var denied *callmemory.Error
if !errors.As(grantErr, &denied) || denied.Status != 403 {
return HomeOverview{}, grantErr
}
}
}
return result, tx.Commit(ctx)
}
func (s *Service) homeConversationSummary(ctx context.Context, tx pgx.Tx, subject, id string, grant callmemory.Access, item *HomeOverviewConversation) error {
if item.Source == "call_memory" {
var allowed, hasUser, hasAssistant bool
var transcript, state, lastError string
err := tx.QueryRow(ctx, `SELECT c.memory_mode<>'OFF' AND managed_call_memory_authorized(c.subject_id)
AND c.privacy_epoch=COALESCE(mus.privacy_epoch,0),c.transcript_state,COALESCE(j.state,''),COALESCE(j.last_error,''),
EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker='USER' AND regexp_replace(u.text_content,'[[:space:]]','','g') NOT IN ('','[deleted]')),
EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker='ASSISTANT' AND regexp_replace(u.text_content,'[[:space:]]','','g') NOT IN ('','[deleted]'))
FROM call_session c LEFT JOIN memory_user_state mus ON mus.subject_id=c.subject_id
LEFT JOIN call_memory_sync j ON j.session_id=c.id AND j.transcript_revision=c.transcript_revision
WHERE c.id=$1::uuid AND c.subject_id=$2::uuid`, id, subject).Scan(&allowed, &transcript, &state, &lastError, &hasUser, &hasAssistant)
if err != nil {
return err
}
if !allowed {
return nil
}
// A current, active, normal memory from this exact session is a short
// memory recap, not a synthesized whole-conversation summary.
err = tx.QueryRow(ctx, `SELECT mv.content FROM managed_memory_origin o
JOIN curated_memory cm ON cm.id=o.memory_id AND cm.subject_id=o.subject_id
JOIN memory_version mv ON mv.id=cm.current_version_id AND mv.memory_id=cm.id
WHERE o.subject_id=$1::uuid AND o.session_id=$2::uuid AND cm.status='ACTIVE' AND cm.sensitivity='normal'
AND NOT EXISTS(SELECT 1 FROM memory_source_suppression ss WHERE ss.subject_id=cm.subject_id AND ss.source_refs @> mv.source_refs)
ORDER BY cm.created_at DESC,cm.public_id DESC LIMIT 1`, subject, id).Scan(&item.Summary)
if err == nil {
item.SummaryState = "READY"
item.SummaryKind = "memory"
return nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return err
}
switch {
case transcript == "DRAINING" || transcript == "COLLECTING":
item.SummaryState = "PROCESSING"
case !hasUser || !hasAssistant:
item.SummaryState = "INSUFFICIENT_TRANSCRIPT"
case lastError != "":
item.SummaryState = "SYNC_FAILED"
case state == "SYNCED" || state == "SKIPPED":
item.SummaryState = "NO_SUMMARY"
default:
item.SummaryState = "PROCESSING"
}
return nil
}
// Legacy summaries have no sensitivity classification; only a reader with
// sensitive access may see them. Metadata permission alone never exposes text.
if !grant.Sensitive {
return nil
}
var allowed, hasUser bool
var jobState string
err := tx.QueryRow(ctx, `SELECT c.retention_mode<>'none'
AND EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=c.subject_id AND cr.consent_type='transcript_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))
AND EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=c.subject_id AND cr.consent_type='story_extraction' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now())),
EXISTS(SELECT 1 FROM conversation_turn ct WHERE ct.conversation_id=c.id AND ct.speaker='subject' AND regexp_replace(ct.final_text,'[[:space:]]','','g') NOT IN ('','[deleted]')),
COALESCE((SELECT o.status FROM outbox_event o WHERE o.aggregate_id=c.id AND o.event_type='conversation.content_extraction_requested' ORDER BY o.created_at DESC,o.id DESC LIMIT 1),'')
FROM conversation c WHERE c.id=$1::uuid AND c.subject_id=$2::uuid`, id, subject).Scan(&allowed, &hasUser, &jobState)
if err != nil {
return err
}
if !allowed {
return nil
}
var state string
err = tx.QueryRow(ctx, `SELECT content,status FROM conversation_summary WHERE subject_id=$1::uuid AND conversation_id=$2::uuid AND summary_type='conversation_summary' AND status<>'SUPERSEDED' ORDER BY generation_version DESC LIMIT 1`, subject, id).Scan(&item.Summary, &state)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
if err == nil && state == "READY" {
item.SummaryState = "READY"
item.SummaryKind = "conversation_summary"
return nil
}
item.Summary = ""
switch {
case !hasUser:
item.SummaryState = "INSUFFICIENT_TRANSCRIPT"
case state == "FAILED" || jobState == "dead":
item.SummaryState = "SYNC_FAILED"
case state == "PROCESSING" || jobState == "pending" || jobState == "processing":
item.SummaryState = "PROCESSING"
default:
item.SummaryState = "NO_SUMMARY"
}
return nil
}
@@ -0,0 +1,309 @@
package saas
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type homeFixture struct {
pool *pgxpool.Pool
service *Service
subject, public, device, snapshot, contextID string
owner identity.Principal
}
func newHomeFixture(t *testing.T, zone string) homeFixture {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL required")
}
pool, err := pgxpool.New(context.Background(), url)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
f := homeFixture{pool: pool, service: NewService(pool, nil, nil, nil), subject: uuid.NewString(), public: uuid.NewString(), device: uuid.NewString(), snapshot: uuid.NewString(), contextID: uuid.NewString(), owner: identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}}
f.exec(t, `INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status,timezone) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','home-test','active',$3)`, f.subject, f.public, zone)
f.exec(t, `INSERT INTO memory_user_state(subject_id,tenant_id,legacy_projection_pending) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',false)`, f.subject)
f.exec(t, `INSERT INTO consent_record(tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref) SELECT tenant_id,$1::uuid,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref FROM consent_record WHERE subject_id='10000000-0000-0000-0000-000000000401' AND withdrawn_at IS NULL`, f.subject)
f.exec(t, `INSERT INTO consent_snapshot(id,tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',$2::uuid,1,'{"voice_chat":true}','[]')`, f.snapshot, f.subject)
f.exec(t, `INSERT INTO context_snapshot(id,tenant_id,subject_id,profile_version,consent_snapshot_id,prompt_bundle_version,engine_mode,content_digest) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',$2::uuid,1,$3::uuid,'test','s2s_hybrid',$4)`, f.contextID, f.subject, f.snapshot, strings.Repeat("0", 64))
f.exec(t, `INSERT INTO device(id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',$2,'test','test','registered','local-dev://home-test','1','1')`, f.device, strings.ReplaceAll(f.device, "-", "")+strings.ReplaceAll(f.device, "-", ""))
return f
}
func (f homeFixture) exec(t *testing.T, q string, args ...any) {
t.Helper()
if _, err := f.pool.Exec(context.Background(), q, args...); err != nil {
t.Fatal(err)
}
}
func (f homeFixture) read(t *testing.T, now time.Time) HomeOverview {
t.Helper()
v, err := f.service.subjectHomeOverviewAt(context.Background(), f.owner, f.public, now)
if err != nil {
t.Fatal(err)
}
return v
}
func (f homeFixture) direct(t *testing.T, start time.Time, duration time.Duration, status string, speakers ...string) string {
t.Helper()
id := uuid.NewString()
var end any = start.Add(duration)
if duration < 0 {
end = nil
}
f.exec(t, `INSERT INTO call_session(id,device_id,tenant_id,subject_id,boot_id,hook_event_id,call_generation,binding_generation,call_state,transcript_state,transcript_revision,memory_mode,budget_snapshot,created_at,ended_at) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',$3::uuid,$1::uuid,$1,0,0,$4,'COMPLETE',1,'HOT_ONLY','{"protocol":"DIRECT-CHAT-1"}',$5,$6)`, id, f.device, f.subject, status, start, end)
for _, speaker := range speakers {
f.utterance(t, id, speaker, "fixture utterance", start.Add(time.Second))
}
return id
}
func (f homeFixture) utterance(t *testing.T, id, speaker, content string, at time.Time) {
t.Helper()
f.exec(t, `INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES($1::uuid,$2,1,$3,$4,false,$5)`, id, uuid.NewString(), speaker, content, at)
}
func (f homeFixture) legacy(t *testing.T, start time.Time, duration time.Duration, status string) (string, string) {
t.Helper()
id, pub := uuid.NewString(), uuid.NewString()
f.exec(t, `INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,created_at) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201',$3::uuid,$4::uuid,$5::uuid,$6::uuid,$1,'s2s_hybrid','transcript_only',$7,$8,$8,$9,$8)`, id, pub, f.subject, f.device, f.snapshot, f.contextID, status, start, start.Add(duration))
return id, pub
}
func (f homeFixture) memory(t *testing.T, session, content string) string {
t.Helper()
id, version := uuid.NewString(), uuid.NewString()
f.exec(t, `INSERT INTO curated_memory(id,tenant_id,subject_id,memory_type,sensitivity) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',$2::uuid,'EVENT','normal')`, id, f.subject)
f.exec(t, `INSERT INTO memory_version(id,memory_id,version_no,content,source_refs,content_digest) VALUES($1::uuid,$2::uuid,1,$3,jsonb_build_array(jsonb_build_object('session_id',$4::text)),$5)`, version, id, content, session, strings.Repeat("0", 64))
f.exec(t, `UPDATE curated_memory SET current_version_id=$2::uuid WHERE id=$1::uuid`, id, version)
f.exec(t, `INSERT INTO managed_memory_origin(memory_id,subject_id,session_id,provider_id,provider_digest) VALUES($1::uuid,$2::uuid,$3::uuid,$1,$4)`, id, f.subject, session, strings.Repeat("0", 64))
return id
}
func TestHomeOverviewWholeWeekAndDirectInterruptedCalls(t *testing.T) {
f := newHomeFixture(t, "Asia/Shanghai")
other := newHomeFixture(t, "Asia/Shanghai")
loc, _ := time.LoadLocation("Asia/Shanghai")
monday := time.Date(2026, 9, 28, 0, 0, 0, 0, loc)
now := monday.AddDate(0, 0, 4).Add(12 * time.Hour)
for i := 0; i < 25; i++ {
f.direct(t, monday.Add(24*time.Hour+time.Duration(i)*time.Minute), time.Minute, "ENDED", "USER", "ASSISTANT", "ASSISTANT")
}
f.legacy(t, monday.Add(48*time.Hour), 2*time.Minute, "COMPLETED")
f.direct(t, monday.Add(23*time.Hour+59*time.Minute), 2*time.Minute, "ENDED", "ASSISTANT")
latest := f.direct(t, monday.Add(72*time.Hour), 98*time.Second, "FAILED", "USER", "ASSISTANT")
f.memory(t, latest, "latest direct recap")
f.direct(t, monday.Add(-time.Minute), 2*time.Minute, "ENDED", "USER", "ASSISTANT") // starts in the prior week
f.direct(t, now.Add(-time.Hour), time.Minute, "FAILED") // failed preparation, no interaction
deleted := f.direct(t, now.Add(-30*time.Minute), time.Minute, "ENDED")
f.utterance(t, deleted, "USER", "[deleted]", now.Add(-29*time.Minute))
f.direct(t, now.Add(-time.Minute), -1, "TALKING", "USER")
f.direct(t, now.Add(time.Hour), time.Minute, "ENDED", "USER")
other.direct(t, now.Add(-time.Minute), time.Second, "ENDED", "USER")
result := f.read(t, now)
if result.ConversationCount != 28 || result.TotalDurationMS != 1838000 || len(result.Daily) != 7 {
t.Fatalf("week=%+v", result)
}
if result.WeekStart != "2026-09-28" || result.WeekEnd != "2026-10-05" || result.Daily[0].DurationMS != 120000 || result.Daily[1].ConversationCount != 25 {
t.Fatalf("calendar=%+v", result)
}
if result.LatestConversation == nil || result.LatestConversation.ID != latest || result.LatestConversation.Status != "FAILED" || result.LatestConversation.DurationMS != 98000 || result.LatestConversation.SummaryKind != "memory" || result.LatestConversation.Summary != "latest direct recap" {
t.Fatalf("latest=%+v", result.LatestConversation)
}
}
func TestHomeOverviewLatestOutsideWeekAndDST(t *testing.T) {
f := newHomeFixture(t, "America/New_York")
loc, _ := time.LoadLocation("America/New_York")
now := time.Date(2026, 3, 9, 12, 0, 0, 0, loc)
a := f.direct(t, time.Date(2026, 3, 8, 1, 59, 0, 0, loc), 2*time.Minute, "ENDED", "USER")
b := f.direct(t, time.Date(2026, 3, 8, 1, 59, 0, 0, loc), 2*time.Minute, "ENDED", "USER")
latest := a
if b > a {
latest = b
}
result := f.read(t, now)
if result.ConversationCount != 0 || result.TotalDurationMS != 0 || result.LatestConversation == nil || result.LatestConversation.ID != latest || result.WeekStart != "2026-03-09" || result.WeekEnd != "2026-03-16" {
t.Fatalf("outside week=%+v", result)
}
// The historical New York profile does not change the Beijing calendar.
// Query one second before Monday in Beijing, still in the prior week.
result = f.read(t, time.Date(2026, 3, 8, 15, 59, 59, 0, time.UTC))
if result.WeekStart != "2026-03-02" || result.WeekEnd != "2026-03-09" || result.Daily[6].DurationMS != 240000 || result.ConversationCount != 2 {
t.Fatalf("DST week=%+v", result)
}
}
func TestHomeOverviewHistoricalProfileTimezoneUsesBeijingWeek(t *testing.T) {
f := newHomeFixture(t, "America/Los_Angeles")
before, _ := time.Parse(time.RFC3339, "2026-10-04T15:58:00Z")
after, _ := time.Parse(time.RFC3339, "2026-10-04T16:01:00Z")
f.direct(t, before, time.Minute, "ENDED", "USER")
id := f.direct(t, after, 12*time.Second, "FAILED", "ASSISTANT")
result := f.read(t, after.Add(time.Minute))
if result.Timezone != "Asia/Shanghai" || result.WeekStart != "2026-10-05" || result.WeekEnd != "2026-10-12" || result.ConversationCount != 1 || result.TotalDurationMS != 12000 || result.LatestConversation == nil || result.LatestConversation.ID != id {
t.Fatalf("Beijing week for historical profile=%+v", result)
}
var savedTimezone string
if err := f.pool.QueryRow(context.Background(), `SELECT timezone FROM subject_profile WHERE id=$1::uuid`, f.subject).Scan(&savedTimezone); err != nil {
t.Fatal(err)
}
if savedTimezone != "America/Los_Angeles" {
t.Fatalf("profile timezone was modified: %s", savedTimezone)
}
}
func TestHomeOverviewDirectSummaryStatesAndNoOlderFallback(t *testing.T) {
f := newHomeFixture(t, "Asia/Shanghai")
now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour)
old := f.direct(t, now.Add(-time.Hour), time.Minute, "ENDED", "USER", "ASSISTANT")
f.memory(t, old, "older recap must not substitute")
latest := f.direct(t, now.Add(-10*time.Minute), time.Minute, "FAILED", "ASSISTANT")
f.exec(t, `UPDATE call_session SET transcript_state='DRAINING' WHERE id=$1::uuid`, latest)
assert := func(want string) {
t.Helper()
v := f.read(t, now).LatestConversation
if v == nil || v.ID != latest || v.SummaryState != want || v.Summary != "" {
t.Fatalf("want %s latest=%+v", want, v)
}
}
assert("PROCESSING")
f.exec(t, `UPDATE call_session SET transcript_state='PARTIAL' WHERE id=$1::uuid`, latest)
assert("INSUFFICIENT_TRANSCRIPT")
f.utterance(t, latest, "USER", "new user turn", now.Add(-9*time.Minute))
assert("PROCESSING")
f.exec(t, `INSERT INTO call_memory_sync(session_id,transcript_revision,state,last_error) VALUES($1::uuid,1,'PENDING','provider unavailable')`, latest)
assert("SYNC_FAILED")
f.exec(t, `UPDATE call_memory_sync SET state='UPLOADED',last_error=NULL WHERE session_id=$1::uuid`, latest)
assert("PROCESSING")
f.exec(t, `UPDATE call_memory_sync SET state='SYNCED' WHERE session_id=$1::uuid`, latest)
assert("NO_SUMMARY")
// A result for an older transcript revision cannot claim the current one synced.
f.exec(t, `UPDATE call_session SET transcript_revision=2 WHERE id=$1::uuid`, latest)
assert("PROCESSING")
}
func TestHomeOverviewSummaryAuthorizationAndPrivacy(t *testing.T) {
f := newHomeFixture(t, "Asia/Shanghai")
now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour)
id := f.direct(t, now.Add(-time.Hour), time.Minute, "ENDED", "USER", "ASSISTANT")
memory := f.memory(t, id, "normal recap")
editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject}
readEditor := func(want string) {
t.Helper()
v, err := f.service.subjectHomeOverviewAt(context.Background(), editor, f.public, now)
if err != nil || v.ConversationCount != 1 || v.LatestConversation == nil || v.LatestConversation.SummaryState != want {
t.Fatalf("editor=%+v err=%v", v, err)
}
if want != "READY" && v.LatestConversation.Summary != "" {
t.Fatal("text exposed without permission")
}
}
readEditor("UNAVAILABLE")
f.exec(t, `INSERT INTO memory_access_grant(subject_id,user_id,can_read,can_write,sensitive_read) VALUES($1::uuid,'10000000-0000-0000-0000-000000000102',true,false,false)`, f.subject)
readEditor("READY")
if _, err := f.service.subjectHomeOverviewAt(context.Background(), identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject}, f.public, now); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("outsider error=%v", err)
}
f.exec(t, `UPDATE memory_user_state SET privacy_epoch=privacy_epoch+1 WHERE subject_id=$1::uuid`, f.subject)
readEditor("UNAVAILABLE")
f.exec(t, `UPDATE call_session SET privacy_epoch=(SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$2::uuid) WHERE id=$1::uuid`, id, f.subject)
readEditor("READY")
f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='transcript_storage'`, f.subject)
readEditor("UNAVAILABLE")
// Even metadata remains subject-scoped; summary consent does not remove the
// actual completed duration, while explicit source deletion removes the row.
f.exec(t, `INSERT INTO memory_source_suppression(subject_id,memory_id,source_refs,privacy_epoch,scope) VALUES($1::uuid,$3::uuid,jsonb_build_array(jsonb_build_object('session_id',$2::text)),1,'SOURCE_AND_DERIVED')`, f.subject, id, memory)
result := f.read(t, now)
if result.LatestConversation != nil || result.ConversationCount != 0 {
t.Fatalf("deleted source resurfaced=%+v", result)
}
}
func TestHomeOverviewExpiredConsentAndDisabledMemory(t *testing.T) {
for _, kind := range []string{"personalized_memory", "transcript_storage"} {
t.Run(kind, func(t *testing.T) {
f := newHomeFixture(t, "Asia/Shanghai")
now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour)
id := f.direct(t, now.Add(-time.Hour), time.Minute, "ENDED", "USER", "ASSISTANT")
f.memory(t, id, "must remain private")
f.exec(t, `UPDATE consent_record SET granted_at=now()-interval '1 day',expires_at=now()-interval '1 second' WHERE subject_id=$1::uuid AND consent_type=$2`, f.subject, kind)
// Re-align epoch so the authorization check itself, not just the fence, is tested.
f.exec(t, `UPDATE call_session SET privacy_epoch=(SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$2::uuid) WHERE id=$1::uuid`, id, f.subject)
v := f.read(t, now)
if v.LatestConversation.SummaryState != "UNAVAILABLE" || v.LatestConversation.Summary != "" {
t.Fatalf("expired %s=%+v", kind, v)
}
})
}
f := newHomeFixture(t, "Asia/Shanghai")
now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour)
id := f.direct(t, now.Add(-time.Hour), time.Minute, "ENDED", "USER", "ASSISTANT")
memory := f.memory(t, id, "disabled recap")
f.exec(t, `UPDATE curated_memory SET status='DISABLED' WHERE id=$1::uuid`, memory)
f.exec(t, `UPDATE call_session SET privacy_epoch=(SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$2::uuid) WHERE id=$1::uuid`, id, f.subject)
f.exec(t, `INSERT INTO call_memory_sync(session_id,transcript_revision,state) VALUES($1::uuid,1,'SYNCED')`, id)
v := f.read(t, now)
if v.LatestConversation.SummaryState != "NO_SUMMARY" || v.LatestConversation.Summary != "" {
t.Fatalf("disabled=%+v", v)
}
}
func TestHomeOverviewLegacySummaryAndSensitiveGrant(t *testing.T) {
f := newHomeFixture(t, "Asia/Shanghai")
now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour)
id, pub := f.legacy(t, now.Add(-time.Hour), time.Minute, "FAILED")
turn := uuid.NewString()
f.exec(t, `INSERT INTO conversation_turn(id,tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type) VALUES($1::uuid,'10000000-0000-0000-0000-000000000001',$2::uuid,$3::uuid,$1,1,'subject',$4,$4,'fixture user text','provider')`, turn, f.subject, id, now.Add(-time.Hour))
f.exec(t, `INSERT INTO conversation_summary(tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version) VALUES('10000000-0000-0000-0000-000000000001',$1::uuid,$2::uuid,'conversation_summary','legacy recap',jsonb_build_array($3::text),'test','test',1)`, f.subject, id, turn)
result := f.read(t, now)
if result.LatestConversation.ID != pub || result.LatestConversation.Source != "conversation" || result.LatestConversation.Summary != "legacy recap" || result.LatestConversation.SummaryKind != "conversation_summary" {
t.Fatalf("legacy=%+v", result)
}
f.exec(t, `INSERT INTO memory_access_grant(subject_id,user_id,can_read,can_write,sensitive_read) VALUES($1::uuid,'10000000-0000-0000-0000-000000000102',true,false,false)`, f.subject)
editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject}
result, err := f.service.subjectHomeOverviewAt(context.Background(), editor, f.public, now)
if err != nil || result.LatestConversation.SummaryState != "UNAVAILABLE" || result.LatestConversation.Summary != "" {
t.Fatalf("legacy sensitive grant=%+v err=%v", result, err)
}
f.exec(t, `UPDATE memory_access_grant SET sensitive_read=true WHERE subject_id=$1::uuid`, f.subject)
result, err = f.service.subjectHomeOverviewAt(context.Background(), editor, f.public, now)
if err != nil || result.LatestConversation.Summary != "legacy recap" {
t.Fatalf("legacy authorized=%+v err=%v", result, err)
}
f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='story_extraction'`, f.subject)
result = f.read(t, now)
if result.LatestConversation.SummaryState != "UNAVAILABLE" || result.LatestConversation.Summary != "" {
t.Fatalf("legacy consent=%+v", result)
}
}
func TestHomeOverviewMemoryCorrectionPreservesCallStatistics(t *testing.T) {
f := newHomeFixture(t, "Asia/Shanghai")
now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour)
id := f.direct(t, now.Add(-time.Hour), time.Minute, "FAILED", "USER", "ASSISTANT")
memory := f.memory(t, id, "original recap")
before := f.read(t, now)
// A critical edit creates MEMORY_AND_DERIVED suppression and increments the
// epoch. Neither event means the historical call itself has been deleted.
f.exec(t, `UPDATE curated_memory SET sensitivity='sensitive' WHERE id=$1::uuid`, memory)
after := f.read(t, now)
if after.ConversationCount != before.ConversationCount || after.TotalDurationMS != before.TotalDurationMS || after.LatestConversation == nil || after.LatestConversation.ID != id || after.LatestConversation.Summary != "" {
t.Fatalf("memory correction changed call facts: before=%+v after=%+v", before, after)
}
// Re-aligning the epoch still cannot expose the sensitive memory in a normal
// homepage recap, even to an owner who can separately read sensitive records.
f.exec(t, `UPDATE call_session SET privacy_epoch=(SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$2::uuid) WHERE id=$1::uuid`, id, f.subject)
f.exec(t, `INSERT INTO call_memory_sync(session_id,transcript_revision,state) VALUES($1::uuid,1,'SYNCED')`, id)
after = f.read(t, now)
if after.LatestConversation.SummaryState != "NO_SUMMARY" || after.LatestConversation.Summary != "" {
t.Fatalf("sensitive recap exposed=%+v", after)
}
}
+77
View File
@@ -0,0 +1,77 @@
package saas
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"data-terminal-cloud/internal/audit"
"github.com/jackc/pgx/v5"
)
type InitialConsentSelection struct {
Types []string `json:"types"`
Confirmed bool `json:"confirmed"`
}
var ErrInitialConsentInvalid = errors.New("initial consent selection is invalid or unconfirmed")
var initialConsentNotices = map[string]string{
ConsentTranscriptStorage: "保存后续聊天的文字记录,便于查看聊天内容。",
ConsentRawAudioStorage: "保存后续聊天的原始录音;播放、下载仍需单独权限,设备支持录音上传时才能生成录音。",
ConsentStoryExtraction: "从后续聊天文字整理故事和摘要,需要同时开启字幕保存。",
ConsentPersonalMemory: "从后续聊天文字整理名字、偏好等记忆,并用于下次聊天;生成新记忆需要同时开启字幕保存。",
ConsentFamilySharing: "允许按后续独立分享规则向家庭成员分享内容;本项不会自动分享全部聊天或录音。",
}
func ValidateInitialConsents(selection InitialConsentSelection) error {
if len(selection.Types) > 0 && !selection.Confirmed {
return ErrInitialConsentInvalid
}
seen := map[string]bool{}
for _, kind := range selection.Types {
if _, ok := initialConsentNotices[kind]; !ok || seen[kind] {
return ErrInitialConsentInvalid
}
seen[kind] = true
}
return nil
}
// InitializeSubjectMemory is called only from new-profile creation, never from
// binding, profile edits, or reads, so a withdrawn permission stays withdrawn.
func InitializeSubjectMemory(ctx context.Context, tx pgx.Tx, tenantID, subjectPublicID, actorID, requestID string, selection InitialConsentSelection) error {
if err := ValidateInitialConsents(selection); err != nil {
return err
}
var subjectID, policy, actorPublicID string
if err := tx.QueryRow(ctx, `SELECT sp.id::text,t.policy_version,ua.public_id::text
FROM subject_profile sp JOIN tenant t ON t.id=sp.tenant_id
JOIN user_account ua ON ua.tenant_id=t.id AND ua.id=$3::uuid
WHERE sp.public_id=$1::uuid AND sp.tenant_id=$2::uuid AND ua.status='active'
FOR UPDATE OF sp`, subjectPublicID, tenantID, actorID).Scan(&subjectID, &policy, &actorPublicID); err != nil {
return fmt.Errorf("resolve initial memory consent: %w", err)
}
for _, kind := range selection.Types {
sum := sha256.Sum256([]byte(initialConsentNotices[kind] + "各项授权适用于后续新通话,可在数据与授权中分别撤回。"))
var consentID string
if err := tx.QueryRow(ctx, `INSERT INTO consent_record
(tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref)
VALUES($1::uuid,$2::uuid,$3,'{"scope":"future_conversations","source":"subject_creation_selection","notice_version":"creation-consents-v1"}'::jsonb,$4,$5,$6::uuid,$7)
RETURNING public_id::text`, tenantID, subjectID, kind, policy, hex.EncodeToString(sum[:]), actorID, "subject-create:"+requestID).Scan(&consentID); err != nil {
return fmt.Errorf("insert initial memory consent: %w", err)
}
if err := audit.NewRepository().Record(ctx, tx, audit.Event{
TenantID: &tenantID, ActorType: "user", ActorPublicID: &actorPublicID,
Action: "consent.grant", ResourceType: "consent", ResourceID: &consentID,
RequestID: requestID, Result: "success", Detail: map[string]any{
"subject_id": subjectPublicID, "consent_type": kind, "source": "subject_creation_selection", "notice_version": "creation-consents-v1",
},
}); err != nil {
return err
}
}
return createConsentSnapshot(ctx, tx, tenantID, subjectID)
}
+25
View File
@@ -0,0 +1,25 @@
package saas
import "testing"
func TestInitialConsentsRequireExplicitSelection(t *testing.T) {
for _, tc := range []struct {
name string
selection InitialConsentSelection
invalid bool
}{
{"legacy omission", InitialConsentSelection{}, false},
{"skip", InitialConsentSelection{Confirmed: true}, false},
{"subset", InitialConsentSelection{Types: []string{ConsentTranscriptStorage}, Confirmed: true}, false},
{"all", InitialConsentSelection{Types: consentTypes, Confirmed: true}, false},
{"unconfirmed", InitialConsentSelection{Types: []string{ConsentPersonalMemory}}, true},
{"unknown", InitialConsentSelection{Types: []string{"voice_chat"}, Confirmed: true}, true},
{"duplicate", InitialConsentSelection{Types: []string{ConsentPersonalMemory, ConsentPersonalMemory}, Confirmed: true}, true},
} {
t.Run(tc.name, func(t *testing.T) {
if err := ValidateInitialConsents(tc.selection); (err != nil) != tc.invalid {
t.Fatalf("error=%v invalid=%v", err, tc.invalid)
}
})
}
}
+22
View File
@@ -0,0 +1,22 @@
package saas
import (
"context"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
func (s *Service) checkMemoryGrant(ctx context.Context, q interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, p identity.Principal, id string, write bool) error {
var elder, sensitivity string
if e := q.QueryRow(ctx, `SELECT sp.public_id::text,cm.sensitivity FROM curated_memory cm JOIN subject_profile sp ON sp.id=cm.subject_id WHERE cm.public_id=$1::uuid`, id).Scan(&elder, &sensitivity); e != nil {
return ErrAccessDenied
}
a, e := callmemory.ResolveAccess(ctx, q, p, elder, write)
if e != nil || (!a.Sensitive && sensitivity != "normal") {
return ErrAccessDenied
}
return nil
}
@@ -0,0 +1,173 @@
package saas
import (
"context"
"errors"
"os"
"strings"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestPrivacyRequestReadsAfterSubjectDeletion(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
exec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatal(err)
}
}
tenantID, ownerID, adminID, outsiderID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
householdID, otherHouseholdID := uuid.NewString(), uuid.NewString()
issuer := "urn:test:privacy-read:" + tenantID
owner := identity.Principal{Issuer: issuer, Subject: ownerID}
admin := identity.Principal{Issuer: issuer, Subject: adminID}
outsider := identity.Principal{Issuer: issuer, Subject: outsiderID}
defer func() {
// DELETE request inserts invoke call_privacy_fence, which retains a
// memory_user_state row even when the synthetic subject has no memories.
for _, table := range []string{"web_event", "privacy_request", "memory_projection_job", "memory_user_state", "subject_profile", "household_member", "household", "user_account"} {
if _, err := pool.Exec(ctx, `DELETE FROM `+table+` WHERE tenant_id=$1::uuid`, tenantID); err != nil {
t.Errorf("clean up %s: %v", table, err)
}
}
if _, err := pool.Exec(ctx, `DELETE FROM tenant WHERE id=$1::uuid`, tenantID); err != nil {
t.Error(err)
}
}()
exec(`INSERT INTO tenant(id,name) VALUES($1::uuid,'synthetic privacy receipt test')`, tenantID)
for _, userID := range []string{ownerID, adminID, outsiderID} {
exec(`INSERT INTO user_account(id,tenant_id,oidc_issuer,oidc_subject) VALUES($1::uuid,$2::uuid,$3,$1::text)`, userID, tenantID, issuer)
}
exec(`INSERT INTO household(id,public_id,tenant_id,owner_user_id,name) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'receipt household'),($4::uuid,$4::uuid,$2::uuid,$5::uuid,'unrelated household')`, householdID, tenantID, ownerID, otherHouseholdID, outsiderID)
exec(`INSERT INTO household_member(tenant_id,household_id,user_id,role) VALUES($1::uuid,$2::uuid,$3::uuid,'owner'),($1::uuid,$2::uuid,$4::uuid,'admin'),($1::uuid,$5::uuid,$6::uuid,'owner')`, tenantID, householdID, ownerID, adminID, otherHouseholdID, outsiderID)
type receipt struct{ subjectID, requestID, status string }
receipts := make([]receipt, 0, 2)
for index, subjectStatus := range []string{"deleting", "deleted"} {
item := receipt{subjectID: uuid.NewString(), requestID: uuid.NewString(), status: []string{"EXECUTING", "COMPLETED"}[index]}
exec(`INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'[已删除]',$4)`, item.subjectID, tenantID, householdID, subjectStatus)
exec(`INSERT INTO privacy_request(id,public_id,tenant_id,subject_id,request_type,requested_by,status,idempotency_key,request_digest,completed_at) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'DELETE',$4::uuid,$5::varchar,$1::text,$6,CASE WHEN $5::varchar='COMPLETED' THEN now() ELSE NULL END)`, item.requestID, tenantID, item.subjectID, ownerID, item.status, strings.Repeat("a", 64))
exec(`INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref,status) VALUES($1::uuid,'postgresql','subject_personal_data',$2,$3)`, item.requestID, item.subjectID, []string{"PENDING", "COMPLETED"}[index])
receipts = append(receipts, item)
}
otherSubjectID, otherRequestID := uuid.NewString(), uuid.NewString()
exec(`INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'[已删除]','deleted')`, otherSubjectID, tenantID, otherHouseholdID)
exec(`INSERT INTO privacy_request(id,public_id,tenant_id,subject_id,request_type,requested_by,status,idempotency_key,request_digest) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'DELETE',$4::uuid,'COMPLETED',$1::text,$5)`, otherRequestID, tenantID, otherSubjectID, outsiderID, strings.Repeat("b", 64))
service := NewService(pool, audit.NewRepository(), nil, nil)
for _, principal := range []identity.Principal{owner, admin} {
items, err := service.ListHouseholdPrivacyRequests(ctx, principal, householdID)
if err != nil || len(items) != 2 {
t.Fatalf("household receipts: items=%+v err=%v", items, err)
}
for _, item := range items {
if item.ID == otherRequestID {
t.Fatal("another household's receipt leaked")
}
}
for _, expected := range receipts {
items, err := service.ListPrivacyRequests(ctx, principal, expected.subjectID)
if err != nil || len(items) != 1 || items[0].ID != expected.requestID || items[0].Status != expected.status {
t.Fatalf("subject receipt: items=%+v err=%v", items, err)
}
item, err := service.GetPrivacyRequest(ctx, principal, expected.requestID)
if err != nil || item.SubjectID != expected.subjectID || item.Status != expected.status || len(item.Subtasks) != 1 {
t.Fatalf("receipt details: item=%+v err=%v", item, err)
}
if expected.status == "COMPLETED" && (item.CompletedAt == nil || item.Subtasks[0].Status != "COMPLETED") {
t.Fatalf("terminal receipt evidence missing: %+v", item)
}
if _, err := service.GetSubject(ctx, principal, expected.subjectID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("deleted profile should remain inaccessible: %v", err)
}
}
}
if subjects, err := service.ListSubjects(ctx, owner, householdID); err != nil || len(subjects) != 0 {
t.Fatalf("deleted profiles returned in subject list: %+v err=%v", subjects, err)
}
t.Run("failed subtask exposes attention state without rewriting receipt", func(t *testing.T) {
item := receipts[0]
exec(`UPDATE privacy_subtask SET status='FAILED',last_error='synthetic provider failure' WHERE privacy_request_id=$1::uuid`, item.requestID)
assertStatus := func(want string) {
t.Helper()
bySubject, err := service.ListPrivacyRequests(ctx, owner, item.subjectID)
if err != nil || len(bySubject) != 1 || bySubject[0].Status != want {
t.Fatalf("subject list status: items=%+v err=%v want=%s", bySubject, err, want)
}
byHousehold, err := service.ListHouseholdPrivacyRequests(ctx, owner, householdID)
if err != nil {
t.Fatal(err)
}
found := false
for _, receipt := range byHousehold {
if receipt.ID == item.requestID {
found = true
if receipt.Status != want {
t.Fatalf("household list status=%s want=%s", receipt.Status, want)
}
}
}
if !found {
t.Fatal("household list omitted the receipt")
}
detail, err := service.GetPrivacyRequest(ctx, owner, item.requestID)
if err != nil || detail.Status != want {
t.Fatalf("detail status: item=%+v err=%v want=%s", detail, err, want)
}
}
assertStatus("PARTIAL_FAILED")
var persistedStatus string
if err := pool.QueryRow(ctx, `SELECT status FROM privacy_request WHERE id=$1::uuid`, item.requestID).Scan(&persistedStatus); err != nil || persistedStatus != "EXECUTING" {
t.Fatalf("read changed stored status=%s err=%v", persistedStatus, err)
}
exec(`UPDATE privacy_subtask SET status='COMPLETED',last_error=NULL,completed_at=now() WHERE privacy_request_id=$1::uuid`, item.requestID)
exec(`UPDATE privacy_request SET status='COMPLETED',completed_at=now() WHERE id=$1::uuid`, item.requestID)
assertStatus("COMPLETED")
})
assertDenied := func(t *testing.T, principal identity.Principal, householdID, subjectID, requestID string) {
t.Helper()
if _, err := service.ListHouseholdPrivacyRequests(ctx, principal, householdID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("household privacy read should be denied: %v", err)
}
if _, err := service.ListPrivacyRequests(ctx, principal, subjectID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("subject privacy read should be denied: %v", err)
}
if _, err := service.GetPrivacyRequest(ctx, principal, requestID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("privacy receipt details should be denied: %v", err)
}
}
t.Run("other household", func(t *testing.T) {
assertDenied(t, owner, otherHouseholdID, otherSubjectID, otherRequestID)
assertDenied(t, outsider, householdID, receipts[0].subjectID, receipts[0].requestID)
})
for _, role := range []string{"editor", "viewer"} {
t.Run(role+" lacks privacy.read", func(t *testing.T) {
exec(`UPDATE household_member SET role=$2 WHERE user_id=$1::uuid`, adminID, role)
assertDenied(t, admin, householdID, receipts[0].subjectID, receipts[0].requestID)
})
}
exec(`UPDATE household_member SET role='admin',status='removed' WHERE user_id=$1::uuid`, adminID)
t.Run("removed member", func(t *testing.T) { assertDenied(t, admin, householdID, receipts[0].subjectID, receipts[0].requestID) })
exec(`UPDATE household_member SET status='active' WHERE user_id=$1::uuid`, adminID)
exec(`UPDATE user_account SET status='disabled' WHERE id=$1::uuid`, adminID)
t.Run("disabled account", func(t *testing.T) { assertDenied(t, admin, householdID, receipts[0].subjectID, receipts[0].requestID) })
exec(`UPDATE tenant SET status='suspended' WHERE id=$1::uuid`, tenantID)
t.Run("suspended tenant", func(t *testing.T) { assertDenied(t, owner, householdID, receipts[0].subjectID, receipts[0].requestID) })
exec(`UPDATE tenant SET status='active' WHERE id=$1::uuid`, tenantID)
exec(`UPDATE household SET status='deleted' WHERE id=$1::uuid`, householdID)
t.Run("deleted household", func(t *testing.T) { assertDenied(t, owner, householdID, receipts[0].subjectID, receipts[0].requestID) })
}
+35 -4
View File
@@ -329,11 +329,36 @@ func containsString(values []string, target string) bool {
}
func (s *Service) ListPrivacyRequests(ctx context.Context, principal identity.Principal, subjectPublicID string) ([]PrivacyRequest, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilityPrivacyRead, false)
// The retained subject row links deletion receipts to their household even
// after normal subject reads are disabled. Never expose the deleted profile.
var tenantID, subjectID, householdPublicID string
err := s.pool.QueryRow(ctx, `SELECT tenant_id::text,id::text,(SELECT public_id::text FROM household WHERE id=sp.household_id AND tenant_id=sp.tenant_id) FROM subject_profile sp WHERE sp.public_id=$1::uuid`, subjectPublicID).Scan(&tenantID, &subjectID, &householdPublicID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrAccessDenied
}
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, privacyRequestSelectSQL+` WHERE pr.tenant_id=$1::uuid AND pr.subject_id=$2::uuid ORDER BY pr.created_at DESC,pr.id DESC`, access.tenantID, access.subjectID)
access, err := resolveHouseholdAccess(ctx, s.pool, principal, householdPublicID, identity.CapabilityPrivacyRead, false)
if err != nil {
return nil, err
}
if access.tenantID != tenantID {
return nil, ErrAccessDenied
}
return s.listPrivacyRequests(ctx, `pr.tenant_id=$1::uuid AND pr.subject_id=$2::uuid`, access.tenantID, subjectID)
}
func (s *Service) ListHouseholdPrivacyRequests(ctx context.Context, principal identity.Principal, householdPublicID string) ([]PrivacyRequest, error) {
access, err := resolveHouseholdAccess(ctx, s.pool, principal, householdPublicID, identity.CapabilityPrivacyRead, false)
if err != nil {
return nil, err
}
return s.listPrivacyRequests(ctx, `pr.tenant_id=$1::uuid AND sp.household_id=$2::uuid`, access.tenantID, access.householdID)
}
func (s *Service) listPrivacyRequests(ctx context.Context, where string, tenantID, resourceID string) ([]PrivacyRequest, error) {
rows, err := s.pool.Query(ctx, privacyRequestSelectSQL+` WHERE `+where+` ORDER BY pr.created_at DESC,pr.id DESC`, tenantID, resourceID)
if err != nil {
return nil, err
}
@@ -352,6 +377,7 @@ func (s *Service) ListPrivacyRequests(ctx context.Context, principal identity.Pr
func (s *Service) GetPrivacyRequest(ctx context.Context, principal identity.Principal, publicID string) (PrivacyRequest, error) {
item, err := scanPrivacyRequest(s.pool.QueryRow(ctx, privacyRequestSelectSQL+`
JOIN household h ON h.id=sp.household_id AND h.tenant_id=sp.tenant_id AND h.status<>'deleted'
JOIN tenant t ON t.id=pr.tenant_id AND t.status='active'
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
WHERE pr.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, publicID, principal.Issuer, principal.Subject))
@@ -362,7 +388,7 @@ func (s *Service) GetPrivacyRequest(ctx context.Context, principal identity.Prin
return PrivacyRequest{}, err
}
var role string
if err := s.pool.QueryRow(ctx, `SELECT hm.role FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id JOIN household_member hm ON hm.household_id=sp.household_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=pr.tenant_id AND ua.status='active' WHERE pr.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, publicID, principal.Issuer, principal.Subject).Scan(&role); err != nil {
if err := s.pool.QueryRow(ctx, `SELECT hm.role FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id AND sp.tenant_id=pr.tenant_id JOIN household h ON h.id=sp.household_id AND h.tenant_id=pr.tenant_id AND h.status<>'deleted' JOIN tenant t ON t.id=pr.tenant_id AND t.status='active' JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=pr.tenant_id AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=pr.tenant_id AND ua.status='active' WHERE pr.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, publicID, principal.Issuer, principal.Subject).Scan(&role); err != nil {
return PrivacyRequest{}, ErrAccessDenied
}
if !identity.Can(identity.Role(role), identity.CapabilityPrivacyRead) {
@@ -374,7 +400,12 @@ func (s *Service) GetPrivacyRequest(ctx context.Context, principal identity.Prin
return item, nil
}
const privacyRequestSelectSQL = `SELECT pr.public_id::text,sp.public_id::text,pr.request_type,pr.scope,pr.status,pr.deadline_at,pr.legal_hold,pr.result_summary,pr.version,pr.created_at,pr.updated_at,pr.completed_at FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id AND sp.tenant_id=pr.tenant_id`
// A worker interruption can leave the request EXECUTING after recording a
// failed subtask. Expose that actionable state consistently in lists and detail
// without changing the durable receipt or restarting destructive work.
const privacyRequestSelectSQL = `SELECT pr.public_id::text,sp.public_id::text,pr.request_type,pr.scope,
CASE WHEN pr.status<>'COMPLETED' AND EXISTS (SELECT 1 FROM privacy_subtask failed WHERE failed.privacy_request_id=pr.id AND failed.status='FAILED') THEN 'PARTIAL_FAILED' ELSE pr.status END,
pr.deadline_at,pr.legal_hold,pr.result_summary,pr.version,pr.created_at,pr.updated_at,pr.completed_at FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id AND sp.tenant_id=pr.tenant_id`
func scanPrivacyRequest(row rowScanner) (PrivacyRequest, error) {
var item PrivacyRequest
+73 -16
View File
@@ -2,6 +2,7 @@ package saas
import (
"context"
"data-terminal-cloud/internal/devicefleet"
"encoding/json"
"errors"
"fmt"
@@ -10,6 +11,7 @@ import (
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/elderagent"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/pagination"
@@ -63,17 +65,18 @@ type Household struct {
}
type Subject struct {
ID string `json:"id"`
HouseholdID string `json:"household_id"`
PreferredName string `json:"preferred_name"`
Language string `json:"language"`
Timezone string `json:"timezone"`
ProfileData map[string]any `json:"profile_data"`
ConversationPreferences map[string]any `json:"conversation_preferences"`
Status string `json:"status"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
VoiceAgent *elderagent.Info `json:"voice_agent,omitempty"`
ID string `json:"id"`
HouseholdID string `json:"household_id"`
PreferredName string `json:"preferred_name"`
Language string `json:"language"`
Timezone string `json:"timezone"`
ProfileData map[string]any `json:"profile_data"`
ConversationPreferences map[string]any `json:"conversation_preferences"`
Status string `json:"status"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Consent struct {
@@ -93,11 +96,12 @@ type Consent struct {
}
type CreateSubjectInput struct {
PreferredName string `json:"preferred_name"`
Language string `json:"language"`
Timezone string `json:"timezone"`
ProfileData map[string]any `json:"profile_data"`
ConversationPreferences map[string]any `json:"conversation_preferences"`
InitialConsents InitialConsentSelection `json:"initial_consents,omitempty"`
PreferredName string `json:"preferred_name"`
Language string `json:"language"`
Timezone string `json:"timezone"`
ProfileData map[string]any `json:"profile_data"`
ConversationPreferences map[string]any `json:"conversation_preferences"`
}
type PatchSubjectInput struct {
@@ -120,6 +124,7 @@ type GrantConsentInput struct {
}
type Service struct {
Fleet *devicefleet.Service
pool *pgxpool.Pool
audit *audit.Repository
redis *redis.Client
@@ -129,6 +134,19 @@ type Service struct {
firmwareStore firmwareObjectStore
firmwareURLTTL time.Duration
providerMemory bool
voiceAgents *elderagent.Service
}
func (s *Service) ConfigureVoiceAgents(agents *elderagent.Service) { s.voiceAgents = agents }
func (s *Service) EnsureSubjectVoiceAgent(ctx context.Context, principal identity.Principal, publicID string) (elderagent.Info, error) {
if _, _, err := resolveSubject(ctx, s.pool, principal, publicID, identity.CapabilitySubjectWrite, false); err != nil {
return elderagent.Info{}, err
}
if s.voiceAgents == nil {
return elderagent.Info{}, errors.New("voice agents not configured")
}
return s.voiceAgents.Ensure(ctx, publicID)
}
func (s *Service) ConfigureProviderMemory(enabled bool) {
@@ -294,10 +312,23 @@ func (s *Service) ListSubjects(ctx context.Context, principal identity.Principal
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate subjects: %w", err)
}
rows.Close()
if s.voiceAgents != nil {
for n := range result {
info, e := s.voiceAgents.Get(ctx, result[n].ID)
if e != nil {
return nil, e
}
result[n].VoiceAgent = &info
}
}
return result, nil
}
func (s *Service) CreateSubject(ctx context.Context, principal identity.Principal, householdPublicID, requestID string, input CreateSubjectInput) (Subject, error) {
if err := ValidateInitialConsents(input.InitialConsents); err != nil {
return Subject{}, err
}
var result Subject
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilitySubjectWrite, true)
@@ -319,18 +350,35 @@ func (s *Service) CreateSubject(ctx context.Context, principal identity.Principa
if err != nil {
return fmt.Errorf("insert subject: %w", err)
}
if err := InitializeSubjectMemory(ctx, tx, access.tenantID, result.ID, access.userID, requestID, input.InitialConsents); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "subject.create", ResourceType: "subject", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"household_id": householdPublicID, "version": result.Version},
})
})
if err == nil && s.voiceAgents != nil {
info, provisionErr := s.voiceAgents.Ensure(ctx, result.ID)
if provisionErr != nil && info.Status == "" {
info = elderagent.Info{Status: "FAILED", Message: "老人已保存,智能体暂未就绪,请重试"}
}
result.VoiceAgent = &info
}
return result, err
}
func (s *Service) GetSubject(ctx context.Context, principal identity.Principal, subjectPublicID string) (Subject, error) {
access, result, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
_ = access
if err == nil && s.voiceAgents != nil {
info, e := s.voiceAgents.Get(ctx, subjectPublicID)
if e != nil {
return result, e
}
result.VoiceAgent = &info
}
return result, err
}
@@ -385,6 +433,15 @@ func (s *Service) PatchSubject(ctx context.Context, principal identity.Principal
RequestID: requestID, Result: "success", Detail: map[string]any{"from_version": expectedVersion, "to_version": result.Version},
})
})
if err == nil && s.voiceAgents != nil {
// The database trigger queues this atomically with the profile change.
// A provider failure must not undo a successfully saved profile.
info, syncErr := s.voiceAgents.Sync(ctx, result.ID)
if syncErr != nil && info.Status == "" {
info = elderagent.Info{Status: "PENDING", PersonaSyncStatus: "PENDING", Message: "资料已保存,等待同步"}
}
result.VoiceAgent = &info
}
return result, err
}
+45 -8
View File
@@ -92,13 +92,31 @@ func TestHouseholdSubjectConsentLifecycleAndIsolation(t *testing.T) {
}
subject, err := service.CreateSubject(ctx, editor, household.ID, "saas-integration-subject-create", CreateSubjectInput{
PreferredName: "集成长者", Language: "zh-CN", Timezone: "Asia/Shanghai",
InitialConsents: InitialConsentSelection{Types: []string{ConsentTranscriptStorage, ConsentPersonalMemory}, Confirmed: true},
PreferredName: "集成长者", Language: "zh-CN", Timezone: "Asia/Shanghai",
ProfileData: map[string]any{"synthetic": true}, ConversationPreferences: map[string]any{"pace": "slow"},
})
if err != nil {
t.Fatalf("create subject: %v", err)
}
subjectID = subject.ID
initialConsents, err := service.ListConsents(ctx, editor, subject.ID)
if err != nil || len(initialConsents) != 2 {
t.Fatalf("initial memory consents: %+v err=%v", initialConsents, err)
}
for _, c := range initialConsents {
if !c.Effective || c.PolicyVersion != "local-v1" || c.ActorUserID == "" || c.NoticeDigest == "" || (c.Type != ConsentTranscriptStorage && c.Type != ConsentPersonalMemory) {
t.Fatalf("invalid initial consent: %+v", c)
}
}
var snapshotOK bool
if err := pool.QueryRow(ctx, `SELECT effective_consents @> '{"voice_chat":true,"transcript_storage":true,"personalized_memory":true,"raw_audio_storage":false,"story_extraction":false,"family_sharing":false}'::jsonb AND jsonb_array_length(source_consent_ids)=2 FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid) ORDER BY snapshot_version DESC LIMIT 1`, subject.ID).Scan(&snapshotOK); err != nil || !snapshotOK {
t.Fatalf("initial memory snapshot=%v err=%v", snapshotOK, err)
}
var grantAuditCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_event WHERE request_id='saas-integration-subject-create' AND action='consent.grant'`).Scan(&grantAuditCount); err != nil || grantAuditCount != 2 {
t.Fatalf("initial memory audit=%d err=%v", grantAuditCount, err)
}
subjects, err := service.ListSubjects(ctx, editor, household.ID)
if err != nil || len(subjects) != 1 || subjects[0].ID != subject.ID {
t.Fatalf("list subjects: %+v err=%v", subjects, err)
@@ -152,7 +170,7 @@ func TestHouseholdSubjectConsentLifecycleAndIsolation(t *testing.T) {
t.Fatalf("replay withdrawal: %+v err=%v", replayed, err)
}
consents, err := service.ListConsents(ctx, editor, subject.ID)
if err != nil || len(consents) != 1 {
if err != nil || len(consents) != 3 {
t.Fatalf("list consents: %+v err=%v", consents, err)
}
privacyInput := CreatePrivacyRequestInput{RequestType: " access ", Scope: map[string]any{"categories": []any{"profile", "consents"}}}
@@ -191,9 +209,14 @@ func TestHouseholdSubjectConsentLifecycleAndIsolation(t *testing.T) {
t.Fatalf("withdrawal request=%+v created=%v err=%v", withdrawal, created, err)
}
consents, err = service.ListConsents(ctx, editor, subject.ID)
if err != nil || len(consents) != 2 || consents[0].Effective {
if err != nil || len(consents) != 4 {
t.Fatalf("consents after batch withdrawal=%+v err=%v", consents, err)
}
for _, c := range consents {
if c.Type == ConsentStoryExtraction && c.Effective {
t.Fatalf("story consent remains active after withdrawal: %+v", c)
}
}
if _, err := service.ListPrivacyRequests(ctx, editor, subject.ID); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("editor privacy list error=%v", err)
}
@@ -231,15 +254,15 @@ func TestHouseholdSubjectConsentLifecycleAndIsolation(t *testing.T) {
if err := pool.QueryRow(ctx, `SELECT count(*) FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subject.ID).Scan(&snapshotCount); err != nil {
t.Fatal(err)
}
if snapshotCount != 4 {
t.Fatalf("snapshot count=%d, want 4", snapshotCount)
if snapshotCount != 5 {
t.Fatalf("snapshot count=%d, want 5", snapshotCount)
}
var auditCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_event WHERE request_id LIKE 'saas-integration-%'`).Scan(&auditCount); err != nil {
t.Fatal(err)
}
if auditCount != 13 {
t.Fatalf("audit count=%d, want 13", auditCount)
if auditCount != 15 {
t.Fatalf("audit count=%d, want 15", auditCount)
}
_ = uuid.MustParse(household.ID)
@@ -330,6 +353,20 @@ func TestDeviceReadTwinUpdateAndIsolation(t *testing.T) {
if _, err := service.PatchDeviceTwin(ctx, editor, deviceID, "saas-integration-device-stale", original.DesiredRevision, map[string]any{}); !errors.Is(err, ErrVersionConflict) {
t.Fatalf("stale twin patch error=%v", err)
}
power := map[string]any{"power": map[string]any{"mode": "standby"}}
if _, err := service.PatchDeviceTwin(ctx, editor, deviceID, "saas-integration-power-unsupported", updated.DesiredRevision, power); !errors.Is(err, ErrDevicePowerUnsupported) {
t.Fatalf("unsupported power: %v", err)
}
if _, err := service.PatchDeviceTwin(ctx, outsider, deviceID, "saas-integration-power-foreign", updated.DesiredRevision, power); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("foreign power: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE device_twin SET reported_config=reported_config || '{"power":{"mode":"active","microphone_paused":false,"backlight_percent":80,"microphone_reads":12}}'::jsonb WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID); err != nil {
t.Fatal(err)
}
sleep, err := service.PatchDeviceTwin(ctx, editor, deviceID, "saas-integration-power-supported", updated.DesiredRevision, power)
if err != nil || sleep.DesiredRevision != updated.DesiredRevision+1 || sleep.ReportedConfig["power"].(map[string]any)["mode"] != "active" {
t.Fatalf("power must stay pending until actual report: %+v %v", sleep, err)
}
}
func containsDevice(items []ManagedDevice, id string) bool {
@@ -364,7 +401,7 @@ func TestDeviceClaimIsOneTimeAndRetrySafe(t *testing.T) {
if bindingID != "" {
_, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE public_id=$1::uuid`, bindingID)
}
_, _ = pool.Exec(ctx,`UPDATE device SET activation_state='CLAIMABLE' WHERE public_id='10000000-0000-0000-0000-000000000512'::uuid AND NOT EXISTS(SELECT 1 FROM device_binding WHERE device_id=device.id AND unbound_at IS NULL)`)
_, _ = pool.Exec(ctx, `UPDATE device SET activation_state='CLAIMABLE' WHERE public_id='10000000-0000-0000-0000-000000000512'::uuid AND NOT EXISTS(SELECT 1 FROM device_binding WHERE device_id=device.id AND unbound_at IS NULL)`)
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id='saas-integration-device-claim'`)
}()
if _, _, err := service.ClaimDevice(ctx, outsider, claimCode, householdID, subjectID, "saas-integration-outsider-claim"); !errors.Is(err, ErrAccessDenied) {
@@ -0,0 +1,148 @@
package saas
import (
"context"
"errors"
"os"
"strings"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/elderagent"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5/pgxpool"
)
type elderProvider struct {
calls int
fail bool
updates int
config map[string]any
}
func (p *elderProvider) Ensure(ctx context.Context, name string, c, a map[string]any) (elderagent.Bot, error) {
p.calls++
if p.fail {
return elderagent.Bot{}, errors.New("provider unavailable")
}
return elderagent.Bot{ID: "bot-" + name, Name: name, Config: c, AgentConfig: a}, nil
}
func (p *elderProvider) Update(_ context.Context, _ string, c, a map[string]any) error {
p.updates++
p.config = c
if p.fail {
return errors.New("SECRET_PROVIDER_FAILURE")
}
return nil
}
func TestSubjectEditSynchronizesPersonaWithoutLosingSavedProfile(t *testing.T) {
if os.Getenv("TEST_DATABASE_URL") == "" {
t.Skip("integration database required")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
defer pool.Close()
s := NewService(pool, audit.NewRepository(), nil, nil)
provider := &elderProvider{}
agents := &elderagent.Service{Pool: pool, Provider: provider, Template: []byte(`{"Config":{"S2SConfig":{"Provider":"volcano","ProviderParams":{"app":{"appid":"test","token":"test"},"dialog":{"system_role":"基础角色"}}}}}`), CallbackURL: "https://example.com/subtitles", CallbackSecret: []byte(strings.Repeat("k", 32))}
s.ConfigureVoiceAgents(agents)
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
h, err := s.CreateHousehold(ctx, owner, testTenantPublicID, "资料同步测试", "persona-save-test")
if err != nil {
t.Fatal(err)
}
sp, err := s.CreateSubject(ctx, owner, h.ID, "persona-save-test", CreateSubjectInput{PreferredName: "原称呼", Language: "zh-CN", Timezone: "Asia/Shanghai", ProfileData: map[string]any{}, ConversationPreferences: map[string]any{}})
if err != nil {
t.Fatal(err)
}
name := "修改后的称呼"
provider.fail = true
sp, err = s.PatchSubject(ctx, owner, sp.ID, "persona-save-test", sp.Version, PatchSubjectInput{PreferredName: &name})
if err != nil || sp.PreferredName != name || sp.VoiceAgent == nil || sp.VoiceAgent.Status != "READY" || sp.VoiceAgent.PersonaSyncStatus != "FAILED" {
t.Fatal("provider failure undid save or lost status", sp, err)
}
loaded, err := s.GetSubject(ctx, owner, sp.ID)
if err != nil || loaded.PreferredName != name {
t.Fatal("save was not durable", err)
}
updates := provider.updates
if _, err = s.PatchSubject(ctx, owner, sp.ID, "persona-save-test-stale", sp.Version-1, PatchSubjectInput{PreferredName: &name}); !errors.Is(err, ErrVersionConflict) || provider.updates != updates {
t.Fatal("stale edit changed provider", err)
}
provider.fail = false
i, err := s.EnsureSubjectVoiceAgent(ctx, owner, sp.ID)
if err != nil || i.PersonaSyncStatus != "SYNCED" {
t.Fatal("manual sync retry failed", i, err)
}
// Corrections share the database trigger, including changes made through privacy.
_, _, err = s.CreatePrivacyRequest(ctx, owner, sp.ID, "persona-correction-test", "persona-correction-test", CreatePrivacyRequestInput{RequestType: "CORRECTION", Scope: map[string]any{"corrections": map[string]any{"preferred_name": "更正后的称呼"}}})
if err != nil {
t.Fatal(err)
}
i, err = agents.Get(ctx, sp.ID)
if err != nil || i.PersonaSyncStatus != "PENDING" {
t.Fatal("correction not queued", i, err)
}
if err = agents.SyncPending(ctx); err != nil {
t.Fatal(err)
}
i, err = agents.Get(ctx, sp.ID)
if err != nil || i.PersonaSyncStatus != "SYNCED" {
t.Fatal("correction not synchronized", i, err)
}
}
func TestCreatingElderCreatesPersistentAgentAndRetry(t *testing.T) {
if os.Getenv("TEST_DATABASE_URL") == "" {
t.Skip("integration database required")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
defer pool.Close()
s := NewService(pool, audit.NewRepository(), nil, nil)
provider := &elderProvider{}
agents := &elderagent.Service{Pool: pool, Provider: provider, Template: []byte(`{"Config":{"S2SConfig":{"Provider":"volcano","ProviderParams":{"app":{"appid":"test","token":"test"}}}}}`), CallbackURL: "https://example.com/subtitles", CallbackSecret: []byte(strings.Repeat("k", 32))}
s.ConfigureVoiceAgents(agents)
owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject}
h, err := s.CreateHousehold(ctx, owner, testTenantPublicID, "专属智能体测试", "elder-agent-test")
if err != nil {
t.Fatal(err)
}
create := func(name string) Subject {
t.Helper()
sp, err := s.CreateSubject(ctx, owner, h.ID, "elder-agent-test", CreateSubjectInput{PreferredName: name, Language: "zh-CN", Timezone: "Asia/Shanghai", ProfileData: map[string]any{}, ConversationPreferences: map[string]any{}})
if err != nil {
t.Fatal(err)
}
return sp
}
first := create("第一位长者")
second := create("第二位长者")
if first.VoiceAgent == nil || first.VoiceAgent.Status != "READY" || first.VoiceAgent.BotID == second.VoiceAgent.BotID {
t.Fatal(first, second)
}
if _, err = s.EnsureSubjectVoiceAgent(ctx, owner, first.ID); err != nil || provider.calls != 2 {
t.Fatal("retry created another bot", err, provider.calls)
}
provider.fail = true
failed := create("失败后重试")
if failed.ID == "" || failed.VoiceAgent.Status != "FAILED" {
t.Fatal(failed)
}
loaded, err := s.GetSubject(ctx, owner, failed.ID)
if err != nil || loaded.VoiceAgent.Status != "FAILED" {
t.Fatal(loaded, err)
}
provider.fail = false
fixed, err := s.EnsureSubjectVoiceAgent(ctx, owner, failed.ID)
if err != nil || fixed.Status != "READY" {
t.Fatal(fixed, err)
}
}