78 lines
3.5 KiB
Go
78 lines
3.5 KiB
Go
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)
|
||
|
|
}
|