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.
420 lines
18 KiB
Go
420 lines
18 KiB
Go
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
|
|
}
|
|
|
|
func (*Registry) SupportsEventStories() bool { return true }
|
|
|
|
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: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.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
|
|
auditResult := "success"
|
|
if testStatus != "passed" {
|
|
auditResult = "failure"
|
|
}
|
|
updateErr := database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
|
row := tx.QueryRow(ctx, modelUpdateReturning, current.tenantID, publicID, testStatus, errorCode, 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: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.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}})
|
|
})
|
|
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: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.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: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.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,
|
|
ua.account_kind='operator' AND ua.platform_role IN ('ops_operator','ops_admin')
|
|
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
|
|
`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.canManage)
|
|
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)
|
|
validProvider := extraction.SupportsCompatibleProvider(input.Provider)
|
|
validURL := err == nil && parsed.Host != "" && (parsed.Scheme == "https" || (parsed.Scheme == "http" && isApprovedLocalModelHost(parsed.Hostname())))
|
|
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
|
|
}
|
|
|
|
func isApprovedLocalModelHost(host string) bool {
|
|
host = strings.ToLower(strings.TrimSpace(host))
|
|
return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "ollama"
|
|
}
|
|
|
|
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`
|
|
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`
|
|
|
|
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
|
|
}
|