Builds on cf70021, which added the read-only yeeke client/session/sync core
but left it unreachable and unconfigurable. This commit:
- Wires config.ExtConfig.Yeeke (settings.yml + config.yaml + GOAUTO_YEEKE_*
env vars), mirroring the existing SYB credential pattern exactly, with a
dedicated OcrURL and shared OCR client from sybclient.
- Adds yeeke.StartSync as the single entry point for both a manual admin
trigger and the scheduled job, sharing one in-memory gate plus the existing
DB-level unique active_slot lease so they can never run concurrently.
- Fixes sync.go bugs found in review: Service.Sync always returned a nil
error even when the run failed (start/resume semantics were untestable),
item upserts on ctx-less s.db calls, and no error_message/last_success_at
was ever recorded on the run row.
- Adds status_unrecognized to yeeke_return_package: an unknown claim status
is preserved verbatim and flagged rather than silently bucketed.
- Adds the admin read-only surface (GET .../sync-runs, GET
.../sync-runs/:runId, POST .../sync) under /api/admin/v1/yeeke-returns,
visible to admin and purchaser per the #336 review comment, registered as
a GoAuto access module/menu group and purchaser API.
- Registers GoAutoYeekeReturnSync in the existing job/lease framework
(app/jobs), seeded disabled (Status 2) by a new version-local migration,
following 1786701600000_syb_hourly_sync_job.go's pattern exactly.
- Expands tests: session reuse/bounded re-login/timeout-preserves-cache in
yeekeclient; paging robustness (total changing mid-run, duplicate page,
empty page, timeout, simulated restart/resume), idempotent upserts,
unrecognized-status flagging, active_slot lease contention, StartSync gate
contention, and a credential/captcha redaction check in yeeke; settings.yml
binding and env var precedence in config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
258 lines
9.3 KiB
Go
258 lines
9.3 KiB
Go
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
sdkconfig "github.com/go-admin-team/go-admin-core/sdk/config"
|
||
"gopkg.in/yaml.v2"
|
||
)
|
||
|
||
// LocalConfigName is the deployment-local, never-tracked configuration file.
|
||
// It sits between settings.yml and the environment:
|
||
//
|
||
// settings.yml (tracked defaults, no secrets)
|
||
// < config.yaml (this file: deployment-local values and credentials)
|
||
// < GOAUTO_* environment variables (highest, for services and containers)
|
||
//
|
||
// Reading it in-process is what lets a packaged binary run on its own, with the
|
||
// same file and the same format the development launcher scripts already use.
|
||
const LocalConfigName = "config.yaml"
|
||
|
||
// LocalConfigPath resolves which config.yaml to read, or "" when there is none.
|
||
//
|
||
// A missing file is not an error: a container or a Windows service supplies
|
||
// everything through the environment and has no such file at all.
|
||
func LocalConfigPath() string {
|
||
if explicit := strings.TrimSpace(os.Getenv("GOAUTO_CONFIG")); explicit != "" {
|
||
return explicit
|
||
}
|
||
if _, err := os.Stat(LocalConfigName); err == nil {
|
||
return LocalConfigName
|
||
}
|
||
// One level up. The server is normally started from server/ while
|
||
// config.yaml lives at the repository root, so without this the file is
|
||
// found only when a launcher happens to export GOAUTO_CONFIG — and a
|
||
// developer running `go run .` by hand gets no local configuration at all.
|
||
parent := filepath.Join("..", LocalConfigName)
|
||
if _, err := os.Stat(parent); err == nil {
|
||
return parent
|
||
}
|
||
// Next to the executable, so double-clicking a packaged binary works no
|
||
// matter what the working directory happens to be.
|
||
if executable, err := os.Executable(); err == nil {
|
||
beside := filepath.Join(filepath.Dir(executable), LocalConfigName)
|
||
if _, err := os.Stat(beside); err == nil {
|
||
return beside
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// localFile mirrors config.yaml as loosely typed maps rather than typed
|
||
// structs.
|
||
//
|
||
// `[必须]` This is deliberate. YAML types an unquoted all-digit password as an
|
||
// integer, so a typed string field would fail to unmarshal and take the whole
|
||
// startup down over a quoting detail. Reading scalars as `any` and coercing
|
||
// them means a numeric password, port, or account id all work whether or not
|
||
// somebody remembered the quotes.
|
||
type localFile struct {
|
||
Database map[string]any `yaml:"database"`
|
||
Ports map[string]any `yaml:"ports"`
|
||
SYB map[string]any `yaml:"syb"`
|
||
Yeeke map[string]any `yaml:"yeeke"`
|
||
}
|
||
|
||
// ApplyLocalConfig loads config.yaml, if one is present, over the values
|
||
// already read from settings.yml. It runs before ApplyEnvironment so the
|
||
// environment keeps the last word.
|
||
//
|
||
// It never returns an error: a malformed or absent local file must not be the
|
||
// difference between a server that starts and one that does not, and the
|
||
// values it would have supplied are all individually optional.
|
||
func ApplyLocalConfig() {
|
||
path := LocalConfigPath()
|
||
if path == "" {
|
||
// `[必须]` Say so out loud. Silently doing nothing here surfaces much
|
||
// later as a puzzling "credential not configured" error that never
|
||
// mentions the file the operator actually edited.
|
||
logInfo("本地配置:未找到 %s(依次查找 GOAUTO_CONFIG、当前目录、上级目录、可执行文件同级目录),仅使用 settings.yml 和环境变量", LocalConfigName)
|
||
return
|
||
}
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
logInfo("本地配置:读取 %s 失败,已忽略: %v", path, err)
|
||
return
|
||
}
|
||
var file localFile
|
||
if err := yaml.Unmarshal(raw, &file); err != nil {
|
||
logInfo("本地配置:解析 %s 失败,已忽略: %v", path, err)
|
||
return
|
||
}
|
||
applyLocalDatabase(file.Database)
|
||
applyLocalPorts(file.Ports)
|
||
ApplyLocalSYB(file.SYB)
|
||
ApplyLocalYeeke(file.Yeeke)
|
||
logInfo("本地配置:已加载 %s", path)
|
||
}
|
||
|
||
// LogEffectiveConfig prints what the layered configuration actually resolved
|
||
// to, without printing any secret. It runs after every layer has been applied.
|
||
//
|
||
// `[必须]` Report presence, never values. An operator needs to know whether the
|
||
// credentials arrived, not what they are — server logs get pasted into tickets.
|
||
func LogEffectiveConfig() {
|
||
syb := ExtConfig.SYB.Resolved()
|
||
source := "未配置"
|
||
switch {
|
||
case strings.TrimSpace(os.Getenv("GOAUTO_SYB_USERNAME")) != "":
|
||
source = "环境变量"
|
||
case syb.HasCredentials():
|
||
source = LocalConfigName
|
||
}
|
||
logInfo("顺云宝配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
|
||
syb.HasCredentials(), source, syb.BaseURL, syb.OcrURL != "")
|
||
|
||
yeeke := ExtConfig.Yeeke.Resolved()
|
||
yeekeSource := "未配置"
|
||
switch {
|
||
case strings.TrimSpace(os.Getenv("GOAUTO_YEEKE_USERNAME")) != "":
|
||
yeekeSource = "环境变量"
|
||
case yeeke.HasCredentials():
|
||
yeekeSource = LocalConfigName
|
||
}
|
||
logInfo("yeeke 配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
|
||
yeeke.HasCredentials(), yeekeSource, yeeke.BaseURL, yeeke.OcrURL != "")
|
||
}
|
||
|
||
func applyLocalDatabase(database map[string]any) {
|
||
host := scalar(database, "host")
|
||
user := scalar(database, "user")
|
||
name := scalar(database, "name")
|
||
port := scalar(database, "port")
|
||
if host == "" || user == "" || name == "" || port == "" {
|
||
return
|
||
}
|
||
// `[必须]` The password is read without trimming: leading or trailing
|
||
// whitespace can be part of a credential, and silently changing it would
|
||
// produce an authentication failure with no visible cause.
|
||
password, _ := database["password"].(string)
|
||
if password == "" {
|
||
password = scalar(database, "password")
|
||
}
|
||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
|
||
user, password, host, port, name)
|
||
sdkconfig.DatabaseConfig.Driver = "mysql"
|
||
sdkconfig.DatabaseConfig.Source = dsn
|
||
for _, database := range sdkconfig.DatabasesConfig {
|
||
database.Driver = "mysql"
|
||
database.Source = dsn
|
||
}
|
||
}
|
||
|
||
func applyLocalPorts(ports map[string]any) {
|
||
port, err := strconv.Atoi(scalar(ports, "server"))
|
||
if err != nil || port < 1 || port > 65535 {
|
||
return
|
||
}
|
||
sdkconfig.ApplicationConfig.Port = int64(port)
|
||
}
|
||
|
||
// ApplyLocalSYB folds a config.yaml `syb:` section into ExtConfig. Only keys
|
||
// actually present override what settings.yml supplied, so config.yaml can
|
||
// carry credentials alone and leave the operational knobs where they are.
|
||
func ApplyLocalSYB(syb map[string]any) {
|
||
if username := strings.TrimSpace(scalar(syb, "username")); username != "" {
|
||
ExtConfig.SYB.Username = username
|
||
}
|
||
if password := scalar(syb, "password"); password != "" {
|
||
ExtConfig.SYB.Password = password
|
||
}
|
||
if baseURL := strings.TrimSpace(scalar(syb, "base_url")); baseURL != "" {
|
||
ExtConfig.SYB.BaseURL = baseURL
|
||
}
|
||
if ocrURL := strings.TrimSpace(scalar(syb, "ocr_url")); ocrURL != "" {
|
||
ExtConfig.SYB.OcrURL = ocrURL
|
||
}
|
||
if pageSize, err := strconv.Atoi(scalar(syb, "page_size")); err == nil && pageSize > 0 {
|
||
ExtConfig.SYB.PageSize = pageSize
|
||
}
|
||
if maxMatches, err := strconv.Atoi(scalar(syb, "max_matches")); err == nil && maxMatches > 0 {
|
||
ExtConfig.SYB.MaxMatches = maxMatches
|
||
}
|
||
if attempts, err := strconv.Atoi(scalar(syb, "ocr_max_attempts")); err == nil && attempts > 0 {
|
||
ExtConfig.SYB.OcrMaxAttempts = attempts
|
||
}
|
||
}
|
||
|
||
// ApplyLocalYeeke folds a config.yaml `yeeke:` section into ExtConfig, mirroring
|
||
// ApplyLocalSYB above (#336).
|
||
func ApplyLocalYeeke(yeeke map[string]any) {
|
||
if username := strings.TrimSpace(scalar(yeeke, "username")); username != "" {
|
||
ExtConfig.Yeeke.Username = username
|
||
}
|
||
if password := scalar(yeeke, "password"); password != "" {
|
||
ExtConfig.Yeeke.Password = password
|
||
}
|
||
if baseURL := strings.TrimSpace(scalar(yeeke, "base_url")); baseURL != "" {
|
||
ExtConfig.Yeeke.BaseURL = baseURL
|
||
}
|
||
if ocrURL := strings.TrimSpace(scalar(yeeke, "ocr_url")); ocrURL != "" {
|
||
ExtConfig.Yeeke.OcrURL = ocrURL
|
||
}
|
||
if pageSize, err := strconv.Atoi(scalar(yeeke, "page_size")); err == nil && pageSize > 0 {
|
||
ExtConfig.Yeeke.PageSize = pageSize
|
||
}
|
||
if maxPages, err := strconv.Atoi(scalar(yeeke, "max_pages")); err == nil && maxPages > 0 {
|
||
ExtConfig.Yeeke.MaxPages = maxPages
|
||
}
|
||
if retry, err := strconv.Atoi(scalar(yeeke, "retry")); err == nil && retry >= 0 {
|
||
ExtConfig.Yeeke.Retry = retry
|
||
}
|
||
if attempts, err := strconv.Atoi(scalar(yeeke, "ocr_max_attempts")); err == nil && attempts > 0 {
|
||
ExtConfig.Yeeke.OcrMaxAttempts = attempts
|
||
}
|
||
}
|
||
|
||
// logInfo writes an informational startup line to stdout.
|
||
//
|
||
// `[必须]` Not stderr. The development launcher pipes the server through
|
||
// `2>&1 | Tee-Object`, which turns every stderr write into a PowerShell
|
||
// NativeCommandError — informational lines would show up as a red error block
|
||
// and look like a failed startup.
|
||
func logInfo(format string, args ...any) {
|
||
fmt.Printf(time.Now().Format("2006/01/02 15:04:05")+" "+format+"\n", args...)
|
||
}
|
||
|
||
// scalar renders one YAML value as a string regardless of how YAML typed it.
|
||
//
|
||
// `[必须]` Floats are formatted without an exponent and without a trailing
|
||
// ".0": YAML reads a bare 3307 as int but some values arrive as float64, and
|
||
// "3307.0" is not a usable port.
|
||
func scalar(values map[string]any, key string) string {
|
||
if values == nil {
|
||
return ""
|
||
}
|
||
switch value := values[key].(type) {
|
||
case nil:
|
||
return ""
|
||
case string:
|
||
return value
|
||
case bool:
|
||
return strconv.FormatBool(value)
|
||
case int:
|
||
return strconv.Itoa(value)
|
||
case int64:
|
||
return strconv.FormatInt(value, 10)
|
||
case float64:
|
||
return strconv.FormatFloat(value, 'f', -1, 64)
|
||
default:
|
||
return fmt.Sprintf("%v", value)
|
||
}
|
||
}
|