Files
2026-09-23 15:10:13 +08:00

635 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package settings
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/providers"
)
type RuntimeUpdater func(context.Context, map[string]string) error
// RuntimeSettingsRepository persists editable settings for shared PostgreSQL
// deployments. Runtime application semantics are independent from storage:
// some settings apply immediately while startup-only settings require restart.
type RuntimeSettingsRepository interface {
LoadRuntimeSettings(context.Context, []string) (map[string]string, error)
SaveRuntimeSettings(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"`
Seedream bool `json:"seedream"`
Bailian bool `json:"bailian"`
Minimax bool `json:"minimax"`
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
database map[string]string
repository RuntimeSettingsRepository
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 NewWithRuntimeRepository(path, environment, nil, updater)
}
func NewWithRuntimeRepository(path string, environment map[string]string, repository RuntimeSettingsRepository, updater RuntimeUpdater) *Service {
return &Service{
path: path, environment: cloneStrings(environment), database: map[string]string{},
repository: repository, 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 !allowedEnvironmentKey(key) {
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 allowedEnvironmentKey(key string) bool {
_, allowed := fieldIndex[key]
return allowed
}
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()
if err := s.refreshDatabaseSettings(ctx); err != nil {
return nil, err
}
return s.get()
}
// RuntimeValues returns one coherent editable configuration snapshot. Database
// values override process/file values, including an explicitly saved empty
// non-secret value. Dynamic callers use a fresh snapshot per request or worker
// action so changes made through another replica are also observed.
func (s *Service) RuntimeValues(ctx context.Context) (map[string]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.refreshDatabaseSettings(ctx); err != nil {
return nil, err
}
return s.runtimeValues()
}
// CachedRuntimeValues is the non-blocking fallback used by liveness reporting
// if PostgreSQL is temporarily unavailable. Mutating and dynamic provider paths
// use RuntimeValues and therefore still fail closed on a refresh error.
func (s *Service) CachedRuntimeValues() map[string]string {
s.mu.Lock()
defer s.mu.Unlock()
values, err := s.runtimeValues()
if err != nil {
values = cloneStrings(s.environment)
for key, value := range s.database {
values[key] = value
}
}
return values
}
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()
if err := s.refreshDatabaseSettings(ctx); err != nil {
return nil, err
}
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 {
databaseUpdates, fileUpdates := map[string]string{}, map[string]string{}
for key, value := range updates {
if s.repository != nil {
databaseUpdates[key] = value
} else {
fileUpdates[key] = value
}
}
// Capture the complete file-backed view before writing so a partial billing
// account update cannot blank sibling fields.
persisted := map[string]string{}
if len(fileUpdates) > 0 || (s.billing != nil && containsBillingAccountUpdate(updates)) {
var err error
persisted, err = readEnv(s.path)
if err != nil {
return nil, err
}
}
if len(fileUpdates) > 0 {
if err := s.write(fileUpdates); err != nil {
return nil, err
}
}
if len(databaseUpdates) > 0 {
if err := s.repository.SaveRuntimeSettings(ctx, cloneStrings(databaseUpdates)); err != nil {
return nil, fmt.Errorf("persist runtime settings: %w", 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 fileUpdates {
s.environment[key] = value
persisted[key] = value
}
for key, value := range databaseUpdates {
s.database[key] = value
}
if s.billing != nil && containsBillingAccountUpdate(updates) {
if err := s.billing.SaveBillingAccount(ctx, BillingAccount{
AccountName: lookup(s.database, s.environment, persisted, "ZHINIAN_BILLING_ACCOUNT_NAME", ""),
BankName: lookup(s.database, s.environment, persisted, "ZHINIAN_BILLING_ACCOUNT_BANK", ""),
AccountNumber: lookup(s.database, s.environment, persisted, "ZHINIAN_BILLING_ACCOUNT_NUMBER", ""),
Contact: lookup(s.database, 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 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 {
if _, required := restartRequiredSettingKeys[key]; required {
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.database[field.Key]; ok {
return value
}
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.database, s.environment, file, "JIMENG_IMAGE_GENERATE_46_REQ_KEY", "jimeng_seedream46_cvtob"), "evolink": lookup(s.database, s.environment, file, "EVOLINK_IMAGE_MODEL", "gpt-image-2"), "bailian": lookup(s.database, s.environment, file, "BAILIAN_IMAGE_MODEL", "wan2.7-image-pro"), "seedream": "doubao-seedream-5-0-pro-260628"}[image]
videoModel := lookup(s.database, s.environment, file, "SEEDANCE_MODEL", "doubao-seedance-2-0-260128")
if video == "bailian" {
videoModel = lookup(s.database, s.environment, file, "BAILIAN_VIDEO_MODEL", "wan2.7-i2v-2026-04-25")
} else if video == "minimax" {
videoModel = providers.MinimaxH3Model
}
imageConnected := connected(image, s.database, s.environment, file)
videoConnected := connected(video, s.database, s.environment, file)
imageField := project(fieldIndex["IMAGE_GENERATE_ENGINE"], image, s.database, s.environment, file)
videoField := project(fieldIndex["VIDEO_GENERATE_ENGINE"], video, s.database, 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.database, s.environment, file), Evolink: connected("evolink", s.database, s.environment, file), Seedance: connected("seedance", s.database, s.environment, file), Seedream: connected("seedream", s.database, s.environment, file), Bailian: connected("bailian", s.database, s.environment, file), Minimax: connected("minimax", s.database, s.environment, file), Auth: lookup(s.database, s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != "", Organization: lookup(s.database, s.environment, file, "DATABASE_URL", "") != "" || lookup(s.database, s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != ""}
imageLabel := "图片生成 4.6"
if image == "seedream" {
imageLabel = "Seedream 5.0 Pro 图片生成"
}
return Payload{Services: services, Capabilities: []Capability{{ID: "image.generate", Label: imageLabel, 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) refreshDatabaseSettings(ctx context.Context) error {
if s.repository == nil {
return nil
}
values, err := s.repository.LoadRuntimeSettings(ctx, RuntimeSettingKeys())
if err != nil {
return fmt.Errorf("load runtime settings: %w", err)
}
s.database = map[string]string{}
for key, value := range values {
if allowedEnvironmentKey(key) {
s.database[key] = value
}
}
return nil
}
func (s *Service) runtimeValues() (map[string]string, error) {
file, err := readEnv(s.path)
if err != nil {
return nil, err
}
values := map[string]string{}
for key, value := range file {
if allowedEnvironmentKey(key) {
values[key] = value
}
}
for key, value := range s.environment {
values[key] = value
}
for key, value := range s.database {
values[key] = value
}
return values, 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(database, environment, file map[string]string, key, fallback string) string {
if value, ok := database[key]; ok {
return value
}
if value, ok := environment[key]; ok {
return value
}
if value, ok := file[key]; ok {
return value
}
return fallback
}
func project(field Field, value string, database, environment, file map[string]string) Field {
field.Value = value
field.Configured = lookup(database, environment, file, field.Key, "") != ""
return field
}
func normalizeImage(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "evolink" || value == "bailian" || value == "seedream" {
return value
}
return "jimeng"
}
func normalizeVideo(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "bailian":
return "bailian"
case "minimax":
return "minimax"
default:
return "seedance"
}
}
func label(engine string) string {
switch engine {
case "evolink":
return "EvoLink"
case "bailian":
return "阿里云百炼"
case "seedance":
return "Seedance"
case "seedream":
return "Seedream 5.0 Pro"
case "minimax":
return "MiniMax H3"
default:
return "即梦"
}
}
func connection(ok bool) string {
if ok {
return "已连接"
}
return "待配置"
}
func connected(engine string, database, environment, file map[string]string) bool {
switch engine {
case "evolink":
return strings.TrimSpace(lookup(database, environment, file, "EVOLINK_API_KEY", "")) != ""
case "seedance", "seedream":
return strings.TrimSpace(lookup(database, environment, file, "SEEDANCE_API_KEY", "")) != ""
case "bailian":
return strings.TrimSpace(lookup(database, environment, file, "BAILIAN_API_KEY", "")) != "" || strings.TrimSpace(lookup(database, environment, file, "DASHSCOPE_API_KEY", "")) != ""
case "minimax":
return strings.TrimSpace(lookup(database, environment, file, "MINIMAX_API_KEY", "")) != ""
default:
return strings.TrimSpace(lookup(database, environment, file, "VOLCENGINE_ACCESS_KEY_ID", "")) != "" && strings.TrimSpace(lookup(database, environment, file, "VOLCENGINE_SECRET_ACCESS_KEY", "")) != ""
}
}
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 / 2.5 图片生成。", 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: "火山方舟 API", Description: "Seedance 视频与 Seedream 5.0 Pro 图片共用此 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: "minimax", Title: "MiniMax API", Description: "MiniMax H3 视频生成。", Fields: []Field{{Key: "MINIMAX_API_KEY", Label: "MiniMax API Key", Secret: true, Type: "password"}, {Key: "MINIMAX_BASE_URL", Label: "Base URL", DefaultValue: "https://api.minimax.cn"}}},
{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: "Bucket 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: "即梦 4.6 / 火山视觉", Value: "jimeng"}, {Label: "Seedream 5.0 Pro / 火山方舟", Value: "seedream"}, {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: "MiniMax H3", Value: "minimax"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}}
return result
}()
var restartRequiredSettingKeys = map[string]struct{}{
"ZHINIAN_AUTH_REQUIRED": {},
"ZHINIAN_AUTH_SESSION_SECRET": {},
"ZHINIAN_BILLING_REQUIRED": {},
"ALI_OSS_ENDPOINT": {},
"ALI_OSS_BUCKET": {},
"ALI_OSS_ACCESS_KEY_ID": {},
"ALI_OSS_ACCESS_KEY_SECRET": {},
"ALI_OSS_PUBLIC_BASE_URL": {},
}