图搜采集成功并自动关联后,拉取该虾皮商品的完整颜色尺码清单合并进档案, 再触发一次匹配。此后同一虾皮商品的其它颜色 SYB 订单无需再采集、再点匹配。 - 新增 shopeespec 叶子包,凭据只从 GOAUTO_ERPGO_BASE_URL/GOAUTO_ERPGO_APIKEY 读取;错误文本不携带 URL,避免 apikey 流进日志。 - 写入档案前经 sybspec.StripAnnotations 剥离 【...】,与 SYB 明细同源, 否则 confirmedMappings 查不到、映射全部落空。 - 标记记录同步时对着哪个 PDD 商品(spec_sync_pdd_product_id),不是布尔值, 重新关联时自动失效。 - 已完整同步的商品再次图搜时返回 IMAGE_SEARCH_SPEC_SYNCED。 - 迁移只加列不回填:既有档案仍是从 SYB 明细增量累积的,不能假装已同步。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
313 lines
12 KiB
PowerShell
313 lines
12 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[string]$ConfigPath,
|
|
[string]$DatabaseHost,
|
|
[Nullable[int]]$DatabasePort,
|
|
[string]$DatabaseUser,
|
|
[string]$DatabaseName,
|
|
[switch]$SkipMigration,
|
|
[switch]$ValidateConfigOnly
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
# The Go processes emit UTF-8. Without this the console decodes their output
|
|
# using the ANSI code page (936 on Simplified Chinese systems) and every
|
|
# non-ASCII log line arrives as mojibake.
|
|
#
|
|
# Setting [Console]::OutputEncoding alone is not enough: PowerShell 5.1 decodes
|
|
# child process output by the console code page, so chcp has to change too.
|
|
#
|
|
# NOTE: keep this file pure ASCII. PowerShell 5.1 reads a .ps1 without a BOM as
|
|
# ANSI, and a comment line holding an odd number of UTF-8 high bytes pairs its
|
|
# last byte with the newline, swallowing it and commenting out the line below.
|
|
try {
|
|
$null = & chcp.com 65001
|
|
[Console]::OutputEncoding = [Text.Encoding]::UTF8
|
|
$OutputEncoding = [Text.Encoding]::UTF8
|
|
} catch { }
|
|
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
|
$serverDirectory = Join-Path $workspaceRoot "server"
|
|
$migrationLog = Join-Path $serverDirectory "temp\startup-migration.log"
|
|
$plainPassword = $null
|
|
|
|
function ConvertFrom-YamlScalar {
|
|
param([string]$Value)
|
|
|
|
$value = $Value.Trim()
|
|
if ($value.Length -ge 2) {
|
|
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
|
return $value.Substring(1, $value.Length - 2).Replace('\"', '"').Replace('\\', '\')
|
|
}
|
|
if ($value.StartsWith("'") -and $value.EndsWith("'")) {
|
|
return $value.Substring(1, $value.Length - 2).Replace("''", "'")
|
|
}
|
|
}
|
|
return $value
|
|
}
|
|
|
|
function Read-DatabaseConfig {
|
|
param([string]$Path)
|
|
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
|
throw "Local configuration file was not found: $Path. Copy config.example.yaml to config.yaml and fill in database.password."
|
|
}
|
|
|
|
$values = @{}
|
|
$insideDatabase = $false
|
|
foreach ($line in Get-Content -LiteralPath $Path) {
|
|
if ($line -match '^\s*(#.*)?$') {
|
|
continue
|
|
}
|
|
if ($line -match '^database\s*:\s*$') {
|
|
$insideDatabase = $true
|
|
continue
|
|
}
|
|
if ($insideDatabase -and $line -match '^\S') {
|
|
break
|
|
}
|
|
if ($insideDatabase -and $line -match '^\s+(host|port|user|password|name)\s*:\s*(.*?)\s*$') {
|
|
$values[$Matches[1]] = ConvertFrom-YamlScalar $Matches[2]
|
|
}
|
|
}
|
|
|
|
foreach ($key in 'host', 'port', 'user', 'password', 'name') {
|
|
if (-not $values.ContainsKey($key) -or [string]::IsNullOrWhiteSpace([string]$values[$key])) {
|
|
throw "Missing database.$key in local configuration file: $Path"
|
|
}
|
|
}
|
|
|
|
$parsedPort = 0
|
|
if (-not [int]::TryParse($values.port, [ref]$parsedPort) -or $parsedPort -lt 1 -or $parsedPort -gt 65535) {
|
|
throw "database.port must be an integer between 1 and 65535 in: $Path"
|
|
}
|
|
if ($values.user -notmatch '^[A-Za-z0-9_.-]+$') {
|
|
throw "database.user contains unsupported characters in: $Path"
|
|
}
|
|
if ($values.name -notmatch '^[A-Za-z0-9_]+$') {
|
|
throw "database.name contains unsupported characters in: $Path"
|
|
}
|
|
|
|
return @{
|
|
Host = [string]$values.host
|
|
Port = $parsedPort
|
|
User = [string]$values.user
|
|
Password = [string]$values.password
|
|
Name = [string]$values.name
|
|
}
|
|
}
|
|
|
|
function Read-ErpGoConfig {
|
|
param([string]$Path)
|
|
|
|
# Optional section. Absent or incomplete means the Shopee spec sync stays
|
|
# off; the server treats that as "skip", not as an error (#290).
|
|
$values = @{}
|
|
$insideSection = $false
|
|
foreach ($line in Get-Content -LiteralPath $Path) {
|
|
if ($line -match '^\s*(#.*)?$') {
|
|
continue
|
|
}
|
|
if ($line -match '^erpgo\s*:\s*$') {
|
|
$insideSection = $true
|
|
continue
|
|
}
|
|
if ($insideSection -and $line -match '^\S') {
|
|
break
|
|
}
|
|
if ($insideSection -and $line -match '^\s+(baseUrl|apikey)\s*:\s*(.*?)\s*$') {
|
|
$values[$Matches[1]] = ConvertFrom-YamlScalar $Matches[2]
|
|
}
|
|
}
|
|
return $values
|
|
}
|
|
|
|
function Read-PortConfig {
|
|
param([string]$Path)
|
|
|
|
$values = @{}
|
|
$insidePorts = $false
|
|
foreach ($line in Get-Content -LiteralPath $Path) {
|
|
if ($line -match '^\s*(#.*)?$') {
|
|
continue
|
|
}
|
|
if ($line -match '^ports\s*:\s*$') {
|
|
$insidePorts = $true
|
|
continue
|
|
}
|
|
if ($insidePorts -and $line -match '^\S') {
|
|
break
|
|
}
|
|
if ($insidePorts -and $line -match '^\s+(server|web)\s*:\s*(.*?)\s*$') {
|
|
$values[$Matches[1]] = ConvertFrom-YamlScalar $Matches[2]
|
|
}
|
|
}
|
|
|
|
foreach ($key in 'server', 'web') {
|
|
$parsedPort = 0
|
|
if (-not $values.ContainsKey($key) -or
|
|
-not [int]::TryParse([string]$values[$key], [ref]$parsedPort) -or
|
|
$parsedPort -lt 1 -or $parsedPort -gt 65535) {
|
|
throw "ports.$key must be an integer between 1 and 65535 in: $Path"
|
|
}
|
|
$values[$key] = $parsedPort
|
|
}
|
|
if ($values.server -eq $values.web) {
|
|
throw "ports.server and ports.web must be different in: $Path"
|
|
}
|
|
|
|
return @{ Server = [int]$values.server; Web = [int]$values.web }
|
|
}
|
|
|
|
function Resolve-MySqlClient {
|
|
$services = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.Name -match '^MySQL' -and $_.State -eq 'Running' }
|
|
foreach ($service in $services) {
|
|
if ($service.PathName -match '^"?(.*?\\mysqld\.exe)"?(?:\s|$)') {
|
|
$serviceClient = Join-Path (Split-Path -Parent $Matches[1]) 'mysql.exe'
|
|
if (Test-Path -LiteralPath $serviceClient) {
|
|
return $serviceClient
|
|
}
|
|
}
|
|
}
|
|
|
|
$defaultPath = "C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe"
|
|
if (Test-Path -LiteralPath $defaultPath) {
|
|
return $defaultPath
|
|
}
|
|
|
|
$command = Get-Command mysql.exe -ErrorAction SilentlyContinue
|
|
if ($command) {
|
|
return $command.Source
|
|
}
|
|
|
|
throw "mysql.exe was not found. Add the MySQL 8.4 bin directory to PATH."
|
|
}
|
|
|
|
try {
|
|
if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
|
|
$ConfigPath = Join-Path $workspaceRoot "config.yaml"
|
|
}
|
|
elseif (-not [IO.Path]::IsPathRooted($ConfigPath)) {
|
|
$ConfigPath = Join-Path $workspaceRoot $ConfigPath
|
|
}
|
|
$ConfigPath = [IO.Path]::GetFullPath($ConfigPath)
|
|
$databaseConfig = Read-DatabaseConfig $ConfigPath
|
|
$portConfig = Read-PortConfig $ConfigPath
|
|
$erpgoConfig = Read-ErpGoConfig $ConfigPath
|
|
|
|
if (-not $PSBoundParameters.ContainsKey('DatabaseHost')) {
|
|
$DatabaseHost = $databaseConfig.Host
|
|
}
|
|
if (-not $PSBoundParameters.ContainsKey('DatabasePort')) {
|
|
$DatabasePort = $databaseConfig.Port
|
|
}
|
|
if (-not $PSBoundParameters.ContainsKey('DatabaseUser')) {
|
|
$DatabaseUser = $databaseConfig.User
|
|
}
|
|
if (-not $PSBoundParameters.ContainsKey('DatabaseName')) {
|
|
$DatabaseName = $databaseConfig.Name
|
|
}
|
|
$plainPassword = $databaseConfig.Password
|
|
|
|
Write-Host "GoAuto local server" -ForegroundColor Cyan
|
|
Write-Host "Config: $ConfigPath"
|
|
Write-Host "MySQL: ${DatabaseUser}@${DatabaseHost}:${DatabasePort}/${DatabaseName}"
|
|
Write-Host "Ports: server=$($portConfig.Server), web=$($portConfig.Web)"
|
|
if ($ValidateConfigOnly) {
|
|
Write-Host "Local database and port configuration is valid." -ForegroundColor Green
|
|
return
|
|
}
|
|
|
|
$mysqlClient = Resolve-MySqlClient
|
|
$env:MYSQL_PWD = $plainPassword
|
|
try {
|
|
Write-Host "[1/3] Checking database ${DatabaseName}..." -ForegroundColor Cyan
|
|
& $mysqlClient `
|
|
--protocol=TCP `
|
|
--host=$DatabaseHost `
|
|
--port=$DatabasePort `
|
|
--user=$DatabaseUser `
|
|
--default-character-set=utf8mb4 `
|
|
--execute="CREATE DATABASE IF NOT EXISTS ``${DatabaseName}`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "MySQL connection or database creation failed."
|
|
}
|
|
}
|
|
finally {
|
|
Remove-Item Env:MYSQL_PWD -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
# Let the server read the same config.yaml for local values such as the SYB
|
|
# credentials. The GOAUTO_* variables below outrank the file, so the
|
|
# database and port stay exactly what this script computed.
|
|
$env:GOAUTO_CONFIG = $ConfigPath
|
|
$env:GOAUTO_DB_DRIVER = "mysql"
|
|
$env:GOAUTO_DB_DSN = "${DatabaseUser}:${plainPassword}@tcp(${DatabaseHost}:${DatabasePort})/${DatabaseName}?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s"
|
|
$env:GOAUTO_SERVER_PORT = [string]$portConfig.Server
|
|
# Shopee spec service. The key never reaches the repo: config.yaml is
|
|
# gitignored and the server only ever reads the environment (#290).
|
|
if ($erpgoConfig.ContainsKey('baseUrl') -and -not [string]::IsNullOrWhiteSpace([string]$erpgoConfig.baseUrl)) {
|
|
$env:GOAUTO_ERPGO_BASE_URL = [string]$erpgoConfig.baseUrl
|
|
}
|
|
if ($erpgoConfig.ContainsKey('apikey') -and -not [string]::IsNullOrWhiteSpace([string]$erpgoConfig.apikey)) {
|
|
$env:GOAUTO_ERPGO_APIKEY = [string]$erpgoConfig.apikey
|
|
}
|
|
|
|
Push-Location $serverDirectory
|
|
try {
|
|
if (-not $SkipMigration) {
|
|
Write-Host "[2/3] Running database migrations..." -ForegroundColor Cyan
|
|
New-Item -ItemType Directory -Path (Split-Path -Parent $migrationLog) -Force | Out-Null
|
|
$previousErrorActionPreference = $ErrorActionPreference
|
|
try {
|
|
$ErrorActionPreference = "Continue"
|
|
# Stringify before Tee-Object. With a bare `2>&1 |`, PowerShell wraps
|
|
# every line the child writes to stderr in an ErrorRecord, which prints
|
|
# as a red NativeCommandError block and reads as a failed migration --
|
|
# even for ordinary informational output such as go-admin's
|
|
# "config init". Chasing individual stderr writers is whack-a-mole; this
|
|
# fixes the whole class. $LASTEXITCODE still reports the child's real
|
|
# exit code through the added pipeline stage, so failure detection below
|
|
# is unaffected.
|
|
& go run . migrate -c config/settings.yml 2>&1 |
|
|
ForEach-Object { "$_" } |
|
|
Tee-Object -FilePath $migrationLog
|
|
$migrationExitCode = $LASTEXITCODE
|
|
}
|
|
finally {
|
|
$ErrorActionPreference = $previousErrorActionPreference
|
|
}
|
|
if ($migrationExitCode -ne 0) {
|
|
$migrationTail = (Get-Content -LiteralPath $migrationLog -Tail 40 -ErrorAction SilentlyContinue) -join [Environment]::NewLine
|
|
throw "Database migration failed. Log: ${migrationLog}`n${migrationTail}"
|
|
}
|
|
}
|
|
else {
|
|
Write-Host "[2/3] Database migration skipped." -ForegroundColor DarkYellow
|
|
}
|
|
|
|
Write-Host "[3/3] Starting server..." -ForegroundColor Green
|
|
Write-Host "Local URL: http://127.0.0.1:$($portConfig.Server)"
|
|
Write-Host "Android URL: use this computer's LAN IP on port $($portConfig.Server)"
|
|
Write-Host "Press Ctrl+C to stop." -ForegroundColor DarkYellow
|
|
& go run . server -c config/settings.yml
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Server exited with code $LASTEXITCODE."
|
|
}
|
|
}
|
|
finally {
|
|
Pop-Location
|
|
}
|
|
}
|
|
finally {
|
|
Remove-Item Env:MYSQL_PWD -ErrorAction SilentlyContinue
|
|
Remove-Item Env:GOAUTO_DB_DSN -ErrorAction SilentlyContinue
|
|
Remove-Item Env:GOAUTO_DB_DRIVER -ErrorAction SilentlyContinue
|
|
Remove-Item Env:GOAUTO_SERVER_PORT -ErrorAction SilentlyContinue
|
|
Remove-Item Env:GOAUTO_CONFIG -ErrorAction SilentlyContinue
|
|
Remove-Item Env:GOAUTO_ERPGO_BASE_URL -ErrorAction SilentlyContinue
|
|
Remove-Item Env:GOAUTO_ERPGO_APIKEY -ErrorAction SilentlyContinue
|
|
$plainPassword = $null
|
|
}
|