9470a127f7
build / build (push) Successful in 1m11s
统一AI分析入口及双任务安全确认,采用全量载入的可见行渲染,保留完整原文。 右侧非模态详情固定五项质量字段,清理冗余展示并对齐查询和同步按钮。 默认4并发由数据库准入与逐任务租约保护,保留共享限流冷却、截断归因和有界重试。 兼容真实引用标题字段,不伪造证据或准确率,不修改源生产问答与公司区域规则。 验证:完整make verify退出0,710项Go通过、124项已声明可选跳过且必需0跳过; 另全量mirror隔离PostgreSQL race退出0,AI单元race退出0。 真实本地浏览器通过六项交互、390px内滚动及显式AI部分失败/零缺口故障夹具。 治理原生备份已真实恢复,schema57、72表计数与29张QA内容哈希一致。 候选镜像、精确提交CI与正式上线验收待本次提交后执行,不宣称已部署。
213 lines
7.7 KiB
Go
213 lines
7.7 KiB
Go
package qaopsai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Options struct {
|
|
Concurrency int
|
|
KeyFile string
|
|
AllowPrivate bool
|
|
DNSOverTLS bool
|
|
}
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
tenant string
|
|
options Options
|
|
}
|
|
|
|
func NewStore(pool *pgxpool.Pool, tenant string, options Options) *Store {
|
|
return &Store{pool: pool, tenant: tenant, options: options}
|
|
}
|
|
|
|
type Settings struct {
|
|
Revision int64 `json:"revision"`
|
|
BaseURL string `json:"base_url"`
|
|
Model string `json:"model"`
|
|
KeyConfigured bool `json:"key_configured"`
|
|
Verification string `json:"verification"`
|
|
CheckedAt *time.Time `json:"checked_at"`
|
|
AutomaticEvaluation bool `json:"automatic_evaluation"`
|
|
AutomaticTranslation bool `json:"automatic_translation"`
|
|
StorageReady bool `json:"storage_ready"`
|
|
}
|
|
type SaveSettings struct {
|
|
BaseRevision int64 `json:"base_revision"`
|
|
BaseURL string `json:"base_url"`
|
|
Model string `json:"model"`
|
|
KeyAction string `json:"key_action"`
|
|
APIKey string `json:"api_key"`
|
|
}
|
|
type privateSettings struct {
|
|
Settings
|
|
sealed []byte
|
|
}
|
|
type settingsQuery interface {
|
|
QueryRow(context.Context, string, ...any) pgx.Row
|
|
}
|
|
|
|
func (s *Store) valid() bool {
|
|
return s != nil && s.pool != nil && s.tenant != "" && len(s.tenant) <= 128 && !strings.ContainsRune(s.tenant, 0)
|
|
}
|
|
func (s *Store) readSettings(ctx context.Context, q settingsQuery) (privateSettings, error) {
|
|
var out privateSettings
|
|
err := q.QueryRow(ctx, `SELECT revision,base_url,model,key_ciphertext,verification,checked_at,automatic_evaluation,automatic_translation FROM qa_ops_ai_settings WHERE tenant_key=$1`, s.tenant).Scan(&out.Revision, &out.BaseURL, &out.Model, &out.sealed, &out.Verification, &out.CheckedAt, &out.AutomaticEvaluation, &out.AutomaticTranslation)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
out.Verification = "not_configured"
|
|
err = nil
|
|
}
|
|
if err != nil {
|
|
return out, ErrUnavailable
|
|
}
|
|
out.KeyConfigured = len(out.sealed) > 0
|
|
_, keyErr := s.masterKey()
|
|
out.StorageReady = keyErr == nil
|
|
return out, nil
|
|
}
|
|
func (s *Store) GetSettings(ctx context.Context) (Settings, error) {
|
|
if !s.valid() {
|
|
return Settings{}, ErrUnavailable
|
|
}
|
|
current, err := s.readSettings(ctx, s.pool)
|
|
return current.Settings, err
|
|
}
|
|
func (s *Store) draft(ctx context.Context, input SaveSettings) (privateSettings, string, error) {
|
|
if !s.valid() || input.BaseRevision < 0 || strings.TrimSpace(input.BaseURL) != input.BaseURL || strings.TrimSpace(input.Model) != input.Model || len(input.BaseURL) > 500 || len(input.Model) > 200 || strings.ContainsAny(input.Model, "\r\n\x00") {
|
|
return privateSettings{}, "", ErrInvalid
|
|
}
|
|
current, err := s.readSettings(ctx, s.pool)
|
|
if err != nil {
|
|
return current, "", err
|
|
}
|
|
if current.Revision != input.BaseRevision {
|
|
return current, "", ErrConflict
|
|
}
|
|
key := ""
|
|
switch input.KeyAction {
|
|
case "keep":
|
|
if input.APIKey != "" || current.KeyConfigured && current.BaseURL != input.BaseURL {
|
|
return current, "", ErrInvalid
|
|
}
|
|
if current.KeyConfigured {
|
|
key, err = s.unseal(current.sealed, current.Revision)
|
|
if err != nil {
|
|
return current, "", ErrUnavailable
|
|
}
|
|
}
|
|
case "replace":
|
|
if input.APIKey == "" || len(input.APIKey) > 4096 || strings.TrimSpace(input.APIKey) != input.APIKey || strings.ContainsAny(input.APIKey, "\r\n\x00") {
|
|
return current, "", ErrInvalid
|
|
}
|
|
key = input.APIKey
|
|
case "clear":
|
|
if input.APIKey != "" {
|
|
return current, "", ErrInvalid
|
|
}
|
|
default:
|
|
return current, "", ErrInvalid
|
|
}
|
|
if key != "" {
|
|
endpoint, e := url.Parse(input.BaseURL)
|
|
if e != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" || (endpoint.Scheme != "https" && !(endpoint.Scheme == "http" && s.options.AllowPrivate)) || input.Model == "" || (Client{AllowPrivate: s.options.AllowPrivate, DNSOverTLS: s.options.DNSOverTLS}).validateHost(endpoint.Hostname()) != nil {
|
|
return current, "", ErrInvalid
|
|
}
|
|
}
|
|
return current, key, nil
|
|
}
|
|
func (s *Store) probe(ctx context.Context, input SaveSettings, key string) (string, error) {
|
|
if key == "" {
|
|
return "not_configured", nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
|
defer cancel()
|
|
result, err := (Client{AllowPrivate: s.options.AllowPrivate, DNSOverTLS: s.options.DNSOverTLS}).Call(ctx, input.BaseURL, input.Model, key, "This is a synthetic connection probe, not company data. Return exactly JSON: {\"ok\":true}", map[string]string{"probe": "governance-model-connection-v1"}, 64)
|
|
if err != nil {
|
|
var remote *RemoteError
|
|
if errors.As(err, &remote) && remote.Retryable {
|
|
return "unverified", nil
|
|
}
|
|
return "rejected", err
|
|
}
|
|
var response struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
if json.Unmarshal([]byte(result.Content), &response) != nil || !response.OK {
|
|
return "rejected", &RemoteError{Code: "probe_invalid_response"}
|
|
}
|
|
return "verified", nil
|
|
}
|
|
func (s *Store) TestSettings(ctx context.Context, input SaveSettings) (Settings, error) {
|
|
current, key, err := s.draft(ctx, input)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
state, err := s.probe(ctx, input, key)
|
|
now := time.Now().UTC()
|
|
current.BaseURL = input.BaseURL
|
|
current.Model = input.Model
|
|
current.KeyConfigured = key != ""
|
|
current.Verification = state
|
|
current.CheckedAt = &now
|
|
return current.Settings, err
|
|
}
|
|
func (s *Store) SaveSettings(ctx context.Context, input SaveSettings, actor string) (Settings, error) {
|
|
if strings.TrimSpace(actor) == "" {
|
|
return Settings{}, ErrInvalid
|
|
}
|
|
current, key, err := s.draft(ctx, input)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
state, err := s.probe(ctx, input, key)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
sealed := []byte{}
|
|
revision := current.Revision + 1
|
|
if key != "" {
|
|
sealed, err = s.seal(key, revision)
|
|
if err != nil {
|
|
return Settings{}, ErrUnavailable
|
|
}
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return Settings{}, ErrUnavailable
|
|
}
|
|
defer tx.Rollback(context.Background())
|
|
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,55))`, s.tenant+":qa-ai-settings"); err != nil {
|
|
return Settings{}, ErrUnavailable
|
|
}
|
|
locked, err := s.readSettings(ctx, tx)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
if locked.Revision != input.BaseRevision {
|
|
return Settings{}, ErrConflict
|
|
}
|
|
now := time.Now().UTC()
|
|
_, err = tx.Exec(ctx, `INSERT INTO qa_ops_ai_settings(tenant_key,revision,base_url,model,key_ciphertext,verification,checked_at,updated_by,automatic_evaluation,automatic_translation) VALUES($1,$2,$3,$4,$5,$6,$7,$8,false,false) ON CONFLICT(tenant_key) DO UPDATE SET revision=EXCLUDED.revision,base_url=EXCLUDED.base_url,model=EXCLUDED.model,key_ciphertext=EXCLUDED.key_ciphertext,verification=EXCLUDED.verification,checked_at=EXCLUDED.checked_at,updated_by=EXCLUDED.updated_by,updated_at=EXCLUDED.checked_at,automatic_evaluation=CASE WHEN EXCLUDED.verification='verified' THEN qa_ops_ai_settings.automatic_evaluation ELSE false END,automatic_translation=CASE WHEN EXCLUDED.verification='verified' THEN qa_ops_ai_settings.automatic_translation ELSE false END`, s.tenant, revision, input.BaseURL, input.Model, sealed, state, now, actor)
|
|
if err != nil {
|
|
return Settings{}, ErrUnavailable
|
|
}
|
|
_, err = tx.Exec(ctx, `INSERT INTO qa_ops_ai_config_history(tenant_key,revision,base_url,model,key_configured,verification,actor)VALUES($1,$2,$3,$4,$5,$6,$7)`, s.tenant, revision, input.BaseURL, input.Model, key != "", state, actor)
|
|
if err != nil {
|
|
return Settings{}, ErrUnavailable
|
|
}
|
|
saved, err := s.readSettings(ctx, tx)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
if tx.Commit(ctx) != nil {
|
|
return Settings{}, ErrUnavailable
|
|
}
|
|
return saved.Settings, nil
|
|
}
|