Files
QiuSWandClaude Opus 5 3e82ad6570 feat(yeeke): complete admin surface, scheduling and tests for return sync #336
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
2026-09-23 11:03:46 +08:00

258 lines
9.3 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 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)
}
}