本次重构实现了标准化的本地配置系统,替换原有的硬编码环境变量读取逻辑: 1. 新增跨平台的原子化配置文件读写,支持Unix和Windows系统 2. 新增init命令用于安全初始化和更新本地凭据 3. 替换原有错误提示文案为更友好的中文提示 4. 更新文档说明新的配置流程和安全规范 5. 新增完整的配置相关测试用例 6. 添加必要的依赖包支持
235 lines
6.5 KiB
Go
235 lines
6.5 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"golang.org/x/mod/modfile"
|
|
)
|
|
|
|
const (
|
|
DefaultAPIBaseURL = "https://api.zhecent.com"
|
|
|
|
APIBaseURLEnv = "LIGHTCORE_API_BASE_URL"
|
|
TokenEnv = "LIGHTCORE_SHOP_CRM_AGENT_TOKEN"
|
|
EnvFileName = ".env"
|
|
|
|
maxEnvFileSize = 16 << 10
|
|
modulePath = "code.zhecent.com/open/shop-crm-agent"
|
|
)
|
|
|
|
type Config struct {
|
|
APIBaseURL string
|
|
Token string
|
|
}
|
|
|
|
func Load() (Config, error) {
|
|
envFile, err := DefaultEnvFile()
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
return load(envFile, os.LookupEnv)
|
|
}
|
|
|
|
func Initialize(token string) (string, error) {
|
|
envFile, err := DefaultEnvFile()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := writeEnvFile(envFile, token); err != nil {
|
|
return "", err
|
|
}
|
|
return envFile, nil
|
|
}
|
|
|
|
func DefaultEnvFile() (string, error) {
|
|
executable, executableErr := os.Executable()
|
|
if executableErr == nil {
|
|
if resolved, err := filepath.EvalSymlinks(executable); err == nil {
|
|
executable = resolved
|
|
}
|
|
}
|
|
|
|
workingDir, err := os.Getwd()
|
|
if err != nil {
|
|
return "", fmt.Errorf("定位 shop-crm-agent 仓库失败: %w", err)
|
|
}
|
|
return resolveEnvFile(executable, workingDir)
|
|
}
|
|
|
|
func resolveEnvFile(executable, workingDir string) (string, error) {
|
|
binaryDir := filepath.Dir(executable)
|
|
if filepath.Base(binaryDir) == "bin" {
|
|
repoRoot := filepath.Dir(binaryDir)
|
|
if isRepositoryRoot(repoRoot) {
|
|
return filepath.Join(repoRoot, EnvFileName), nil
|
|
}
|
|
}
|
|
if !isRepositoryRoot(workingDir) {
|
|
return "", errors.New("无法定位 shop-crm-agent 仓库根目录,请从仓库根运行 CLI")
|
|
}
|
|
return filepath.Join(workingDir, EnvFileName), nil
|
|
}
|
|
|
|
func load(envFile string, lookupEnv func(string) (string, bool)) (Config, error) {
|
|
apiBaseURL := DefaultAPIBaseURL
|
|
if rawAPIBaseURL, exists := lookupEnv(APIBaseURLEnv); exists && strings.TrimSpace(rawAPIBaseURL) != "" {
|
|
apiBaseURL = normalizeBaseURL(rawAPIBaseURL)
|
|
if err := validateAPIBaseURL(apiBaseURL); err != nil {
|
|
return Config{}, err
|
|
}
|
|
}
|
|
|
|
if rawToken, exists := lookupEnv(TokenEnv); exists && strings.TrimSpace(rawToken) != "" {
|
|
token, err := normalizeToken(rawToken)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
return Config{APIBaseURL: apiBaseURL, Token: token}, nil
|
|
}
|
|
if apiBaseURL != DefaultAPIBaseURL {
|
|
return Config{}, fmt.Errorf("覆盖 %s 时必须同时设置 %s", APIBaseURLEnv, TokenEnv)
|
|
}
|
|
|
|
token, err := readEnvFile(envFile)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
return Config{APIBaseURL: apiBaseURL, Token: token}, nil
|
|
}
|
|
|
|
func readEnvFile(path string) (string, error) {
|
|
pathInfo, err := os.Lstat(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "", errors.New("CRM Agent 尚未初始化,请运行 shop-crm-agent init")
|
|
}
|
|
return "", fmt.Errorf("读取 CRM Agent .env 信息失败: %w", err)
|
|
}
|
|
if !pathInfo.Mode().IsRegular() {
|
|
return "", errors.New("CRM Agent .env 必须是普通文件")
|
|
}
|
|
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("打开 CRM Agent .env 失败: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return "", fmt.Errorf("读取 CRM Agent .env 信息失败: %w", err)
|
|
}
|
|
if !info.Mode().IsRegular() || !os.SameFile(pathInfo, info) {
|
|
return "", errors.New("CRM Agent .env 必须是普通文件")
|
|
}
|
|
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
|
|
return "", errors.New("CRM Agent .env 不得允许组或其他用户访问,请设置为 0600 权限")
|
|
}
|
|
|
|
data, err := io.ReadAll(io.LimitReader(file, maxEnvFileSize+1))
|
|
if err != nil {
|
|
return "", fmt.Errorf("读取 CRM Agent .env 失败: %w", err)
|
|
}
|
|
if len(data) > maxEnvFileSize {
|
|
return "", errors.New("CRM Agent .env 超过 16 KiB 限制")
|
|
}
|
|
contents := strings.TrimSuffix(string(data), "\n")
|
|
contents = strings.TrimSuffix(contents, "\r")
|
|
if strings.ContainsAny(contents, "\r\n\x00") {
|
|
return "", errors.New("CRM Agent .env 格式无效,请重新运行 shop-crm-agent init")
|
|
}
|
|
prefix := TokenEnv + "="
|
|
if !strings.HasPrefix(contents, prefix) {
|
|
return "", errors.New("CRM Agent .env 缺少凭证,请重新运行 shop-crm-agent init")
|
|
}
|
|
return normalizeToken(strings.TrimPrefix(contents, prefix))
|
|
}
|
|
|
|
func writeEnvFile(path, token string) error {
|
|
return writeEnvFileWithReplace(path, token, replaceFile)
|
|
}
|
|
|
|
func writeEnvFileWithReplace(path, token string, replace func(string, string) error) error {
|
|
token, err := normalizeToken(token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
contents := TokenEnv + "=" + token + "\n"
|
|
if len(contents) > maxEnvFileSize {
|
|
return errors.New("CRM Agent Token 超过 .env 大小限制")
|
|
}
|
|
if info, err := os.Lstat(path); err == nil {
|
|
if !info.Mode().IsRegular() {
|
|
return errors.New("CRM Agent .env 已存在且不是普通文件,拒绝覆盖")
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("检查 CRM Agent .env 失败: %w", err)
|
|
}
|
|
|
|
tempFile, err := os.CreateTemp(filepath.Dir(path), EnvFileName+".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("创建 CRM Agent .env 临时文件失败: %w", err)
|
|
}
|
|
tempPath := tempFile.Name()
|
|
defer func() {
|
|
_ = tempFile.Close()
|
|
_ = os.Remove(tempPath)
|
|
}()
|
|
if runtime.GOOS != "windows" {
|
|
if err := tempFile.Chmod(0o600); err != nil {
|
|
return fmt.Errorf("设置 CRM Agent .env 权限失败: %w", err)
|
|
}
|
|
}
|
|
if _, err := io.WriteString(tempFile, contents); err != nil {
|
|
return fmt.Errorf("写入 CRM Agent .env 失败: %w", err)
|
|
}
|
|
if err := tempFile.Sync(); err != nil {
|
|
return fmt.Errorf("同步 CRM Agent .env 失败: %w", err)
|
|
}
|
|
if err := tempFile.Close(); err != nil {
|
|
return fmt.Errorf("关闭 CRM Agent .env 失败: %w", err)
|
|
}
|
|
if err := replace(tempPath, path); err != nil {
|
|
return fmt.Errorf("替换 CRM Agent .env 失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeToken(value string) (string, error) {
|
|
token := strings.TrimSpace(value)
|
|
if token == "" {
|
|
return "", errors.New("CRM Agent Token 不能为空")
|
|
}
|
|
if len(token) > maxEnvFileSize || strings.ContainsAny(token, "\r\n\x00") {
|
|
return "", errors.New("CRM Agent Token 格式无效")
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func normalizeBaseURL(value string) string {
|
|
return strings.TrimRight(strings.TrimSpace(value), "/")
|
|
}
|
|
|
|
func validateAPIBaseURL(value string) error {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return errors.New("CRM Agent API Base URL 必须是有效的 http/https URL")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isRepositoryRoot(path string) bool {
|
|
data, err := os.ReadFile(filepath.Join(path, "go.mod"))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return modfile.ModulePath(data) == modulePath
|
|
}
|