feat: complete remaining Go backend modules
This commit is contained in:
1 parent
cea2751dc5
commit
aef5a97165
145 files changed
+18376
-199
No files matched your search
@@ -0,0 +1,496 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type RuntimeUpdater func(context.Context, map[string]string) error
|
||||
type Option struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
type Field struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Options []Option `json:"options,omitempty"`
|
||||
Value string `json:"value"`
|
||||
Configured bool `json:"configured"`
|
||||
DefaultValue string `json:"-"`
|
||||
}
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Fields []Field `json:"fields"`
|
||||
}
|
||||
type Capability struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
ReqKey string `json:"reqKey"`
|
||||
Engine string `json:"engine,omitempty"`
|
||||
EngineLabel string `json:"engineLabel,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
type EngineAssignment struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Engine string `json:"engine"`
|
||||
EngineLabel string `json:"engineLabel"`
|
||||
Connected bool `json:"connected"`
|
||||
ConnectionLabel string `json:"connectionLabel"`
|
||||
ReqKey string `json:"reqKey"`
|
||||
Configurable bool `json:"configurable"`
|
||||
Field *Field `json:"field,omitempty"`
|
||||
}
|
||||
type Services struct {
|
||||
Visual bool `json:"visual"`
|
||||
Evolink bool `json:"evolink"`
|
||||
Seedance bool `json:"seedance"`
|
||||
Bailian bool `json:"bailian"`
|
||||
Auth bool `json:"auth"`
|
||||
Organization bool `json:"organization"`
|
||||
}
|
||||
type Payload struct {
|
||||
Services Services `json:"services"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
EngineAssignments []EngineAssignment `json:"engineAssignments"`
|
||||
Groups []Group `json:"groups"`
|
||||
RestartRequired bool `json:"restartRequired,omitempty"`
|
||||
}
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
environment map[string]string
|
||||
update RuntimeUpdater
|
||||
billing BillingAccountWriter
|
||||
}
|
||||
|
||||
type BillingAccount struct {
|
||||
AccountName, BankName, AccountNumber, Contact string
|
||||
}
|
||||
|
||||
type BillingAccountWriter interface {
|
||||
SaveBillingAccount(context.Context, BillingAccount) error
|
||||
}
|
||||
|
||||
func New(path string, environment map[string]string, updater RuntimeUpdater) *Service {
|
||||
return &Service{path: path, environment: cloneStrings(environment), update: updater}
|
||||
}
|
||||
|
||||
// LoadEnvironment reads only the settings whitelist. Values already supplied
|
||||
// by the process win, matching Next's process.env-over-file precedence.
|
||||
func LoadEnvironment(path string, environment map[string]string) (map[string]string, error) {
|
||||
file, err := readEnv(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged := cloneStrings(environment)
|
||||
for key, value := range file {
|
||||
if _, allowed := fieldIndex[key]; !allowed {
|
||||
continue
|
||||
}
|
||||
if _, exists := merged[key]; !exists {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func RuntimeSettingKeys() []string {
|
||||
keys := make([]string, 0, len(fieldIndex))
|
||||
for key := range fieldIndex {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func (s *Service) WithBillingAccountWriter(writer BillingAccountWriter) *Service {
|
||||
s.billing = writer
|
||||
return s
|
||||
}
|
||||
func (s *Service) Get(ctx context.Context) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.get()
|
||||
}
|
||||
func (s *Service) Save(ctx context.Context, values map[string]any) (any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
updates := map[string]string{}
|
||||
for key, raw := range values {
|
||||
field, ok := fieldIndex[key]
|
||||
next, stringValue := raw.(string)
|
||||
if !ok || !stringValue {
|
||||
continue
|
||||
}
|
||||
next = strings.TrimSpace(next)
|
||||
if field.Secret && next == "" {
|
||||
continue
|
||||
}
|
||||
updates[key] = next
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
// Capture the complete persisted view before writing so a partial billing
|
||||
// account update cannot blank sibling fields that only exist in the file.
|
||||
persisted, err := readEnv(s.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.write(updates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.update != nil {
|
||||
if err := s.update(ctx, cloneStrings(updates)); err != nil {
|
||||
return nil, fmt.Errorf("apply runtime settings: %w", err)
|
||||
}
|
||||
}
|
||||
for key, value := range updates {
|
||||
s.environment[key] = value
|
||||
persisted[key] = value
|
||||
}
|
||||
if s.billing != nil && containsBillingAccountUpdate(updates) {
|
||||
if err := s.billing.SaveBillingAccount(ctx, BillingAccount{
|
||||
AccountName: billingSetting(s.environment, persisted, "ZHINIAN_BILLING_ACCOUNT_NAME"),
|
||||
BankName: billingSetting(s.environment, persisted, "ZHINIAN_BILLING_ACCOUNT_BANK"),
|
||||
AccountNumber: billingSetting(s.environment, persisted, "ZHINIAN_BILLING_ACCOUNT_NUMBER"),
|
||||
Contact: billingSetting(s.environment, persisted, "ZHINIAN_BILLING_CONTACT"),
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("apply billing account settings: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
payload, err := s.get()
|
||||
if err == nil && requiresRestart(updates) {
|
||||
payload.RestartRequired = true
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func billingSetting(environment, persisted map[string]string, key string) string {
|
||||
if value, ok := environment[key]; ok {
|
||||
return value
|
||||
}
|
||||
return persisted[key]
|
||||
}
|
||||
|
||||
func containsBillingAccountUpdate(updates map[string]string) bool {
|
||||
for key := range updates {
|
||||
switch key {
|
||||
case "ZHINIAN_BILLING_ACCOUNT_NAME", "ZHINIAN_BILLING_ACCOUNT_BANK", "ZHINIAN_BILLING_ACCOUNT_NUMBER", "ZHINIAN_BILLING_CONTACT":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func requiresRestart(updates map[string]string) bool {
|
||||
for key := range updates {
|
||||
switch key {
|
||||
case "ZHINIAN_BILLING_ACCOUNT_NAME", "ZHINIAN_BILLING_ACCOUNT_BANK", "ZHINIAN_BILLING_ACCOUNT_NUMBER", "ZHINIAN_BILLING_CONTACT":
|
||||
continue
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (s *Service) get() (Payload, error) {
|
||||
file, err := readEnv(s.path)
|
||||
if err != nil {
|
||||
return Payload{}, err
|
||||
}
|
||||
current := func(field Field) string {
|
||||
if value, ok := s.environment[field.Key]; ok {
|
||||
return value
|
||||
}
|
||||
if value, ok := file[field.Key]; ok {
|
||||
return value
|
||||
}
|
||||
return field.DefaultValue
|
||||
}
|
||||
groups := definitions()
|
||||
for gi := range groups {
|
||||
for fi := range groups[gi].Fields {
|
||||
field := &groups[gi].Fields[fi]
|
||||
raw := current(*field)
|
||||
field.Configured = raw != ""
|
||||
if !field.Secret {
|
||||
field.Value = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
image := normalizeImage(current(fieldIndex["IMAGE_GENERATE_ENGINE"]))
|
||||
video := normalizeVideo(current(fieldIndex["VIDEO_GENERATE_ENGINE"]))
|
||||
imageModel := map[string]string{"jimeng": lookup(s.environment, file, "JIMENG_IMAGE_GENERATE_46_REQ_KEY", "jimeng_seedream46_cvtob"), "evolink": lookup(s.environment, file, "EVOLINK_IMAGE_MODEL", "gpt-image-2"), "bailian": lookup(s.environment, file, "BAILIAN_IMAGE_MODEL", "wan2.7-image-pro")}[image]
|
||||
videoModel := lookup(s.environment, file, "SEEDANCE_MODEL", "doubao-seedance-2-0-260128")
|
||||
if video == "bailian" {
|
||||
videoModel = lookup(s.environment, file, "BAILIAN_VIDEO_MODEL", "wan2.7-i2v-2026-04-25")
|
||||
}
|
||||
imageConnected := connected(image, s.environment, file)
|
||||
videoConnected := connected(video, s.environment, file)
|
||||
imageField := project(fieldIndex["IMAGE_GENERATE_ENGINE"], image, s.environment, file)
|
||||
videoField := project(fieldIndex["VIDEO_GENERATE_ENGINE"], video, s.environment, file)
|
||||
assignments := []EngineAssignment{{ID: "image.generate", Label: "图片生成", Engine: image, EngineLabel: label(image), Connected: imageConnected, ConnectionLabel: connection(imageConnected), ReqKey: imageModel, Configurable: true, Field: &imageField}, {ID: "video.generate", Label: "视频生成", Engine: video, EngineLabel: label(video), Connected: videoConnected, ConnectionLabel: connection(videoConnected), ReqKey: videoModel, Configurable: true, Field: &videoField}}
|
||||
services := Services{Visual: connected("jimeng", s.environment, file), Evolink: connected("evolink", s.environment, file), Seedance: connected("seedance", s.environment, file), Bailian: connected("bailian", s.environment, file), Auth: lookup(s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != "", Organization: lookup(s.environment, file, "DATABASE_URL", "") != "" || lookup(s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != ""}
|
||||
return Payload{Services: services, Capabilities: []Capability{{ID: "image.generate", Label: "图片生成 4.6", ReqKey: imageModel, Engine: image, EngineLabel: label(image), Enabled: true}, {ID: "video.generate", Label: "视频生成", ReqKey: videoModel, Engine: video, EngineLabel: label(video), Enabled: true}}, EngineAssignments: assignments, Groups: groups}, nil
|
||||
}
|
||||
func (s *Service) write(updates map[string]string) error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("read settings file: %w", err)
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
data = []byte("# 智念AIGC平台 API 配置\n")
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||
seen := map[string]bool{}
|
||||
for index, line := range lines {
|
||||
key, _, ok := splitLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if value, replace := updates[key]; replace {
|
||||
lines[index] = key + "=" + formatValue(value)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
missing := []string{}
|
||||
for key := range updates {
|
||||
if !seen[key] {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, "# Managed by 智念AIGC平台 设置页")
|
||||
for _, key := range missing {
|
||||
lines = append(lines, key+"="+formatValue(updates[key]))
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temp, err := os.CreateTemp(filepath.Dir(s.path), ".settings-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := temp.Name()
|
||||
defer os.Remove(name)
|
||||
if err = temp.Chmod(0o600); err == nil {
|
||||
_, err = temp.WriteString(strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n")
|
||||
}
|
||||
if err == nil {
|
||||
err = temp.Sync()
|
||||
}
|
||||
closeErr := temp.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(name, s.path)
|
||||
}
|
||||
func readEnv(path string) (map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
result := map[string]string{}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
key, raw, ok := splitLine(scanner.Text())
|
||||
if ok {
|
||||
result[key] = parseValue(raw)
|
||||
}
|
||||
}
|
||||
return result, scanner.Err()
|
||||
}
|
||||
func splitLine(line string) (string, string, bool) {
|
||||
index := strings.IndexByte(line, '=')
|
||||
if index < 1 {
|
||||
return "", "", false
|
||||
}
|
||||
key := strings.TrimSpace(line[:index])
|
||||
if !validKey(key) {
|
||||
return "", "", false
|
||||
}
|
||||
return key, strings.TrimSpace(line[index+1:]), true
|
||||
}
|
||||
func validKey(key string) bool {
|
||||
for index, r := range key {
|
||||
if !(r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || index > 0 && r >= '0' && r <= '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return key != ""
|
||||
}
|
||||
func parseValue(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if len(raw) >= 2 && ((raw[0] == '"' && raw[len(raw)-1] == '"') || (raw[0] == '\'' && raw[len(raw)-1] == '\'')) {
|
||||
if raw[0] == '\'' {
|
||||
return raw[1 : len(raw)-1]
|
||||
}
|
||||
var out strings.Builder
|
||||
escaped := false
|
||||
for _, r := range raw[1 : len(raw)-1] {
|
||||
if escaped {
|
||||
switch r {
|
||||
case 'n':
|
||||
out.WriteByte('\n')
|
||||
case '"', '\\':
|
||||
out.WriteRune(r)
|
||||
default:
|
||||
out.WriteByte('\\')
|
||||
out.WriteRune(r)
|
||||
}
|
||||
escaped = false
|
||||
} else if r == '\\' {
|
||||
escaped = true
|
||||
} else {
|
||||
out.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
if i := strings.Index(raw, " #"); i >= 0 {
|
||||
raw = raw[:i]
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
func formatValue(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.ContainsAny(value, " \t\r\n#\"'\\") {
|
||||
return value
|
||||
}
|
||||
return "\"" + strings.NewReplacer("\\", "\\\\", "\"", "\\\"", "\n", "\\n", "\r", "\\r").Replace(value) + "\""
|
||||
}
|
||||
func cloneStrings(input map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for key, value := range input {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
func lookup(environment, file map[string]string, key, fallback string) string {
|
||||
if value, ok := environment[key]; ok {
|
||||
return value
|
||||
}
|
||||
if value, ok := file[key]; ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
func project(field Field, value string, environment, file map[string]string) Field {
|
||||
field.Value = value
|
||||
field.Configured = lookup(environment, file, field.Key, "") != ""
|
||||
return field
|
||||
}
|
||||
func normalizeImage(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "evolink" || value == "bailian" {
|
||||
return value
|
||||
}
|
||||
return "jimeng"
|
||||
}
|
||||
func normalizeVideo(value string) string {
|
||||
if strings.ToLower(strings.TrimSpace(value)) == "bailian" {
|
||||
return "bailian"
|
||||
}
|
||||
return "seedance"
|
||||
}
|
||||
func label(engine string) string {
|
||||
switch engine {
|
||||
case "evolink":
|
||||
return "EvoLink"
|
||||
case "bailian":
|
||||
return "阿里云百炼"
|
||||
case "seedance":
|
||||
return "Seedance"
|
||||
default:
|
||||
return "即梦"
|
||||
}
|
||||
}
|
||||
func connection(ok bool) string {
|
||||
if ok {
|
||||
return "已连接"
|
||||
}
|
||||
return "待配置"
|
||||
}
|
||||
func connected(engine string, environment, file map[string]string) bool {
|
||||
switch engine {
|
||||
case "evolink":
|
||||
return mock(lookup(environment, file, "EVOLINK_MOCK", "auto"), lookup(environment, file, "EVOLINK_API_KEY", "") != "")
|
||||
case "seedance":
|
||||
return mock(lookup(environment, file, "SEEDANCE_MOCK", "auto"), lookup(environment, file, "SEEDANCE_API_KEY", "") != "")
|
||||
case "bailian":
|
||||
flag := strings.ToLower(strings.TrimSpace(lookup(environment, file, "BAILIAN_MOCK", "auto")))
|
||||
return flag != "1" && flag != "true" && lookup(environment, file, "BAILIAN_API_KEY", "") != ""
|
||||
default:
|
||||
return mock(lookup(environment, file, "JIMENG_VISUAL_MOCK", "auto"), lookup(environment, file, "VOLCENGINE_ACCESS_KEY_ID", "") != "" && lookup(environment, file, "VOLCENGINE_SECRET_ACCESS_KEY", "") != "")
|
||||
}
|
||||
}
|
||||
func mock(flag string, configured bool) bool {
|
||||
flag = strings.ToLower(strings.TrimSpace(flag))
|
||||
if flag == "1" || flag == "true" {
|
||||
return false
|
||||
}
|
||||
if flag == "0" || flag == "false" {
|
||||
return true
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
func definitions() []Group {
|
||||
return []Group{
|
||||
{ID: "auth", Title: "平台账号安全", Description: "平台自建手机号账号登录。", Fields: []Field{{Key: "ZHINIAN_AUTH_REQUIRED", Label: "登录保护", Type: "select", DefaultValue: "auto", Options: []Option{{Label: "自动", Value: "auto"}, {Label: "启用", Value: "1"}, {Label: "停用", Value: "0"}}}, {Key: "ZHINIAN_AUTH_SESSION_SECRET", Label: "会话签名密钥", Secret: true, Type: "password"}}},
|
||||
{ID: "billing", Title: "企业计费", Description: "企业计费和对公账户信息。", Fields: []Field{{Key: "ZHINIAN_BILLING_REQUIRED", Label: "真实任务计费", Type: "select", DefaultValue: "1", Options: []Option{{Label: "启用", Value: "1"}, {Label: "停用(免计费)", Value: "0"}}}, {Key: "ZHINIAN_BILLING_ACCOUNT_NAME", Label: "对公账户名称"}, {Key: "ZHINIAN_BILLING_ACCOUNT_BANK", Label: "开户行"}, {Key: "ZHINIAN_BILLING_ACCOUNT_NUMBER", Label: "银行账号"}, {Key: "ZHINIAN_BILLING_CONTACT", Label: "充值对接信息"}}},
|
||||
{ID: "visual", Title: "即梦图片 API", Description: "火山 AK/SK。", Fields: []Field{{Key: "VOLCENGINE_ACCESS_KEY_ID", Label: "Access Key ID", Secret: true, Type: "password"}, {Key: "VOLCENGINE_SECRET_ACCESS_KEY", Label: "Secret Access Key", Secret: true, Type: "password"}}},
|
||||
{ID: "evolink", Title: "EvoLink 图片 API", Description: "GPT Image 2 图片生成。", Fields: []Field{{Key: "EVOLINK_API_KEY", Label: "EvoLink API Key", Secret: true, Type: "password"}, {Key: "EVOLINK_BASE_URL", Label: "Base URL", DefaultValue: "https://api.evolink.ai"}, {Key: "EVOLINK_IMAGE_MODEL", Label: "图片模型", DefaultValue: "gpt-image-2"}, {Key: "EVOLINK_IMAGE_QUALITY", Label: "质量", DefaultValue: "medium"}}},
|
||||
{ID: "seedance", Title: "Seedance 视频 API", Description: "火山方舟 API Key。", Fields: []Field{{Key: "SEEDANCE_API_KEY", Label: "方舟 API Key", Secret: true, Type: "password"}}},
|
||||
{ID: "bailian", Title: "阿里云百炼 API", Description: "万相图片与视频。", Fields: []Field{{Key: "BAILIAN_API_KEY", Label: "百炼 API Key", Secret: true, Type: "password"}, {Key: "BAILIAN_BASE_URL", Label: "Base URL", DefaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"}, {Key: "BAILIAN_IMAGE_MODEL", Label: "图片模型", DefaultValue: "wan2.7-image-pro"}, {Key: "BAILIAN_VIDEO_MODEL", Label: "视频模型", DefaultValue: "wan2.7-i2v-2026-04-25"}}},
|
||||
{ID: "oss", Title: "OSS 资产存储", Description: "共享资产存储。", Fields: []Field{{Key: "ALI_OSS_ENDPOINT", Label: "Endpoint"}, {Key: "ALI_OSS_BUCKET", Label: "Bucket"}, {Key: "ALI_OSS_ACCESS_KEY_ID", Label: "Access Key ID", Secret: true, Type: "password"}, {Key: "ALI_OSS_ACCESS_KEY_SECRET", Label: "Access Key Secret", Secret: true, Type: "password"}, {Key: "ALI_OSS_PUBLIC_BASE_URL", Label: "公开访问 Base URL"}}},
|
||||
}
|
||||
}
|
||||
|
||||
var fieldIndex = func() map[string]Field {
|
||||
result := map[string]Field{}
|
||||
for _, group := range definitions() {
|
||||
for _, field := range group.Fields {
|
||||
result[field.Key] = field
|
||||
}
|
||||
}
|
||||
result["IMAGE_GENERATE_ENGINE"] = Field{Key: "IMAGE_GENERATE_ENGINE", Label: "图片生成", Type: "select", DefaultValue: "jimeng", Options: []Option{{Label: "即梦 / 火山视觉", Value: "jimeng"}, {Label: "EvoLink GPT Image 2", Value: "evolink"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}}
|
||||
result["VIDEO_GENERATE_ENGINE"] = Field{Key: "VIDEO_GENERATE_ENGINE", Label: "视频生成", Type: "select", DefaultValue: "bailian", Options: []Option{{Label: "Seedance", Value: "seedance"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}}
|
||||
return result
|
||||
}()
|
||||
@@ -0,0 +1,180 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFixtureIsConsumedByGo(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../contracts/settings/runtime-v1.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture struct {
|
||||
Version int `json:"version"`
|
||||
FileName string `json:"fileName"`
|
||||
AllowedKeys []string `json:"allowedKeys"`
|
||||
SecretKeys []string `json:"secretKeys"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fixture.Version != 1 || fixture.FileName != ".env.local" || len(fixture.AllowedKeys) != 25 || len(fixture.SecretKeys) != 8 {
|
||||
t.Fatalf("fixture = %#v", fixture)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSavePreservesSecretsAndUnrelatedEnvLines(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".env.local")
|
||||
original := "# operator note\nDATABASE_URL=postgres://untouched\nZHINIAN_AUTH_SESSION_SECRET=keep-me\nZHINIAN_BILLING_CONTACT=old # prior\nCUSTOM_QUOTED=\"a b\"\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment := map[string]string{"EVOLINK_BASE_URL": "https://runtime.example"}
|
||||
var applied map[string]string
|
||||
service := New(path, environment, func(_ context.Context, updates map[string]string) error { applied = clone(updates); return nil })
|
||||
|
||||
value, err := service.Save(context.Background(), map[string]any{
|
||||
"ZHINIAN_AUTH_SESSION_SECRET": " ",
|
||||
"ZHINIAN_BILLING_CONTACT": " ",
|
||||
"EVOLINK_BASE_URL": " https://new.example/v1 ",
|
||||
"DATABASE_URL": "postgres://attack",
|
||||
"ALI_OSS_BUCKET": 42,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(text)
|
||||
for _, expected := range []string{"# operator note", "DATABASE_URL=postgres://untouched", "ZHINIAN_AUTH_SESSION_SECRET=keep-me", "ZHINIAN_BILLING_CONTACT=", "CUSTOM_QUOTED=\"a b\"", "EVOLINK_BASE_URL=https://new.example/v1"} {
|
||||
if !strings.Contains(got, expected) {
|
||||
t.Fatalf("file missing %q:\n%s", expected, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "postgres://attack") || strings.Contains(got, "ALI_OSS_BUCKET=42") {
|
||||
t.Fatalf("unexpected unapproved update:\n%s", got)
|
||||
}
|
||||
if !reflect.DeepEqual(applied, map[string]string{"ZHINIAN_BILLING_CONTACT": "", "EVOLINK_BASE_URL": "https://new.example/v1"}) {
|
||||
t.Fatalf("applied = %#v", applied)
|
||||
}
|
||||
if payload := value.(Payload); !payload.RestartRequired {
|
||||
t.Fatal("provider setting update must declare restartRequired")
|
||||
}
|
||||
assertSecretProjection(t, value, "ZHINIAN_AUTH_SESSION_SECRET", true)
|
||||
}
|
||||
|
||||
func TestServiceGetUsesInjectedEnvironmentWithoutMutatingProcess(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".env.local")
|
||||
if err := os.WriteFile(path, []byte("EVOLINK_BASE_URL=file-value\nSEEDANCE_API_KEY=file-secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const sentinel = "settings-service-must-not-write-this"
|
||||
old, present := os.LookupEnv(sentinel)
|
||||
t.Cleanup(func() {
|
||||
if present {
|
||||
_ = os.Setenv(sentinel, old)
|
||||
} else {
|
||||
_ = os.Unsetenv(sentinel)
|
||||
}
|
||||
})
|
||||
_ = os.Unsetenv(sentinel)
|
||||
service := New(path, map[string]string{"EVOLINK_BASE_URL": "runtime-value", "SEEDANCE_API_KEY": "runtime-secret"}, nil)
|
||||
value, err := service.Get(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := os.LookupEnv(sentinel); exists {
|
||||
t.Fatal("process environment mutated")
|
||||
}
|
||||
if got := fieldValue(t, value, "EVOLINK_BASE_URL"); got != "runtime-value" {
|
||||
t.Fatalf("value = %q", got)
|
||||
}
|
||||
assertSecretProjection(t, value, "SEEDANCE_API_KEY", true)
|
||||
}
|
||||
|
||||
func TestServiceSynchronizesBillingAccountWithoutRequiringRestart(t *testing.T) {
|
||||
writer := &billingWriterStub{}
|
||||
service := New(filepath.Join(t.TempDir(), ".env.local"), nil, nil).WithBillingAccountWriter(writer)
|
||||
value, err := service.Save(context.Background(), map[string]any{
|
||||
"ZHINIAN_BILLING_ACCOUNT_NAME": " Acme ",
|
||||
"ZHINIAN_BILLING_ACCOUNT_BANK": " Bank ",
|
||||
"ZHINIAN_BILLING_ACCOUNT_NUMBER": " 123 ",
|
||||
"ZHINIAN_BILLING_CONTACT": " Ops ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if writer.value != (BillingAccount{AccountName: "Acme", BankName: "Bank", AccountNumber: "123", Contact: "Ops"}) {
|
||||
t.Fatalf("billing account=%#v", writer.value)
|
||||
}
|
||||
if value.(Payload).RestartRequired {
|
||||
t.Fatal("billing account update should be applied immediately")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePartialBillingUpdatePreservesPersistedSiblingFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".env.local")
|
||||
if err := os.WriteFile(path, []byte("ZHINIAN_BILLING_ACCOUNT_NAME=Acme\nZHINIAN_BILLING_ACCOUNT_BANK=Legacy Bank\nZHINIAN_BILLING_ACCOUNT_NUMBER=123\nZHINIAN_BILLING_CONTACT=Old Ops\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer := &billingWriterStub{}
|
||||
service := New(path, nil, nil).WithBillingAccountWriter(writer)
|
||||
if _, err := service.Save(context.Background(), map[string]any{"ZHINIAN_BILLING_CONTACT": "New Ops"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := BillingAccount{AccountName: "Acme", BankName: "Legacy Bank", AccountNumber: "123", Contact: "New Ops"}
|
||||
if writer.value != want {
|
||||
t.Fatalf("billing account=%#v want %#v", writer.value, want)
|
||||
}
|
||||
}
|
||||
|
||||
type billingWriterStub struct{ value BillingAccount }
|
||||
|
||||
func (writer *billingWriterStub) SaveBillingAccount(_ context.Context, value BillingAccount) error {
|
||||
writer.value = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func fieldValue(t *testing.T, value any, key string) string {
|
||||
t.Helper()
|
||||
payload := value.(Payload)
|
||||
for _, group := range payload.Groups {
|
||||
for _, field := range group.Fields {
|
||||
if field.Key == key {
|
||||
return field.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("field %s not found", key)
|
||||
return ""
|
||||
}
|
||||
func assertSecretProjection(t *testing.T, value any, key string, configured bool) {
|
||||
t.Helper()
|
||||
payload := value.(Payload)
|
||||
for _, group := range payload.Groups {
|
||||
for _, field := range group.Fields {
|
||||
if field.Key == key {
|
||||
if field.Value != "" || field.Configured != configured {
|
||||
t.Fatalf("field = %#v", field)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("field %s not found", key)
|
||||
}
|
||||
func clone(input map[string]string) map[string]string {
|
||||
output := map[string]string{}
|
||||
for key, value := range input {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
Reference in new issue
Block a user