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) }