2026-08-30 10:51:32 +08:00
package contentmodel
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/extraction"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrAccessDenied = errors . New ( "content model access denied" )
ErrNotFound = errors . New ( "content model not found" )
ErrInvalid = errors . New ( "invalid content model configuration" )
ErrVersion = errors . New ( "content model version conflict" )
ErrTestRequired = errors . New ( "content model must pass a connection test before activation" )
ErrActiveRevoke = errors . New ( "active content model must be replaced before revocation" )
)
type Model struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
DisplayName string `json:"display_name"`
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
ModelID string `json:"model_id"`
CredentialHint string `json:"credential_hint"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
Status string `json:"status"`
TestStatus string `json:"test_status"`
LastTestedAt * time . Time `json:"last_tested_at,omitempty"`
LastErrorCode string `json:"last_error_code,omitempty"`
ActivatedAt * time . Time `json:"activated_at,omitempty"`
Version int64 `json:"version"`
CreatedAt time . Time `json:"created_at"`
UpdatedAt time . Time `json:"updated_at"`
}
type CreateInput struct {
TenantID string `json:"tenant_id"`
DisplayName string `json:"display_name"`
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
ModelID string `json:"model_id"`
APIKey string `json:"api_key"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
}
type Registry struct {
pool * pgxpool . Pool
aead cipher . AEAD
fallback extraction . Generator
}
type Service struct {
registry * Registry
audit * audit . Repository
}
type storedModel struct {
Model
tenantInternalID string
encrypted [] byte
nonce [] byte
keyVersion int64
}
type actor struct {
tenantID , tenantPublicID , userID , userPublicID string
canManage bool
}
func NewRegistry ( pool * pgxpool . Pool , masterKeyHex string , fallback extraction . Generator ) ( * Registry , error ) {
if pool == nil || fallback == nil {
return nil , errors . New ( "content model database and fallback generator are required" )
}
key , err := hex . DecodeString ( strings . TrimSpace ( masterKeyHex ))
if err != nil || len ( key ) != 32 {
return nil , errors . New ( "content model master key must be 32 bytes encoded as 64 hexadecimal characters" )
}
block , err := aes . NewCipher ( key )
if err != nil {
return nil , fmt . Errorf ( "create content model cipher: %w" , err )
}
aead , err := cipher . NewGCM ( block )
if err != nil {
return nil , fmt . Errorf ( "create content model AEAD: %w" , err )
}
return & Registry { pool : pool , aead : aead , fallback : fallback }, nil
}
func NewService ( registry * Registry , auditRepository * audit . Repository ) ( * Service , error ) {
if registry == nil || auditRepository == nil {
return nil , errors . New ( "content model registry and audit repository are required" )
}
return & Service { registry : registry , audit : auditRepository }, nil
}
// Generate resolves the active tenant model at execution time. This makes an
// activation change effective for the next summary job without restarting the worker.
func ( r * Registry ) Generate ( ctx context . Context , input extraction . GenerationInput ) ( extraction . GenerationResult , error ) {
if strings . TrimSpace ( input . TenantID ) == "" {
return r . fallback . Generate ( ctx , input )
}
item , err := r . active ( ctx , input . TenantID )
if errors . Is ( err , pgx . ErrNoRows ) {
return r . fallback . Generate ( ctx , input )
}
if err != nil {
return extraction . GenerationResult {}, err
}
generator , err := r . generator ( item )
if err != nil {
return extraction . GenerationResult {}, err
}
return generator . Generate ( ctx , input )
}
func ( s * Service ) List ( ctx context . Context , principal identity . Principal , tenantPublicID string ) ([] Model , error ) {
current , err := s . resolveActor ( ctx , principal , tenantPublicID )
if err != nil {
return nil , err
}
rows , err := s . registry . pool . Query ( ctx , modelSelect + ` WHERE cm.tenant_id=$1::uuid AND cm.status<>'revoked' ORDER BY (cm.status='active') DESC,cm.updated_at DESC` , current . tenantID )
if err != nil {
return nil , err
}
defer rows . Close ()
items := [] Model {}
for rows . Next () {
item , scanErr := scanModel ( rows )
if scanErr != nil {
return nil , scanErr
}
items = append ( items , item . Model )
}
return items , rows . Err ()
}
func ( s * Service ) Create ( ctx context . Context , principal identity . Principal , input CreateInput , requestID string ) ( Model , error ) {
normalizeInput ( & input )
if err := validateInput ( input ); err != nil {
return Model {}, err
}
current , err := s . resolveActor ( ctx , principal , input . TenantID )
if err != nil {
return Model {}, err
}
if ! current . canManage {
return Model {}, ErrAccessDenied
}
var result Model
err = database . Transact ( ctx , s . registry . pool , func ( tx pgx . Tx ) error {
publicID := uuid . NewString ()
nonce := make ([] byte , s . registry . aead . NonceSize ())
if _ , err := io . ReadFull ( rand . Reader , nonce ); err != nil {
return err
}
const keyVersion int64 = 1
encrypted := s . registry . aead . Seal ( nil , nonce , [] byte ( input . APIKey ), modelAAD ( current . tenantID , publicID , keyVersion ))
row := tx . QueryRow ( ctx , `
INSERT INTO content_model_config(public_id,tenant_id,display_name,provider,base_url,model_id,encrypted_api_key,nonce,key_version,credential_hint,temperature,max_tokens,created_by,updated_by)
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::uuid,$13::uuid)
RETURNING public_id::text,$14::text,display_name,provider,base_url,model_id,credential_hint,temperature::float8,max_tokens,status,test_status,last_tested_at,COALESCE(last_error_code,''),activated_at,version,created_at,updated_at,tenant_id::text,encrypted_api_key,nonce,key_version` ,
publicID , current . tenantID , input . DisplayName , input . Provider , input . BaseURL , input . ModelID , encrypted , nonce , keyVersion , credentialHint ( input . APIKey ), input . Temperature , input . MaxTokens , current . userID , current . tenantPublicID )
item , scanErr := scanModel ( row )
if scanErr != nil {
return scanErr
}
result = item . Model
resourceID := result . ID
return s . audit . Record ( ctx , tx , audit . Event { TenantID : & current . tenantID , ActorType : "user" , ActorPublicID : & current . userPublicID , Action : "content_model.create" , ResourceType : "content_model" , ResourceID : & resourceID , RequestID : requestID , Purpose : "配置聊天内容总结模型" , Result : "success" , Detail : map [ string ] any { "provider" : result . Provider , "model_id" : result . ModelID }})
})
return result , err
}
func ( s * Service ) Test ( ctx context . Context , principal identity . Principal , tenantPublicID , publicID , requestID string ) ( Model , error ) {
current , err := s . resolveActor ( ctx , principal , tenantPublicID )
if err != nil || ! current . canManage {
return Model {}, ErrAccessDenied
}
item , err := s . get ( ctx , current . tenantID , publicID )
if err != nil {
return Model {}, err
}
generator , err := s . registry . generator ( item )
testStatus , errorCode := "passed" , ""
if err == nil {
_ , err = generator . Generate ( ctx , extraction . GenerationInput { TenantID : current . tenantID , Content : "模型连通性测试,不包含长者或家庭数据。" })
}
if err != nil {
testStatus , errorCode = "failed" , normalizedErrorCode ( err )
}
var result Model
2026-08-30 15:20:08 +08:00
auditResult := "success"
if testStatus != "passed" {
auditResult = "failure"
}
2026-08-30 10:51:32 +08:00
updateErr := database . Transact ( ctx , s . registry . pool , func ( tx pgx . Tx ) error {
2026-08-30 15:20:08 +08:00
row := tx . QueryRow ( ctx , modelUpdateReturning , current . tenantID , publicID , testStatus , errorCode , current . userID , current . tenantPublicID )
2026-08-30 10:51:32 +08:00
updated , scanErr := scanModel ( row )
if scanErr != nil {
return scanErr
}
result = updated . Model
resourceID := result . ID
2026-08-30 15:20:08 +08:00
return s . audit . Record ( ctx , tx , audit . Event { TenantID : & current . tenantID , ActorType : "user" , ActorPublicID : & current . userPublicID , Action : "content_model.test" , ResourceType : "content_model" , ResourceID : & resourceID , RequestID : requestID , Purpose : "验证总结模型连通性" , Result : auditResult , Detail : map [ string ] any { "test_status" : testStatus , "error_code" : errorCode }})
2026-08-30 10:51:32 +08:00
})
if updateErr != nil {
return Model {}, updateErr
}
return result , nil
}
func ( s * Service ) Activate ( ctx context . Context , principal identity . Principal , tenantPublicID , publicID , requestID string , expectedVersion int64 ) ( Model , error ) {
current , err := s . resolveActor ( ctx , principal , tenantPublicID )
if err != nil || ! current . canManage {
return Model {}, ErrAccessDenied
}
var result Model
err = database . Transact ( ctx , s . registry . pool , func ( tx pgx . Tx ) error {
var internalID string
var version int64
var testStatus , status string
if err := tx . QueryRow ( ctx , `SELECT id::text,version,test_status,status FROM content_model_config WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE` , current . tenantID , publicID ). Scan ( & internalID , & version , & testStatus , & status ); errors . Is ( err , pgx . ErrNoRows ) {
return ErrNotFound
} else if err != nil {
return err
}
if version != expectedVersion {
return ErrVersion
}
if status == "revoked" {
return ErrNotFound
}
if testStatus != "passed" {
return ErrTestRequired
}
if _ , err := tx . Exec ( ctx , `UPDATE content_model_config SET status='inactive',activated_at=NULL,updated_at=now(),version=version+1,updated_by=$2::uuid WHERE tenant_id=$1::uuid AND status='active' AND id<>$3::uuid` , current . tenantID , current . userID , internalID ); err != nil {
return err
}
row := tx . QueryRow ( ctx , `UPDATE content_model_config cm SET status='active',activated_at=now(),updated_at=now(),version=version+1,updated_by=$3::uuid WHERE tenant_id=$1::uuid AND public_id=$2::uuid RETURNING ` + modelReturning , current . tenantID , publicID , current . userID , current . tenantPublicID )
updated , scanErr := scanModel ( row )
if scanErr != nil {
return scanErr
}
result = updated . Model
resourceID := result . ID
return s . audit . Record ( ctx , tx , audit . Event { TenantID : & current . tenantID , ActorType : "user" , ActorPublicID : & current . userPublicID , Action : "content_model.activate" , ResourceType : "content_model" , ResourceID : & resourceID , RequestID : requestID , Purpose : "激活聊天总结模型" , Result : "success" , Detail : map [ string ] any { "model_id" : result . ModelID }})
})
return result , err
}
func ( s * Service ) Revoke ( ctx context . Context , principal identity . Principal , tenantPublicID , publicID , requestID string , expectedVersion int64 ) ( Model , error ) {
current , err := s . resolveActor ( ctx , principal , tenantPublicID )
if err != nil || ! current . canManage {
return Model {}, ErrAccessDenied
}
var result Model
err = database . Transact ( ctx , s . registry . pool , func ( tx pgx . Tx ) error {
item , lockErr := s . getTx ( ctx , tx , current . tenantID , publicID , true )
if lockErr != nil {
return lockErr
}
if item . Version != expectedVersion {
return ErrVersion
}
if item . Status == "active" {
return ErrActiveRevoke
}
row := tx . QueryRow ( ctx , `UPDATE content_model_config cm SET status='revoked',encrypted_api_key=decode('','hex'),nonce=decode('','hex'),updated_at=now(),version=version+1,updated_by=$3::uuid WHERE tenant_id=$1::uuid AND public_id=$2::uuid RETURNING ` + modelReturning , current . tenantID , publicID , current . userID , current . tenantPublicID )
updated , scanErr := scanModel ( row )
if scanErr != nil {
return scanErr
}
result = updated . Model
resourceID := result . ID
return s . audit . Record ( ctx , tx , audit . Event { TenantID : & current . tenantID , ActorType : "user" , ActorPublicID : & current . userPublicID , Action : "content_model.revoke" , ResourceType : "content_model" , ResourceID : & resourceID , RequestID : requestID , Purpose : "移除未激活的总结模型" , Result : "success" })
})
return result , err
}
func ( r * Registry ) active ( ctx context . Context , tenantID string ) ( storedModel , error ) {
row := r . pool . QueryRow ( ctx , modelSelect + ` WHERE cm.tenant_id=$1::uuid AND cm.status='active'` , tenantID )
return scanModel ( row )
}
func ( r * Registry ) generator ( item storedModel ) ( extraction . Generator , error ) {
key , err := r . aead . Open ( nil , item . nonce , item . encrypted , modelAAD ( item . tenantInternalID , item . ID , item . keyVersion ))
if err != nil || len ( key ) < 8 {
return nil , errors . New ( "content model credential cannot be decrypted" )
}
return extraction . NewCompatibleGenerator ( item . Provider , item . BaseURL , string ( key ), item . ModelID , item . Temperature , item . MaxTokens , 30 * time . Second )
}
func ( s * Service ) get ( ctx context . Context , tenantID , publicID string ) ( storedModel , error ) {
return s . getTx ( ctx , s . registry . pool , tenantID , publicID , false )
}
type rowQueryer interface {
QueryRow ( context . Context , string , ... any ) pgx . Row
}
func ( s * Service ) getTx ( ctx context . Context , q rowQueryer , tenantID , publicID string , lock bool ) ( storedModel , error ) {
lockClause := ""
if lock {
lockClause = " FOR UPDATE OF cm"
}
item , err := scanModel ( q . QueryRow ( ctx , modelSelect + ` WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid` + lockClause , tenantID , publicID ))
if errors . Is ( err , pgx . ErrNoRows ) {
return storedModel {}, ErrNotFound
}
return item , err
}
func ( s * Service ) resolveActor ( ctx context . Context , principal identity . Principal , tenantPublicID string ) ( actor , error ) {
var result actor
err := s . registry . pool . QueryRow ( ctx , `
SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,
2026-08-31 11:29:56 +08:00
ua.account_kind='operator' AND ua.platform_role IN ('ops_operator','ops_admin')
2026-08-30 10:51:32 +08:00
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active'
WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
2026-08-31 11:29:56 +08:00
` , tenantPublicID , principal . Issuer , principal . Subject ). Scan ( & result . tenantID , & result . tenantPublicID , & result . userID , & result . userPublicID , & result . canManage )
2026-08-30 10:51:32 +08:00
if errors . Is ( err , pgx . ErrNoRows ) {
return actor {}, ErrAccessDenied
}
return result , err
}
func normalizeInput ( input * CreateInput ) {
input . TenantID = strings . TrimSpace ( input . TenantID )
input . DisplayName = strings . TrimSpace ( input . DisplayName )
input . Provider = strings . ToLower ( strings . TrimSpace ( input . Provider ))
input . BaseURL = strings . TrimRight ( strings . TrimSpace ( input . BaseURL ), "/" )
input . ModelID = strings . TrimSpace ( input . ModelID )
input . APIKey = strings . TrimSpace ( input . APIKey )
if input . Temperature == 0 {
input . Temperature = 0.2
}
if input . MaxTokens == 0 {
input . MaxTokens = 1200
}
}
func validateInput ( input CreateInput ) error {
parsed , err := url . Parse ( input . BaseURL )
2026-08-30 19:55:17 +08:00
validProvider := extraction . SupportsCompatibleProvider ( input . Provider )
2026-08-30 15:20:08 +08:00
validURL := err == nil && parsed . Host != "" && ( parsed . Scheme == "https" || ( parsed . Scheme == "http" && isApprovedLocalModelHost ( parsed . Hostname ())))
2026-08-30 10:51:32 +08:00
if ! validUUID ( input . TenantID ) || len ([] rune ( input . DisplayName )) < 1 || len ([] rune ( input . DisplayName )) > 120 || ! validProvider || ! validURL || input . ModelID == "" || len ( input . APIKey ) < 8 || input . Temperature < 0 || input . Temperature > 2 || input . MaxTokens < 256 || input . MaxTokens > 8192 {
return ErrInvalid
}
return nil
}
2026-08-30 15:20:08 +08:00
func isApprovedLocalModelHost ( host string ) bool {
host = strings . ToLower ( strings . TrimSpace ( host ))
return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "ollama"
}
2026-08-30 10:51:32 +08:00
func credentialHint ( key string ) string {
runes := [] rune ( strings . TrimSpace ( key ))
if len ( runes ) <= 4 {
return "••••"
}
return "••••" + string ( runes [ len ( runes ) - 4 :])
}
func normalizedErrorCode ( err error ) string {
value := strings . ToLower ( err . Error ())
switch {
case strings . Contains ( value , "401" ), strings . Contains ( value , "403" ):
return "AUTHENTICATION_FAILED"
case strings . Contains ( value , "429" ):
return "RATE_LIMITED"
case strings . Contains ( value , "timeout" ), strings . Contains ( value , "deadline" ):
return "TIMEOUT"
case strings . Contains ( value , "validation" ), strings . Contains ( value , "decode" ):
return "INVALID_RESPONSE"
default :
return "CONNECTION_FAILED"
}
}
func modelAAD ( tenantID , publicID string , version int64 ) [] byte {
return [] byte ( fmt . Sprintf ( "content-model-credential-v1\n%s\n%s\n%d" , tenantID , publicID , version ))
}
func validUUID ( value string ) bool { _ , err := uuid . Parse ( value ); return err == nil }
const modelSelect = `SELECT cm.public_id::text,t.public_id::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version FROM content_model_config cm JOIN tenant t ON t.id=cm.tenant_id`
const modelReturning = `cm.public_id::text,$4::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
2026-08-30 15:20:08 +08:00
const modelUpdateReturning = `UPDATE content_model_config cm SET test_status=$3,last_tested_at=now(),last_error_code=NULLIF($4,''),updated_at=now(),version=version+1,updated_by=$5::uuid WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid RETURNING cm.public_id::text,$6::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
2026-08-30 10:51:32 +08:00
type rowScanner interface { Scan ( ... any ) error }
func scanModel ( row rowScanner ) ( storedModel , error ) {
var item storedModel
err := row . Scan ( & item . ID , & item . TenantID , & item . DisplayName , & item . Provider , & item . BaseURL , & item . ModelID , & item . CredentialHint , & item . Temperature , & item . MaxTokens , & item . Status , & item . TestStatus , & item . LastTestedAt , & item . LastErrorCode , & item . ActivatedAt , & item . Version , & item . CreatedAt , & item . UpdatedAt , & item . tenantInternalID , & item . encrypted , & item . nonce , & item . keyVersion )
return item , err
}