Files
knowledge-governance/internal/qaopsai/settings.go
T
姚章浩 9470a127f7
build / build (push) Successful in 1m11s
feat(qaops): 完成流水表六项体验与AI执行修正
统一AI分析入口及双任务安全确认,采用全量载入的可见行渲染,保留完整原文。
右侧非模态详情固定五项质量字段,清理冗余展示并对齐查询和同步按钮。
默认4并发由数据库准入与逐任务租约保护,保留共享限流冷却、截断归因和有界重试。
兼容真实引用标题字段,不伪造证据或准确率,不修改源生产问答与公司区域规则。

验证:完整make verify退出0,710项Go通过、124项已声明可选跳过且必需0跳过;
另全量mirror隔离PostgreSQL race退出0,AI单元race退出0。
真实本地浏览器通过六项交互、390px内滚动及显式AI部分失败/零缺口故障夹具。
治理原生备份已真实恢复,schema57、72表计数与29张QA内容哈希一致。
候选镜像、精确提交CI与正式上线验收待本次提交后执行,不宣称已部署。
2026-10-19 22:09:32 +08:00

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
}