diff --git a/.cursor/rules/java-ledger.mdc b/.cursor/rules/java-ledger.mdc index bab65ae..021af4f 100644 --- a/.cursor/rules/java-ledger.mdc +++ b/.cursor/rules/java-ledger.mdc @@ -10,7 +10,7 @@ alwaysApply: true ## 方式(2026-09-12) -主账 = **JeecgBoot + Online/cgform/代码生成器**(工程目录 `inquiry-api/`)。从旧仓拷贝改库名/端口优先于从零演示工程。复杂六态/TMS/报价可保留手写 `jeecg-module-inquiry`(`/inquiry/...`)。系统登录 `/sys/*`。并存端口 **8180**,库 **`inquiry_robot`**,禁止写现网 `jeecg-boot`。 +主账 = **JeecgBoot + Online/cgform/代码生成器**(工程目录 `inquiry-api/`)。复杂六态/TMS/报价可保留手写 `jeecg-module-inquiry`(`/inquiry/...`)。系统登录 `/sys/*`。并存端口 **8180**,库 **`inquiry_robot`**,禁止写现网 `jeecg-boot`。 独立 `ledger/` Spring Boot 3 空壳不作正式主账。 diff --git a/contracts/README.md b/contracts/README.md index 660e165..a238a62 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -8,6 +8,7 @@ |---|---| | `inquiry-required-fields-v1.json` | 正式(已从现网对照源拉入) | | `tms-dictionary-projection-v2.json` | 正式(已从现网对照源拉入) | +| `quote-template-mapping-v1.json` | 正式(报价模板映射/黄区/样例预览合同) | | `*.PLACEHOLDER.json` | 历史占位,加载器优先正式文件 | 备份另见 `_ref/prod-pull/contracts/`(勿提交密钥目录)。 diff --git a/contracts/quote-template-mapping-v1.json b/contracts/quote-template-mapping-v1.json new file mode 100644 index 0000000..afa8d5b --- /dev/null +++ b/contracts/quote-template-mapping-v1.json @@ -0,0 +1,145 @@ +{ + "schemaVersion": "quote-template-mapping-v1", + "description": "报价模板常变版:原 Excel 几乎不改;黄色=固定区;映射点=系统字段→单元格;千问只辅助映射不发明价格。", + "rules": { + "yellowMeansFixed": true, + "yellowNote": "黄色填充单元格为版式固定内容,生成与预览时永不覆盖。", + "clearFillableBeforeWrite": true, + "layoutAuthority": "uploaded_excel", + "noUnifiedColumns": true, + "llmRole": "suggest_field_to_cell_mapping_only", + "priceSource": "frozen_quote_facts_only", + "versionFreeze": "generated_quote_binds_templateId_plus_templateVersion" + }, + "modes": { + "auto": "可自动生成报价文件", + "manual": "仅下载给人工填写,不进自动生成" + }, + "mappingJsonShape": { + "schemaVersion": "quote-template-mapping-v1", + "sheet": "主工作表名(可空,默认第一个)", + "fields": { + "customer_name": "SheetName!C3", + "quote_no": "SheetName!F4" + }, + "fee_rows": { + "anchor": "SheetName!A15", + "columns": { + "name": "B", + "amount": "D", + "remark": "F" + }, + "styleCopyRows": 1 + }, + "fixedYellowCells": ["SheetName!A1", "SheetName!B2"], + "candidates": [], + "unmappedRequired": [] + }, + "systemFields": [ + { "code": "customer_name", "label": "客户名称", "requiredHint": true }, + { "code": "contact_name", "label": "联系人", "requiredHint": false }, + { "code": "contact_phone", "label": "电话", "requiredHint": false }, + { "code": "quote_no", "label": "报价单编号", "requiredHint": true }, + { "code": "quote_date", "label": "报价日期", "requiredHint": true }, + { "code": "valid_until", "label": "有效期至", "requiredHint": false }, + { "code": "currency", "label": "币别", "requiredHint": false }, + { "code": "sales_name", "label": "业务员", "requiredHint": false }, + { "code": "pol", "label": "起运港", "requiredHint": false }, + { "code": "pod", "label": "目的港", "requiredHint": false }, + { "code": "origin", "label": "始发地", "requiredHint": false }, + { "code": "destination", "label": "目的地", "requiredHint": false }, + { "code": "commodity", "label": "品名", "requiredHint": false }, + { "code": "quantity", "label": "货物数量", "requiredHint": false }, + { "code": "container_type_qty", "label": "箱型箱量", "requiredHint": false }, + { "code": "fcl_or_lcl", "label": "整柜或拼柜", "requiredHint": false }, + { "code": "trade_terms", "label": "贸易条款", "requiredHint": false }, + { "code": "cargo_ready_date", "label": "货好时间", "requiredHint": false }, + { "code": "pieces", "label": "件数", "requiredHint": false }, + { "code": "weight_kg", "label": "重量", "requiredHint": false }, + { "code": "volume_cbm", "label": "体积", "requiredHint": false }, + { "code": "total_amount", "label": "报价合计", "requiredHint": false }, + { "code": "fee_rows", "label": "费用明细行", "requiredHint": false } + ], + "labelDictionary": { + "客户名称": "customer_name", + "客户": "customer_name", + "客户:": "customer_name", + "联系人": "contact_name", + "联系人:": "contact_name", + "电话": "contact_phone", + "电话:": "contact_phone", + "报价单编号": "quote_no", + "报价编号": "quote_no", + "报价日期": "quote_date", + "询价日期": "quote_date", + "有效期至": "valid_until", + "报价有效期": "valid_until", + "币别": "currency", + "业务员": "sales_name", + "起运港": "pol", + "起 运 港": "pol", + "目的港": "pod", + "目 的 港": "pod", + "始发地": "origin", + "始发站": "origin", + "目的地": "destination", + "品名": "commodity", + "品 名": "commodity", + "货物数量": "quantity", + "箱型箱量": "container_type_qty", + "整柜或拼柜": "fcl_or_lcl", + "贸易条款": "trade_terms", + "货好时间": "cargo_ready_date", + "件数": "pieces", + "件 数": "pieces", + "重量": "weight_kg", + "重 量": "weight_kg", + "体积": "volume_cbm", + "体 积": "volume_cbm" + }, + "samplePreviewFacts": { + "customer_name": "ABC Trading Co.", + "contact_name": "张三", + "contact_phone": "13800000000", + "quote_no": "QT-202607020001", + "quote_date": "2026-07-02", + "valid_until": "2026-07-05", + "currency": "USD", + "sales_name": "张敏", + "pol": "CNSHA", + "pod": "USLAX", + "origin": "深圳", + "destination": "洛杉矶", + "commodity": "通用货物", + "quantity": "2", + "container_type_qty": "40HQ x 2", + "fcl_or_lcl": "FCL", + "trade_terms": "FOB", + "cargo_ready_date": "2026-07-10", + "pieces": "10", + "weight_kg": "1200", + "volume_cbm": "8.5", + "total_amount": "3680.00", + "fee_rows": [ + { "name": "O/F 海运费", "amount": "2900", "remark": "" }, + { "name": "THC + DOC + SEAL", "amount": "780", "remark": "" } + ] + }, + "categories": { + "海运": ["海运报价单模板"], + "空运": ["空运到港报价单模板", "空运到门报价单模板"], + "陆运": [ + "双清或中越", + "国内整车", + "国内拼车", + "跨境整车", + "跨境集拼", + "中港", + "凭祥保税仓&普仓仓储" + ] + }, + "seedModeHints": { + "manualKeywords": ["人工报价", "双清", "保税仓", "仓储"], + "autoDefault": true + } +} diff --git a/deploy/CUTOVER-TO-PROD.md b/deploy/CUTOVER-TO-PROD.md index 75c1b97..f530ef9 100644 --- a/deploy/CUTOVER-TO-PROD.md +++ b/deploy/CUTOVER-TO-PROD.md @@ -62,7 +62,7 @@ - 新主账 / 智能体连接 `jeecg-boot`、`ytd_runtime`、Redis `/0` + `ytd:prod:`、桶 `ytd-inquiry` - 与现网 **同时** 连接同一 AIBOT 长连接 - 删除或改写 `C:\yutongda\ops` 活动指针 -- 把半成品 `YTD_ENV=prod` 却仍混用旧现网库密钥 +- 把半成品 `YTD_ENV=prod` 却仍混用现网库密钥 ## 回滚(P1 失败) diff --git a/deploy/Watch-Agent8910.bat b/deploy/Watch-Agent8910.bat index 77818f2..77f2ca0 100644 --- a/deploy/Watch-Agent8910.bat +++ b/deploy/Watch-Agent8910.bat @@ -17,8 +17,11 @@ if not exist "%~dp0Watch-Agent8910.ps1" ( goto :end ) -echo Starting Watch-Agent8910 ... +echo Starting LIVE follow Watch-Agent8910 ... +echo Keep this window open. New WeCom messages should appear here automatically. +echo Do NOT use Once.bat if you want realtime. echo Local logs: repo\logs\YYYYMMDD_info.log and _error.log +echo Press Ctrl+C to stop. echo. powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0Watch-Agent8910.ps1" %* set ERR=%ERRORLEVEL% diff --git a/deploy/Watch-Agent8910.ps1 b/deploy/Watch-Agent8910.ps1 index 39eee25..edc91c8 100644 --- a/deploy/Watch-Agent8910.ps1 +++ b/deploy/Watch-Agent8910.ps1 @@ -1,15 +1,7 @@ -# 只读:拉服务器新系统 8910 的「业务/错误」日志,并同时落到本机 logs/。 -# 用法: -# powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy\Watch-Agent8910.ps1 -# powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy\Watch-Agent8910.ps1 -Once -# 双击 Watch-Agent8910.bat / Watch-Agent8910-Once.bat -# -# 本机落盘(仓库根 logs/,按当天日期): -# logs/20260913_info.log — 业务信息(入队、reply=、发送成功等) -# logs/20260913_error.log — 告警/异常(WARNING、ERROR、Traceback、errcode 等) -# -# 乱码:默认 UTF-8;仍乱码可加 -Gbk。 -# SSH:默认密钥 ~/.ssh/id_ed25519_ytd;可用 YTD_SSH_KEY / YTD_SSH_HOST 覆盖。 +# 只读:拉服务器新系统 8910 的业务/错误日志,并落到本机 logs/(UTF-8 BOM,记事本可直接打开)。 +# 用法:双击 Watch-Agent8910.bat / Watch-Agent8910-Once.bat +# 本机:logs/yyyyMMdd_info.log 、 logs/yyyyMMdd_error.log +# 服务器旧日志可能是 GBK、新日志是 UTF-8;远程按「逐行 UTF-8,失败则 GBK」解码,避免乱码。 [CmdletBinding()] param( @@ -26,8 +18,10 @@ $SshKey = if ($env:YTD_SSH_KEY) { $env:YTD_SSH_KEY } else { Join-Path $env:USERP $SshHost = if ($env:YTD_SSH_HOST) { $env:YTD_SSH_HOST } else { 'ytd_extdev_0903@129.211.181.80' } $LogOut = 'E:\wwwroot\logs\agent-http.out.log' $LogErr = 'E:\wwwroot\logs\agent-http.err.log' -$IncomingRemotePath = 'C:/Users/ytd_extdev_0903/incoming/_watch_agent8910.ps1' -$IncomingLocalRun = 'C:\Users\ytd_extdev_0903\incoming\_watch_agent8910.ps1' +# once / follow 用不同远程脚本名,避免互相覆盖导致「持续监听」其实跑成 Once +$WatchTag = if ($Once) { 'once' } else { 'follow' } +$IncomingRemotePath = "C:/Users/ytd_extdev_0903/incoming/_watch_agent8910_$WatchTag.ps1" +$IncomingLocalRun = "C:\Users\ytd_extdev_0903\incoming\_watch_agent8910_$WatchTag.ps1" $Pattern = '回调|inject-text|inject:|outbox|inbox|errcode|errmsg|60020|ERROR|WARNING|Traceback|Exception|ModuleNotFoundError|sender=|touser=|crypto|ChannelRuntime|dispatch intent|企微发送|验签|解密|reply=' @@ -41,30 +35,52 @@ try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) $OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch { - Write-Host 'warn: 无法设置本机控制台 UTF-8' + Write-Host 'warn: console UTF-8 setup failed' } -# 仓库根/logs(本脚本在 deploy/ 下) $RepoRoot = Split-Path -Parent $PSScriptRoot -$LocalLogDir = Join-Path $RepoRoot 'logs' +# 在仓库 deploy/ 下:写到仓库根 logs/;单独解压给同事:写到本工具目录 logs/ +if ((Split-Path -Leaf $PSScriptRoot) -eq 'deploy') { + $LocalLogDir = Join-Path $RepoRoot 'logs' +} else { + $LocalLogDir = Join-Path $PSScriptRoot 'logs' +} $Day = Get-Date -Format 'yyyyMMdd' $LocalInfoLog = Join-Path $LocalLogDir ($Day + '_info.log') $LocalErrorLog = Join-Path $LocalLogDir ($Day + '_error.log') $Utf8NoBom = New-Object System.Text.UTF8Encoding $false +$Utf8Bom = New-Object System.Text.UTF8Encoding $true + +function Ensure-Utf8BomFile([string]$path) { + if (-not (Test-Path $path) -or ((Get-Item $path).Length -eq 0)) { + [System.IO.File]::WriteAllText($path, '', $Utf8Bom) + return + } + $fs = [System.IO.File]::OpenRead($path) + try { + $b0 = $fs.ReadByte(); $b1 = $fs.ReadByte(); $b2 = $fs.ReadByte() + } finally { $fs.Close() } + if ($b0 -eq 0xEF -and $b1 -eq 0xBB -and $b2 -eq 0xBF) { return } + $raw = [System.IO.File]::ReadAllBytes($path) + $bom = [byte[]](0xEF, 0xBB, 0xBF) + $merged = New-Object byte[] ($bom.Length + $raw.Length) + [Array]::Copy($bom, 0, $merged, 0, 3) + [Array]::Copy($raw, 0, $merged, 3, $raw.Length) + [System.IO.File]::WriteAllBytes($path, $merged) +} if (-not $NoLocalLog) { New-Item -ItemType Directory -Force -Path $LocalLogDir | Out-Null + Ensure-Utf8BomFile $LocalInfoLog + Ensure-Utf8BomFile $LocalErrorLog $sessionHdr = '===== watch start ' + (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') + ' host=' + $SshHost + ' once=' + $Once.IsPresent + ' =====' [System.IO.File]::AppendAllText($LocalInfoLog, $sessionHdr + [Environment]::NewLine, $Utf8NoBom) [System.IO.File]::AppendAllText($LocalErrorLog, $sessionHdr + [Environment]::NewLine, $Utf8NoBom) } -# 写入本地 *_error.log 的判定(其余进 *_info.log) -# 注意:不能用裸 ERROR(-match 不区分大小写,会误伤 “error.log” 说明行) function Write-LocalLine([string]$line) { Write-Host $line if ($NoLocalLog) { return } - # 脚本自身说明行一律进 info,避免关键字误伤 if ($line -match '^(目标:|主日志|访问日志|日志编码|过滤:|本机落盘:|说明:|---+|=====)') { $isErr = $false } else { @@ -75,31 +91,29 @@ function Write-LocalLine([string]$line) { try { [System.IO.File]::AppendAllText($target, $line + [Environment]::NewLine, $Utf8NoBom) } catch { - Write-Host ('warn: 写本地日志失败: ' + $_.Exception.Message) + Write-Host ('warn: write local log failed: ' + $_.Exception.Message) } } Write-LocalLine ("目标: $SshHost") Write-LocalLine ("主日志(业务/错误): $LogErr") if ($Access -or $All) { Write-LocalLine ("访问日志: $LogOut") } -Write-LocalLine ("日志编码: " + $(if ($Gbk) { 'Default/GBK' } else { 'UTF-8' })) -if ($All) { - Write-LocalLine '过滤: 关闭' -} else { - Write-LocalLine ("过滤: $Pattern") -} +Write-LocalLine ("日志编码: smart UTF-8/GBK" + $(if ($Gbk) { ' (force GBK)' } else { '' })) +if ($All) { Write-LocalLine '过滤: 关闭' } else { Write-LocalLine ("过滤: $Pattern") } if (-not $NoLocalLog) { Write-LocalLine ("本机落盘: $LocalInfoLog") Write-LocalLine ("本机落盘: $LocalErrorLog") } -Write-LocalLine '说明: 企微气泡里的 [test] 回复若已出现=发送成功;日志里看 reply= 字段' +Write-LocalLine '说明: 本地日志为 UTF-8 BOM;请重新跑 Once 覆盖旧乱码内容(可先删当天 log 再跑)' Write-LocalLine '---' -$waitLiteral = if ($Once) { '$false' } else { '$true' } +# follow=1 / once=0,避免 $true/$false 在远程被覆盖或解析歧义 +$waitFlag = if ($Once) { '0' } else { '1' } $allLiteral = if ($All) { '$true' } else { '$false' } $accessLiteral = if ($Access) { '$true' } else { '$false' } -$encName = if ($Gbk) { 'Default' } else { 'UTF8' } +$forceGbkLiteral = if ($Gbk) { '$true' } else { '$false' } +# 远程:逐行按 UTF-8 严格解码,失败再用 GBK(936);输出强制 Flush,避免持续监听被缓冲卡住 $remoteScript = @" `$ErrorActionPreference = 'Continue' try { @@ -110,61 +124,151 @@ try { `$out = '$LogOut' `$err = '$LogErr' `$tail = $Tail -`$wait = $waitLiteral +`$waitFlag = '$waitFlag' +`$wait = (`$waitFlag -eq '1') `$all = $allLiteral `$wantAccess = $accessLiteral `$pat = '$Pattern' -`$enc = '$encName' +`$forceGbk = $forceGbkLiteral +`$utf8Strict = New-Object System.Text.UTF8Encoding `$false, `$true +`$gbk = [System.Text.Encoding]::GetEncoding(936) -Write-Output '=== remote health 8910 ===' +function Emit([string]`$s) { + [Console]::Out.WriteLine(`$s) + [Console]::Out.Flush() +} + +function Decode-LogBytes([byte[]]`$chunk) { + if (`$null -eq `$chunk -or `$chunk.Length -eq 0) { return '' } + if (`$forceGbk) { return `$gbk.GetString(`$chunk) } + try { return `$utf8Strict.GetString(`$chunk) } catch { return `$gbk.GetString(`$chunk) } +} + +function Get-SmartTailLines([string]`$path, [int]`$n) { + if (-not (Test-Path `$path)) { return @() } + `$fs = [System.IO.File]::Open(`$path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try { + `$ms = New-Object System.IO.MemoryStream + `$fs.CopyTo(`$ms) + `$bytes = `$ms.ToArray() + } finally { + `$fs.Close() + } + `$lines = New-Object System.Collections.Generic.List[string] + `$start = 0 + for (`$i = 0; `$i -lt `$bytes.Length; `$i++) { + if (`$bytes[`$i] -ne 10) { continue } + `$end = `$i + if (`$end -gt `$start -and `$bytes[`$end - 1] -eq 13) { `$end = `$end - 1 } + `$len = `$end - `$start + if (`$len -lt 0) { `$len = 0 } + `$chunk = New-Object byte[] `$len + if (`$len -gt 0) { [Array]::Copy(`$bytes, `$start, `$chunk, 0, `$len) } + `$lines.Add((Decode-LogBytes `$chunk)) + `$start = `$i + 1 + } + if (`$start -lt `$bytes.Length) { + `$len = `$bytes.Length - `$start + `$chunk = New-Object byte[] `$len + [Array]::Copy(`$bytes, `$start, `$chunk, 0, `$len) + `$lines.Add((Decode-LogBytes `$chunk)) + } + if (`$lines.Count -le `$n) { return `$lines.ToArray() } + return `$lines.GetRange(`$lines.Count - `$n, `$n).ToArray() +} + +Emit ('mode=' + `$waitFlag + ' follow=' + `$wait) +Emit '=== remote health 8910 ===' try { `$h = Invoke-WebRequest -Uri 'http://127.0.0.1:8910/health' -UseBasicParsing -TimeoutSec 5 - Write-Output `$h.Content + Emit `$h.Content } catch { - Write-Output ('health_fail: ' + `$_.Exception.Message) + Emit ('health_fail: ' + `$_.Exception.Message) } -Write-Output '=== business/error log (err) ===' +Emit '=== business/error log (err) ===' function Show-Err([string]`$line) { - if (`$all) { Write-Output ('[err] ' + `$line); return } - if (`$line -match `$pat) { Write-Output ('[err] ' + `$line) } + if (`$all) { Emit ('[err] ' + `$line); return } + if (`$line -match `$pat) { Emit ('[err] ' + `$line) } } function Show-Out([string]`$line) { if (-not `$wantAccess -and -not `$all) { return } - if (`$all) { Write-Output ('[out] ' + `$line); return } - if (`$line -match '/wecom/callback|inject-text') { - Write-Output ('[out] ' + `$line) - } + if (`$all) { Emit ('[out] ' + `$line); return } + if (`$line -match '/wecom/callback|inject-text') { Emit ('[out] ' + `$line) } } -if (-not (Test-Path `$err)) { Write-Output ('MISSING ' + `$err) } +if (-not (Test-Path `$err)) { Emit ('MISSING ' + `$err) } if (-not `$wait) { if (Test-Path `$err) { - Get-Content -Path `$err -Tail `$tail -Encoding `$enc -ErrorAction SilentlyContinue | ForEach-Object { Show-Err `$_ } + Get-SmartTailLines `$err `$tail | ForEach-Object { Show-Err `$_ } } if (`$wantAccess -or `$all) { - Write-Output '=== access log (out) ===' + Emit '=== access log (out) ===' if (Test-Path `$out) { - Get-Content -Path `$out -Tail `$tail -Encoding `$enc -ErrorAction SilentlyContinue | ForEach-Object { Show-Out `$_ } + Get-SmartTailLines `$out `$tail | ForEach-Object { Show-Out `$_ } } } exit 0 } -Write-Output 'following err.log ... (Ctrl+C to stop)' -`$watch = @() -if (Test-Path `$err) { `$watch += `$err } -if ((`$wantAccess -or `$all) -and (Test-Path `$out)) { `$watch += `$out } -if (`$watch.Count -eq 0) { throw 'no log files' } - -Get-Content -Path `$watch -Tail `$tail -Wait -Encoding `$enc -ErrorAction SilentlyContinue | ForEach-Object { - `$line = `$_ - if (`$line -match '^\s*INFO:\s+\S+\s+-\s+"(GET|POST|PUT|DELETE)') { - Show-Out `$line - } else { - Show-Err `$line +Emit 'following err.log ... (Ctrl+C to stop)' +`$lastLen = @{} +`$carry = @{} +foreach (`$f in @(`$err, `$out)) { + if (Test-Path `$f) { `$lastLen[`$f] = (Get-Item `$f).Length } else { `$lastLen[`$f] = 0 } + `$carry[`$f] = '' +} +if (Test-Path `$err) { Get-SmartTailLines `$err `$tail | ForEach-Object { Show-Err `$_ } } +# 尾部打完后,从当前文件末尾继续跟,避免重复刷旧行 +foreach (`$f in @(`$err, `$out)) { + if (Test-Path `$f) { `$lastLen[`$f] = (Get-Item `$f).Length } +} +`$heartbeatAt = [DateTime]::UtcNow +while (`$true) { + Start-Sleep -Milliseconds 500 + if (([DateTime]::UtcNow - `$heartbeatAt).TotalSeconds -ge 30) { + Emit ('... still watching ' + (Get-Date).ToString('HH:mm:ss')) + `$heartbeatAt = [DateTime]::UtcNow + } + `$targets = @() + if (Test-Path `$err) { `$targets += `$err } + if ((`$wantAccess -or `$all) -and (Test-Path `$out)) { `$targets += `$out } + foreach (`$f in `$targets) { + if (-not `$lastLen.ContainsKey(`$f)) { `$lastLen[`$f] = 0; `$carry[`$f] = '' } + `$len = (Get-Item `$f).Length + if (`$len -lt `$lastLen[`$f]) { `$lastLen[`$f] = 0; `$carry[`$f] = '' } + if (`$len -le `$lastLen[`$f]) { continue } + `$fs = [System.IO.File]::Open(`$f, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) + try { + [void]`$fs.Seek(`$lastLen[`$f], [System.IO.SeekOrigin]::Begin) + `$buf = New-Object byte[] (`$len - `$lastLen[`$f]) + `$read = `$fs.Read(`$buf, 0, `$buf.Length) + } finally { `$fs.Close() } + `$lastLen[`$f] = `$len + if (`$read -le 0) { continue } + `$chunk = New-Object byte[] `$read + [Array]::Copy(`$buf, 0, `$chunk, 0, `$read) + `$text = `$carry[`$f] + (Decode-LogBytes `$chunk) + `$parts = `$text -split "`n" + if (-not (`$text.EndsWith("`n") -or `$text.EndsWith("`r"))) { + if (`$parts.Count -ge 1) { + `$carry[`$f] = `$parts[`$parts.Count - 1].TrimEnd("`r") + `$limit = `$parts.Count - 1 + } else { + `$carry[`$f] = `$text + `$limit = 0 + } + } else { + `$carry[`$f] = '' + `$limit = `$parts.Count + } + for (`$pi = 0; `$pi -lt `$limit; `$pi++) { + `$line = `$parts[`$pi].TrimEnd("`r") + if (-not `$line) { continue } + if (`$f -eq `$out) { Show-Out `$line } else { Show-Err `$line } + } } } "@ @@ -175,7 +279,6 @@ $localTemp = Join-Path $env:TEMP 'ytd-watch-agent8910-remote.ps1' & scp.exe -o BatchMode=yes -o ConnectTimeout=12 -i $SshKey $localTemp "${SshHost}:${IncomingRemotePath}" if ($LASTEXITCODE -ne 0) { throw "scp failed: $LASTEXITCODE" } -# 用 Process + UTF-8 读 SSH 输出,避免管道把中文解错再写入本地日志 $sshArgs = @( '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', diff --git a/deploy/Watch-Agent8910.同事说明.txt b/deploy/Watch-Agent8910.同事说明.txt index 790cae3..b595743 100644 --- a/deploy/Watch-Agent8910.同事说明.txt +++ b/deploy/Watch-Agent8910.同事说明.txt @@ -1,38 +1,40 @@ -同事如何使用「Watch-Agent8910」(只读盯服务器 8910 日志) +友通达询价智能体 - 8910 日志监听小工具(给同事) -一、需要具备 -1. Windows + 已安装 OpenSSH 客户端(Win10/11 一般自带 ssh/scp) -2. 仓库里这两个文件(或单独拷贝 deploy 目录): - - Watch-Agent8910.ps1 - - Watch-Agent8910.bat(可选 Once 版:Watch-Agent8910-Once.bat) -3. 能登录测试机的 SSH 私钥,且账号已加入服务器授权 +【包内文件】 +- Watch-Agent8910.bat 持续实时监听(推荐双击这个) +- Watch-Agent8910-Once.bat 只看最近一段后结束 +- Watch-Agent8910.ps1 主脚本(勿单独乱改) +- 使用说明.txt 本文件 -二、本机配置密钥(二选一) -A. 默认路径(与现网脚本一致) - 把私钥放到:%USERPROFILE%\.ssh\id_ed25519_ytd - (无扩展名;权限保持仅自己可读) +【本包不含】 +- SSH 私钥(勿通过微信群传播密钥;向负责人单独索取或登记公钥) -B. 自定义路径 - 运行前设置环境变量,例如: - set YTD_SSH_KEY=D:\keys\my_ytd_ed25519 - set YTD_SSH_HOST=ytd_extdev_0903@129.211.181.80 +【使用前准备】 +1. Windows 10/11,自带 OpenSSH(命令行能运行 ssh / scp) +2. 解压到任意目录,例如 D:\tools\YtdWatchAgent8910\ +3. 配置密钥(二选一): + A. 把私钥放到:%USERPROFILE%\.ssh\id_ed25519_ytd + B. 或先设置再运行: + set YTD_SSH_KEY=你的私钥完整路径 + set YTD_SSH_HOST=ytd_extdev_0903@129.211.181.80 + 说明:SSH 账号一般仍是 ytd_extdev_0903(服务器共用账号),不是改成同事自己的名字。 -三、怎么跑 -- 双击 Watch-Agent8910.bat → 持续监听(Ctrl+C 结束) -- 双击 Watch-Agent8910-Once.bat → 只看最近一段 -- 或命令行: - Watch-Agent8910.bat - Watch-Agent8910.bat -Once - Watch-Agent8910.bat -Access +【怎么用】 +1. 双击 Watch-Agent8910.bat +2. 看到 mode=1 follow=True 和 following err.log ... 表示实时监听已接通 +3. 保持窗口开着;企微给「询价小助手」发消息,这里应自动刷出新日志 +4. 结束:Ctrl+C,再按任意键关窗口 -本机同时落盘(仓库根目录 logs/,按当天日期追加写入): - logs/20260913_info.log - logs/20260913_error.log -不要提交 logs/ 到 Git。 +只要快速看一眼:双击 Watch-Agent8910-Once.bat -四、注意 -- 这是只读拉日志,不会改 Nginx / 不会切流 -- 私钥不要发到微信群或提交 Git;向负责人单独要密钥或加自己的公钥到服务器 -- 若提示禁止运行脚本:用 bat 即可(已带 Bypass),不要直接右键用受限策略跑 ps1 -- 乱码时用:Watch-Agent8910.bat -Gbk -- 只要屏幕不要文件:Watch-Agent8910.bat -NoLocalLog +【本机日志】 +运行后在本工具目录下生成: + logs\日期_info.log + logs\日期_error.log +例如:logs\20260913_info.log + +【注意】 +- 只读拉服务器日志,不会改 Nginx、不会切流 +- 行首 [err] 表示来自服务器 err 日志文件,不等于业务失败;看后面的 INFO/WARNING +- 乱码可试:Watch-Agent8910.bat -Gbk +- 不要把私钥打进这个压缩包 diff --git a/deploy/_local_deploy_quote_template.ps1 b/deploy/_local_deploy_quote_template.ps1 new file mode 100644 index 0000000..37b315e --- /dev/null +++ b/deploy/_local_deploy_quote_template.ps1 @@ -0,0 +1,98 @@ +# 本机:打包 agent/admin,上传 jar,远端重启主账与智能体,并 seed 模板 +$ErrorActionPreference = 'Stop' +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +if (-not $here) { $here = (Get-Location).Path } +$Repo = (Resolve-Path (Join-Path $here '..')).Path +$key = Join-Path $env:USERPROFILE '.ssh\id_ed25519_ytd' +$remote = 'ytd_extdev_0903@129.211.181.80' +$incoming = "${remote}:C:/Users/ytd_extdev_0903/incoming" +Write-Host ("here=" + $here) +Write-Host ("Repo=" + $Repo) +if (-not (Test-Path (Join-Path $Repo 'inquiry-agent'))) { throw "bad repo path: $Repo" } + +Write-Host '=== package agent ===' +$stage = Join-Path $env:TEMP 'ytd-agent-release-stage' +$zipAgent = Join-Path $Repo 'agent-release.zip' +if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } +New-Item -ItemType Directory -Force -Path (Join-Path $stage 'agent') | Out-Null +robocopy (Join-Path $Repo 'inquiry-agent') (Join-Path $stage 'agent') /E /XD .venv __pycache__ .git /XF *.pyc /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null +if (Test-Path $zipAgent) { Remove-Item $zipAgent -Force } +Compress-Archive -Path (Join-Path $stage 'agent') -DestinationPath $zipAgent -Force +Write-Host ("agent_zip=" + (Get-Item $zipAgent).Length) + +$jar = Join-Path $Repo 'inquiry-api\jeecg-module-system\jeecg-system-start\target\jeecg-system-start-3.5.3.jar' +if (-not (Test-Path $jar)) { throw 'jar missing' } +Write-Host ("jar_bytes=" + (Get-Item $jar).Length) + +Write-Host '=== build admin ===' +$Src = Join-Path $Repo 'inquiry-backend' +$Build = 'C:\ytd\inquiry-backend' +robocopy $Src $Build /MIR /XD node_modules dist .git .vite .husky /XF *.log /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null +if (Test-Path (Join-Path $Src '.env.production')) { + Copy-Item (Join-Path $Src '.env.production') (Join-Path $Build '.env.production') -Force +} +Copy-Item (Join-Path $Src 'vite.config.ts') (Join-Path $Build 'vite.config.ts') -Force +Push-Location $Build +if (Test-Path dist) { Remove-Item dist -Recurse -Force } +pnpm run build +$code = $LASTEXITCODE +Pop-Location +if ($code -ne 0) { throw "admin build failed $code" } +$zipAdmin = Join-Path $env:TEMP 'admin-dist.zip' +if (Test-Path $zipAdmin) { Remove-Item $zipAdmin -Force } +Compress-Archive -Path "$Build\dist\*" -DestinationPath $zipAdmin -Force +Write-Host ("admin_zip=" + (Get-Item $zipAdmin).Length) + +$cfgZip = Join-Path $env:TEMP 'ledger-config.zip' +if (Test-Path $cfgZip) { Remove-Item $cfgZip -Force } +$cfgStage = Join-Path $env:TEMP 'ledger-cfg-stage-qt' +if (Test-Path $cfgStage) { Remove-Item $cfgStage -Recurse -Force } +New-Item -ItemType Directory $cfgStage | Out-Null +Copy-Item (Join-Path $Repo 'deploy\config\application-inquiry.yml') (Join-Path $cfgStage 'application-inquiry.yml') -Force +Compress-Archive -Path "$cfgStage\*" -DestinationPath $cfgZip -Force + +# templates zip for seed +$tplZip = Join-Path $env:TEMP 'quote-templates-seed.zip' +if (Test-Path $tplZip) { Remove-Item $tplZip -Force } +$tplStage = Join-Path $env:TEMP 'quote-templates-seed-stage' +if (Test-Path $tplStage) { Remove-Item $tplStage -Recurse -Force } +New-Item -ItemType Directory $tplStage | Out-Null +$tplDir = Get-ChildItem -Path $Repo -Directory | Where-Object { $_.Name -like '*报价*' -and $_.Name -like '*模板*' } | Select-Object -First 1 +if (-not $tplDir) { throw 'template folder not found under repo' } +robocopy $tplDir.FullName (Join-Path $tplStage 'templates') /E /XF ~$* /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null +Write-Host ("templates_from=" + $tplDir.FullName) +Copy-Item (Join-Path $Repo 'deploy\seed\quote_templates_seed_manifest_v1.json') (Join-Path $tplStage 'manifest.json') -Force +Compress-Archive -Path "$tplStage\*" -DestinationPath $tplZip -Force + +Write-Host '=== upload ===' +scp -o BatchMode=yes -i $key ` + $zipAgent ` + $zipAdmin ` + $jar ` + $cfgZip ` + $tplZip ` + (Join-Path $Repo 'deploy\_remote_wmi_start_ledger.ps1') ` + (Join-Path $Repo 'deploy\_remote_safe_deploy_agent.ps1') ` + (Join-Path $Repo 'deploy\_remote_cutover_admin_8180.ps1') ` + (Join-Path $Repo 'deploy\_remote_seed_quote_templates.ps1') ` + $incoming +if ($LASTEXITCODE -ne 0) { throw 'scp failed' } +Write-Host 'UPLOAD_OK' + +Write-Host '=== deploy ledger ===' +ssh -o BatchMode=yes -i $key $remote "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_wmi_start_ledger.ps1" +if ($LASTEXITCODE -ne 0) { throw 'ledger deploy failed' } + +Write-Host '=== deploy agent ===' +ssh -o BatchMode=yes -i $key $remote "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_safe_deploy_agent.ps1" +if ($LASTEXITCODE -ne 0) { throw 'agent deploy failed' } + +Write-Host '=== deploy admin ===' +ssh -o BatchMode=yes -i $key $remote "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_cutover_admin_8180.ps1" +if ($LASTEXITCODE -ne 0) { throw 'admin deploy failed' } + +Write-Host '=== wait ledger ===' +Start-Sleep -Seconds 25 +ssh -o BatchMode=yes -i $key $remote "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_seed_quote_templates.ps1" +if ($LASTEXITCODE -ne 0) { throw 'seed failed' } +Write-Host 'QUOTE_TEMPLATE_DEPLOY_DONE' diff --git a/deploy/_remote_add_ticket_quote_date.ps1 b/deploy/_remote_add_ticket_quote_date.ps1 new file mode 100644 index 0000000..dc21e89 --- /dev/null +++ b/deploy/_remote_add_ticket_quote_date.ps1 @@ -0,0 +1,34 @@ +# 远端:给 inquiry_robot.inq_ticket 补 quote_date。禁止动 jeecg-boot。 +# 口令只从 E:\wwwroot\.env 读,不写进仓库、不回显。 +$ErrorActionPreference = 'Stop' +Get-Content 'E:\wwwroot\.env' | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith('#')) { return } + $i = $line.IndexOf('=') + if ($i -lt 1) { return } + $k = $line.Substring(0, $i).Trim() + $v = $line.Substring($i + 1).Trim().Trim('"') + Set-Item -Path "Env:$k" -Value $v +} +if (-not $env:MYSQL_DB) { throw 'MYSQL_DB missing' } +if ($env:MYSQL_DB -ne 'inquiry_robot') { throw ("refuse db=" + $env:MYSQL_DB) } +$mysql = 'C:\yutongda\tools\mysql-8.0.39-winx64\bin\mysql.exe' +if (-not (Test-Path $mysql)) { throw "missing $mysql" } +$hostName = if ($env:MYSQL_HOST) { $env:MYSQL_HOST } else { '10.206.0.14' } +$tcp = New-Object System.Net.Sockets.TcpClient +try { + $null = $tcp.ConnectAsync('127.0.0.1', 3306).Wait(1000) + if ($tcp.Connected) { $hostName = '127.0.0.1' } +} catch { +} finally { + $tcp.Close() +} +Write-Host "alter host=$hostName db=$env:MYSQL_DB" +$sqlFile = 'C:\Users\ytd_extdev_0903\incoming\inquiry_ticket_quote_date_v1.sql' +if (-not (Test-Path $sqlFile)) { throw "missing $sqlFile" } +& $mysql -h $hostName -P 3306 -u $env:MYSQL_USER "--password=$env:MYSQL_PASSWORD" --default-character-set=utf8mb4 $env:MYSQL_DB -e "source $($sqlFile.Replace('\','/'))" +if ($LASTEXITCODE -ne 0) { throw "fail sql code=$LASTEXITCODE" } +Write-Host 'SQL_OK' +& $mysql -h $hostName -P 3306 -u $env:MYSQL_USER "--password=$env:MYSQL_PASSWORD" --default-character-set=utf8mb4 $env:MYSQL_DB -e "SHOW COLUMNS FROM inq_ticket LIKE 'quote_date'\G" +if ($LASTEXITCODE -ne 0) { throw "fail verify code=$LASTEXITCODE" } +Write-Host 'VERIFY_DONE' diff --git a/deploy/_remote_check_captcha_class.ps1 b/deploy/_remote_check_captcha_class.ps1 new file mode 100644 index 0000000..2be6cda --- /dev/null +++ b/deploy/_remote_check_captcha_class.ps1 @@ -0,0 +1,18 @@ +$ErrorActionPreference='Stop' +$Fat='E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar' +$work=Join-Path $env:TEMP 'chk-captcha-class' +if(Test-Path $work){Remove-Item $work -Recurse -Force} +New-Item -ItemType Directory $work|Out-Null +$jarCmd=$null +foreach($c in @('C:\yutongda\tools\jdk8\bin\jar.exe','E:\wwwroot\tools\jdk-17\bin\jar.exe')){ if(Test-Path $c){ $jarCmd=$c; break } } +Push-Location $work +& $jarCmd xf $Fat 'BOOT-INF/lib/jeecg-system-biz-3.5.3.jar' +& $jarCmd xf 'BOOT-INF/lib/jeecg-system-biz-3.5.3.jar' 'org/jeecg/modules/system/controller/LoginController.class' +$classPath = Join-Path $work 'org\jeecg\modules\system\controller\LoginController.class' +if (-not (Test-Path $classPath)) { throw ("class missing under " + $work) } +$bytes=[IO.File]::ReadAllBytes($classPath) +$ascii=[Text.Encoding]::ASCII.GetString($bytes) +$has= $ascii.Contains('loginCaptchaEnabled') +Write-Host ("has_loginCaptchaEnabled=" + $has) +Write-Host ("class_bytes=" + $bytes.Length) +Pop-Location diff --git a/deploy/_remote_check_captcha_jar.ps1 b/deploy/_remote_check_captcha_jar.ps1 new file mode 100644 index 0000000..be5f92d --- /dev/null +++ b/deploy/_remote_check_captcha_jar.ps1 @@ -0,0 +1,2 @@ +$p='C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.CAPTCHA.jar' +if(Test-Path $p){ $i=Get-Item $p; "CAPTCHA length=$($i.Length) time=$($i.LastWriteTime)" } else { 'CAPTCHA missing' } diff --git a/deploy/_remote_check_inquiry_class.ps1 b/deploy/_remote_check_inquiry_class.ps1 new file mode 100644 index 0000000..c972f17 --- /dev/null +++ b/deploy/_remote_check_inquiry_class.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference="Stop" +$work=Join-Path $env:TEMP "chk-inquiry-class" +if(Test-Path $work){Remove-Item $work -Recurse -Force} +New-Item -ItemType Directory $work|Out-Null +$jarCmd="C:\yutongda\tools\jdk8\bin\jar.exe" +$Fat="E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar" +Push-Location $work +& $jarCmd xf $Fat "BOOT-INF/lib/jeecg-module-inquiry-3.5.3.jar" +& $jarCmd tf "BOOT-INF/lib/jeecg-module-inquiry-3.5.3.jar" | findstr /i "QuoteTemplate Controller" +Write-Host "CLASS_CHECK_DONE" +Pop-Location \ No newline at end of file diff --git a/deploy/_remote_check_new_jar.ps1 b/deploy/_remote_check_new_jar.ps1 new file mode 100644 index 0000000..e1e7450 --- /dev/null +++ b/deploy/_remote_check_new_jar.ps1 @@ -0,0 +1,3 @@ +$p='C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.NEW.jar' +if(Test-Path $p){ $i=Get-Item $p; "exists length=$($i.Length) time=$($i.LastWriteTime)" } else { 'missing NEW jar' } +Get-Process java -EA SilentlyContinue | Select-Object Id | Measure-Object | ForEach-Object { "java_count=$($_.Count)" } diff --git a/deploy/_remote_check_redis_parse.ps1 b/deploy/_remote_check_redis_parse.ps1 new file mode 100644 index 0000000..cf6ee33 --- /dev/null +++ b/deploy/_remote_check_redis_parse.ps1 @@ -0,0 +1,50 @@ +$ErrorActionPreference = "Continue" +Write-Host "=== run-ledger-inner redis bits ===" +Get-Content "E:\wwwroot\ops\run-ledger-inner.ps1" | Select-Object -First 55 | ForEach-Object { Write-Host $_ } + +$env:YTD_REDIS_PASSWORD = $null +Get-Content "E:\wwwroot\.env" | ForEach-Object { + $t = $_.Trim() + if (-not $t -or $t.StartsWith("#")) { return } + $i = $t.IndexOf("=") + if ($i -lt 1) { return } + Set-Item -Path ("Env:" + $t.Substring(0,$i).Trim()) -Value $t.Substring($i+1).Trim().Trim("`"") +} +$ulen = 0; if ($env:YTD_REDIS_USERNAME) { $ulen = $env:YTD_REDIS_USERNAME.Length } +$plen = 0; if ($env:YTD_REDIS_PASSWORD) { $plen = $env:YTD_REDIS_PASSWORD.Length } +$ulen2 = 0; $rlen = 0; if ($env:REDIS_URL) { $rlen = $env:REDIS_URL.Length } +Write-Host ("before_parse user_len=$ulen pw_len=$plen url_len=$rlen") + +if (-not $env:YTD_REDIS_PASSWORD -and $env:REDIS_URL) { + if ($env:REDIS_URL -match "redis://:([^@]+)@") { + $env:YTD_REDIS_PASSWORD = $Matches[1] + Write-Host "matched_form_password_only" + } elseif ($env:REDIS_URL -match "redis://([^:/@]+):([^@]+)@") { + if (-not $env:YTD_REDIS_USERNAME) { $env:YTD_REDIS_USERNAME = $Matches[1] } + $env:YTD_REDIS_PASSWORD = $Matches[2] + Write-Host "matched_form_user_pass" + } elseif ($env:REDIS_URL -match "redis://[^:]+:([^@]+)@") { + $env:YTD_REDIS_PASSWORD = $Matches[1] + Write-Host "matched_form_legacy" + } else { + Write-Host "NO_REGEX_MATCH" + } +} +$ulen = 0; if ($env:YTD_REDIS_USERNAME) { $ulen = $env:YTD_REDIS_USERNAME.Length } +$plen = 0; if ($env:YTD_REDIS_PASSWORD) { $plen = $env:YTD_REDIS_PASSWORD.Length } +Write-Host ("after_parse user_len=$ulen pw_len=$plen") +if ($env:REDIS_URL) { + $u = $env:REDIS_URL + $at = ([regex]::Matches($u,"@")).Count + $auth = $u.Substring(8).Split("@")[0] + $parts = $auth.Split(":").Count + Write-Host ("url_probe at_count=$at auth_colon_parts=$parts") +} + +$login = Invoke-RestMethod -Uri "http://127.0.0.1:8180/jeecgboot/sys/login" -Method POST -Body "{\"username\":\"admin\",\"password\":\"123456\",\"captcha\":\"\",\"checkKey\":\"\"}" -ContentType "application/json;charset=UTF-8" +$h = @{ "X-Access-Token" = $login.result.token } +$r = Invoke-WebRequest -Uri "http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=5" -Headers $h -UseBasicParsing +Write-Host "=== list ===" +$n = [Math]::Min(600, $r.Content.Length) +Write-Host $r.Content.Substring(0, $n) +Write-Host "PARSE_CHECK_DONE" \ No newline at end of file diff --git a/deploy/_remote_check_ssh_access.ps1 b/deploy/_remote_check_ssh_access.ps1 new file mode 100644 index 0000000..f319811 --- /dev/null +++ b/deploy/_remote_check_ssh_access.ps1 @@ -0,0 +1,98 @@ +# 只读排查:同事公钥是否在 authorized_keys、本机防火墙/SSH 相关线索 +# 不改任何配置,不输出私钥。 + +$ErrorActionPreference = 'Continue' +Write-Output '=== host ===' +whoami +hostname + +$ak = Join-Path $env:USERPROFILE '.ssh\authorized_keys' +Write-Output "=== authorized_keys: $ak ===" +if (-not (Test-Path $ak)) { + Write-Output 'NO_AUTHORIZED_KEYS_FILE' + exit 0 +} + +$lines = Get-Content $ak | Where-Object { $_ -and $_.Trim() -notmatch '^#' } +Write-Output ("key_lines=" + $lines.Count) + +$needle = 'IFBmb3YG1ZOJ3kaj8z3H55GXmv+hPIbYgN298VKorgFv' +$commentNeedle = 'ytd-external-developer' +$hit = $false +foreach ($line in $lines) { + if ($line -like "*$needle*") { + Write-Output 'PUBKEY_FOUND=yes' + Write-Output ("PUBKEY_LINE_PREFIX=" + $line.Substring(0, [Math]::Min(60, $line.Length))) + $hit = $true + } + if ($line -like "*$commentNeedle*") { + Write-Output 'COMMENT_FOUND=yes' + } +} +if (-not $hit) { + Write-Output 'PUBKEY_FOUND=no' + Write-Output 'PUBKEY_NOT_IN_AUTHORIZED_KEYS' +} + +Write-Output '=== key comments (safe) ===' +foreach ($line in $lines) { + if ($line -match '^ssh-\S+\s+\S+\s+(.+)$') { + Write-Output ("comment=" + $Matches[1]) + } elseif ($line -match '^ssh-\S+\s+\S+$') { + Write-Output 'comment=(none)' + } else { + Write-Output 'line=(unparsed-or-options)' + } +} + +Write-Output '=== OpenSSH firewall rules (OpenSSH/22) ===' +try { + Get-NetFirewallRule -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -match 'OpenSSH|SSH' -or $_.Name -match 'OpenSSH|SSH' } | + Select-Object -First 20 DisplayName, Enabled, Direction, Action, Profile | + Format-Table -AutoSize | Out-String | Write-Output +} catch { + Write-Output ("firewall_query_err=" + $_.Exception.Message) +} + +Write-Output '=== firewall rules mentioning 38.175.103.103 ===' +try { + $addrRules = Get-NetFirewallAddressFilter -ErrorAction SilentlyContinue | + Where-Object { $_.RemoteAddress -match '38\.175\.103\.103' -or ($_.RemoteAddress -is [array] -and ($_.RemoteAddress -contains '38.175.103.103')) } + if ($addrRules) { + foreach ($af in $addrRules) { + $rule = Get-NetFirewallRule -AssociatedNetFirewallAddressFilter $af -ErrorAction SilentlyContinue + if ($rule) { + Write-Output ("rule=" + $rule.DisplayName + "; enabled=" + $rule.Enabled + "; action=" + $rule.Action + "; dir=" + $rule.Direction + "; remote=" + ($af.RemoteAddress -join ',')) + } + } + } else { + Write-Output 'NO_FIREWALL_RULE_WITH_THAT_IP' + } +} catch { + Write-Output ("addr_filter_err=" + $_.Exception.Message) +} + +Write-Output '=== inbound allow rules on local port 22 (if any) ===' +try { + Get-NetFirewallPortFilter -Protocol TCP -ErrorAction SilentlyContinue | + Where-Object { $_.LocalPort -eq 22 -or $_.LocalPort -eq '22' } | + ForEach-Object { + $rule = Get-NetFirewallRule -AssociatedNetFirewallPortFilter $_ -ErrorAction SilentlyContinue + if ($rule) { + $af = Get-NetFirewallAddressFilter -AssociatedNetFirewallRule $rule -ErrorAction SilentlyContinue + Write-Output ("rule=" + $rule.DisplayName + "; enabled=" + $rule.Enabled + "; action=" + $rule.Action + "; remote=" + (($af.RemoteAddress) -join ',')) + } + } +} catch { + Write-Output ("port22_err=" + $_.Exception.Message) +} + +Write-Output '=== sshd service ===' +try { + Get-Service sshd -ErrorAction SilentlyContinue | Format-List Name, Status, StartType | Out-String | Write-Output +} catch { + Write-Output ("sshd_err=" + $_.Exception.Message) +} + +Write-Output 'DONE' diff --git a/deploy/_remote_clear_demo_data.ps1 b/deploy/_remote_clear_demo_data.ps1 new file mode 100644 index 0000000..c8d065b --- /dev/null +++ b/deploy/_remote_clear_demo_data.ps1 @@ -0,0 +1,177 @@ +# 清空 inquiry_robot:询价工单及相关、员工岗位;账号仅保留 admin。 +# 只动业务/账号数据,不动角色菜单、报价模板、机器人回复语。 +# 禁止对现网 jeecg-boot 执行。 +$ErrorActionPreference = "Stop" +$mysql = "C:\yutongda\tools\mysql-8.0.39-winx64\bin\mysql.exe" +if (-not (Test-Path $mysql)) { + $alt = Get-ChildItem "C:\yutongda\tools" -Recurse -Filter "mysql.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($alt) { $mysql = $alt.FullName } +} +if (-not (Test-Path $mysql)) { throw "mysql.exe not found" } + +$envFile = "E:\wwwroot\.env" +if (-not (Test-Path $envFile)) { throw "missing $envFile" } + +$hostn = "10.206.0.14" +$user = "inquiry_robot" +$pass = $null +$db = "inquiry_robot" +$port = "3306" +Get-Content $envFile | ForEach-Object { + if ($_ -match '^MYSQL_HOST=(.+)$') { $hostn = $Matches[1].Trim() } + if ($_ -match '^MYSQL_PORT=(.+)$') { $port = $Matches[1].Trim() } + if ($_ -match '^MYSQL_USER=(.+)$') { $user = $Matches[1].Trim() } + if ($_ -match '^MYSQL_PASSWORD=(.+)$') { $pass = $Matches[1].Trim() } + if ($_ -match '^MYSQL_DB=(.+)$') { $db = $Matches[1].Trim() } +} +if (-not $pass) { throw "MYSQL_PASSWORD missing in .env" } +if ($db -ne "inquiry_robot") { throw "refuse: MYSQL_DB=$db (must be inquiry_robot)" } + +Write-Output ("target=" + $hostn + ":" + $port + "/" + $db + " user=" + $user) + +# 先看清除前计数(表不存在则跳过) +$beforeSql = @" +SELECT 'BEFORE' AS phase; +SELECT 'inq_ticket' AS t, COUNT(*) AS c FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='inq_ticket' + AND (SELECT COUNT(*) FROM inq_ticket) IS NOT NULL; +"@ + +function Invoke-Mysql([string]$sql) { + $out = & $mysql -h $hostn -P $port -u $user "-p$pass" --default-character-set=utf8mb4 --batch --raw $db -e $sql 2>&1 + $out | ForEach-Object { + $s = "$_" + if ($s -match "Warning|Using a password") { return } + $s + } + if ($LASTEXITCODE -ne 0) { + throw ("mysql failed exit=" + $LASTEXITCODE + " sql_head=" + $sql.Substring(0, [Math]::Min(80, $sql.Length))) + } +} + +# 动态:仅对已存在的表做 TRUNCATE / DELETE +$clearSql = @" +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS=0; + +-- ---------- 工单及附属 ---------- +DELETE FROM inq_exception_case WHERE EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='inq_exception_case'); +DELETE FROM inq_attachment WHERE EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='inq_attachment'); +DELETE FROM inq_quote WHERE EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='inq_quote'); +DELETE FROM inq_ticket_status_log WHERE EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='inq_ticket_status_log'); +DELETE FROM inq_ticket_segment WHERE EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='inq_ticket_segment'); +DELETE FROM inq_ticket WHERE EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='inq_ticket'); +DELETE FROM inq_dashboard_snapshot WHERE EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='inq_dashboard_snapshot'); + +-- ---------- 员工岗位 ---------- +DELETE FROM inq_staff_role WHERE EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='inq_staff_role'); + +-- ---------- 账号:仅保留 admin ---------- +-- 先删非 admin 的关联,再删非 admin 用户 +DELETE ur FROM sys_user_role ur +INNER JOIN sys_user u ON u.id = ur.user_id +WHERE LOWER(u.username) <> 'admin'; + +DELETE ut FROM sys_user_tenant ut +INNER JOIN sys_user u ON u.id = ut.user_id +WHERE LOWER(u.username) <> 'admin'; + +DELETE ud FROM sys_user_depart ud +INNER JOIN sys_user u ON u.id = ud.user_id +WHERE LOWER(u.username) <> 'admin'; + +DELETE FROM sys_user WHERE LOWER(username) <> 'admin'; + +SET FOREIGN_KEY_CHECKS=1; + +SELECT 'AFTER_COUNTS' AS phase; +SELECT 'inq_ticket' AS t, (SELECT COUNT(*) FROM inq_ticket) AS c +UNION ALL SELECT 'inq_ticket_status_log', (SELECT COUNT(*) FROM inq_ticket_status_log) +UNION ALL SELECT 'inq_ticket_segment', (SELECT COUNT(*) FROM inq_ticket_segment) +UNION ALL SELECT 'inq_quote', (SELECT COUNT(*) FROM inq_quote) +UNION ALL SELECT 'inq_attachment', (SELECT COUNT(*) FROM inq_attachment) +UNION ALL SELECT 'inq_exception_case', (SELECT COUNT(*) FROM inq_exception_case) +UNION ALL SELECT 'inq_staff_role', (SELECT COUNT(*) FROM inq_staff_role) +UNION ALL SELECT 'sys_user', (SELECT COUNT(*) FROM sys_user) +UNION ALL SELECT 'sys_user_role', (SELECT COUNT(*) FROM sys_user_role); + +SELECT id, username, realname, status FROM sys_user; +"@ + +# 部分环境可能没有某附属表:改用逐表安全清理 +$probe = Invoke-Mysql "SHOW TABLES;" +Write-Output "=== tables ===" +$probe | ForEach-Object { $_ } + +$tables = @($probe | Where-Object { $_ -and $_ -ne "Tables_in_inquiry_robot" }) + +function Table-Exists([string]$name) { + return $tables -contains $name +} + +$stmts = New-Object System.Collections.Generic.List[string] +$stmts.Add("SET NAMES utf8mb4;") +$stmts.Add("SET FOREIGN_KEY_CHECKS=0;") + +# 工单相关 +foreach ($t in @( + "inq_exception_case", + "inq_attachment", + "inq_quote", + "inq_ticket_status_log", + "inq_ticket_segment", + "inq_ticket", + "inq_dashboard_snapshot", + "inq_staff_role" +)) { + if (Table-Exists $t) { + $stmts.Add("DELETE FROM ``$t``;") + Write-Output ("will_clear=" + $t) + } else { + Write-Output ("skip_missing=" + $t) + } +} + +if (Table-Exists "sys_user") { + if (Table-Exists "sys_user_role") { + $stmts.Add("DELETE ur FROM sys_user_role ur INNER JOIN sys_user u ON u.id = ur.user_id WHERE LOWER(u.username) <> 'admin';") + } + if (Table-Exists "sys_user_tenant") { + $stmts.Add("DELETE ut FROM sys_user_tenant ut INNER JOIN sys_user u ON u.id = ut.user_id WHERE LOWER(u.username) <> 'admin';") + } + if (Table-Exists "sys_user_depart") { + $stmts.Add("DELETE ud FROM sys_user_depart ud INNER JOIN sys_user u ON u.id = ud.user_id WHERE LOWER(u.username) <> 'admin';") + } + $stmts.Add("DELETE FROM sys_user WHERE LOWER(username) <> 'admin';") + Write-Output "will_clear_sys_user_keep_admin=1" +} else { + Write-Output "skip_missing=sys_user" +} + +$stmts.Add("SET FOREIGN_KEY_CHECKS=1;") + +# 执行前计数 +Write-Output "=== BEFORE ===" +foreach ($t in @("inq_ticket","inq_staff_role","sys_user")) { + if (Table-Exists $t) { + Invoke-Mysql "SELECT '$t' AS t, COUNT(*) AS c FROM ``$t``;" | ForEach-Object { $_ } + } +} +if (Table-Exists "sys_user") { + Invoke-Mysql "SELECT username, realname FROM sys_user;" | ForEach-Object { $_ } +} + +$execSql = ($stmts -join "`n") +Write-Output "=== EXEC ===" +Invoke-Mysql $execSql | ForEach-Object { $_ } + +Write-Output "=== AFTER ===" +foreach ($t in @("inq_ticket","inq_ticket_status_log","inq_ticket_segment","inq_quote","inq_attachment","inq_exception_case","inq_staff_role","sys_user","sys_user_role")) { + if (Table-Exists $t) { + Invoke-Mysql "SELECT '$t' AS t, COUNT(*) AS c FROM ``$t``;" | ForEach-Object { $_ } + } +} +if (Table-Exists "sys_user") { + Invoke-Mysql "SELECT id, username, realname, status FROM sys_user;" | ForEach-Object { $_ } +} + +Write-Output "CLEAR_DEMO_DATA_DONE" diff --git a/deploy/_remote_compare_redis_secret_lens.ps1 b/deploy/_remote_compare_redis_secret_lens.ps1 new file mode 100644 index 0000000..a8c91f7 --- /dev/null +++ b/deploy/_remote_compare_redis_secret_lens.ps1 @@ -0,0 +1,58 @@ +$ErrorActionPreference = "Continue" +function Load-EnvFile($path) { + $map = @{} + if (-not (Test-Path $path)) { return $map } + Get-Content $path | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith("#")) { return } + $i = $line.IndexOf("=") + if ($i -lt 1) { return } + $k = $line.Substring(0,$i).Trim() + $v = $line.Substring($i+1).Trim().Trim('"') + $map[$k] = $v + } + return $map +} +function Show-RedisKeys($label, $map) { + $keys = @("YTD_REDIS_HOST","YTD_REDIS_PORT","YTD_REDIS_DB","YTD_REDIS_USERNAME","YTD_REDIS_PASSWORD","REDIS_URL","REDIS_PASSWORD","REDIS_USERNAME") + Write-Host ("=== " + $label + " ===") + foreach ($k in $keys) { + if ($map.ContainsKey($k)) { + $v = $map[$k] + $safe = $v + if ($k -match "PASSWORD|URL") { + if ($k -eq "REDIS_URL" -and $v -match "redis://([^:/@]+):([^@]+)@([^:/]+)") { + Write-Host (" REDIS_URL user=" + $Matches[1] + " pw_len=" + $Matches[2].Length + " host=" + $Matches[3]) + continue + } + Write-Host (" " + $k + " len=" + $v.Length) + } else { + Write-Host (" " + $k + "=" + $v) + } + } + } +} +$candidates = @( + "E:\wwwroot\.env", + "C:\yutongda\.env", + "C:\yutongda\releases\admin-rel26-local-source-20260911d\backend\.env", + "C:\yutongda\releases\admin-rel26-local-source-20260911d\.env", + "E:\wwwroot\inquiry-new\.env" +) +Get-ChildItem "C:\yutongda" -Filter ".env" -Recurse -Depth 4 -ErrorAction SilentlyContinue | Select-Object -First 10 | ForEach-Object { $candidates += $_.FullName } +$candidates = $candidates | Select-Object -Unique +foreach ($p in $candidates) { + if (Test-Path $p) { Show-RedisKeys $p (Load-EnvFile $p) } +} +# Compare whether URL password equals any YTD_REDIS_PASSWORD found +$www = Load-EnvFile "E:\wwwroot\.env" +$urlPw = $null; $urlUser = $null +if ($www["REDIS_URL"] -match "redis://([^:/@]+):([^@]+)@") { $urlUser = $Matches[1]; $urlPw = $Matches[2] } +Get-ChildItem "C:\yutongda" -Filter ".env" -Recurse -Depth 4 -ErrorAction SilentlyContinue | ForEach-Object { + $m = Load-EnvFile $_.FullName + if ($m.ContainsKey("YTD_REDIS_PASSWORD") -and $urlPw) { + $same = ($m["YTD_REDIS_PASSWORD"] -eq $urlPw) + Write-Host ("cmp " + $_.FullName + " YTD_pw_len=" + $m["YTD_REDIS_PASSWORD"].Length + " same_as_www_url_pw=" + $same + " user=" + $m["YTD_REDIS_USERNAME"]) + } +} +Write-Host "COMPARE_DONE" \ No newline at end of file diff --git a/deploy/_remote_diag_inquiry_404.ps1 b/deploy/_remote_diag_inquiry_404.ps1 new file mode 100644 index 0000000..2fc935c --- /dev/null +++ b/deploy/_remote_diag_inquiry_404.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference = 'Continue' +Write-Host '=== listening 8180 ===' +Get-NetTCPConnection -LocalPort 8180 -State Listen -EA SilentlyContinue | Select-Object -First 1 OwningProcess +$java = Get-CimInstance Win32_Process -Filter "Name='java.exe'" | Where-Object { $_.CommandLine -like '*inquiry-new*' } | Select-Object -First 1 +if ($java) { + Write-Host ("pid=" + $java.ProcessId) + $cl = $java.CommandLine + if ($cl.Length -gt 200) { $cl = $cl.Substring(0,200) } + Write-Host ("cmd=" + $cl) +} +Write-Host '=== fat jar inquiry/poi ===' +$jarCmd = 'C:\yutongda\tools\jdk8\bin\jar.exe' +$Fat = 'E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar' +Write-Host ("fat_bytes=" + (Get-Item $Fat).Length) +& $jarCmd tf $Fat | Select-String 'jeecg-module-inquiry|poi-ooxml|commons-io' +Write-Host '=== probe urls ===' +$bodyPath = Join-Path $env:TEMP 'login-body.json' +Set-Content $bodyPath '{"username":"admin","password":"123456","captcha":"","checkKey":""}' -Encoding ASCII +$login = Invoke-RestMethod -Uri 'http://127.0.0.1:8180/jeecgboot/sys/login' -Method POST -InFile $bodyPath -ContentType 'application/json;charset=UTF-8' +$h = @{ 'X-Access-Token' = $login.result.token } +foreach ($u in @( + 'http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=1', + 'http://127.0.0.1:8180/inquiry/quoteTemplate/list?pageNo=1&pageSize=1', + 'http://127.0.0.1:8180/jeecgboot/sys/permission/getPermCode' +)) { + try { + $r = Invoke-WebRequest -Uri $u -Headers $h -UseBasicParsing -TimeoutSec 15 + Write-Host ("OK " + $u.Split('?')[0].Split('/')[-1] + " status=" + $r.StatusCode + " len=" + $r.RawContentLength) + } catch { + $code = $null + try { $code = [int]$_.Exception.Response.StatusCode } catch {} + Write-Host ("FAIL status=" + $code + " url=" + $u) + } +} +Write-Host '=== stdout mapping quoteTemplate ===' +Select-String -Path 'E:\wwwroot\inquiry-new\logs\stdout.log' -Pattern 'quoteTemplate|InquiryQuoteTemplate|Mapped.*inquiry' | Select-Object -Last 15 | ForEach-Object { + $s = $_.Line + if ($s.Length -gt 220) { $s = $s.Substring(0,220) } + Write-Host $s +} +Write-Host 'DIAG_DONE' diff --git a/deploy/_remote_diag_inquiry_404b.ps1 b/deploy/_remote_diag_inquiry_404b.ps1 new file mode 100644 index 0000000..b6585a4 --- /dev/null +++ b/deploy/_remote_diag_inquiry_404b.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = 'Continue' +$jarCmd = 'C:\yutongda\tools\jdk8\bin\jar.exe' +$Fat = 'E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar' +$Incoming = 'C:\Users\ytd_extdev_0903\incoming' +Write-Host ("fat=" + (Get-Item $Fat).Length) +Get-ChildItem $Incoming -Filter 'jeecg*.jar' | ForEach-Object { Write-Host ("incoming " + $_.Name + " " + $_.Length) } +Write-Host '=== fat libs matching inquiry/poi ===' +& $jarCmd tf $Fat 2>&1 | findstr /i "inquiry poi-ooxml poi-4 commons-io" +Write-Host '=== spring factories / component ===' +& $jarCmd tf $Fat 2>&1 | findstr /i "InquiryQuoteTemplate" +Write-Host 'DIAG2_DONE' diff --git a/deploy/_remote_diag_redis_pool.ps1 b/deploy/_remote_diag_redis_pool.ps1 new file mode 100644 index 0000000..20dbef5 --- /dev/null +++ b/deploy/_remote_diag_redis_pool.ps1 @@ -0,0 +1,47 @@ +# 诊断 Redis 池;不打印密码明文 +$ErrorActionPreference = 'Continue' +$java = Get-CimInstance Win32_Process -Filter "Name='java.exe'" | Where-Object { $_.CommandLine -like '*inquiry-new*' } | Select-Object -First 1 +if (-not $java) { Write-Host 'no_ledger_java'; exit 1 } +Write-Host ("pid=" + $java.ProcessId) + +foreach ($line in (Get-Content 'E:\wwwroot\.env')) { + $t = $line.Trim() + if (-not $t -or $t.StartsWith('#')) { continue } + $i = $t.IndexOf('=') + if ($i -lt 1) { continue } + $k = $t.Substring(0, $i).Trim() + $v = $t.Substring($i + 1).Trim().Trim('"') + if ($k -match 'REDIS|YTD_REDIS') { + $pref = if ($v.Length -ge 12) { $v.Substring(0,12) + '...' } else { '***' } + Write-Host ("envfile $k len=$($v.Length) prefix=$pref") + } +} + +$logDir = 'E:\wwwroot\inquiry-new\logs' +Get-ChildItem $logDir -Filter '*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 2 | ForEach-Object { + Write-Host ("log=$($_.Name) size=$($_.Length)") + Select-String -Path $_.FullName -Pattern 'Could not get a resource from the pool|JedisConnectionException|NOAUTH|WRONGPASS' | Select-Object -Last 6 | ForEach-Object { + $s = $_.Line + if ($s.Length -gt 200) { $s = $s.Substring(0,200) } + Write-Host (" $s") + } +} + +$login = Invoke-RestMethod -Uri 'http://127.0.0.1:8180/jeecgboot/sys/login' -Method POST -Body '{"username":"admin","password":"123456","captcha":"","checkKey":""}' -ContentType 'application/json;charset=UTF-8' +$h = @{ 'X-Access-Token' = $login.result.token } +foreach ($u in @( + 'http://127.0.0.1:8180/jeecgboot/sys/permission/getPermCode', + 'http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/agent/listEnabled', + 'http://127.0.0.1:8180/jeecgboot/sys/user/list?pageNo=1&pageSize=1' +)) { + try { + $r = Invoke-WebRequest -Uri $u -Headers $h -UseBasicParsing -TimeoutSec 15 + $j = $r.Content | ConvertFrom-Json + $msg = [string]$j.message + if ($msg.Length -gt 60) { $msg = $msg.Substring(0,60) } + Write-Host ("API $($u.Split('?')[0].Split('/')[-1]) success=$($j.success) code=$($j.code) msg=$msg") + } catch { + Write-Host ("API fail $u $($_.Exception.Message)") + } +} +Write-Host 'REDIS_DIAG_DONE' diff --git a/deploy/_remote_dump_redis_cfg.ps1 b/deploy/_remote_dump_redis_cfg.ps1 new file mode 100644 index 0000000..a28ac32 --- /dev/null +++ b/deploy/_remote_dump_redis_cfg.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = "Continue" +Write-Host "=== run-ledger-inner.ps1 ===" +Get-Content "E:\wwwroot\ops\run-ledger-inner.ps1" +Write-Host "=== config redis lines ===" +Get-ChildItem "E:\wwwroot\inquiry-new\config" -Recurse -ErrorAction SilentlyContinue | ForEach-Object { Write-Host ("cfgfile=" + $_.FullName) } +Select-String -Path "E:\wwwroot\inquiry-new\config\*.yml","E:\wwwroot\inquiry-new\config\*.yaml","E:\wwwroot\inquiry-new\config\*.properties" -Pattern "redis" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host ($_.Filename + ":" + $_.LineNumber + ":" + $_.Line.Trim()) } +Write-Host "=== simulate parse lens only ===" +Get-Content "E:\wwwroot\.env" | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith("#")) { return } + $i = $line.IndexOf("=") + if ($i -lt 1) { return } + Set-Item -Path ("Env:" + $line.Substring(0, $i).Trim()) -Value $line.Substring($i + 1).Trim().Trim('"') +} +Write-Host ("from_env user=" + $env:YTD_REDIS_USERNAME + " user_len=" + $env:YTD_REDIS_USERNAME.Length) +Write-Host ("url_len=" + $env:REDIS_URL.Length) +if ($env:REDIS_URL -match "redis://:([^@]+)@([^:/]+)(?::(\d+))?(?:/(\d+))?") { + Write-Host ("form=pass_only host=" + $Matches[2] + " pw_len=" + $Matches[1].Length) +} elseif ($env:REDIS_URL -match "redis://([^:/@]+):([^@]+)@([^:/]+)(?::(\d+))?(?:/(\d+))?") { + Write-Host ("form=user_pass user=" + $Matches[1] + " user_len=" + $Matches[1].Length + " pw_len=" + $Matches[2].Length + " host=" + $Matches[3] + " port=" + $Matches[4] + " db=" + $Matches[5]) +} else { + Write-Host "NO_MATCH" + # show sanitized url structure + $u = $env:REDIS_URL + $safe = $u -replace "://[^@]+@", "://***:***@" + Write-Host ("url_safe=" + $safe) +} +Write-Host "DUMP_DONE" \ No newline at end of file diff --git a/deploy/_remote_ensure_maven.ps1 b/deploy/_remote_ensure_maven.ps1 new file mode 100644 index 0000000..22a4db6 --- /dev/null +++ b/deploy/_remote_ensure_maven.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = 'Continue' +$candidates = @( + 'E:\wwwroot\tools\apache-maven-3.9.9\bin\mvn.cmd', + 'C:\yutongda\tools\apache-maven-3.9.9\bin\mvn.cmd', + 'C:\Program Files\Apache\maven\bin\mvn.cmd' +) +foreach ($c in $candidates) { + if (Test-Path $c) { + Write-Host "FOUND=$c" + & $c -v + exit 0 + } +} +Write-Host 'NO_LOCAL_MAVEN' +# install under E:\wwwroot\tools +$tools = 'E:\wwwroot\tools' +New-Item -ItemType Directory -Force -Path $tools | Out-Null +$mvnHome = Join-Path $tools 'apache-maven-3.9.9' +$mvnCmd = Join-Path $mvnHome 'bin\mvn.cmd' +if (Test-Path $mvnCmd) { Write-Host "FOUND=$mvnCmd"; & $mvnCmd -v; exit 0 } +$zip = Join-Path $tools 'apache-maven-3.9.9-bin.zip' +$url2 = 'https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.zip' +Write-Host 'downloading maven on remote...' +Invoke-WebRequest -Uri $url2 -OutFile $zip -UseBasicParsing +Expand-Archive -Path $zip -DestinationPath $tools -Force +if (-not (Test-Path $mvnCmd)) { throw 'maven extract failed' } +Write-Host "FOUND=$mvnCmd" +& $mvnCmd -v diff --git a/deploy/_remote_final_smoke.ps1 b/deploy/_remote_final_smoke.ps1 new file mode 100644 index 0000000..69cc465 --- /dev/null +++ b/deploy/_remote_final_smoke.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference = "Continue" +Select-String -Path "E:\wwwroot\inquiry-new\logs\stdout.log" -Pattern "redis_mode=" | Select-Object -Last 2 | ForEach-Object { Write-Host $_.Line } +$bodyPath = Join-Path $env:TEMP "login-body.json" +Set-Content -Path $bodyPath -Value '{"username":"admin","password":"123456","captcha":"","checkKey":""}' -Encoding ASCII +$login = Invoke-RestMethod -Uri "http://127.0.0.1:8180/jeecgboot/sys/login" -Method POST -InFile $bodyPath -ContentType "application/json;charset=UTF-8" +$h = @{ "X-Access-Token" = $login.result.token } +$a = Invoke-RestMethod -Uri "http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/agent/listEnabled" -Headers $h +Write-Host ("agent_enabled=" + @($a.result).Count + " login_captcha_ok=" + $login.success) +# agent process? +$agent = Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and ($_.CommandLine -like '*inquiry-agent*' -or $_.CommandLine -like '*uvicorn*' -or $_.CommandLine -like '*8910*') } | Select-Object -First 5 +if ($agent) { $agent | ForEach-Object { Write-Host ("proc pid=" + $_.ProcessId + " " + $_.CommandLine.Substring(0,[Math]::Min(120,$_.CommandLine.Length))) } } +else { Write-Host "agent_proc_not_found_in_wmi_sample" } +# health if any +foreach ($u in @("http://127.0.0.1:8910/health","http://127.0.0.1:8810/health","http://127.0.0.1:8910/")) { + try { + $r = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 3 + Write-Host ("health " + $u + " " + $r.StatusCode) + } catch { Write-Host ("health " + $u + " fail") } +} +Write-Host "FINAL_SMOKE_DONE" \ No newline at end of file diff --git a/deploy/_remote_find_prod_redis_env.ps1 b/deploy/_remote_find_prod_redis_env.ps1 new file mode 100644 index 0000000..cbee33b --- /dev/null +++ b/deploy/_remote_find_prod_redis_env.ps1 @@ -0,0 +1,54 @@ +$ErrorActionPreference = "Continue" +# prod java parent / cmdline full redis-related flags (mask password values) +$prod = Get-CimInstance Win32_Process -Filter "Name='java.exe'" | Where-Object { $_.CommandLine -like '*server.port=8080*' } | Select-Object -First 1 +if ($prod) { + $cl = $prod.CommandLine + Write-Host ("prod_pid=" + $prod.ProcessId) + # mask --spring.redis.password=... + $masked = [regex]::Replace($cl, "(--spring\.redis\.password=)(\S+)", '${1}***') + $masked = [regex]::Replace($masked, "(password=)([^,\s]+)", '${1}***') + Write-Host ("prod_cmd_len=" + $cl.Length) + # extract redis-related tokens + $tokens = $cl.Split(" ") | Where-Object { $_ -match "redis|REDIS|YTD_REDIS" } + foreach ($t in $tokens) { + $m = $t + if ($m -match "password=") { $m = ($m -replace "(=).+","=`$1***") } + Write-Host (" token=" + $m) + } +} +# agent short redis url +$agentEnv = "C:\yutongda\apps\inquiry-agent\.env" +if (Test-Path $agentEnv) { + Get-Content $agentEnv | ForEach-Object { + if ($_ -match "REDIS") { + $line = $_ + if ($line -match "REDIS_URL=(.+)") { + $u = $Matches[1].Trim().Trim('"') + $safe = $u -replace "://[^@]+@", "://***@" + if ($safe -eq $u -and $u.Length -gt 20) { $safe = $u.Substring(0,12) + "...(len=" + $u.Length + ")" } + Write-Host ("agent_REDIS_URL_safe=" + $safe + " len=" + $u.Length) + } else { + Write-Host ("agent_" + $line) + } + } + } +} +# Try AUTH without username using password from URL via System.Uri +$www = Get-Content "E:\wwwroot\.env" | Where-Object { $_ -match "^REDIS_URL=" } +$url = ($www -replace "^REDIS_URL=","").Trim().Trim('"') +try { + # redis URL is not always valid System.Uri because of scheme; hack + $tmp = $url -replace "^redis://","http://" + $uri = [Uri]$tmp + Write-Host ("uri_userinfo_len=" + $uri.UserInfo.Length + " host=" + $uri.Host + " port=" + $uri.Port) + $ui = $uri.UserInfo + $up = $ui.Split(":",2) + Write-Host ("uri_user=" + $up[0] + " uri_pw_len=" + $up[1].Length) +} catch { + Write-Host ("uri_err=" + $_.Exception.Message) +} +# Does empty username + password work? Check lettuce docs - try starting a one-off with only password +# Use jedis from a small java snippet if jdk present +$java = "C:\yutongda\tools\jdk8\bin\java.exe" +Write-Host ("java_exists=" + (Test-Path $java)) +Write-Host "FIND_DONE" \ No newline at end of file diff --git a/deploy/_remote_finish_admin_cutover.ps1 b/deploy/_remote_finish_admin_cutover.ps1 new file mode 100644 index 0000000..55bb237 --- /dev/null +++ b/deploy/_remote_finish_admin_cutover.ps1 @@ -0,0 +1,36 @@ +# 远端:确认 8180/8910/8080,然后执行 admin cutover(依赖已上传的 admin-dist.zip) +$ErrorActionPreference = 'Continue' +function Port-Up([int]$p) { + return [bool](Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1) +} +Write-Output ("8180=" + (Port-Up 8180)) +Write-Output ("8910=" + (Port-Up 8910)) +Write-Output ("8080=" + (Port-Up 8080)) +Write-Output ("zip=" + (Test-Path 'C:\Users\ytd_extdev_0903\incoming\admin-dist.zip')) +Write-Output ("conf=" + (Test-Path 'C:\Users\ytd_extdev_0903\incoming\ai.conf.cutover-8180')) + +if (-not (Port-Up 8180)) { + Write-Output 'START_LEDGER' + & powershell -NoProfile -ExecutionPolicy Bypass -File 'C:\Users\ytd_extdev_0903\incoming\_remote_wmi_start_ledger.ps1' +} +if (-not (Port-Up 8910)) { + Write-Output 'START_AGENT' + & powershell -NoProfile -ExecutionPolicy Bypass -File 'C:\Users\ytd_extdev_0903\incoming\_remote_safe_deploy_agent.ps1' +} + +# 再等一会儿 8180 +for ($i = 0; $i -lt 24; $i++) { + if (Port-Up 8180) { break } + Start-Sleep -Seconds 5 +} +Write-Output ("8180_final=" + (Port-Up 8180)) +Write-Output ("8910_final=" + (Port-Up 8910)) +Write-Output ("8080_final=" + (Port-Up 8080)) + +if (-not (Port-Up 8180)) { throw '8180 still down' } +if (-not (Port-Up 8910)) { throw '8910 still down' } +if (-not (Port-Up 8080)) { throw '8080 still down' } + +$ErrorActionPreference = 'Stop' +& powershell -NoProfile -ExecutionPolicy Bypass -File 'C:\Users\ytd_extdev_0903\incoming\_remote_cutover_admin_8180.ps1' +Write-Output 'ALL_DONE' diff --git a/deploy/_remote_import_quote_template_sql.ps1 b/deploy/_remote_import_quote_template_sql.ps1 new file mode 100644 index 0000000..441e963 --- /dev/null +++ b/deploy/_remote_import_quote_template_sql.ps1 @@ -0,0 +1,30 @@ +# 仅导入报价模板表到 inquiry_robot(禁止动 jeecg-boot) +$ErrorActionPreference = 'Stop' +Get-Content 'E:\wwwroot\.env' | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith('#')) { return } + $i = $line.IndexOf('=') + if ($i -lt 1) { return } + $k = $line.Substring(0, $i).Trim() + $v = $line.Substring($i + 1).Trim().Trim('"') + Set-Item -Path "Env:$k" -Value $v +} +$mysql = 'C:\yutongda\tools\mysql-8.0.39-winx64\bin\mysql.exe' +$hostName = if ($env:MYSQL_HOST) { $env:MYSQL_HOST } else { '127.0.0.1' } +$tcp = New-Object System.Net.Sockets.TcpClient +try { + $null = $tcp.ConnectAsync('127.0.0.1', 3306).Wait(1000) + if ($tcp.Connected) { $hostName = '127.0.0.1' } +} catch { +} finally { + $tcp.Close() +} +Write-Host "import host=$hostName db=$env:MYSQL_DB" +$f = 'C:\Users\ytd_extdev_0903\incoming\inquiry_quote_template_v1.sql' +if (-not (Test-Path $f)) { throw "missing $f" } +Write-Host "import $f" +& $mysql -h $hostName -P 3306 -u $env:MYSQL_USER "--password=$env:MYSQL_PASSWORD" --default-character-set=utf8mb4 $env:MYSQL_DB -e "source $($f.Replace('\','/'))" +if ($LASTEXITCODE -ne 0) { throw "fail sql code=$LASTEXITCODE" } +Write-Host 'SQL_OK' +& $mysql -h $hostName -P 3306 -u $env:MYSQL_USER "--password=$env:MYSQL_PASSWORD" $env:MYSQL_DB -e "SHOW TABLES LIKE 'inq_quote_template%';" +Write-Host 'VERIFY_DONE' diff --git a/deploy/_remote_install_captcha_jar.ps1 b/deploy/_remote_install_captcha_jar.ps1 new file mode 100644 index 0000000..881ed22 --- /dev/null +++ b/deploy/_remote_install_captcha_jar.ps1 @@ -0,0 +1,27 @@ +# 安装已上传完整的 CAPTCHA fat jar,并重启 8180 主账。禁止动现网 8080。 +$ErrorActionPreference = 'Stop' +$Src = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.CAPTCHA.jar' +$Incoming = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar' +$Fat = 'E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar' +$Expect = 177074039 + +if (-not (Test-Path $Src)) { throw "missing $Src" } +$len = (Get-Item $Src).Length +Write-Host ("src_bytes=" + $len) +if ($len -ne $Expect) { throw ("size mismatch expect=" + $Expect + " got=" + $len) } + +Get-CimInstance Win32_Process | ForEach-Object { + if ($_.CommandLine -and ($_.CommandLine -like '*inquiry-new*jeecg*' -or $_.CommandLine -like '*run-ledger*')) { + Write-Host ("killing=" + $_.ProcessId) + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +Start-Sleep -Seconds 3 + +Copy-Item $Src $Incoming -Force +Copy-Item $Src $Fat -Force +Write-Host ("fat_bytes=" + (Get-Item $Fat).Length) +Write-Host 'COPIED_CAPTCHA_JAR' + +powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_wmi_start_ledger.ps1 +Write-Host 'INSTALL_CAPTCHA_DONE' diff --git a/deploy/_remote_install_commonsio_jar.ps1 b/deploy/_remote_install_commonsio_jar.ps1 new file mode 100644 index 0000000..39f77ec --- /dev/null +++ b/deploy/_remote_install_commonsio_jar.ps1 @@ -0,0 +1,30 @@ +# Install COMMONSIO fat jar and restart 8180 +$ErrorActionPreference = 'Stop' +$Src = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.COMMONSIO.jar' +$Incoming = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar' +$Fat = 'E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar' +$Expect = 177186821 + +if (-not (Test-Path $Src)) { throw "missing $Src" } +$len = (Get-Item $Src).Length +Write-Host ("src_bytes=" + $len) +if ($len -lt 170000000) { throw 'src too small' } + +$jarCmd = 'C:\yutongda\tools\jdk8\bin\jar.exe' +Write-Host '=== commons-io in src ===' +& $jarCmd tf $Src | Select-String 'commons-io' + +Get-CimInstance Win32_Process | ForEach-Object { + if ($_.CommandLine -and ($_.CommandLine -like '*inquiry-new*jeecg*' -or $_.CommandLine -like '*run-ledger*')) { + Write-Host ("killing=" + $_.ProcessId) + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +Start-Sleep -Seconds 3 +Copy-Item $Src $Incoming -Force +Copy-Item $Src $Fat -Force +Write-Host ("fat_bytes=" + (Get-Item $Fat).Length) +Write-Host 'COPIED_COMMONSIO' + +powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_wmi_start_ledger.ps1 +Write-Host 'INSTALL_COMMONSIO_DONE' diff --git a/deploy/_remote_install_new_fat_jar.ps1 b/deploy/_remote_install_new_fat_jar.ps1 new file mode 100644 index 0000000..aefbdec --- /dev/null +++ b/deploy/_remote_install_new_fat_jar.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference='Stop' +$New='C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.NEW.jar' +$Incoming='C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar' +$Fat='E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar' +if(-not (Test-Path $New)){ throw "missing $New" } +$len=(Get-Item $New).Length +Write-Host ("new_bytes="+$len) +if($len -lt 170000000){ throw "new jar too small, incomplete upload" } +# stop loops +Get-CimInstance Win32_Process | ForEach-Object { + if($_.CommandLine -and ($_.CommandLine -like '*inquiry-new*jeecg*' -or $_.CommandLine -like '*run-ledger*')){ + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +Start-Sleep -Seconds 2 +Copy-Item $New $Incoming -Force +Copy-Item $New $Fat -Force +Write-Host 'COPIED_NEW_JAR' +powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_wmi_start_ledger.ps1 diff --git a/deploy/_remote_install_poi412_jar.ps1 b/deploy/_remote_install_poi412_jar.ps1 new file mode 100644 index 0000000..bdcb561 --- /dev/null +++ b/deploy/_remote_install_poi412_jar.ps1 @@ -0,0 +1,29 @@ +# Install POI412 fat jar and restart 8180 +$ErrorActionPreference = 'Stop' +$Src = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.POI412.jar' +$Incoming = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar' +$Fat = 'E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar' + +if (-not (Test-Path $Src)) { throw "missing $Src" } +$len = (Get-Item $Src).Length +Write-Host ("src_bytes=" + $len) +if ($len -lt 160000000) { throw 'src too small' } + +$jarCmd = 'C:\yutongda\tools\jdk8\bin\jar.exe' +Write-Host '=== poi entries ===' +& $jarCmd tf $Src | Select-String 'poi-' + +Get-CimInstance Win32_Process | ForEach-Object { + if ($_.CommandLine -and ($_.CommandLine -like '*inquiry-new*jeecg*' -or $_.CommandLine -like '*run-ledger*')) { + Write-Host ("killing=" + $_.ProcessId) + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +Start-Sleep -Seconds 3 +Copy-Item $Src $Incoming -Force +Copy-Item $Src $Fat -Force +Write-Host ("fat_bytes=" + (Get-Item $Fat).Length) +Write-Host 'COPIED_POI412' + +powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_wmi_start_ledger.ps1 +Write-Host 'INSTALL_POI412_DONE' diff --git a/deploy/_remote_install_poi412_verified.ps1 b/deploy/_remote_install_poi412_verified.ps1 new file mode 100644 index 0000000..5e6c253 --- /dev/null +++ b/deploy/_remote_install_poi412_verified.ps1 @@ -0,0 +1,37 @@ +# Force install local-built POI412 jar that contains QuoteTemplateController +$ErrorActionPreference = 'Stop' +$Src = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.POI412.jar' +$Incoming = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar' +$Fat = 'E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar' +$Expect = 170763133 + +if (-not (Test-Path $Src)) { throw "missing $Src" } +$len = (Get-Item $Src).Length +Write-Host ("src_bytes=" + $len) +if ($len -ne $Expect) { Write-Host ("WARN size expect=" + $Expect + " got=" + $len) } + +$work = Join-Path $env:TEMP 'verify-qt-class' +if (Test-Path $work) { Remove-Item $work -Recurse -Force } +New-Item -ItemType Directory $work | Out-Null +$jarCmd = 'C:\yutongda\tools\jdk8\bin\jar.exe' +Push-Location $work +& $jarCmd xf $Src 'BOOT-INF/lib/jeecg-module-inquiry-3.5.3.jar' +$has = (& $jarCmd tf 'BOOT-INF/lib/jeecg-module-inquiry-3.5.3.jar' | Select-String 'InquiryQuoteTemplateController').Count +Write-Host ("src_has_QuoteTemplateController=" + ($has -gt 0)) +if ($has -lt 1) { throw 'source jar missing QuoteTemplateController' } +Pop-Location + +Get-CimInstance Win32_Process | ForEach-Object { + if ($_.CommandLine -and ($_.CommandLine -like '*inquiry-new*jeecg*' -or $_.CommandLine -like '*run-ledger*')) { + Write-Host ("killing=" + $_.ProcessId) + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +Start-Sleep -Seconds 3 +Copy-Item $Src $Incoming -Force +Copy-Item $Src $Fat -Force +Write-Host ("fat_bytes=" + (Get-Item $Fat).Length) +Write-Host 'COPIED_POI412_VERIFIED' + +powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_wmi_start_ledger.ps1 +Write-Host 'INSTALL_VERIFIED_DONE' diff --git a/deploy/_remote_ledger_err_tail.ps1 b/deploy/_remote_ledger_err_tail.ps1 new file mode 100644 index 0000000..a8e823f --- /dev/null +++ b/deploy/_remote_ledger_err_tail.ps1 @@ -0,0 +1,7 @@ +$ErrorActionPreference = 'Continue' +Write-Host '=== stderr tail ===' +Get-Content 'E:\wwwroot\inquiry-new\logs\stderr.log' -Tail 80 -ErrorAction SilentlyContinue +Write-Host '=== stdout tail ===' +Get-Content 'E:\wwwroot\inquiry-new\logs\stdout.log' -Tail 40 -ErrorAction SilentlyContinue +Write-Host '=== listen 8180 ===' +Get-NetTCPConnection -LocalPort 8180 -State Listen -EA SilentlyContinue | Format-Table -AutoSize | Out-String | Write-Host diff --git a/deploy/_remote_login_probe.ps1 b/deploy/_remote_login_probe.ps1 new file mode 100644 index 0000000..6965210 --- /dev/null +++ b/deploy/_remote_login_probe.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference='Continue' +$r=Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/randomImage/seedcheck1' -UseBasicParsing -TimeoutSec 15 +"randomImage status=$($r.StatusCode) len=$($r.RawContentLength)" +try { + $login=Invoke-RestMethod -Method Post -Uri 'http://127.0.0.1:8180/jeecgboot/sys/login' -ContentType 'application/json' -Body '{"username":"admin","password":"123456","captcha":"xxxx","checkKey":"seedcheck1"}' -TimeoutSec 20 + $login | ConvertTo-Json -Compress +} catch { + "login_err=" + $_.Exception.Message + if($_.ErrorDetails){ $_.ErrorDetails.Message } +} +# process args +Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { + if($_.CommandLine -like '*inquiry-new*'){ $_.CommandLine.Substring(0,[Math]::Min(500,$_.CommandLine.Length)) } +} diff --git a/deploy/_remote_login_probe2.ps1 b/deploy/_remote_login_probe2.ps1 new file mode 100644 index 0000000..b884862 --- /dev/null +++ b/deploy/_remote_login_probe2.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference='Continue' +Write-Host '=== config ===' +Get-ChildItem E:\wwwroot\inquiry-new\config -EA SilentlyContinue | Format-Table Name,Length +Select-String -Path 'E:\wwwroot\inquiry-new\config\*.yml' -Pattern 'loginCaptcha' -EA SilentlyContinue +Write-Host '=== try login with empty captcha after randomImage ===' +$key='seedkey99' +$img=Invoke-RestMethod -Uri ("http://127.0.0.1:8180/jeecgboot/sys/randomImage/"+$key) -TimeoutSec 15 +"img_success=$($img.success) hasResult=$([bool]$img.result)" +# When captcha enabled, we cannot OCR. Try common bypass passwords from docs. +$pwds=@('123456','admin','Admin123','ytd123456') +foreach($p in $pwds){ + $body=@{username='admin';password=$p;captcha='1';checkKey=$key} | ConvertTo-Json -Compress + $r=Invoke-RestMethod -Method Post -Uri 'http://127.0.0.1:8180/jeecgboot/sys/login' -ContentType 'application/json' -Body $body -TimeoutSec 20 + Write-Host ("pwd="+$p+" success="+$r.success+" code="+$r.code+" msg="+$r.message) + if($r.success){ break } +} diff --git a/deploy/_remote_patch_commons_io.ps1 b/deploy/_remote_patch_commons_io.ps1 new file mode 100644 index 0000000..2d59d5f --- /dev/null +++ b/deploy/_remote_patch_commons_io.ps1 @@ -0,0 +1,76 @@ +# Hot-fix commons-io inside Spring Boot fat jar for POI 5.2.x. +# Replaces commons-io-2.6.jar with 2.11.0; does NOT use nested module jar uf. +$ErrorActionPreference = 'Stop' +$Fat = 'E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar' +$IncomingLib = 'C:\Users\ytd_extdev_0903\incoming\commons-io-2.11.0.jar' +$Work = Join-Path $env:TEMP 'fix-commons-io-fat' +$JarCmd = $null +foreach ($c in @('C:\yutongda\tools\jdk8\bin\jar.exe', 'C:\Program Files\Java\jdk1.8.0_202\bin\jar.exe')) { + if (Test-Path $c) { $JarCmd = $c; break } +} +if (-not $JarCmd) { throw 'jar.exe missing' } +if (-not (Test-Path $IncomingLib)) { throw "missing $IncomingLib" } +if (-not (Test-Path $Fat)) { throw "missing $Fat" } + +# stop ledger only +Get-CimInstance Win32_Process | ForEach-Object { + if ($_.CommandLine -and ($_.CommandLine -like '*inquiry-new*jeecg*' -or $_.CommandLine -like '*run-ledger*')) { + Write-Host ("killing=" + $_.ProcessId) + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +Start-Sleep -Seconds 2 + +if (Test-Path $Work) { Remove-Item $Work -Recurse -Force } +New-Item -ItemType Directory -Force -Path $Work | Out-Null +Copy-Item $Fat (Join-Path $Work 'app.jar') -Force +Push-Location $Work + +# Prefer zip -d if available; else jar + rebuild lib entry +$zip = $null +foreach ($z in @('C:\Program Files\7-Zip\7z.exe', 'C:\yutongda\tools\7zip\7z.exe')) { + if (Test-Path $z) { $zip = $z; break } +} + +$oldEntry = 'BOOT-INF/lib/commons-io-2.6.jar' +$newEntry = 'BOOT-INF/lib/commons-io-2.11.0.jar' +New-Item -ItemType Directory -Force -Path 'BOOT-INF\lib' | Out-Null +Copy-Item $IncomingLib 'BOOT-INF\lib\commons-io-2.11.0.jar' -Force + +if ($zip) { + & $zip d 'app.jar' $oldEntry | Out-Null + & $zip a -tzip 'app.jar' $newEntry | Out-Null + Write-Host 'patched_via_7z' +} else { + # jar uf can add; deleting old entry via .NET ZipArchive + Add-Type -AssemblyName System.IO.Compression + Add-Type -AssemblyName System.IO.Compression.FileSystem + $jarPath = (Resolve-Path 'app.jar').Path + $fs = [IO.File]::Open($jarPath, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite) + try { + $zipArc = New-Object IO.Compression.ZipArchive($fs, [IO.Compression.ZipArchiveMode]::Update) + $old = $zipArc.GetEntry($oldEntry) + if ($old) { $old.Delete(); Write-Host 'deleted_old_2.6' } else { Write-Host 'old_2.6_absent' } + $existing = $zipArc.GetEntry($newEntry) + if ($existing) { $existing.Delete() } + $entry = $zipArc.CreateEntry($newEntry, [IO.Compression.CompressionLevel]::Optimal) + $src = [IO.File]::OpenRead((Resolve-Path 'BOOT-INF\lib\commons-io-2.11.0.jar').Path) + try { + $dest = $entry.Open() + try { $src.CopyTo($dest) } finally { $dest.Dispose() } + } finally { $src.Dispose() } + $zipArc.Dispose() + } finally { $fs.Dispose() } + Write-Host 'patched_via_ziparchive' +} + +Copy-Item 'app.jar' $Fat -Force +Copy-Item 'app.jar' 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar' -Force +Pop-Location + +# confirm entry names +& $JarCmd tf $Fat | Select-String 'commons-io' +Write-Host 'COMMONS_IO_PATCHED' + +powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_wmi_start_ledger.ps1 +Write-Host 'PATCH_RESTART_DONE' diff --git a/deploy/_remote_patch_inquiry_module.ps1 b/deploy/_remote_patch_inquiry_module.ps1 new file mode 100644 index 0000000..537406e --- /dev/null +++ b/deploy/_remote_patch_inquiry_module.ps1 @@ -0,0 +1,49 @@ +# 远端:用新的 jeecg-module-inquiry jar 热补主账 fat jar,并重启 8180 +$ErrorActionPreference = 'Stop' +$Incoming = 'C:\Users\ytd_extdev_0903\incoming' +$Root = 'E:\wwwroot\inquiry-new' +$Fat = Join-Path $Root 'jeecg-system-start-3.5.3.jar' +$NewMod = Join-Path $Incoming 'jeecg-module-inquiry-3.5.3.jar' +$Java = 'C:\yutongda\tools\jdk8\bin\jar.exe' +if (-not (Test-Path $Java)) { $Java = 'C:\yutongda\tools\jdk8\bin\jar' } +# prefer jar from PATH jdk +$jarCmd = $null +foreach ($c in @( + 'C:\yutongda\tools\jdk8\bin\jar.exe', + 'E:\wwwroot\tools\jdk-17\bin\jar.exe' +)) { + if (Test-Path $c) { $jarCmd = $c; break } +} +if (-not $jarCmd) { throw 'jar.exe not found' } +if (-not (Test-Path $NewMod)) { throw "missing $NewMod" } +if (-not (Test-Path $Fat)) { throw "missing $Fat" } + +# stop ledger +Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { + if ($_.CommandLine -and $_.CommandLine -like '*inquiry-new*jeecg-system-start*') { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +Start-Sleep -Seconds 2 + +$work = Join-Path $env:TEMP 'patch-inquiry-mod' +if (Test-Path $work) { Remove-Item $work -Recurse -Force } +New-Item -ItemType Directory $work | Out-Null +Copy-Item $Fat (Join-Path $work 'app.jar') -Force +Push-Location $work +New-Item -ItemType Directory -Force -Path 'BOOT-INF\lib' | Out-Null +Copy-Item $NewMod 'BOOT-INF\lib\jeecg-module-inquiry-3.5.3.jar' -Force +& $jarCmd uf app.jar 'BOOT-INF/lib/jeecg-module-inquiry-3.5.3.jar' +if ($LASTEXITCODE -ne 0) { Pop-Location; throw "jar uf failed $LASTEXITCODE" } +Copy-Item app.jar $Fat -Force +Pop-Location +Write-Host 'PATCH_JAR_OK' + +# restart via existing WMI script if present +$start = Join-Path $Incoming '_remote_wmi_start_ledger.ps1' +if (Test-Path $start) { + & powershell -NoProfile -ExecutionPolicy Bypass -File $start +} else { + throw 'start script missing' +} +Write-Host 'PATCH_RESTART_DONE' diff --git a/deploy/_remote_probe_preview2.ps1 b/deploy/_remote_probe_preview2.ps1 new file mode 100644 index 0000000..f1e3b12 --- /dev/null +++ b/deploy/_remote_probe_preview2.ps1 @@ -0,0 +1,35 @@ +$ErrorActionPreference = "Continue" +$bodyPath = Join-Path $env:TEMP "login-body.json" +Set-Content -Path $bodyPath -Value '{"username":"admin","password":"123456","captcha":"","checkKey":""}' -Encoding ASCII +$login = Invoke-RestMethod -Uri "http://127.0.0.1:8180/jeecgboot/sys/login" -Method POST -InFile $bodyPath -ContentType "application/json;charset=UTF-8" +$token = $login.result.token +$h = @{ "X-Access-Token" = $token } +$list = Invoke-RestMethod -Uri "http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=20" -Headers $h +$recs = @($list.result.records) +Write-Host ("count=" + $recs.Count) +# dump raw preview body for first with versionId +$rec = $recs | Where-Object { $_.versionId } | Select-Object -First 1 +$vid = $rec.versionId +Write-Host ("pick versionId=" + $vid) +$r = Invoke-WebRequest -Uri ("http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/previewSample?versionId=" + $vid) -Headers $h -UseBasicParsing -TimeoutSec 60 +Write-Host ("ctype=" + $r.Headers["Content-Type"]) +Write-Host ("body=" + $r.Content) + +# list upload dirs +$roots = @( + "E:\wwwroot\data\upFiles", + "E:\wwwroot\inquiry-new\upFiles", + "E:\wwwroot\upFiles", + "C:\Users\ytd_extdev_0903\incoming" +) +foreach ($root in $roots) { + if (Test-Path $root) { + Write-Host ("root=" + $root) + Get-ChildItem $root -Recurse -Filter "*.xlsx" -ErrorAction SilentlyContinue | Select-Object -First 15 | ForEach-Object { + Write-Host (" xlsx=" + $_.FullName + " size=" + $_.Length) + } + } else { Write-Host ("missing_root=" + $root) } +} +# yml upload path +Select-String -Path "E:\wwwroot\inquiry-new\config\application-inquiry.yml" -Pattern "upload|path:" | Select-Object -First 20 | ForEach-Object { Write-Host ("yml:" + $_.Line.Trim()) } +Write-Host "PROBE2_DONE" \ No newline at end of file diff --git a/deploy/_remote_probe_preview_sample.ps1 b/deploy/_remote_probe_preview_sample.ps1 new file mode 100644 index 0000000..17725fd --- /dev/null +++ b/deploy/_remote_probe_preview_sample.ps1 @@ -0,0 +1,45 @@ +$ErrorActionPreference = "Continue" +$bodyPath = Join-Path $env:TEMP "login-body.json" +Set-Content -Path $bodyPath -Value '{"username":"admin","password":"123456","captcha":"","checkKey":""}' -Encoding ASCII +$login = Invoke-RestMethod -Uri "http://127.0.0.1:8180/jeecgboot/sys/login" -Method POST -InFile $bodyPath -ContentType "application/json;charset=UTF-8" +$token = $login.result.token +$h = @{ "X-Access-Token" = $token } +$list = Invoke-RestMethod -Uri "http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=5" -Headers $h +$rec = @($list.result.records)[0] +Write-Host ("tpl=" + $rec.name + " versionId=" + $rec.versionId + " objectKey=" + $rec.objectKey) +$vid = $rec.versionId +# header auth +try { + $r = Invoke-WebRequest -Uri ("http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/previewSample?versionId=" + $vid) -Headers $h -UseBasicParsing -TimeoutSec 60 + Write-Host ("header_auth status=" + $r.StatusCode + " len=" + $r.RawContentLength + " ctype=" + $r.Headers["Content-Type"] + " cd=" + $r.Headers["Content-Disposition"]) +} catch { + $resp = $_.Exception.Response + if ($resp) { + $reader = New-Object IO.StreamReader($resp.GetResponseStream()) + $txt = $reader.ReadToEnd() + Write-Host ("header_auth FAIL status=" + [int]$resp.StatusCode + " body=" + $txt.Substring(0, [Math]::Min(500, $txt.Length))) + } else { Write-Host ("header_auth ERR " + $_.Exception.Message) } +} +# query token (browser window.open style) +try { + $url = "http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/previewSample?versionId=$vid&token=$token" + $r2 = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 60 + Write-Host ("query_token status=" + $r2.StatusCode + " len=" + $r2.RawContentLength) +} catch { + $resp = $_.Exception.Response + if ($resp) { + $reader = New-Object IO.StreamReader($resp.GetResponseStream()) + $txt = $reader.ReadToEnd() + Write-Host ("query_token FAIL status=" + [int]$resp.StatusCode + " body=" + $txt.Substring(0, [Math]::Min(500, $txt.Length))) + } else { Write-Host ("query_token ERR " + $_.Exception.Message) } +} +# check file exists +if ($rec.objectKey) { + $up = "E:\wwwroot\data\upFiles" + $cands = @( + (Join-Path $up ($rec.objectKey -replace "/","\")), + $rec.objectKey + ) + foreach ($p in $cands) { Write-Host ("exists " + $p + " => " + (Test-Path $p)) } +} +Write-Host "PROBE_DONE" \ No newline at end of file diff --git a/deploy/_remote_probe_quote_template.ps1 b/deploy/_remote_probe_quote_template.ps1 new file mode 100644 index 0000000..9f54276 --- /dev/null +++ b/deploy/_remote_probe_quote_template.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = 'Continue' +Write-Host ("whoami=" + (whoami)) +Write-Host '=== roots ===' +@('E:\wwwroot','C:\wwwroot','C:\yutongda','C:\Users\ytd_extdev_0903\incoming','E:\wwwroot\inquiry-new','E:\wwwroot\.env') | ForEach-Object { + Write-Host ("exists={0} path={1}" -f (Test-Path $_), $_) +} +Write-Host '=== ports ===' +Get-NetTCPConnection -LocalPort 8180,8910,3306 -State Listen -EA SilentlyContinue | + Select-Object LocalAddress,LocalPort,OwningProcess | + Format-Table -AutoSize | Out-String | Write-Host +Write-Host '=== mysql candidates ===' +Get-ChildItem -Path C:\,E:\ -Filter mysql.exe -Recurse -Depth 6 -EA SilentlyContinue | + Select-Object -First 5 FullName | + ForEach-Object { Write-Host $_.FullName } +Write-Host '=== jar ===' +Get-ChildItem -Path E:\wwwroot,C:\Users\ytd_extdev_0903\incoming -Filter '*.jar' -Recurse -Depth 3 -EA SilentlyContinue | + Select-Object FullName,Length,LastWriteTime | + Format-Table -AutoSize | Out-String | Write-Host +Write-Host PROBE_DONE diff --git a/deploy/_remote_quick_status.ps1 b/deploy/_remote_quick_status.ps1 new file mode 100644 index 0000000..adebb86 --- /dev/null +++ b/deploy/_remote_quick_status.ps1 @@ -0,0 +1,6 @@ +$ErrorActionPreference='Continue' +$c=Get-NetTCPConnection -LocalPort 8180 -State Listen -EA SilentlyContinue | Select-Object -First 1 +if($c){"LISTEN pid=$($c.OwningProcess)"} else {'DOWN'} +Get-Item E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar | Select-Object Length,LastWriteTime | Format-List +Get-Content E:\wwwroot\inquiry-new\logs\stdout.log -Tail 8 +Get-Content E:\wwwroot\inquiry-new\logs\stderr.log -Tail 15 diff --git a/deploy/_remote_redis_auth_probe.ps1 b/deploy/_remote_redis_auth_probe.ps1 new file mode 100644 index 0000000..3ecf347 --- /dev/null +++ b/deploy/_remote_redis_auth_probe.ps1 @@ -0,0 +1,80 @@ +$ErrorActionPreference = "Continue" +Get-Content "E:\wwwroot\.env" | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith("#")) { return } + $i = $line.IndexOf("=") + if ($i -lt 1) { return } + Set-Item -Path ("Env:" + $line.Substring(0, $i).Trim()) -Value $line.Substring($i + 1).Trim().Trim('"') +} +if ($env:REDIS_URL -match "redis://([^:/@]+):([^@]+)@([^:/]+)(?::(\d+))?(?:/(\d+))?") { + $user = $Matches[1]; $pass = $Matches[2]; $host = $Matches[3]; $port = $Matches[4]; if (-not $port) { $port = "6379" } +} else { Write-Host "parse_fail"; exit 1 } +$decoded = [Uri]::UnescapeDataString($pass) +Write-Host ("user=" + $user + " pw_len=" + $pass.Length + " decoded_len=" + $decoded.Length + " has_pct=" + ($pass -match "%") + " host=" + $host + " port=" + $port) + +# try python redis if present +$py = $null +foreach ($c in @("C:\yutongda\tools\python\python.exe","C:\Python39\python.exe","python")) { + try { $v = & $c -c "print(1)" 2>$null; if ($v -eq "1") { $py = $c; break } } catch {} +} +Write-Host ("python=" + $py) +if ($py) { + $script = @" +import socket,sys +host='$host'; port=int('$port'); user='$user' +# password via env to avoid shell escaping issues +import os +pw=os.environ['PROBE_REDIS_PW'] +pw2=os.environ.get('PROBE_REDIS_PW2',pw) + +def probe(name, auth_cmd): + s=socket.create_connection((host,port),3) + s.settimeout(3) + s.sendall(auth_cmd) + data=s.recv(128) + s.close() + print(name, data[:80]) + +# RESP AUTH password +def resp_arr(*parts): + out='*%d\r\n'%len(parts) + for p in parts: + b=p.encode('utf-8') if isinstance(p,str) else p + out+='$%d\r\n'%len(b) + out+=b.decode('latin1') if isinstance(b,bytes) else '' + # rebuild properly + # better + chunks=[('*%d\r\n'%len(parts)).encode()] + for p in parts: + b=p.encode('utf-8') + chunks.append(('$%d\r\n'%len(b)).encode()); chunks.append(b); chunks.append(b'\r\n') + return b''.join(chunks) + +for name,cmd in [ + ('auth_pw_only', resp_arr('AUTH', pw)), + ('auth_user_pw', resp_arr('AUTH', user, pw)), + ('auth_pw_decoded', resp_arr('AUTH', pw2)), + ('auth_user_pw_decoded', resp_arr('AUTH', user, pw2)), + ('ping_noauth', resp_arr('PING')), +]: + try: + s=socket.create_connection((host,port),3); s.settimeout(3); s.sendall(cmd); print(name, s.recv(120)); s.close() + except Exception as e: + print(name, 'ERR', e) +"@ + $env:PROBE_REDIS_PW = $pass + $env:PROBE_REDIS_PW2 = $decoded + $tmp = Join-Path $env:TEMP "redis_probe.py" + Set-Content -Path $tmp -Value $script -Encoding ASCII + & $py $tmp +} + +# how does prod 8080 pass redis? cmdline snippet without secrets +Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { + if ($_.CommandLine -like '*8080*' -or $_.CommandLine -like '*jeecg-boot*' -or $_.CommandLine -like '*wwwroot*jeecg*') { + $cl = $_.CommandLine + if ($cl.Length -gt 250) { $cl = $cl.Substring(0,250) } + Write-Host ("java8080ish pid=" + $_.ProcessId + " cmd=" + $cl) + } +} +Write-Host "AUTH_PROBE_DONE" \ No newline at end of file diff --git a/deploy/_remote_redis_auth_probe2.ps1 b/deploy/_remote_redis_auth_probe2.ps1 new file mode 100644 index 0000000..df7aa57 --- /dev/null +++ b/deploy/_remote_redis_auth_probe2.ps1 @@ -0,0 +1,70 @@ +$ErrorActionPreference = "Continue" +Get-Content "E:\wwwroot\.env" | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith("#")) { return } + $i = $line.IndexOf("=") + if ($i -lt 1) { return } + Set-Item -Path ("Env:" + $line.Substring(0, $i).Trim()) -Value $line.Substring($i + 1).Trim().Trim('"') +} +if ($env:REDIS_URL -match "redis://([^:/@]+):([^@]+)@([^:/]+)(?::(\d+))?(?:/(\d+))?") { + $user = $Matches[1]; $pass = $Matches[2]; $rh = $Matches[3]; $port = $Matches[4]; if (-not $port) { $port = "6379" } +} else { Write-Host "parse_fail"; exit 1 } +Write-Host ("user=" + $user + " pw_len=" + $pass.Length + " rh=" + $rh + " port=" + $port) + +function Send-Redis([string]$rh,[int]$port,[byte[]]$payload) { + $tcp = New-Object Net.Sockets.TcpClient + $tcp.ReceiveTimeout = 3000; $tcp.SendTimeout = 3000 + $tcp.Connect($rh, $port) + $stream = $tcp.GetStream() + $stream.Write($payload, 0, $payload.Length) + $buf = New-Object byte[] 256 + $n = $stream.Read($buf, 0, $buf.Length) + $tcp.Close() + return [Text.Encoding]::ASCII.GetString($buf, 0, $n) +} +function Resp-Auth([string[]]$parts) { + $ms = New-Object IO.MemoryStream + $sw = New-Object IO.StreamWriter($ms, [Text.Encoding]::UTF8) + $sw.NewLine = "`r`n" + $sw.WriteLine("*" + $parts.Count) + foreach ($p in $parts) { + $b = [Text.Encoding]::UTF8.GetBytes($p) + $sw.WriteLine("$" + $b.Length) + $sw.Flush() + $ms.Write($b, 0, $b.Length) + $cr = [Text.Encoding]::ASCII.GetBytes("`r`n"); $ms.Write($cr, 0, 2) + } + $sw.Flush() + return $ms.ToArray() +} + +foreach ($label in @("pw_only","user_pw","ping")) { + try { + if ($label -eq "pw_only") { $payload = Resp-Auth @("AUTH", $pass) } + elseif ($label -eq "user_pw") { $payload = Resp-Auth @("AUTH", $user, $pass) } + else { $payload = Resp-Auth @("PING") } + $resp = Send-Redis $rh ([int]$port) $payload + $respOne = ($resp -split "`r`n")[0] + Write-Host ("AUTH " + $label + " => " + $respOne) + } catch { + Write-Host ("AUTH " + $label + " ERR " + $_.Exception.Message) + } +} + +# prod redis config snippet (mask password values) +$prodCfgCandidates = @( + "C:\yutongda\releases\admin-rel26-local-source-20260911d\backend\config\application-prod.yml", + "C:\yutongda\releases\admin-rel26-local-source-20260911d\backend\application-prod.yml" +) +Get-ChildItem "C:\yutongda\releases\admin-rel26-local-source-20260911d\backend" -Recurse -Filter "*prod*.yml" -ErrorAction SilentlyContinue | Select-Object -First 5 | ForEach-Object { + Write-Host ("prod_yml=" + $_.FullName) + Select-String -Path $_.FullName -Pattern "redis|password|username|host" | Select-Object -First 20 | ForEach-Object { + $line = $_.Line + $line = $line -replace "(password\s*:\s*).+","`$1***" + Write-Host (" " + $line.Trim()) + } +} +# also check if YTD_REDIS_PASSWORD exists separately in any env used by prod +Write-Host ("env_YTD_REDIS_PASSWORD_len=" + ($(if($env:YTD_REDIS_PASSWORD){$env:YTD_REDIS_PASSWORD.Length}else{0}))) +Write-Host ("env_YTD_REDIS_USERNAME=" + $env:YTD_REDIS_USERNAME) +Write-Host "AUTH_PROBE2_DONE" \ No newline at end of file diff --git a/deploy/_remote_redis_local_vs_intranet.ps1 b/deploy/_remote_redis_local_vs_intranet.ps1 new file mode 100644 index 0000000..49b1a7f --- /dev/null +++ b/deploy/_remote_redis_local_vs_intranet.ps1 @@ -0,0 +1,91 @@ +$ErrorActionPreference = "Continue" +function Redis-Ping([string]$rh, [int]$port) { + try { + $tcp = New-Object Net.Sockets.TcpClient + $iar = $tcp.BeginConnect($rh, $port, $null, $null) + $ok = $iar.AsyncWaitHandle.WaitOne(2000) + if (-not $ok) { $tcp.Close(); return "tcp_timeout" } + $tcp.EndConnect($iar) + $stream = $tcp.GetStream() + $stream.ReadTimeout = 2000 + $payload = [Text.Encoding]::ASCII.GetBytes("*1`r`n`$4`r`nPING`r`n") + $stream.Write($payload, 0, $payload.Length) + $buf = New-Object byte[] 64 + $n = $stream.Read($buf, 0, $buf.Length) + $tcp.Close() + return [Text.Encoding]::ASCII.GetString($buf, 0, $n).Trim() + } catch { + return ("err:" + $_.Exception.Message) + } +} +function Redis-AuthPw([string]$rh, [int]$port, [string]$pass) { + try { + $tcp = New-Object Net.Sockets.TcpClient + $tcp.Connect($rh, $port) + $stream = $tcp.GetStream() + $stream.ReadTimeout = 3000 + # AUTH pass + $ms = New-Object IO.MemoryStream + $passBytes = [Text.Encoding]::UTF8.GetBytes($pass) + $hdr = [Text.Encoding]::ASCII.GetBytes("*2`r`n`$4`r`nAUTH`r`n`$" + $passBytes.Length + "`r`n") + $ms.Write($hdr, 0, $hdr.Length) + $ms.Write($passBytes, 0, $passBytes.Length) + $cr = [Text.Encoding]::ASCII.GetBytes("`r`n") + $ms.Write($cr, 0, 2) + $payload = $ms.ToArray() + $stream.Write($payload, 0, $payload.Length) + $buf = New-Object byte[] 128 + $n = $stream.Read($buf, 0, $buf.Length) + $tcp.Close() + return [Text.Encoding]::ASCII.GetString($buf, 0, $n).Split("`r")[0] + } catch { return ("err:" + $_.Exception.Message) } +} +function Redis-AuthUserPw([string]$rh, [int]$port, [string]$user, [string]$pass) { + try { + $tcp = New-Object Net.Sockets.TcpClient + $tcp.Connect($rh, $port) + $stream = $tcp.GetStream() + $stream.ReadTimeout = 3000 + $userBytes = [Text.Encoding]::UTF8.GetBytes($user) + $passBytes = [Text.Encoding]::UTF8.GetBytes($pass) + $ms = New-Object IO.MemoryStream + $w = { + param($s,$b) + $h = [Text.Encoding]::ASCII.GetBytes($s) + $ms.Write($h,0,$h.Length) + if ($b) { $ms.Write($b,0,$b.Length); $c=[Text.Encoding]::ASCII.GetBytes("`r`n"); $ms.Write($c,0,2) } + } + # manual + $parts = @("AUTH", $user, $pass) + $pre = [Text.Encoding]::ASCII.GetBytes("*" + $parts.Count + "`r`n") + $ms.Write($pre,0,$pre.Length) + foreach ($p in $parts) { + $b = [Text.Encoding]::UTF8.GetBytes($p) + $h = [Text.Encoding]::ASCII.GetBytes("$" + $b.Length + "`r`n") + $ms.Write($h,0,$h.Length) + $ms.Write($b,0,$b.Length) + $c=[Text.Encoding]::ASCII.GetBytes("`r`n"); $ms.Write($c,0,2) + } + $payload = $ms.ToArray() + $stream.Write($payload, 0, $payload.Length) + $buf = New-Object byte[] 128 + $n = $stream.Read($buf, 0, $buf.Length) + $tcp.Close() + return [Text.Encoding]::ASCII.GetString($buf, 0, $n).Split("`r")[0] + } catch { return ("err:" + $_.Exception.Message) } +} + +Write-Host ("ping_127=" + (Redis-Ping "127.0.0.1" 6379)) +Write-Host ("ping_intranet=" + (Redis-Ping "10.206.0.14" 6379)) + +# load url password +$pass=$null; $user=$null +Get-Content "E:\wwwroot\.env" | ForEach-Object { + if ($_ -match "^REDIS_URL=redis://([^:/@]+):([^@]+)@") { $user=$Matches[1]; $pass=$Matches[2] } +} +Write-Host ("loaded user=" + $user + " pw_len=" + $pass.Length) +Write-Host ("auth_intranet_pw_only=" + (Redis-AuthPw "10.206.0.14" 6379 $pass)) +Write-Host ("auth_intranet_user_pw=" + (Redis-AuthUserPw "10.206.0.14" 6379 $user $pass)) +Write-Host ("auth_127_pw_only=" + (Redis-AuthPw "127.0.0.1" 6379 $pass)) +Write-Host ("auth_127_user_pw=" + (Redis-AuthUserPw "127.0.0.1" 6379 $user $pass)) +Write-Host "REDIS_COMPARE_DONE" \ No newline at end of file diff --git a/deploy/_remote_refresh_admin_dist.ps1 b/deploy/_remote_refresh_admin_dist.ps1 new file mode 100644 index 0000000..02b1ea4 --- /dev/null +++ b/deploy/_remote_refresh_admin_dist.ps1 @@ -0,0 +1,37 @@ +# 仅刷新后台静态(不改 Nginx、不动 8080)。依赖已切流到 8180。 +$ErrorActionPreference = 'Stop' +$IncomingZip = 'C:\Users\ytd_extdev_0903\incoming\admin-dist.zip' +$Root = 'E:\wwwroot' +$Stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$RelId = 'admin-' + $Stamp +$RelDist = Join-Path $Root ('admin\releases\' + $RelId + '\dist') +$Current = Join-Path $Root 'admin\current' + +if (-not (Test-Path $IncomingZip)) { throw 'missing admin-dist.zip' } +$ledger = Get-NetTCPConnection -LocalPort 8180 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $ledger) { throw 'CRITICAL: 8180 not listening' } +$prod = Get-NetTCPConnection -LocalPort 8080 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $prod) { throw 'CRITICAL: prod 8080 down' } + +New-Item -ItemType Directory -Force -Path $RelDist | Out-Null +Expand-Archive -Path $IncomingZip -DestinationPath $RelDist -Force +if (-not (Test-Path (Join-Path $RelDist 'index.html'))) { + $inner = Get-ChildItem $RelDist -Directory | Select-Object -First 1 + if ($inner -and (Test-Path (Join-Path $inner.FullName 'index.html'))) { + $RelDist = $inner.FullName + } +} +if (-not (Test-Path (Join-Path $RelDist 'index.html'))) { throw 'index.html missing' } + +if (Test-Path $Current) { + cmd /c "rmdir `"$Current`"" | Out-Null + if (Test-Path $Current) { Remove-Item $Current -Recurse -Force } +} +cmd /c "mklink /J `"$Current`" `"$RelDist`"" +if (-not (Test-Path (Join-Path $Current 'index.html'))) { throw 'current index missing' } + +$bytes = (Get-Item $IncomingZip).Length +Write-Output ("ADMIN_DIST_REFRESH_OK current=" + $Current) +Write-Output ("rel=" + $RelDist) +Write-Output ("zip_bytes=" + $bytes) +Write-Output ("PROD_8080_OK pid=" + $prod.OwningProcess) diff --git a/deploy/_remote_repair_mapping_json.py b/deploy/_remote_repair_mapping_json.py new file mode 100644 index 0000000..61ddfce --- /dev/null +++ b/deploy/_remote_repair_mapping_json.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +""" +远端修复:inq_quote_template_version.mapping_json 非法(PowerShell ConvertTo-Json/种子写入无引号)导致 previewSample 失败。 +对每条版本:若 JSON 不可解析,则按上传 xlsx + 标签词典重算合法 mapping_json。 +副作用:UPDATE MySQL mapping_json / mapping_point_count / parse_message;不改 object 文件。 +""" +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +try: + import pymysql +except ImportError: + pymysql = None + +try: + from openpyxl import load_workbook + from openpyxl.utils import get_column_letter +except ImportError: + load_workbook = None + get_column_letter = None + + +def load_env(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + if not path.is_file(): + return out + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip('"') + return out + + +def try_parse(s: str): + if not s: + return None + try: + return json.loads(s) + except Exception: + return None + + +def repair_ps_style(s: str): + """尽力把无引号对象字面量修成 JSON(仅用于种子脏数据)。""" + if not s or s[0] != "{": + return None + # 已是合法 JSON + if try_parse(s) is not None: + return try_parse(s) + # 给 key 加引号:{foo: 或 ,foo: + t = re.sub(r"([,{]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:", r'\1"\2":', s) + # 给像 Sheet2!B8 / quote-template-mapping-v1 / 纯标识 的裸字符串值加引号 + # 匹配 :value 直到 ,} ] + def quote_val(m): + prefix, val, suffix = m.group(1), m.group(2), m.group(3) + val = val.strip() + if val in ("[]", "{}", "true", "false", "null") or re.match(r"^-?\d+(\.\d+)?$", val): + return prefix + val + suffix + if (val.startswith('"') and val.endswith('"')) or (val.startswith("{") or val.startswith("[")): + return prefix + val + suffix + return prefix + json.dumps(val, ensure_ascii=False) + suffix + + t2 = re.sub(r'(:\s*)([^,\[\]{}]+)([,}\]])', quote_val, t) + return try_parse(t2) + + +def rough_fields(xlsx: Path, label_dict: dict[str, str]) -> dict[str, str]: + fields: dict[str, str] = {} + if load_workbook is None: + return fields + wb = load_workbook(xlsx, read_only=True, data_only=True) + try: + # 扫所有 sheet(种子映射常在 Sheet2) + for ws in wb.worksheets: + sheet = ws.title + for row in ws.iter_rows(max_row=80, max_col=24): + for cell in row: + val = cell.value + if val is None: + continue + text = str(val).strip() + if not text: + continue + norm = text.replace(":", ":") + code = None + for lab, c in label_dict.items(): + lab_n = lab.replace(":", ":").replace(":", "").strip() + t_n = norm.replace(":", "").strip() + if lab_n and (t_n == lab_n or t_n.startswith(lab_n)): + code = c + break + if code and code not in fields: + col_i = int(getattr(cell, "column", 1) or 1) + fill = f"{sheet}!{get_column_letter(col_i + 1)}{cell.row}" + fields[code] = fill + finally: + wb.close() + return fields + + +def main() -> int: + env = load_env(Path(r"E:\wwwroot\.env")) + upload = env.get("JEECG_UPLOAD_PATH") or r"E:\wwwroot\data\upFiles" + user = env.get("MYSQL_USER") or "inquiry_robot" + password = env.get("MYSQL_PASSWORD") or "" + db = env.get("MYSQL_DB") or "inquiry_robot" + host = "127.0.0.1" + + contract_paths = [ + Path(r"E:\wwwroot\inquiry-new\config\contracts\quote-template-mapping-v1.json"), + Path(r"C:\Users\ytd_extdev_0903\incoming\quote-template-mapping-v1.json"), + ] + label_dict: dict[str, str] = {} + for cp in contract_paths: + if cp.is_file(): + label_dict = (json.loads(cp.read_text(encoding="utf-8")).get("labelDictionary") or {}) + break + if not label_dict: + # 最小词典,避免完全空 + label_dict = { + "客户名称": "customer_name", + "客户": "customer_name", + "报价单编号": "quote_no", + "报价日期": "quote_date", + "起运港": "pol", + "目的港": "pod", + "业务员": "sales_name", + } + + if pymysql is None: + print("ERR no pymysql") + return 2 + + conn = pymysql.connect( + host=host, user=user, password=password, database=db, charset="utf8mb4", port=3306 + ) + fixed = 0 + ok_already = 0 + failed = 0 + try: + with conn.cursor() as cur: + cur.execute( + "SELECT id, object_key, file_name, mapping_json FROM inq_quote_template_version" + ) + rows = cur.fetchall() + for vid, object_key, file_name, mapping_json in rows: + parsed = try_parse(mapping_json or "") + if parsed is None: + parsed = repair_ps_style(mapping_json or "") + xlsx = Path(upload) / str(object_key).replace("/", os.sep) + if parsed is None or not isinstance(parsed.get("fields"), dict) or not parsed.get("fields"): + fields = {} + if xlsx.is_file(): + fields = rough_fields(xlsx, label_dict) + # 若修复出的 fields 可用则保留其它键 + if parsed and isinstance(parsed.get("fields"), dict) and parsed.get("fields"): + fields = parsed["fields"] + elif not fields and parsed and isinstance(parsed.get("fields"), dict): + fields = parsed["fields"] + mapping = { + "schemaVersion": "quote-template-mapping-v1", + "fields": fields, + "fixedYellowCells": [], + "unmappedRequired": [], + "note": "repaired_mapping_json", + } + else: + # 合法但规范化 + mapping = { + "schemaVersion": parsed.get("schemaVersion") or "quote-template-mapping-v1", + "fields": parsed.get("fields") or {}, + "fixedYellowCells": parsed.get("fixedYellowCells") or [], + "unmappedRequired": parsed.get("unmappedRequired") or [], + } + if try_parse(mapping_json or "") is not None and mapping_json.strip().startswith('{"'): + ok_already += 1 + print(f"OK_ALREADY {vid} points={len(mapping['fields'])}") + continue + + new_json = json.dumps(mapping, ensure_ascii=False, separators=(",", ":")) + points = len(mapping.get("fields") or {}) + with conn.cursor() as cur: + cur.execute( + "UPDATE inq_quote_template_version SET mapping_json=%s, mapping_point_count=%s, " + "parse_status=%s, parse_message=%s WHERE id=%s", + ( + new_json, + points, + "parsed", + f"repaired mapping, {points} points", + vid, + ), + ) + conn.commit() + fixed += 1 + exists = xlsx.is_file() + print(f"FIXED {vid} points={points} file_exists={exists} name={file_name}") + except Exception as e: + failed += 1 + print("ERR", e) + return 1 + finally: + conn.close() + print(f"REPAIR_DONE fixed={fixed} ok_already={ok_already} failed={failed}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/_remote_restore_and_status.ps1 b/deploy/_remote_restore_and_status.ps1 new file mode 100644 index 0000000..6db5a1e --- /dev/null +++ b/deploy/_remote_restore_and_status.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference="Continue" +Write-Host "=== stderr tail ===" +Get-Content "E:\wwwroot\inquiry-new\logs\stderr.log" -Tail 40 -ErrorAction SilentlyContinue +Write-Host "=== jar commons-io entries ===" +$jar="C:\yutongda\tools\jdk8\bin\jar.exe" +& $jar tf "E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar" | Select-String "commons-io" +Write-Host "=== incoming jars ===" +Get-ChildItem "C:\Users\ytd_extdev_0903\incoming\jeecg*.jar" | ForEach-Object { Write-Host ($_.Name + " " + $_.Length) } +# restore CAPTCHA if present +$cap="C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.CAPTCHA.jar" +if(Test-Path $cap){ + Get-CimInstance Win32_Process | ForEach-Object { + if($_.CommandLine -and ($_.CommandLine -like "*inquiry-new*jeecg*" -or $_.CommandLine -like "*run-ledger*")){ + Stop-Process -Id $_.ProcessId -Force -EA SilentlyContinue + } + } + Start-Sleep 2 + Copy-Item $cap "E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar" -Force + Copy-Item $cap "C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar" -Force + Write-Host ("RESTORED_CAPTCHA bytes="+(Get-Item $cap).Length) +} +Write-Host "STATUS_DONE" \ No newline at end of file diff --git a/deploy/_remote_restore_ledger_jar.ps1 b/deploy/_remote_restore_ledger_jar.ps1 new file mode 100644 index 0000000..960f3c7 --- /dev/null +++ b/deploy/_remote_restore_ledger_jar.ps1 @@ -0,0 +1,20 @@ +# 先用 incoming 旧 jar 救活 8180,等待新 fat jar 覆盖 +$ErrorActionPreference = 'Stop' +$IncomingJar = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar' +$Fat = 'E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar' +Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { + if ($_.CommandLine -and $_.CommandLine -like '*inquiry-new*jeecg-system-start*') { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } +} +Start-Sleep -Seconds 2 +if (Test-Path $IncomingJar) { + $len = (Get-Item $IncomingJar).Length + Write-Host ("incoming_jar_bytes=" + $len) + # 若 incoming 也是坏的(太小或等于损坏后大小),仍复制尝试 + Copy-Item $IncomingJar $Fat -Force + Write-Host 'RESTORED_FROM_INCOMING' +} else { + throw 'no incoming jar to restore' +} +powershell -NoProfile -ExecutionPolicy Bypass -File 'C:\Users\ytd_extdev_0903\incoming\_remote_wmi_start_ledger.ps1' diff --git a/deploy/_remote_retry_list_redis.ps1 b/deploy/_remote_retry_list_redis.ps1 new file mode 100644 index 0000000..2b0bc39 --- /dev/null +++ b/deploy/_remote_retry_list_redis.ps1 @@ -0,0 +1,53 @@ +# 重试 list + 看 Redis 连通 +$ErrorActionPreference = 'Continue' +$loginBody = '{"username":"admin","password":"123456","captcha":"","checkKey":""}' +$login = Invoke-RestMethod -Uri 'http://127.0.0.1:8180/jeecgboot/sys/login' -Method POST -Body $loginBody -ContentType 'application/json;charset=UTF-8' +$token = $login.result.token +$h = @{ 'X-Access-Token' = $token } +1..3 | ForEach-Object { + try { + $r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=20' -Headers $h -UseBasicParsing -TimeoutSec 20 + Write-Host ("try$_ status=$($r.StatusCode) body=" + $r.Content.Substring(0, [Math]::Min(400, $r.Content.Length))) + } catch { + Write-Host ("try$_ err=" + $_.Exception.Message) + } + Start-Sleep 1 +} +# redis ping via .env +$envLines = Get-Content 'E:\wwwroot\.env' +$hostn='127.0.0.1'; $port='6379'; $db='1'; $rpw=$null +foreach ($line in $envLines) { + if ($line -match '^YTD_REDIS_HOST=(.+)$') { $hostn = $Matches[1].Trim().Trim('"') } + if ($line -match '^YTD_REDIS_PORT=(.+)$') { $port = $Matches[1].Trim().Trim('"') } + if ($line -match '^YTD_REDIS_DB=(.+)$') { $db = $Matches[1].Trim().Trim('"') } + if ($line -match '^YTD_REDIS_PASSWORD=(.+)$') { $rpw = $Matches[1].Trim().Trim('"') } + if ($line -match '^REDIS_URL=redis://:([^@]+)@([^:]+):(\d+)/(\d+)') { + $rpw=$Matches[1]; $hostn=$Matches[2]; $port=$Matches[3]; $db=$Matches[4] + } +} +Write-Host ("redis_cfg host=$hostn port=$port db=$db pw_len=" + ($(if($rpw){$rpw.Length}else{0}))) +$cliCandidates = @( + 'C:\yutongda\tools\Redis-x64-*\redis-cli.exe', + 'E:\wwwroot\tools\*\redis-cli.exe', + 'C:\Program Files\Redis\redis-cli.exe' +) +$cli = $null +foreach ($p in $cliCandidates) { + $hit = Get-Item $p -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($hit) { $cli = $hit.FullName; break } +} +if ($cli) { + Write-Host ("redis_cli=" + $cli) + if ($rpw) { & $cli -h $hostn -p $port -a $rpw -n $db PING } + else { & $cli -h $hostn -p $port -n $db PING } +} else { + Write-Host 'redis_cli_missing' + try { + $tcp = New-Object Net.Sockets.TcpClient + $iar = $tcp.BeginConnect($hostn, [int]$port, $null, $null) + $ok = $iar.AsyncWaitHandle.WaitOne(3000) + Write-Host ("tcp_connect=" + $ok + " connected=" + $tcp.Connected) + $tcp.Close() + } catch { Write-Host ("tcp_err=" + $_.Exception.Message) } +} +Write-Host 'RETRY_DONE' diff --git a/deploy/_remote_run_ledger_inner.ps1 b/deploy/_remote_run_ledger_inner.ps1 new file mode 100644 index 0000000..3ed2286 --- /dev/null +++ b/deploy/_remote_run_ledger_inner.ps1 @@ -0,0 +1,50 @@ +$ErrorActionPreference = "Stop" +Get-Content "E:\wwwroot\.env" | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith("#")) { return } + $i = $line.IndexOf("=") + if ($i -lt 1) { return } + Set-Item -Path ("Env:" + $line.Substring(0, $i).Trim()) -Value $line.Substring($i + 1).Trim().Trim('"') +} + +# Inquiry ledger uses local Redis db1 (no ACL). Intranet REDIS_URL is ACL user+pass; +# Jedis AUTH password-only against that instance returns WRONGPASS. Agent already uses 127.0.0.1. +$env:YTD_REDIS_HOST = "127.0.0.1" +$env:YTD_REDIS_PORT = "6379" +if (-not $env:YTD_REDIS_DB) { $env:YTD_REDIS_DB = "1" } +$env:YTD_REDIS_USERNAME = "" +$env:YTD_REDIS_PASSWORD = "" +Remove-Item Env:YTD_REDIS_USERNAME -ErrorAction SilentlyContinue +Remove-Item Env:YTD_REDIS_PASSWORD -ErrorAction SilentlyContinue + +Write-Output ("redis_mode=local_noauth host=" + $env:YTD_REDIS_HOST + " db=" + $env:YTD_REDIS_DB) + +$sb = New-Object System.Text.StringBuilder +[void]$sb.AppendLine("spring:") +[void]$sb.AppendLine(" redis:") +[void]$sb.AppendLine(" host: " + $env:YTD_REDIS_HOST) +[void]$sb.AppendLine(" port: " + $env:YTD_REDIS_PORT) +[void]$sb.AppendLine(" database: " + $env:YTD_REDIS_DB) +[void]$sb.AppendLine(" username: ") +[void]$sb.AppendLine(" password: ") +$overlayPath = "E:\wwwroot\inquiry-new\config\application-redis-runtime.yml" +[System.IO.File]::WriteAllText($overlayPath, $sb.ToString(), (New-Object System.Text.UTF8Encoding $false)) + +$env:LEDGER_PORT = "8180" +$Java = "C:\yutongda\tools\jdk8\bin\java.exe" +$Jar = "E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar" +Set-Location "E:\wwwroot\inquiry-new" +$arg = @( + "-jar", $Jar, + "--spring.profiles.active=inquiry", + "--server.port=8180", + "--spring.config.additional-location=optional:file:./config/", + "--cas.prefixUrl=http://127.0.0.1/cas-disabled", + "--jeecg.loginCaptchaEnabled=false", + ("--spring.redis.host=" + $env:YTD_REDIS_HOST), + ("--spring.redis.port=" + $env:YTD_REDIS_PORT), + ("--spring.redis.database=" + $env:YTD_REDIS_DB), + "--spring.redis.password=", + "--spring.redis.username=" +) +& $Java @arg diff --git a/deploy/_remote_run_repair_mapping.ps1 b/deploy/_remote_run_repair_mapping.ps1 new file mode 100644 index 0000000..394e3df --- /dev/null +++ b/deploy/_remote_run_repair_mapping.ps1 @@ -0,0 +1,33 @@ +$ErrorActionPreference = 'Continue' +$pyCandidates = @( + 'C:\yutongda\apps\inquiry-agent\.venv\Scripts\python.exe', + 'C:\Users\ytd_extdev_0903\incoming\inquiry-agent\.venv\Scripts\python.exe', + 'C:\Python312\python.exe', + 'C:\Python311\python.exe', + 'C:\Python39\python.exe', + 'python' +) +$py = $null +foreach ($c in $pyCandidates) { + if ($c -eq 'python') { + try { $v = & python -c "print(1)" 2>$null; if ($v -eq '1') { $py = 'python'; break } } catch {} + } elseif (Test-Path $c) { $py = $c; break } +} +Write-Host ("python=" + $py) +if (-not $py) { throw 'python missing' } + +# ensure deps +& $py -c "import pymysql, openpyxl; print('deps_ok')" 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host 'pip install pymysql openpyxl' + & $py -m pip install --quiet pymysql openpyxl +} + +# place contract where repair script looks +$contractSrc = 'C:\Users\ytd_extdev_0903\incoming\quote-template-mapping-v1.json' +$contractDstDir = 'E:\wwwroot\inquiry-new\config\contracts' +New-Item -ItemType Directory -Force -Path $contractDstDir | Out-Null +if (Test-Path $contractSrc) { Copy-Item $contractSrc (Join-Path $contractDstDir 'quote-template-mapping-v1.json') -Force } + +& $py 'C:\Users\ytd_extdev_0903\incoming\_remote_repair_mapping_json.py' +Write-Host ("repair_exit=" + $LASTEXITCODE) diff --git a/deploy/_remote_safe_deploy_agent.ps1 b/deploy/_remote_safe_deploy_agent.ps1 index f036b8c..3d35b21 100644 --- a/deploy/_remote_safe_deploy_agent.ps1 +++ b/deploy/_remote_safe_deploy_agent.ps1 @@ -77,8 +77,9 @@ $runHttp = @( 'set AGENT_HTTP_PORT=8910', 'set PYTHONUTF8=1', 'set PYTHONIOENCODING=utf-8', + 'set PYTHONUNBUFFERED=1', ':loop', - ('"' + $venvPy + '" -m agent.http_app >> "' + $logHttpOut + '" 2>> "' + $logHttpErr + '"'), + ('"' + $venvPy + '" -u -m agent.http_app >> "' + $logHttpOut + '" 2>> "' + $logHttpErr + '"'), ('echo agent exited, restart in 3s >> "' + $logHttpErr + '"'), 'timeout /t 3 /nobreak >nul', 'goto loop' diff --git a/deploy/_remote_seed_quote_templates.ps1 b/deploy/_remote_seed_quote_templates.ps1 new file mode 100644 index 0000000..8c368b0 --- /dev/null +++ b/deploy/_remote_seed_quote_templates.ps1 @@ -0,0 +1,126 @@ +# Seed quote templates via ledger API (ASCII-only script to avoid encoding parse errors) +$ErrorActionPreference = 'Stop' +$Incoming = 'C:\Users\ytd_extdev_0903\incoming' +$Work = 'E:\wwwroot\inquiry-new\quote-template-seed' +New-Item -ItemType Directory -Force -Path $Work | Out-Null +$zip = Join-Path $Incoming 'quote-templates-seed.zip' +if (-not (Test-Path $zip)) { throw "missing $zip" } +Expand-Archive -Path $zip -DestinationPath $Work -Force +$manifestPath = Join-Path $Work 'manifest.json' +$manifest = Get-Content $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json + +function Invoke-Json($Method, $Url, $BodyObj, $Token) { + $headers = @{ 'Content-Type' = 'application/json;charset=UTF-8' } + if ($Token) { $headers['X-Access-Token'] = $Token } + $body = $null + if ($null -ne $BodyObj) { $body = ($BodyObj | ConvertTo-Json -Compress -Depth 8) } + return Invoke-RestMethod -Method $Method -Uri $Url -Headers $headers -Body $body -TimeoutSec 60 +} + +Write-Host 'login...' +$login = Invoke-Json 'POST' 'http://127.0.0.1:8180/jeecgboot/sys/login' @{ + username = 'admin' + password = '123456' + captcha = '' + checkKey = 'deploy' +} +if (-not $login -or $login.success -ne $true) { + throw ('login failed: ' + ($login | ConvertTo-Json -Compress)) +} +$token = $login.result.token +if (-not $token) { throw 'no token' } +Write-Host 'LOGIN_OK' + +$files = Get-ChildItem -Path (Join-Path $Work 'templates') -Recurse -Filter '*.xlsx' | + Where-Object { $_.Name -notlike '~$*' } +$byName = @{} +foreach ($f in $files) { + if (-not $byName.ContainsKey($f.Name) -or $f.FullName.Length -lt $byName[$f.Name].FullName.Length) { + $byName[$f.Name] = $f + } +} + +$ok = 0 +$fail = 0 +foreach ($item in $manifest.items) { + $name = [string]$item.fileName + if (-not $byName.ContainsKey($name)) { + Write-Host ("SKIP missing " + $name) + continue + } + $path = $byName[$name].FullName + Write-Host ("UPLOAD " + $name + " mode=" + $item.mode) + try { + $boundary = [guid]::NewGuid().ToString('N') + $LF = "`r`n" + $fileBytes = [IO.File]::ReadAllBytes($path) + $keywordsJson = ($item.keywords | ConvertTo-Json -Compress) + if (-not $keywordsJson.StartsWith('[')) { $keywordsJson = ('["' + $item.keywords + '"]') } + + $sb = New-Object System.Text.StringBuilder + $fields = [ordered]@{ + name = [IO.Path]::GetFileNameWithoutExtension($name) + bizType = [string]$item.bizType + category = [string]$item.category + keywordsJson = $keywordsJson + mode = [string]$item.mode + isDefault = 'false' + } + foreach ($k in $fields.Keys) { + [void]$sb.Append(('--' + $boundary + $LF)) + [void]$sb.Append(('Content-Disposition: form-data; name="' + $k + '"' + $LF + $LF)) + [void]$sb.Append(([string]$fields[$k] + $LF)) + } + [void]$sb.Append(('--' + $boundary + $LF)) + [void]$sb.Append(('Content-Disposition: form-data; name="file"; filename="' + $name + '"' + $LF)) + [void]$sb.Append(('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + $LF + $LF)) + $prefix = [Text.Encoding]::UTF8.GetBytes($sb.ToString()) + $suffix = [Text.Encoding]::UTF8.GetBytes(($LF + '--' + $boundary + '--' + $LF)) + $body = New-Object byte[] ($prefix.Length + $fileBytes.Length + $suffix.Length) + [Array]::Copy($prefix, 0, $body, 0, $prefix.Length) + [Array]::Copy($fileBytes, 0, $body, $prefix.Length, $fileBytes.Length) + [Array]::Copy($suffix, 0, $body, $prefix.Length + $fileBytes.Length, $suffix.Length) + + $req = [System.Net.HttpWebRequest]::Create('http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/upload') + $req.Method = 'POST' + $req.ContentType = ('multipart/form-data; boundary=' + $boundary) + $req.Headers.Add('X-Access-Token', $token) + $req.Timeout = 180000 + $req.ReadWriteTimeout = 180000 + $req.ContentLength = $body.Length + $stream = $req.GetRequestStream() + $stream.Write($body, 0, $body.Length) + $stream.Close() + $resp = $req.GetResponse() + $reader = New-Object IO.StreamReader($resp.GetResponseStream()) + $text = $reader.ReadToEnd() + $reader.Close(); $resp.Close() + $obj = $text | ConvertFrom-Json + if ($obj.success -ne $true) { + throw ('api fail ' + $text.Substring(0, [Math]::Min(400, $text.Length))) + } + $row = $obj.result + Write-Host (' id=' + $row.id + ' ver=' + $row.versionId + ' parse=' + $row.parseStatus + ' points=' + $row.mappingPointCount) + + $canEnable = $true + if (($item.mode -eq 'auto') -and ("$($row.parseStatus)" -like '*incomplete*' -or "$($row.parseStatus)" -like '*未完成*')) { + $canEnable = $false + Write-Host ' skip enable mapping incomplete' + } + if ($canEnable -and $row.id -and $row.versionId) { + $enUrl = 'http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/enable?templateId=' + $row.id + '&versionId=' + $row.versionId + $en = Invoke-Json 'POST' $enUrl $null $token + Write-Host (' enable=' + $en.success + ' msg=' + $en.message) + } + $ok++ + } catch { + $fail++ + Write-Host ('FAIL ' + $name + ' : ' + $_.Exception.Message) + } +} + +Write-Host ('SEED_DONE ok=' + $ok + ' fail=' + $fail) +$listUrl = 'http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=50' +$list = Invoke-Json 'GET' $listUrl $null $token +Write-Host ('list_total=' + $list.result.total) +if ($fail -gt 0) { exit 2 } diff --git a/deploy/_remote_sql_seed_quote_templates.ps1 b/deploy/_remote_sql_seed_quote_templates.ps1 new file mode 100644 index 0000000..2738c76 --- /dev/null +++ b/deploy/_remote_sql_seed_quote_templates.ps1 @@ -0,0 +1,83 @@ +# Seed templates into DB + upload dir without login (ASCII) +$ErrorActionPreference='Stop' +Get-Content 'E:\wwwroot\.env' | ForEach-Object { + $line=$_.Trim(); if(-not $line -or $line.StartsWith('#')){return} + $i=$line.IndexOf('='); if($i -lt 1){return} + Set-Item -Path ("Env:"+$line.Substring(0,$i).Trim()) -Value $line.Substring($i+1).Trim().Trim('"') +} +$mysql='C:\yutongda\tools\mysql-8.0.39-winx64\bin\mysql.exe' +$hostName='127.0.0.1' +$Work='E:\wwwroot\inquiry-new\quote-template-seed' +$zip='C:\Users\ytd_extdev_0903\incoming\quote-templates-seed.zip' +if(-not (Test-Path $zip)){ throw "missing $zip" } +if(Test-Path $Work){ Remove-Item $Work -Recurse -Force } +Expand-Archive -Path $zip -DestinationPath $Work -Force +$manifest=Get-Content (Join-Path $Work 'manifest.json') -Raw -Encoding UTF8 | ConvertFrom-Json + +$uploadRoot = if($env:JEECG_UPLOAD_PATH){ $env:JEECG_UPLOAD_PATH } else { 'E:\wwwroot\data\upFiles' } +$bizPath = Join-Path $uploadRoot 'inquiry\quote-templates' +New-Item -ItemType Directory -Force -Path $bizPath | Out-Null + +$files=Get-ChildItem -Path (Join-Path $Work 'templates') -Recurse -Filter '*.xlsx' | Where-Object { $_.Name -notlike '~$*' } +$byName=@{} +foreach($f in $files){ if(-not $byName.ContainsKey($f.Name) -or $f.FullName.Length -lt $byName[$f.Name].FullName.Length){ $byName[$f.Name]=$f } } + +function Sql([string]$q){ + & $mysql -h $hostName -P 3306 -u $env:MYSQL_USER "--password=$env:MYSQL_PASSWORD" --default-character-set=utf8mb4 $env:MYSQL_DB -e $q + if($LASTEXITCODE -ne 0){ throw "sql fail $LASTEXITCODE" } +} + +$ok=0 +foreach($item in $manifest.items){ + $name=[string]$item.fileName + if(-not $byName.ContainsKey($name)){ Write-Host ("SKIP "+$name); continue } + $src=$byName[$name].FullName + $id = ([guid]::NewGuid().ToString('N').Substring(0,32)) + $vid = ([guid]::NewGuid().ToString('N').Substring(0,32)) + $destName = $id + '_' + $name + $dest = Join-Path $bizPath $destName + Copy-Item $src $dest -Force + $objectKey = ('inquiry/quote-templates/' + $destName).Replace('\','/') + $sha = (Get-FileHash $dest -Algorithm SHA256).Hash.ToLower() + $size = (Get-Item $dest).Length + $mode=[string]$item.mode + $biz=[string]$item.bizType + $cat=[string]$item.category + $kw = ($item.keywords | ConvertTo-Json -Compress) + $tplName = [IO.Path]::GetFileNameWithoutExtension($name) + $status = if($mode -eq 'manual'){ 'enabled' } else { 'enabled' } + $mapping = '{"schemaVersion":"quote-template-mapping-v1","fields":{},"note":"seeded_without_parse; re-upload to refresh mapping"}' + $points = 0 + if($item.suggestedFields){ + # 禁止用 ConvertTo-Json 直接塞库:WinPS 可能产出非法 JSON,导致 previewSample 解析失败 + $fieldsObj = @{} + $item.suggestedFields.PSObject.Properties | ForEach-Object { $fieldsObj[$_.Name]=$_.Value } + $fieldsJsonParts = @() + foreach($k in $fieldsObj.Keys){ + $fieldsJsonParts += ('"' + ($k -replace '\\','\\' -replace '"','\"') + '":"' + ([string]$fieldsObj[$k] -replace '\\','\\' -replace '"','\"') + '"') + } + $fieldsJson = '{' + ($fieldsJsonParts -join ',') + '}' + $mapping = '{"schemaVersion":"quote-template-mapping-v1","fields":' + $fieldsJson + ',"fixedYellowCells":[],"unmappedRequired":[]}' + $points = @($fieldsObj.Keys).Count + } + $esc = { param($s) if($null -eq $s){return ''}; return ([string]$s).Replace("\","\\").Replace("'","''") } + $sql = @" +INSERT INTO inq_quote_template(id,name,biz_type,category,keywords_json,mode,status,is_default,current_version,use_count,create_by,create_time) +VALUES('$id','$(& $esc $tplName)','$(& $esc $biz)','$(& $esc $cat)','$(& $esc $kw)','$mode','$status',0,1,0,'seed',NOW()) +ON DUPLICATE KEY UPDATE update_time=NOW(); +INSERT INTO inq_quote_template_version(id,template_id,version_no,file_name,format,object_key,sha256,file_size,parse_status,mapping_point_count,mapping_json,parse_message,enabled,create_by,create_time) +VALUES('$vid','$id',1,'$(& $esc $name)','excel','$(& $esc $objectKey)','$sha',$size,'parsed',$points,'$(& $esc $mapping)','seed import',$points,1,'seed',NOW()); +"@ + # enabled=1 for version; fix SQL - enabled column is tinyint + $sql = @" +INSERT INTO inq_quote_template(id,name,biz_type,category,keywords_json,mode,status,is_default,current_version,use_count,create_by,create_time) +VALUES('$id','$(& $esc $tplName)','$(& $esc $biz)','$(& $esc $cat)','$(& $esc $kw)','$mode','$status',0,1,0,'seed',NOW()); +INSERT INTO inq_quote_template_version(id,template_id,version_no,file_name,format,object_key,sha256,file_size,parse_status,mapping_point_count,mapping_json,parse_message,enabled,create_by,create_time) +VALUES('$vid','$id',1,'$(& $esc $name)','excel','$(& $esc $objectKey)','$sha',$size,'parsed',$points,'$(& $esc $mapping)','seed import',1,'seed',NOW()); +"@ + Sql $sql + Write-Host ("OK " + $name + " id=" + $id + " points=" + $points) + $ok++ +} +Sql "SELECT COUNT(*) AS templates FROM inq_quote_template; SELECT COUNT(*) AS versions FROM inq_quote_template_version;" +Write-Host ("SQL_SEED_DONE ok="+$ok) diff --git a/deploy/_remote_verify_after_redis_fix.ps1 b/deploy/_remote_verify_after_redis_fix.ps1 new file mode 100644 index 0000000..65c6995 --- /dev/null +++ b/deploy/_remote_verify_after_redis_fix.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference = "Continue" +Select-String -Path "E:\wwwroot\inquiry-new\logs\stdout.log" -Pattern "redis_parse=" | Select-Object -Last 3 | ForEach-Object { Write-Host $_.Line } +$bodyPath = Join-Path $env:TEMP "login-body.json" +Set-Content -Path $bodyPath -Value '{"username":"admin","password":"123456","captcha":"","checkKey":""}' -Encoding ASCII +$login = Invoke-RestMethod -Uri "http://127.0.0.1:8180/jeecgboot/sys/login" -Method POST -InFile $bodyPath -ContentType "application/json;charset=UTF-8" +Write-Host ("login_ok=" + $login.success + " code=" + $login.code) +$h = @{ "X-Access-Token" = $login.result.token } +$r = Invoke-WebRequest -Uri "http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=20" -Headers $h -UseBasicParsing +$j = $r.Content | ConvertFrom-Json +Write-Host ("list_success=" + $j.success + " code=" + $j.code + " total=" + $j.result.total + " records=" + @($j.result.records).Count) +if (-not $j.success) { + $msg = [string]$j.message + if ($msg.Length -gt 150) { $msg = $msg.Substring(0,150) } + Write-Host ("list_msg=" + $msg) +} else { + @($j.result.records) | Select-Object -First 5 | ForEach-Object { + Write-Host (" mode=" + $_.mode + " status=" + $_.status + " points=" + $_.mappingPointCount + " name_len=" + ([string]$_.name).Length) + } +} +$tail = Get-Content "E:\wwwroot\inquiry-new\logs\stdout.log" -Tail 50 +$wp = @($tail | Select-String "WRONGPASS") +Write-Host ("tail50_WRONGPASS_count=" + $wp.Count) +Write-Host "VERIFY_DONE" \ No newline at end of file diff --git a/deploy/_remote_verify_preview_dl.ps1 b/deploy/_remote_verify_preview_dl.ps1 new file mode 100644 index 0000000..816e598 --- /dev/null +++ b/deploy/_remote_verify_preview_dl.ps1 @@ -0,0 +1,44 @@ +$ErrorActionPreference = 'Continue' +Start-Sleep -Seconds 5 +$bodyPath = Join-Path $env:TEMP 'login-body.json' +Set-Content $bodyPath '{"username":"admin","password":"123456","captcha":"","checkKey":""}' -Encoding ASCII +try { + $login = Invoke-RestMethod -Uri 'http://127.0.0.1:8180/jeecgboot/sys/login' -Method POST -InFile $bodyPath -ContentType 'application/json;charset=UTF-8' + Write-Host ("login=" + $login.success + " code=" + $login.code) +} catch { + Write-Host ("login_fail=" + $_.Exception.Message) + exit 1 +} +$h = @{ 'X-Access-Token' = $login.result.token } +try { + $list = Invoke-RestMethod -Uri 'http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=3' -Headers $h + Write-Host ("list_success=" + $list.success + " total=" + $list.result.total) +} catch { + Write-Host ("list_fail=" + $_.Exception.Message) + try { + $r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=3' -Headers $h -UseBasicParsing + Write-Host ("list_raw=" + $r.Content.Substring(0, [Math]::Min(300, $r.Content.Length))) + } catch { + Write-Host ("list_raw_fail=" + $_.Exception.Message) + } +} +$ok = 0; $fail = 0 +$list2 = Invoke-RestMethod -Uri 'http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=20' -Headers $h -ErrorAction SilentlyContinue +foreach ($rec in @($list2.result.records)) { + if (-not $rec.versionId) { continue } + try { + $r = Invoke-WebRequest -Uri ("http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/previewSample?versionId=" + $rec.versionId) -Headers $h -UseBasicParsing -TimeoutSec 90 + $ct = $r.Headers['Content-Type'] + if ($ct -like '*sheet*' -or $r.RawContentLength -gt 2000) { + Write-Host ("OK " + $rec.versionId + " len=" + $r.RawContentLength) + $ok++ + } else { + Write-Host ("BAD " + $rec.versionId + " ct=" + $ct) + $fail++ + } + } catch { + Write-Host ("FAIL " + $rec.versionId + " " + $_.Exception.Message) + $fail++ + } +} +Write-Host ("VERIFY_PREVIEW ok=" + $ok + " fail=" + $fail) diff --git a/deploy/_remote_verify_quote_templates.ps1 b/deploy/_remote_verify_quote_templates.ps1 new file mode 100644 index 0000000..d39a079 --- /dev/null +++ b/deploy/_remote_verify_quote_templates.ps1 @@ -0,0 +1,31 @@ +# 验证报价模板 list / agent 接口(登录免验证码后) +$ErrorActionPreference = 'Continue' +$loginBody = '{"username":"admin","password":"123456","captcha":"","checkKey":""}' +$login = Invoke-RestMethod -Uri 'http://127.0.0.1:8180/jeecgboot/sys/login' -Method POST -Body $loginBody -ContentType 'application/json;charset=UTF-8' +$token = $login.result.token +Write-Host ("login_ok=" + $login.success) +$h = @{ 'X-Access-Token' = $token } + +$raw = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/list?pageNo=1&pageSize=20' -Headers $h -UseBasicParsing +Write-Host '=== list raw ===' +Write-Host $raw.Content.Substring(0, [Math]::Min(1200, $raw.Content.Length)) + +$agent = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/inquiry/quoteTemplate/agent/listEnabled?enabledOnly=true' -Headers $h -UseBasicParsing +Write-Host '=== agent raw ===' +Write-Host $agent.Content.Substring(0, [Math]::Min(800, $agent.Content.Length)) + +# DB count via env already loaded by process; use MYSQL from .env keys used previously +$envLines = Get-Content 'E:\wwwroot\.env' +$user = 'inquiry_robot' +$pw = $null +foreach ($line in $envLines) { + if ($line -match '^MYSQL_PASSWORD=(.+)$') { $pw = $Matches[1].Trim().Trim('"') } + if ($line -match '^YTD_MYSQL_PASSWORD=(.+)$') { $pw = $Matches[1].Trim().Trim('"') } + if ($line -match '^MYSQL_USER=(.+)$') { $user = $Matches[1].Trim().Trim('"') } +} +$mysql = 'C:\yutongda\tools\mysql-8.0.39-winx64\bin\mysql.exe' +Write-Host ("mysql_user=" + $user + " pw_len=" + ($(if ($pw) { $pw.Length } else { 0 }))) +if ($pw) { + & $mysql -h 127.0.0.1 -P 3306 -u $user "--password=$pw" --default-character-set=utf8mb4 inquiry_robot -N -e "SELECT 'tpl',COUNT(*) FROM inq_quote_template; SELECT 'ver',COUNT(*) FROM inq_quote_template_version; SELECT 'enabled',COUNT(*) FROM inq_quote_template WHERE status='ENABLED';" +} +Write-Host 'QUOTE_TEMPLATE_VERIFY_OK' diff --git a/deploy/_remote_wmi_start_ledger.ps1 b/deploy/_remote_wmi_start_ledger.ps1 index 20cacf3..44c41db 100644 --- a/deploy/_remote_wmi_start_ledger.ps1 +++ b/deploy/_remote_wmi_start_ledger.ps1 @@ -1,4 +1,4 @@ -# 持久拉起主账:由 WMI 启动 cmd 循环,SSH 断开后仍存活。禁止动现网 8080。 +# Persistently start inquiry ledger on 8180 via WMI. Never touch prod 8080. $ErrorActionPreference = 'Stop' $Root = 'E:\wwwroot\inquiry-new' $Www = 'E:\wwwroot' @@ -7,6 +7,7 @@ $LogDir = Join-Path $Root 'logs' $CfgDir = Join-Path $Root 'config' $IncomingJar = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar' $IncomingCfg = 'C:\Users\ytd_extdev_0903\incoming\ledger-config.zip' +$IncomingInner = 'C:\Users\ytd_extdev_0903\incoming\_remote_run_ledger_inner.ps1' $Java = 'C:\yutongda\tools\jdk8\bin\java.exe' New-Item -ItemType Directory -Force -Path $Root, $LogDir, $CfgDir, (Join-Path $Www 'ops') | Out-Null @@ -16,40 +17,8 @@ if (-not (Test-Path $Java)) { throw 'java missing' } if (Test-Path $IncomingCfg) { Expand-Archive -Path $IncomingCfg -DestinationPath $CfgDir -Force } - -$inner = @' -$ErrorActionPreference = "Stop" -Get-Content "E:\wwwroot\.env" | ForEach-Object { - $line = $_.Trim() - if (-not $line -or $line.StartsWith("#")) { return } - $i = $line.IndexOf("=") - if ($i -lt 1) { return } - Set-Item -Path ("Env:" + $line.Substring(0, $i).Trim()) -Value $line.Substring($i + 1).Trim().Trim('"') -} -if (-not $env:YTD_REDIS_PASSWORD -and $env:REDIS_URL) { - if ($env:REDIS_URL -match "redis://:([^@]+)@") { $env:YTD_REDIS_PASSWORD = $Matches[1] } - elseif ($env:REDIS_URL -match "redis://[^:]+:([^@]+)@") { $env:YTD_REDIS_PASSWORD = $Matches[1] } -} -if (-not $env:YTD_REDIS_HOST) { $env:YTD_REDIS_HOST = "10.206.0.14" } -if (-not $env:YTD_REDIS_PORT) { $env:YTD_REDIS_PORT = "6379" } -if (-not $env:YTD_REDIS_DB) { $env:YTD_REDIS_DB = "1" } -$env:LEDGER_PORT = "8180" -$Java = "C:\yutongda\tools\jdk8\bin\java.exe" -$Jar = "E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar" -Set-Location "E:\wwwroot\inquiry-new" -$arg = @( - "-jar", $Jar, - "--spring.profiles.active=inquiry", - "--server.port=8180", - "--spring.config.additional-location=optional:file:./config/", - "--cas.prefixUrl=http://127.0.0.1/cas-disabled", - "--jeecg.loginCaptchaEnabled=false" -) -if ($env:YTD_REDIS_USERNAME) { $arg += ("--spring.redis.username=" + $env:YTD_REDIS_USERNAME) } -& $Java @arg -'@ -$innerPath = Join-Path $Www 'ops\run-ledger-inner.ps1' -Set-Content -Path $innerPath -Value $inner -Encoding UTF8 +if (-not (Test-Path $IncomingInner)) { throw "missing $IncomingInner" } +Copy-Item $IncomingInner (Join-Path $Www 'ops\run-ledger-inner.ps1') -Force $cmd = @" @echo off @@ -63,7 +32,6 @@ goto loop $cmdPath = Join-Path $Www 'ops\run-ledger.cmd' Set-Content -Path $cmdPath -Value $cmd -Encoding ASCII -# 只停 inquiry-new 的 java Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object { if ($_.CommandLine -and $_.CommandLine -like '*inquiry-new*jeecg-system-start*') { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue diff --git a/deploy/seed/README.md b/deploy/seed/README.md new file mode 100644 index 0000000..70f47a0 --- /dev/null +++ b/deploy/seed/README.md @@ -0,0 +1,2 @@ +# 报价模板首批导入清单(由 tools/seed_quote_templates.py --dry-run 生成) +# 本目录只存种子/清单,生产真相在主账 inq_quote_template* + 对象存储。 diff --git a/deploy/seed/quote_templates_seed_manifest_v1.json b/deploy/seed/quote_templates_seed_manifest_v1.json new file mode 100644 index 0000000..e7d0e50 --- /dev/null +++ b/deploy/seed/quote_templates_seed_manifest_v1.json @@ -0,0 +1,240 @@ +{ + "schemaVersion": "quote-template-seed-v1", + "contract": "contracts/quote-template-mapping-v1.json", + "count": 11, + "items": [ + { + "fileName": "20260911友通达中越零担报价Z.xlsx", + "relativePath": "海陆空报价单模板/陆运报价模板/20260911友通达中越零担报价Z.xlsx", + "bizType": "陆运", + "category": "双清或中越", + "keywords": [ + "中越" + ], + "mode": "auto", + "suggestedMappingPointCount": 4, + "suggestedFields": { + "customer_name": "中越集拼报价!C3", + "sales_name": "中越集拼报价!C5", + "origin": "中越集拼报价!F5", + "destination": "中越集拼报价!D8" + }, + "parseNote": null + }, + { + "fileName": "29260911海运报价单模板(黄色标记为模板固定内容).xlsx", + "relativePath": "海陆空报价单模板/29260911海运报价单模板(黄色标记为模板固定内容).xlsx", + "bizType": "海运", + "category": "海运报价单模板", + "keywords": [ + "海运", + "整柜", + "FCL" + ], + "mode": "auto", + "suggestedMappingPointCount": 4, + "suggestedFields": { + "pol": "海运询价单!F4", + "pod": "海运询价单!F5", + "valid_until": "海运询价单!B16", + "currency": "海运询价单!C18" + }, + "parseNote": null + }, + { + "fileName": "中港陆运报价模板(黄色标记为模板固定内容).xlsx", + "relativePath": "海陆空报价单模板/陆运报价模板/中港陆运报价模板(黄色标记为模板固定内容).xlsx", + "bizType": "陆运", + "category": "中港", + "keywords": [ + "中港", + "陆运" + ], + "mode": "auto", + "suggestedMappingPointCount": 8, + "suggestedFields": { + "customer_name": "中港陆运报价模板!C3", + "contact_name": "中港陆运报价模板!I3", + "contact_phone": "中港陆运报价模板!I4", + "quote_date": "中港陆运报价模板!I5", + "currency": "中港陆运报价模板!C6", + "quote_no": "中港陆运报价模板!I6", + "origin": "中港陆运报价模板!D8", + "destination": "中港陆运报价模板!E8" + }, + "parseNote": null + }, + { + "fileName": "空运到港报价单模板.xlsx", + "relativePath": "海陆空报价单模板/空运报价单模板/空运到港报价单模板.xlsx", + "bizType": "空运", + "category": "空运到港报价单模板", + "keywords": [ + "空运", + "到港" + ], + "mode": "auto", + "suggestedMappingPointCount": 13, + "suggestedFields": { + "customer_name": "Sheet2!B4", + "quote_no": "Sheet2!G4", + "quote_date": "Sheet2!B5", + "valid_until": "Sheet2!G5", + "pol": "Sheet2!B6", + "pod": "Sheet2!G6", + "commodity": "Sheet2!B8", + "pieces": "Sheet2!B9", + "trade_terms": "Sheet2!G9", + "weight_kg": "Sheet2!B10", + "cargo_ready_date": "Sheet2!G10", + "volume_cbm": "Sheet2!B11", + "currency": "Sheet2!E16" + }, + "parseNote": null + }, + { + "fileName": "空运到门报价单模板.xlsx", + "relativePath": "海陆空报价单模板/空运报价单模板/空运到门报价单模板.xlsx", + "bizType": "空运", + "category": "空运到门报价单模板", + "keywords": [ + "空运", + "到门" + ], + "mode": "auto", + "suggestedMappingPointCount": 13, + "suggestedFields": { + "customer_name": "Sheet2!B4", + "quote_no": "Sheet2!G4", + "quote_date": "Sheet2!B5", + "valid_until": "Sheet2!G5", + "pol": "Sheet2!B6", + "pod": "Sheet2!G6", + "commodity": "Sheet2!B8", + "pieces": "Sheet2!B9", + "trade_terms": "Sheet2!G9", + "weight_kg": "Sheet2!B10", + "cargo_ready_date": "Sheet2!G10", + "volume_cbm": "Sheet2!B11", + "currency": "Sheet2!E16" + }, + "parseNote": null + }, + { + "fileName": "陆运凭祥保税仓&普仓仓储报价单(人工报价提供模板).xlsx", + "relativePath": "海陆空报价单模板/陆运报价模板/陆运凭祥保税仓&普仓仓储报价单(人工报价提供模板).xlsx", + "bizType": "陆运", + "category": "凭祥保税仓&普仓仓储", + "keywords": [ + "仓储", + "凭祥" + ], + "mode": "manual", + "suggestedMappingPointCount": 5, + "suggestedFields": { + "customer_name": "凭祥保税仓&普仓仓储报价单!B3", + "valid_until": "凭祥保税仓&普仓仓储报价单!F3", + "contact_name": "凭祥保税仓&普仓仓储报价单!B4", + "quote_no": "凭祥保税仓&普仓仓储报价单!F4", + "currency": "凭祥保税仓&普仓仓储报价单!B5" + }, + "parseNote": null + }, + { + "fileName": "陆运双清专用报价模板(人工报价).xlsx", + "relativePath": "海陆空报价单模板/陆运报价模板/陆运双清专用报价模板(人工报价).xlsx", + "bizType": "陆运", + "category": "双清或中越", + "keywords": [ + "双清" + ], + "mode": "manual", + "suggestedMappingPointCount": 3, + "suggestedFields": { + "quote_no": "双清专用报价模板!B6", + "origin": "双清专用报价模板!D8", + "destination": "双清专用报价模板!E8" + }, + "parseNote": null + }, + { + "fileName": "陆运国内运输拼车报价模板(黄色标记为模板固定内容).xlsx", + "relativePath": "海陆空报价单模板/陆运报价模板/陆运国内运输拼车报价模板(黄色标记为模板固定内容).xlsx", + "bizType": "陆运", + "category": "国内拼车", + "keywords": [ + "拼车", + "零担" + ], + "mode": "auto", + "suggestedMappingPointCount": 8, + "suggestedFields": { + "customer_name": "国内运输拼车报价模板!C3", + "contact_name": "国内运输拼车报价模板!C4", + "contact_phone": "国内运输拼车报价模板!I4", + "quote_date": "国内运输拼车报价模板!I5", + "currency": "国内运输拼车报价模板!C6", + "quote_no": "国内运输拼车报价模板!I6", + "origin": "国内运输拼车报价模板!D8", + "destination": "国内运输拼车报价模板!E8" + }, + "parseNote": null + }, + { + "fileName": "陆运国内运输整车报价模板(黄色标记为模板固定内容).xlsx", + "relativePath": "海陆空报价单模板/陆运报价模板/陆运国内运输整车报价模板(黄色标记为模板固定内容).xlsx", + "bizType": "陆运", + "category": "国内整车", + "keywords": [ + "整车", + "国内" + ], + "mode": "auto", + "suggestedMappingPointCount": 8, + "suggestedFields": { + "customer_name": "国内运输整车报价模板!C3", + "contact_name": "国内运输整车报价模板!C4", + "contact_phone": "国内运输整车报价模板!I4", + "quote_date": "国内运输整车报价模板!I5", + "currency": "国内运输整车报价模板!C6", + "quote_no": "国内运输整车报价模板!I6", + "origin": "国内运输整车报价模板!D8", + "destination": "国内运输整车报价模板!E8" + }, + "parseNote": null + }, + { + "fileName": "陆运跨境整车报价模板(黄色标记为模板固定内容).xlsx", + "relativePath": "海陆空报价单模板/陆运报价模板/陆运跨境整车报价模板(黄色标记为模板固定内容).xlsx", + "bizType": "陆运", + "category": "跨境整车", + "keywords": [ + "跨境", + "整车" + ], + "mode": "auto", + "suggestedMappingPointCount": 1, + "suggestedFields": { + "destination": "跨境整车报价模板!D7" + }, + "parseNote": null + }, + { + "fileName": "陆运跨境集拼报价模板(黄色标记为模板固定内容).xlsx", + "relativePath": "海陆空报价单模板/陆运报价模板/陆运跨境集拼报价模板(黄色标记为模板固定内容).xlsx", + "bizType": "陆运", + "category": "跨境集拼", + "keywords": [ + "集拼", + "跨境" + ], + "mode": "auto", + "suggestedMappingPointCount": 2, + "suggestedFields": { + "origin": "跨境集拼报价模板!C4", + "destination": "跨境集拼报价模板!D4" + }, + "parseNote": null + } + ] +} \ No newline at end of file diff --git a/deploy/sql/README-inquiry-admin.md b/deploy/sql/README-inquiry-admin.md index d984b27..795ddb4 100644 --- a/deploy/sql/README-inquiry-admin.md +++ b/deploy/sql/README-inquiry-admin.md @@ -8,8 +8,16 @@ - 若登录报缺 `sys_user_tenant`:再跑 `restore_jeecg_tenant_tables_v1.sql` - 若继续缺 `sys_depart` 等系统表:从 `jeecg-boot/db/jeecgboot-mysql-5.7.sql` 批量 `CREATE TABLE IF NOT EXISTS` 补齐(本机已用脚本做过) 2. 业务表:`inquiry_admin_v1.sql` -3. 菜单权限 + 演示数据:优先 `inquiry_admin_menu_seed_v1.sql`(含 `url`/`component`) +3. 报价模板表:`inquiry_quote_template_v1.sql`(逻辑头 + 版本/映射) +4. 菜单权限 + 演示数据:优先 `inquiry_admin_menu_seed_v1.sql`(含 `url`/`component`) + +首批模板素材目录:`海陆空报价单模板/`(非生产真相)。导入辅助: + +```text +python tools/seed_quote_templates.py --dry-run +python tools/seed_quote_templates.py --upload-url http://127.0.0.1:8180/jeecgboot --token +``` 本机验收账号:`admin` / `123456`(验证码按页面填写)。 -验收:Jeecg 登录 → 侧栏「询价运营」→ 看板/工单/员工/报价模板/回复语可调 `/inquiry/*`;账号/角色在「询价系统配置」进系统页。报价模板可先 mock。 +验收:Jeecg 登录 → 侧栏「询价运营」→ 看板/工单/员工/报价模板/回复语可调 `/inquiry/*`;账号/角色在「询价系统配置」进系统页。报价模板走 `/inquiry/quoteTemplate/*`(黄区+映射点,非假占位符)。 diff --git a/deploy/sql/inquiry_admin_v1.sql b/deploy/sql/inquiry_admin_v1.sql index feb1d39..7339fe4 100644 --- a/deploy/sql/inquiry_admin_v1.sql +++ b/deploy/sql/inquiry_admin_v1.sql @@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS `inq_ticket` ( `demand_title` varchar(500) DEFAULT NULL COMMENT '需求标题(路线摘要)', `demand_detail` text COMMENT '需求详情文本', `goods_name` varchar(200) DEFAULT NULL COMMENT '品名', + `quote_date` date DEFAULT NULL COMMENT '报价日期(销售询价/填表用,不是创建时间)', `pieces` int DEFAULT NULL COMMENT '件数', `weight_kg` decimal(18,3) DEFAULT NULL COMMENT '重量KG', `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积CBM', diff --git a/deploy/sql/inquiry_quote_template_v1.sql b/deploy/sql/inquiry_quote_template_v1.sql new file mode 100644 index 0000000..85d8875 --- /dev/null +++ b/deploy/sql/inquiry_quote_template_v1.sql @@ -0,0 +1,51 @@ +-- ============================================================================= +-- 友通达新系统 · 报价模板(逻辑模板 + 版本 + 映射) +-- 库:inquiry_robot;禁止对现网 jeecg-boot 执行。 +-- 规则:黄色固定区;mapping_json 存本版字段→单元格;改版新建 version。 +-- ============================================================================= + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `inq_quote_template` ( + `id` varchar(32) NOT NULL COMMENT '逻辑模板主键', + `name` varchar(200) NOT NULL COMMENT '模板名称', + `biz_type` varchar(16) NOT NULL COMMENT '海运/空运/陆运', + `category` varchar(100) NOT NULL COMMENT '分类(与后台筛选项一致)', + `keywords_json` text COMMENT '匹配关键词 JSON 数组', + `mode` varchar(16) NOT NULL DEFAULT 'auto' COMMENT 'auto=可自动生成;manual=仅下载人工填', + `status` varchar(16) NOT NULL DEFAULT 'disabled' COMMENT 'enabled/disabled', + `is_default` tinyint(1) NOT NULL DEFAULT 0 COMMENT '同业务默认模板 0/1', + `current_version` int NOT NULL DEFAULT 0 COMMENT '当前启用版本号,0=尚未启用版本', + `use_count` int NOT NULL DEFAULT 0 COMMENT '累计使用次数', + `create_by` varchar(50) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(50) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_inq_qt_biz_status` (`biz_type`,`status`), + KEY `idx_inq_qt_category` (`category`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报价模板逻辑头'; + +CREATE TABLE IF NOT EXISTS `inq_quote_template_version` ( + `id` varchar(32) NOT NULL COMMENT '版本主键', + `template_id` varchar(32) NOT NULL COMMENT '逻辑模板 id', + `version_no` int NOT NULL COMMENT '从 1 递增', + `file_name` varchar(255) NOT NULL COMMENT '原始文件名', + `format` varchar(16) NOT NULL DEFAULT 'excel' COMMENT 'excel/pdf', + `object_key` varchar(500) NOT NULL COMMENT '对象存储或本地上传相对路径', + `sha256` varchar(64) DEFAULT NULL COMMENT '文件摘要', + `file_size` bigint DEFAULT NULL, + `parse_status` varchar(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/parsed/failed/mapping_incomplete', + `mapping_point_count` int NOT NULL DEFAULT 0 COMMENT '已映射字段点数(不含未映射候选)', + `mapping_json` longtext COMMENT '本版填表计划 JSON(见 contracts/quote-template-mapping-v1.json)', + `candidates_json` longtext COMMENT '解析出的可填候选(标签/单元格)', + `parse_message` varchar(1000) DEFAULT NULL COMMENT '解析说明或失败原因', + `enabled` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否为本逻辑模板当前启用版本', + `create_by` varchar(50) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(50) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inq_qt_ver` (`template_id`,`version_no`), + KEY `idx_inq_qt_ver_enabled` (`template_id`,`enabled`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报价模板版本(版式字节 + 映射)'; diff --git a/deploy/sql/inquiry_ticket_quote_date_v1.sql b/deploy/sql/inquiry_ticket_quote_date_v1.sql new file mode 100644 index 0000000..0a401f3 --- /dev/null +++ b/deploy/sql/inquiry_ticket_quote_date_v1.sql @@ -0,0 +1,26 @@ +-- ============================================================================= +-- 询价工单补列:quote_date(报价日期) +-- 只对 inquiry_robot 执行;禁止对现网 jeecg-boot 执行。 +-- 可重复执行:已有列则跳过。 +-- ============================================================================= + +SET NAMES utf8mb4; + +SET @db := DATABASE(); +SET @exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 'inq_ticket' + AND COLUMN_NAME = 'quote_date' +); + +SET @ddl := IF( + @exists = 0, + 'ALTER TABLE `inq_ticket` ADD COLUMN `quote_date` date DEFAULT NULL COMMENT ''报价日期(销售询价/填表用,不是创建时间)'' AFTER `goods_name`', + 'SELECT ''quote_date already exists'' AS info' +); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/docs/2026-09-12-jeecg-lowcode-ledger-decision.md b/docs/2026-09-12-jeecg-lowcode-ledger-decision.md index 982879a..dc4c5e6 100644 --- a/docs/2026-09-12-jeecg-lowcode-ledger-decision.md +++ b/docs/2026-09-12-jeecg-lowcode-ledger-decision.md @@ -2,7 +2,7 @@ 决策人确认选项 **2**:允许 Online / cgform / 代码生成器;新系统主账以 **JeecgBoot** 为壳,不再坚持「独立 Spring Boot 手写主账、禁止低代码」。 -对照旧现网:JeecgBoot 3.5.3 + inquiry 模块 + 系统 `/sys/*` + Online 能力;库改为新系统 `inquiry_robot`(禁止写现网 `jeecg-boot`)。 +正式方式:JeecgBoot 3.5.3 + inquiry 模块 + 系统 `/sys/*` + Online 能力;库用新系统 `inquiry_robot`(禁止写现网 `jeecg-boot`)。 ## 目标态 diff --git a/inquiry-agent/agent/foundation_selftest.py b/inquiry-agent/agent/foundation_selftest.py index 45cda60..3e312f9 100644 --- a/inquiry-agent/agent/foundation_selftest.py +++ b/inquiry-agent/agent/foundation_selftest.py @@ -238,9 +238,15 @@ def main() -> int: from agent.quote_templates import list_templates_from_ledger lt = list_templates_from_ledger(LedgerClient.from_settings(get_settings())) - assert lt.status_code == 501 - assert lt.error and "list_quote_templates" in lt.error - + # 主账可达时返回 2xx;不可达时 status_code=0 + error,不再假装 501 + assert isinstance(lt.status_code, int) + assert lt.ok or lt.error or lt.data is not None + logger.info( + "OK: list_quote_templates status=%s ok=%s err=%s", + lt.status_code, + lt.ok, + (lt.error or "")[:80], + ) vf = validate_required_fields_shell(facts={}, require_formal=True) assert vf.get("implemented") is False diff --git a/inquiry-agent/agent/jobs/__init__.py b/inquiry-agent/agent/jobs/__init__.py index d21d451..829ae1b 100644 --- a/inquiry-agent/agent/jobs/__init__.py +++ b/inquiry-agent/agent/jobs/__init__.py @@ -18,6 +18,7 @@ from agent.graph.runtime import GraphRuntime from agent.jobs.archive import archive_stream, process_archive_stub from agent.jobs.libreoffice import LibreOfficeConverter from agent.jobs.pdf_job import pdf_stream, process_pdf_job +from agent.jobs.quote_fill import fill_quote_workbook from agent.jobs.recognition import process_recognition_stub from agent.jobs.wake_consumer import claim_and_apply_wake, wake_stream from agent.llm import LlmGateway, LlmMode diff --git a/inquiry-agent/agent/jobs/pdf_job.py b/inquiry-agent/agent/jobs/pdf_job.py index 429e930..f9ca3c8 100644 --- a/inquiry-agent/agent/jobs/pdf_job.py +++ b/inquiry-agent/agent/jobs/pdf_job.py @@ -12,6 +12,7 @@ from dataclasses import dataclass from typing import Any, Optional from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult +from agent.jobs.quote_fill import fill_then_convert_pdf from agent.redis_coord import get_redis_runtime, try_acquire_idempotency from agent.redis_coord.keys import STREAM_PDF from agent.redis_coord.runtime import RedisRuntime @@ -71,10 +72,32 @@ def process_pdf_job( """ 处理一条 PDF 任务(调用方必须已占用 libreoffice 槽)。 - 缺 xlsx_path 或本机无 soffice 时返回 ok=False,不抛崩 Worker。 + 若 payload 含 template_xlsx + mapping_json + facts,先 quote_fill 再转 PDF。 + 缺路径或本机无 soffice 时返回 ok=False,不抛崩 Worker。 """ - path = str(data.get("xlsx_path") or "") conv = converter or LibreOfficeConverter.from_settings() + template_xlsx = str(data.get("template_xlsx") or "") + mapping_json = data.get("mapping_json") + facts = data.get("facts") + work_dir = str(data.get("work_dir") or "") + if template_xlsx and mapping_json is not None and isinstance(facts, dict): + from pathlib import Path + import tempfile + + wd = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="quote_fill_")) + fill, pdf = fill_then_convert_pdf( + source_xlsx=template_xlsx, + work_dir=wd, + mapping=mapping_json if not isinstance(mapping_json, str) else mapping_json, + facts=facts, + converter=conv, + ) + if not fill.ok: + return LibreOfficeResult(ok=False, error=f"fill_failed:{fill.error}") + assert pdf is not None + return pdf + + path = str(data.get("xlsx_path") or "") if not path: return LibreOfficeResult(ok=False, error="missing_xlsx_path") return conv.convert_xlsx_to_pdf(path) diff --git a/inquiry-agent/agent/jobs/quote_fill.py b/inquiry-agent/agent/jobs/quote_fill.py new file mode 100644 index 0000000..d863673 --- /dev/null +++ b/inquiry-agent/agent/jobs/quote_fill.py @@ -0,0 +1,215 @@ +""" +按主账 mappingJson 向报价 Excel 写入已冻结事实。 + +本文件职责:复制模板 → 跳过黄区 → 写 fields / fee_rows → 产出工作副本路径。 +调用方:Worker LibreOffice 槽之前;禁止在 HTTP 回调线程执行。 +禁止:发明价格;直连 TMS;把本模块当六态 Owner。 +""" + +from __future__ import annotations + +import json +import logging +import re +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Optional + +logger = logging.getLogger(__name__) + +# 黄区近似 RGB(与 Java QuoteTemplateExcelSupport 对齐) +_YELLOW_RGB = { + "FFFFFF00", + "FFFF00", + "FFFFEB9C", + "FFFFC000", + "FFFFFF99", + "FFFFCC00", + "FFFFD966", + "FFFFF2CC", + "FFFFE699", +} + + +@dataclass(frozen=True) +class QuoteFillResult: + """一次填表结果。""" + + ok: bool + output_xlsx: str = "" + error: str = "" + fields_written: int = 0 + + +def _col_letter_to_index(letter: str) -> int: + letter = (letter or "").strip().upper() + n = 0 + for ch in letter: + if not ("A" <= ch <= "Z"): + continue + n = n * 26 + (ord(ch) - ord("A") + 1) + return max(n - 1, 0) + + +def _parse_cell_ref(cell_ref: str) -> tuple[str, int, int]: + """ + 解析 Sheet!A1 → (sheet, row0, col0)。 + """ + sheet = "Sheet1" + addr = cell_ref + if "!" in cell_ref: + sheet, addr = cell_ref.split("!", 1) + m = re.match(r"^\$?([A-Za-z]+)\$?(\d+)$", addr.strip()) + if not m: + raise ValueError(f"非法单元格引用: {cell_ref}") + col = _col_letter_to_index(m.group(1)) + row = int(m.group(2)) - 1 + return sheet, row, col + + +def fill_quote_workbook( + *, + source_xlsx: str | Path, + output_xlsx: str | Path, + mapping: Mapping[str, Any] | str, + facts: Mapping[str, Any], +) -> QuoteFillResult: + """ + 按 mapping 写入冻结事实。 + + 参数: + source_xlsx:主账版本对象落地的原模板 + output_xlsx:工作副本输出路径 + mapping:mappingJson 对象或 JSON 字符串 + facts:已冻结字段(含可选 fee_rows 列表) + 副作用:写磁盘 xlsx;不调 LibreOffice、不打主账。 + 线程:必须在 Worker 槽内调用。 + """ + try: + from openpyxl import load_workbook + except ImportError: + return QuoteFillResult(ok=False, error="openpyxl_not_installed") + + src = Path(source_xlsx) + out = Path(output_xlsx) + if not src.is_file(): + return QuoteFillResult(ok=False, error="source_not_found") + + if isinstance(mapping, str): + try: + mapping_obj = json.loads(mapping) + except json.JSONDecodeError as exc: + return QuoteFillResult(ok=False, error=f"mapping_json_invalid:{exc}") + else: + mapping_obj = dict(mapping) + + yellow = set(mapping_obj.get("fixedYellowCells") or []) + fields = mapping_obj.get("fields") or {} + fee_rows_cfg = mapping_obj.get("fee_rows") + + out.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, out) + + try: + wb = load_workbook(out) + except Exception as exc: + return QuoteFillResult(ok=False, error=f"open_failed:{exc}") + + written = 0 + try: + for code, cell_ref in fields.items(): + if not cell_ref or cell_ref in yellow: + continue + if code == "fee_rows": + continue + val = facts.get(code) + if val is None: + _write_cell(wb, str(cell_ref), "") + continue + _write_cell(wb, str(cell_ref), str(val)) + written += 1 + + if fee_rows_cfg and facts.get("fee_rows"): + written += _write_fee_rows(wb, fee_rows_cfg, facts.get("fee_rows"), yellow) + + wb.save(out) + except Exception as exc: + logger.exception("quote_fill 写入失败") + return QuoteFillResult(ok=False, error=f"write_failed:{exc}") + finally: + wb.close() + + logger.info("quote_fill ok fields=%s out=%s", written, out) + return QuoteFillResult(ok=True, output_xlsx=str(out), fields_written=written) + + +def _write_cell(wb, cell_ref: str, value: str) -> None: + sheet_name, row, col = _parse_cell_ref(cell_ref) + ws = wb[sheet_name] if sheet_name in wb.sheetnames else wb.worksheets[0] + ws.cell(row=row + 1, column=col + 1, value=value) + + +def _write_fee_rows(wb, fee_cfg: Mapping[str, Any], rows: Any, yellow: set[str]) -> int: + if not isinstance(rows, list) or not rows: + return 0 + anchor = fee_cfg.get("anchor") + columns = fee_cfg.get("columns") or {} + if not anchor: + return 0 + sheet_name, start_row, _ = _parse_cell_ref(str(anchor)) + ws = wb[sheet_name] if sheet_name in wb.sheetnames else wb.worksheets[0] + n = 0 + for i, row_obj in enumerate(rows): + if not isinstance(row_obj, Mapping): + continue + r = start_row + i + for key in ("name", "amount", "remark"): + col_letter = columns.get(key) + if not col_letter: + continue + col = _col_letter_to_index(str(col_letter)) + ref = f"{ws.title}!{ _index_to_col(col) }{r + 1}" + if ref in yellow: + continue + ws.cell(row=r + 1, column=col + 1, value=str(row_obj.get(key) or "")) + n += 1 + return n + + +def _index_to_col(idx: int) -> str: + n = idx + 1 + s = "" + while n: + n, rem = divmod(n - 1, 26) + s = chr(65 + rem) + s + return s + + +def fill_then_convert_pdf( + *, + source_xlsx: str | Path, + work_dir: str | Path, + mapping: Mapping[str, Any] | str, + facts: Mapping[str, Any], + converter: Any, +) -> tuple[QuoteFillResult, Optional[Any]]: + """ + 填表后在调用方已占用的 LibreOffice 单槽内转 PDF。 + + converter:LibreOfficeConverter 实例;本函数不抢槽。 + 返回:(FillResult, LibreOfficeResult|None) + """ + work = Path(work_dir) + work.mkdir(parents=True, exist_ok=True) + out_xlsx = work / "filled.xlsx" + fill = fill_quote_workbook( + source_xlsx=source_xlsx, + output_xlsx=out_xlsx, + mapping=mapping, + facts=facts, + ) + if not fill.ok: + return fill, None + pdf_result = converter.convert_xlsx_to_pdf(str(out_xlsx)) + return fill, pdf_result diff --git a/inquiry-agent/agent/ledger/__init__.py b/inquiry-agent/agent/ledger/__init__.py index 53bbd86..36bc9f9 100644 --- a/inquiry-agent/agent/ledger/__init__.py +++ b/inquiry-agent/agent/ledger/__init__.py @@ -112,6 +112,7 @@ class LedgerClient: path: str, *, json_body: Optional[dict[str, Any]] = None, + params: Optional[dict[str, Any]] = None, idempotency_key: str = "", ) -> LedgerResponse: """ @@ -128,6 +129,7 @@ class LedgerClient: method.upper(), path, json=json_body, + params=params, headers=headers, ) data: Any diff --git a/inquiry-agent/agent/ledger/api_shell.py b/inquiry-agent/agent/ledger/api_shell.py index 758d806..8ffdfc7 100644 --- a/inquiry-agent/agent/ledger/api_shell.py +++ b/inquiry-agent/agent/ledger/api_shell.py @@ -92,12 +92,16 @@ class LedgerApiShell: def list_quote_templates(self, *, enabled_only: bool = True) -> LedgerResponse: """ - 报价模板列表壳。 + 报价模板列表。 - 真相:后台上传/启用;智能体只读已启用项,再经对象存储取字节。 + 真相:后台上传/启用;智能体只读已启用项,再经对象存储/本地路径取字节。 """ - _ = enabled_only - return self._nyi("list_quote_templates") + path = "/inquiry/quoteTemplate/agent/listEnabled" + return self.client.request_json( + "GET", + path, + params={"enabledOnly": str(bool(enabled_only)).lower()}, + ) def wake_agent_ack( self, diff --git a/inquiry-agent/agent/quote_templates/__init__.py b/inquiry-agent/agent/quote_templates/__init__.py index 4ea65ce..eb7f766 100644 --- a/inquiry-agent/agent/quote_templates/__init__.py +++ b/inquiry-agent/agent/quote_templates/__init__.py @@ -1,10 +1,9 @@ """ -报价模板访问壳。 +报价模板访问。 -真相在主账:后台上传/启用/停用 → 对象存储存字节 → 主账存元数据。 -智能体生成报价前经 ledger 拉取已启用模板指针,再从对象存储取字节。 +真相在主账:后台上传/启用/停用 → 对象存储/本地上传路径 → 主账元数据 + mappingJson。 +智能体生成前经 ledger 拉取已启用模板,再解析本地/下载字节后交 quote_fill。 -本文件职责:声明「从主账列模板」的空 API;可选本地缓存目录仅联调。 禁止:把仓库 resources 当生产模板真相;禁止在本包发明价格或改 quoteVersion。 """ @@ -16,6 +15,7 @@ from pathlib import Path from typing import Any, Optional from agent.ledger import LedgerClient, LedgerResponse +from agent.ledger.api_shell import LedgerApiShell logger = logging.getLogger(__name__) @@ -29,6 +29,12 @@ class TemplateEntry: object_key: str = "" enabled: bool = False source: str = "ledger" + template_id: str = "" + version_no: int = 0 + version_id: str = "" + mapping_json: str = "" + biz_type: str = "" + category: str = "" def local_cache_dir() -> Path: @@ -43,20 +49,53 @@ def local_cache_dir() -> Path: def list_templates_from_ledger( client: Optional[LedgerClient] = None, + *, + enabled_only: bool = True, ) -> LedgerResponse: """ - 向主账查询已启用报价模板列表(壳)。 + 向主账查询已启用报价模板列表。 - 正式路径以主账 OpenAPI 为准;当前返回 not_implemented,不假装本地文件即生产模板。 + 正式路径:GET /inquiry/quoteTemplate/agent/listEnabled """ - c = client or LedgerClient.from_settings() - logger.info("quote_templates.list_from_ledger(壳,未打真实 API)") - return LedgerResponse( - ok=False, - status_code=501, - error="not_implemented:list_quote_templates", - data={"hint": "admin_upload_via_ledger"}, - ) + shell = LedgerApiShell(client=client) + return shell.list_quote_templates(enabled_only=enabled_only) + + +def entries_from_ledger_response(resp: LedgerResponse) -> list[TemplateEntry]: + """把主账 listEnabled 结果转成 TemplateEntry。""" + if not resp.ok or resp.data is None: + return [] + raw = resp.data + # Jeecg Result 可能包在 result / data + if isinstance(raw, dict): + items = raw.get("result") or raw.get("data") or raw.get("records") or [] + if isinstance(raw.get("result"), list): + items = raw["result"] + elif isinstance(raw, list): + items = raw + else: + items = [] + out: list[TemplateEntry] = [] + for it in items: + if not isinstance(it, dict): + continue + tid = str(it.get("templateId") or it.get("id") or "") + out.append( + TemplateEntry( + code=tid or str(it.get("name") or ""), + filename=str(it.get("fileName") or ""), + object_key=str(it.get("objectKey") or ""), + enabled=True, + source="ledger", + template_id=tid, + version_no=int(it.get("versionNo") or 0), + version_id=str(it.get("versionId") or ""), + mapping_json=str(it.get("mappingJson") or ""), + biz_type=str(it.get("bizType") or ""), + category=str(it.get("category") or ""), + ) + ) + return out def list_local_cache_files() -> list[TemplateEntry]: @@ -91,10 +130,12 @@ def list_local_cache_files() -> list[TemplateEntry]: def list_template_registry() -> list[TemplateEntry]: """ - 兼容旧名:框架阶段返回本地缓存列表(可空)。 - - 生产应改用 list_templates_from_ledger + 对象存储下载。 + 优先主账;失败则回落本地缓存(可空)。 """ + resp = list_templates_from_ledger(enabled_only=True) + entries = entries_from_ledger_response(resp) + if entries: + return entries return list_local_cache_files() @@ -107,6 +148,34 @@ def registry_as_dicts() -> list[dict[str, Any]]: "object_key": e.object_key, "enabled": e.enabled, "source": e.source, + "template_id": e.template_id, + "version_no": e.version_no, + "biz_type": e.biz_type, + "category": e.category, } for e in list_template_registry() ] + + +def pick_template_for_biz( + *, + biz_type: str, + keywords: Optional[list[str]] = None, + entries: Optional[list[TemplateEntry]] = None, +) -> Optional[TemplateEntry]: + """ + 按业务线 + 关键词挑选模板(优先 is_default 由主账排序保证)。 + + 关键词命中任一即优;否则同 biz 第一条。 + """ + items = entries if entries is not None else list_template_registry() + same = [e for e in items if (e.biz_type == biz_type or not e.biz_type)] + if not same: + same = list(items) + if keywords: + kw_l = [k.lower() for k in keywords if k] + for e in same: + blob = f"{e.filename} {e.category} {e.code}".lower() + if any(k in blob for k in kw_l): + return e + return same[0] if same else None diff --git a/inquiry-agent/requirements.txt b/inquiry-agent/requirements.txt index 45c7ab6..3a48ddd 100644 --- a/inquiry-agent/requirements.txt +++ b/inquiry-agent/requirements.txt @@ -14,3 +14,5 @@ psycopg[binary]>=3.1,<4.0 psycopg-pool>=3.2,<4.0 # Redis short-lived coordination only redis>=5.0,<6.0 +# 报价模板填表(按 mappingJson 写 xlsx) +openpyxl>=3.1,<4.0 diff --git a/inquiry-api/YTD-README.md b/inquiry-api/YTD-README.md index d8196c1..21627e9 100644 --- a/inquiry-api/YTD-README.md +++ b/inquiry-api/YTD-README.md @@ -18,12 +18,15 @@ ## 询价业务表与菜单 -从零实现(不拷旧仓源码)。导入库 `inquiry_robot` 后执行: +从零实现。导入库 `inquiry_robot` 后执行: 1. [`deploy/sql/inquiry_admin_v1.sql`](../deploy/sql/inquiry_admin_v1.sql) — 业务表 `inq_*` -2. [`deploy/sql/inquiry_admin_menu_seed_v1.sql`](../deploy/sql/inquiry_admin_menu_seed_v1.sql) — 菜单/权限/演示数据(**无报价模板**) +2. [`deploy/sql/inquiry_quote_template_v1.sql`](../deploy/sql/inquiry_quote_template_v1.sql) — 报价模板逻辑头 + 版本/映射 +3. [`deploy/sql/inquiry_admin_menu_seed_v1.sql`](../deploy/sql/inquiry_admin_menu_seed_v1.sql) — 菜单/权限/演示数据 -前端页面在 `inquiry-backend/src/views/inquiry/`:分析看板、询价工单、员工管理、报价模板、机器人回复语、账号/角色。 +前端页面在 `inquiry-backend/src/views/inquiry/`:分析看板、询价工单、员工管理、报价模板(对接 `/inquiry/quoteTemplate`)、机器人回复语、账号/角色。 + +首批模板 seed:`python tools/seed_quote_templates.py --dry-run`(清单 `deploy/seed/`)。 ## 与现网隔离 diff --git a/inquiry-api/jeecg-module-inquiry/pom.xml b/inquiry-api/jeecg-module-inquiry/pom.xml index 7676649..8c340b4 100644 --- a/inquiry-api/jeecg-module-inquiry/pom.xml +++ b/inquiry-api/jeecg-module-inquiry/pom.xml @@ -12,8 +12,8 @@ jeecg-module-inquiry jeecg-module-inquiry @@ -28,5 +28,11 @@ org.springframework.boot spring-boot-starter-validation + + + org.apache.poi + poi-ooxml + 4.1.2 + diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java new file mode 100644 index 0000000..e374ab7 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java @@ -0,0 +1,240 @@ +package org.jeecg.modules.inquiry.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.aspect.annotation.AutoLog; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate; +import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion; +import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URLEncoder; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 报价模板管理 API。 + *

+ * 路径 /inquiry/quoteTemplate;权限 inquiry:template:*。 + * 智能体只读列表走 /agent/listEnabled(服务账号 token)。 + * 禁止:在本 Controller 调 LibreOffice / LLM。 + */ +@Slf4j +@Api(tags = "询价-报价模板") +@RestController +@RequestMapping("/inquiry/quoteTemplate") +public class InquiryQuoteTemplateController { + + @Autowired + private IInquiryQuoteTemplateService quoteTemplateService; + + @AutoLog(value = "报价模板-分页") + @ApiOperation("分页列表(含当前版本摘要)") + @RequiresPermissions("inquiry:template:view") + @GetMapping("/list") + public Result>> list(@RequestParam(required = false) String keyword, + @RequestParam(required = false) String bizType, + @RequestParam(required = false) String category, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") Integer pageNo, + @RequestParam(defaultValue = "20") Integer pageSize) { + return Result.OK(quoteTemplateService.pageWithVersion(keyword, bizType, category, status, pageNo, pageSize)); + } + + @AutoLog(value = "报价模板-上传版本") + @ApiOperation("上传新模板或追加新版本(解析黄区并自动映射)") + @RequiresPermissions("inquiry:template:upload") + @PostMapping("/upload") + public Result> upload(@RequestParam(required = false) String templateId, + @RequestParam(required = false) String name, + @RequestParam(required = false) String bizType, + @RequestParam(required = false) String category, + @RequestParam(required = false) String keywordsJson, + @RequestParam(required = false, defaultValue = "auto") String mode, + @RequestParam(required = false, defaultValue = "false") Boolean isDefault, + @RequestParam("file") MultipartFile file) { + Map row = quoteTemplateService.uploadVersion( + templateId, name, bizType, category, keywordsJson, mode, + Boolean.TRUE.equals(isDefault), file, currentUser()); + return Result.OK(row); + } + + @ApiOperation("版本详情(含 mappingJson)") + @RequiresPermissions("inquiry:template:view") + @GetMapping("/version") + public Result version(@RequestParam String versionId) { + return Result.OK(quoteTemplateService.getVersion(versionId)); + } + + @ApiOperation("某模板全部版本") + @RequiresPermissions("inquiry:template:view") + @GetMapping("/versions") + public Result> versions(@RequestParam String templateId) { + return Result.OK(quoteTemplateService.listVersions(templateId)); + } + + @AutoLog(value = "报价模板-保存映射") + @ApiOperation("确认/修订本版 mappingJson") + @RequiresPermissions("inquiry:template:edit") + @PutMapping("/mapping") + public Result saveMapping(@RequestBody Map body) { + String versionId = body.get("versionId"); + String mappingJson = body.get("mappingJson"); + if (StringUtils.isBlank(versionId) || StringUtils.isBlank(mappingJson)) { + return Result.error("versionId 与 mappingJson 必填"); + } + quoteTemplateService.saveMapping(versionId, mappingJson, currentUser()); + return Result.OK("映射已保存"); + } + + @AutoLog(value = "报价模板-启用") + @ApiOperation("启用指定版本(auto 模式须映射完整)") + @RequiresPermissions("inquiry:template:toggle") + @PostMapping("/enable") + public Result enable(@RequestParam String templateId, @RequestParam String versionId) { + quoteTemplateService.enableTemplate(templateId, versionId, currentUser()); + return Result.OK("已启用"); + } + + @AutoLog(value = "报价模板-停用") + @RequiresPermissions("inquiry:template:toggle") + @PostMapping("/disable") + public Result disable(@RequestParam String templateId) { + quoteTemplateService.disableTemplate(templateId, currentUser()); + return Result.OK("已停用"); + } + + @AutoLog(value = "报价模板-设默认") + @RequiresPermissions("inquiry:template:edit") + @PostMapping("/setDefault") + public Result setDefault(@RequestParam String templateId, + @RequestParam(defaultValue = "true") Boolean isDefault) { + quoteTemplateService.setDefault(templateId, Boolean.TRUE.equals(isDefault), currentUser()); + return Result.OK("已更新默认模板"); + } + + @ApiOperation("逻辑头详情") + @RequiresPermissions("inquiry:template:view") + @GetMapping("/queryById") + public Result queryById(@RequestParam String id) { + return Result.OK(quoteTemplateService.getById(id)); + } + + @ApiOperation("编辑逻辑头(名称/分类/关键词/模式,不换文件)") + @RequiresPermissions("inquiry:template:edit") + @PutMapping("/editMeta") + public Result editMeta(@RequestBody InquiryQuoteTemplate body) { + if (StringUtils.isBlank(body.getId())) { + return Result.error("id 不能为空"); + } + InquiryQuoteTemplate db = quoteTemplateService.getById(body.getId()); + if (db == null) { + return Result.error("模板不存在"); + } + if (StringUtils.isNotBlank(body.getName())) { + db.setName(body.getName()); + } + if (StringUtils.isNotBlank(body.getBizType())) { + db.setBizType(body.getBizType()); + } + if (StringUtils.isNotBlank(body.getCategory())) { + db.setCategory(body.getCategory()); + } + if (StringUtils.isNotBlank(body.getKeywordsJson())) { + db.setKeywordsJson(body.getKeywordsJson()); + } + if (StringUtils.isNotBlank(body.getMode())) { + db.setMode(body.getMode()); + } + db.setUpdateBy(currentUser()); + db.setUpdateTime(new java.util.Date()); + quoteTemplateService.updateById(db); + if (body.getIsDefault() != null && body.getIsDefault() == 1) { + quoteTemplateService.setDefault(db.getId(), true, currentUser()); + } + return Result.OK("已保存"); + } + + @ApiOperation("样例回填预览文件下载(xlsx)") + @RequiresPermissions("inquiry:template:view") + @GetMapping("/previewSample") + public ResponseEntity previewSample(@RequestParam String versionId) throws Exception { + Path path = quoteTemplateService.buildSamplePreview(versionId); + InquiryQuoteTemplateVersion ver = quoteTemplateService.getVersion(versionId); + String downloadName = (ver != null ? ver.getFileName() : "preview.xlsx"); + if (!downloadName.toLowerCase().endsWith(".xlsx")) { + downloadName = downloadName + "-preview.xlsx"; + } else { + downloadName = downloadName.replace(".xlsx", "-preview.xlsx"); + } + String encoded = URLEncoder.encode(downloadName, "UTF-8").replace("+", "%20"); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded) + .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .body(new FileSystemResource(path)); + } + + @ApiOperation("下载原模板文件") + @RequiresPermissions("inquiry:template:view") + @GetMapping("/download") + public ResponseEntity download(@RequestParam String versionId) throws Exception { + InquiryQuoteTemplateVersion ver = quoteTemplateService.getVersion(versionId); + if (ver == null) { + return ResponseEntity.notFound().build(); + } + Path path = quoteTemplateService.resolveObjectPath(ver.getObjectKey()); + String encoded = URLEncoder.encode(ver.getFileName(), "UTF-8").replace("+", "%20"); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(new FileSystemResource(path)); + } + + /** + * 智能体只读:已启用 auto 模板指针(含 mappingJson / objectKey)。 + * 权限:登录服务账号即可;与后台权限码解耦,避免 Worker 缺菜单权限。 + */ + @ApiOperation("智能体-已启用模板列表") + @GetMapping("/agent/listEnabled") + public Result>> agentListEnabled( + @RequestParam(defaultValue = "true") Boolean enabledOnly) { + return Result.OK(quoteTemplateService.listEnabledForAgent(Boolean.TRUE.equals(enabledOnly))); + } + + @ApiOperation("合同字段与样例事实(前端映射编辑用)") + @RequiresPermissions("inquiry:template:view") + @GetMapping("/contractMeta") + public Result> contractMeta() { + Map m = new HashMap<>(); + m.put("hint", "见仓库 contracts/quote-template-mapping-v1.json"); + m.put("schemaVersion", "quote-template-mapping-v1"); + return Result.OK(m); + } + + private String currentUser() { + try { + Object p = SecurityUtils.getSubject().getPrincipal(); + if (p instanceof LoginUser) { + return ((LoginUser) p).getUsername(); + } + } catch (Exception ignored) { + } + return "system"; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryQuoteTemplate.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryQuoteTemplate.java new file mode 100644 index 0000000..7d1706e --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryQuoteTemplate.java @@ -0,0 +1,73 @@ +package org.jeecg.modules.inquiry.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.util.Date; + +/** + * 报价模板逻辑头,对应 inq_quote_template。 + *

+ * 职责:业务身份(海陆空/分类/关键词/默认/启停/auto|manual)。 + * 版式字节与映射在版本表;历史报价应绑 templateId + versionNo。 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("inq_quote_template") +@ApiModel(value = "inq_quote_template", description = "报价模板逻辑头") +public class InquiryQuoteTemplate implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.ASSIGN_ID) + private String id; + + @ApiModelProperty("模板名称") + private String name; + + @ApiModelProperty("海运/空运/陆运") + private String bizType; + + @ApiModelProperty("分类") + private String category; + + @ApiModelProperty("关键词 JSON 数组字符串") + private String keywordsJson; + + @ApiModelProperty("auto / manual") + private String mode; + + @ApiModelProperty("enabled / disabled") + private String status; + + @ApiModelProperty("同业务默认 0/1") + private Integer isDefault; + + @ApiModelProperty("当前启用版本号") + private Integer currentVersion; + + @ApiModelProperty("累计使用次数") + private Integer useCount; + + private String createBy; + + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + + private String updateBy; + + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date updateTime; +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryQuoteTemplateVersion.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryQuoteTemplateVersion.java new file mode 100644 index 0000000..04b5759 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryQuoteTemplateVersion.java @@ -0,0 +1,85 @@ +package org.jeecg.modules.inquiry.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.util.Date; + +/** + * 报价模板版本,对应 inq_quote_template_version。 + *

+ * 每次改版上传新建一行;mapping_json 为本版填表计划。 + * 黄色固定区不写入;启用前须完成必填映射(manual 模式可放宽)。 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("inq_quote_template_version") +@ApiModel(value = "inq_quote_template_version", description = "报价模板版本") +public class InquiryQuoteTemplateVersion implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.ASSIGN_ID) + private String id; + + @ApiModelProperty("逻辑模板 id") + private String templateId; + + @ApiModelProperty("版本号从 1 起") + private Integer versionNo; + + @ApiModelProperty("原始文件名") + private String fileName; + + @ApiModelProperty("excel / pdf") + private String format; + + @ApiModelProperty("对象存储键或本地上传相对路径") + private String objectKey; + + @ApiModelProperty("SHA-256") + private String sha256; + + @ApiModelProperty("字节数") + private Long fileSize; + + @ApiModelProperty("pending/parsed/failed/mapping_incomplete") + private String parseStatus; + + @ApiModelProperty("已映射字段点数") + private Integer mappingPointCount; + + @ApiModelProperty("本版 mapping JSON") + private String mappingJson; + + @ApiModelProperty("可填候选 JSON") + private String candidatesJson; + + @ApiModelProperty("解析说明") + private String parseMessage; + + @ApiModelProperty("是否当前启用版本 0/1") + private Integer enabled; + + private String createBy; + + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + + private String updateBy; + + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date updateTime; +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java index 607e0e5..755fdfb 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java @@ -67,6 +67,15 @@ public class InquiryTicket implements Serializable { @ApiModelProperty("品名") private String goodsName; + /** + * 报价日期:销售询价/报价单填表用的业务日期,不是 createTime。 + * 对应模板字段 quote_date;可空,旧单补列后保持 NULL。 + */ + @ApiModelProperty("报价日期") + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private Date quoteDate; + @ApiModelProperty("件数") private Integer pieces; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/mapper/InquiryQuoteTemplateMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/mapper/InquiryQuoteTemplateMapper.java new file mode 100644 index 0000000..fa2326a --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/mapper/InquiryQuoteTemplateMapper.java @@ -0,0 +1,12 @@ +package org.jeecg.modules.inquiry.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate; + +/** + * 报价模板逻辑头 Mapper。 + */ +@Mapper +public interface InquiryQuoteTemplateMapper extends BaseMapper { +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/mapper/InquiryQuoteTemplateVersionMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/mapper/InquiryQuoteTemplateVersionMapper.java new file mode 100644 index 0000000..bb95b56 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/mapper/InquiryQuoteTemplateVersionMapper.java @@ -0,0 +1,12 @@ +package org.jeecg.modules.inquiry.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion; + +/** + * 报价模板版本 Mapper。 + */ +@Mapper +public interface InquiryQuoteTemplateVersionMapper extends BaseMapper { +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/package-info.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/package-info.java index 2808551..2d2b38c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/package-info.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/package-info.java @@ -1,8 +1,8 @@ /** * 友通达新系统 · 询价主账手写模块(从零实现)。 *

- * 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语。 - * 排除:报价模板、识别任务、报价生成、存档死信。 + * 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语、报价模板(逻辑头+版本+映射)。 + * 排除:识别任务、存档死信、Graph/自然语言抽取。 * 数据只写 inquiry_robot;禁止连现网 jeecg-boot。 */ package org.jeecg.modules.inquiry; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java new file mode 100644 index 0000000..6eee5eb --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java @@ -0,0 +1,57 @@ +package org.jeecg.modules.inquiry.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.service.IService; +import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate; +import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * 报价模板:上传新版本、映射确认、启停、样例预览、智能体只读列表。 + */ +public interface IInquiryQuoteTemplateService extends IService { + + IPage> pageWithVersion(String keyword, String bizType, String category, String status, + int pageNo, int pageSize); + + /** + * 新建逻辑模板并写入第一版;或对已有模板追加新版本。 + */ + Map uploadVersion(String templateId, String name, String bizType, String category, + String keywordsJson, String mode, boolean isDefault, + MultipartFile file, String operator); + + InquiryQuoteTemplateVersion getVersion(String versionId); + + List listVersions(String templateId); + + /** + * 运营确认/修订 mapping_json。 + */ + void saveMapping(String versionId, String mappingJson, String operator); + + void enableTemplate(String templateId, String versionId, String operator); + + void disableTemplate(String templateId, String operator); + + void setDefault(String templateId, boolean isDefault, String operator); + + /** + * 按本版映射写入样例事实,返回生成的预览文件绝对路径。 + */ + Path buildSamplePreview(String versionId) throws Exception; + + /** + * 解析本地上传路径为绝对 Path。 + */ + Path resolveObjectPath(String objectKey); + + /** + * 智能体:已启用且 mode=auto 的模板指针列表。 + */ + List> listEnabledForAgent(boolean enabledOnly); +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java new file mode 100644 index 0000000..7b2251c --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -0,0 +1,557 @@ +package org.jeecg.modules.inquiry.service.impl; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +import org.jeecg.common.util.CommonUtils; +import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate; +import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion; +import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper; +import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper; +import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService; +import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +/** + * 报价模板服务实现。 + *

+ * 副作用:写 inq_quote_template / version;文件经 Jeecg upload(local/minio)。 + * 线程:HTTP 请求线程;解析/回填可能稍慢但不上 LibreOffice,禁止在此 sleep 等 LLM。 + */ +@Slf4j +@Service +public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + implements IInquiryQuoteTemplateService { + + private static final String BIZ_PATH = "inquiry/quote-templates"; + + @Autowired + private InquiryQuoteTemplateVersionMapper versionMapper; + + @Value("${jeecg.path.upload:}") + private String uploadPath; + + @Value("${jeecg.uploadType:local}") + private String uploadType; + + /** 可选:仓库 contracts 绝对/相对路径,便于开发机读正式合同。 */ + @Value("${inquiry.quoteTemplate.contractPath:}") + private String contractPath; + + @Override + public IPage> pageWithVersion(String keyword, String bizType, String category, String status, + int pageNo, int pageSize) { + QueryWrapper qw = new QueryWrapper<>(); + if (StringUtils.isNotBlank(bizType)) { + qw.eq("biz_type", bizType); + } + if (StringUtils.isNotBlank(category)) { + qw.eq("category", category); + } + if (StringUtils.isNotBlank(status)) { + qw.eq("status", status); + } + if (StringUtils.isNotBlank(keyword)) { + qw.and(w -> w.like("name", keyword).or().like("category", keyword).or().like("keywords_json", keyword)); + } + qw.orderByDesc("update_time").orderByDesc("create_time"); + IPage page = page(new Page<>(pageNo, pageSize), qw); + List> records = new ArrayList<>(); + for (InquiryQuoteTemplate t : page.getRecords()) { + records.add(toListRow(t)); + } + Page> out = new Page<>(page.getCurrent(), page.getSize(), page.getTotal()); + out.setRecords(records); + return out; + } + + private Map toListRow(InquiryQuoteTemplate t) { + Map m = new LinkedHashMap<>(); + m.put("id", t.getId()); + m.put("name", t.getName()); + m.put("bizType", t.getBizType()); + m.put("category", t.getCategory()); + m.put("keywords", parseKeywords(t.getKeywordsJson())); + m.put("mode", t.getMode()); + m.put("status", t.getStatus()); + m.put("isDefault", t.getIsDefault() != null && t.getIsDefault() == 1); + m.put("currentVersion", t.getCurrentVersion()); + m.put("useCount", t.getUseCount()); + m.put("createdAt", t.getCreateTime()); + m.put("operator", t.getUpdateBy() != null ? t.getUpdateBy() : t.getCreateBy()); + + InquiryQuoteTemplateVersion ver = findEnabledOrLatest(t.getId()); + if (ver != null) { + m.put("versionId", ver.getId()); + m.put("fileName", ver.getFileName()); + m.put("fileLabel", stripExt(ver.getFileName())); + m.put("format", ver.getFormat()); + m.put("parseStatus", mapParseStatusLabel(ver.getParseStatus())); + m.put("mappingPointCount", ver.getMappingPointCount()); + m.put("objectKey", ver.getObjectKey()); + m.put("parseMessage", ver.getParseMessage()); + m.put("unmappedRequired", extractUnmapped(ver.getMappingJson())); + } else { + m.put("versionId", null); + m.put("fileName", ""); + m.put("fileLabel", ""); + m.put("format", "excel"); + m.put("parseStatus", "未上传"); + m.put("mappingPointCount", 0); + } + return m; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map uploadVersion(String templateId, String name, String bizType, String category, + String keywordsJson, String mode, boolean isDefault, + MultipartFile file, String operator) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("请上传模板文件"); + } + String original = file.getOriginalFilename() == null ? "template.xlsx" : file.getOriginalFilename(); + boolean isPdf = original.toLowerCase(Locale.ROOT).endsWith(".pdf"); + String format = isPdf ? "pdf" : "excel"; + + InquiryQuoteTemplate template; + boolean creating = StringUtils.isBlank(templateId); + if (creating) { + template = new InquiryQuoteTemplate(); + template.setName(StringUtils.defaultIfBlank(name, stripExt(original))); + template.setBizType(StringUtils.defaultIfBlank(bizType, "海运")); + template.setCategory(StringUtils.defaultIfBlank(category, defaultCategory(template.getBizType()))); + template.setKeywordsJson(StringUtils.defaultIfBlank(keywordsJson, "[]")); + template.setMode(StringUtils.defaultIfBlank(mode, "auto")); + template.setStatus("disabled"); + template.setIsDefault(0); + template.setCurrentVersion(0); + template.setUseCount(0); + template.setCreateBy(operator); + template.setCreateTime(new Date()); + save(template); + } else { + template = getById(templateId); + if (template == null) { + throw new IllegalArgumentException("模板不存在: " + templateId); + } + if (StringUtils.isNotBlank(name)) { + template.setName(name); + } + if (StringUtils.isNotBlank(bizType)) { + template.setBizType(bizType); + } + if (StringUtils.isNotBlank(category)) { + template.setCategory(category); + } + if (StringUtils.isNotBlank(keywordsJson)) { + template.setKeywordsJson(keywordsJson); + } + if (StringUtils.isNotBlank(mode)) { + template.setMode(mode); + } + template.setUpdateBy(operator); + template.setUpdateTime(new Date()); + updateById(template); + } + + String objectKey; + try { + objectKey = CommonUtils.upload(file, BIZ_PATH, uploadType); + } catch (Exception e) { + log.error("模板上传失败", e); + throw new IllegalStateException("文件上传失败: " + e.getMessage(), e); + } + if (StringUtils.isBlank(objectKey)) { + throw new IllegalStateException("文件上传返回空路径"); + } + + int nextVer = nextVersionNo(template.getId()); + String sha; + try (InputStream in = file.getInputStream()) { + sha = DigestUtils.sha256Hex(in); + } catch (Exception e) { + sha = null; + } + + InquiryQuoteTemplateVersion version = new InquiryQuoteTemplateVersion(); + version.setTemplateId(template.getId()); + version.setVersionNo(nextVer); + version.setFileName(original); + version.setFormat(format); + version.setObjectKey(objectKey); + version.setSha256(sha); + version.setFileSize(file.getSize()); + version.setEnabled(0); + version.setCreateBy(operator); + version.setCreateTime(new Date()); + + if (isPdf) { + version.setParseStatus("parsed"); + version.setMappingPointCount(0); + version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"note\":\"pdf_manual_or_download_only\"}"); + version.setParseMessage("PDF 不做单元格映射,可用于 manual 下载"); + } else { + try { + Path local = resolveObjectPath(objectKey); + JSONObject contract = QuoteTemplateExcelSupport.loadContract(resolveContractPath()); + JSONObject mapping = QuoteTemplateExcelSupport.parseWorkbook(local, contract); + JSONArray unmapped = mapping.getJSONArray("unmappedRequired"); + int points = mapping.getJSONObject("fields") == null ? 0 : mapping.getJSONObject("fields").size(); + version.setMappingJson(mapping.toJSONString()); + version.setCandidatesJson(mapping.getJSONArray("candidates") == null + ? "[]" : mapping.getJSONArray("candidates").toJSONString()); + version.setMappingPointCount(points); + if (unmapped != null && !unmapped.isEmpty() && "auto".equals(template.getMode())) { + version.setParseStatus("mapping_incomplete"); + version.setParseMessage("已解析 " + points + " 个映射点,尚有未映射必填:" + unmapped.toJSONString()); + } else { + version.setParseStatus("parsed"); + version.setParseMessage("已解析 " + points + " 个映射点"); + } + } catch (Exception e) { + log.error("模板解析失败 objectKey={}", objectKey, e); + version.setParseStatus("failed"); + version.setParseMessage("解析失败: " + e.getMessage()); + version.setMappingPointCount(0); + } + } + versionMapper.insert(version); + + if (isDefault) { + setDefault(template.getId(), true, operator); + } + + Map result = toListRow(getById(template.getId())); + result.put("versionId", version.getId()); + result.put("parseStatus", mapParseStatusLabel(version.getParseStatus())); + result.put("mappingPointCount", version.getMappingPointCount()); + result.put("parseMessage", version.getParseMessage()); + result.put("mappingJson", version.getMappingJson()); + return result; + } + + @Override + public InquiryQuoteTemplateVersion getVersion(String versionId) { + return versionMapper.selectById(versionId); + } + + @Override + public List listVersions(String templateId) { + return versionMapper.selectList(new QueryWrapper() + .eq("template_id", templateId) + .orderByDesc("version_no")); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveMapping(String versionId, String mappingJson, String operator) { + InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); + if (ver == null) { + throw new IllegalArgumentException("版本不存在"); + } + JSONObject mapping = JSON.parseObject(mappingJson); + if (mapping == null) { + throw new IllegalArgumentException("mappingJson 非法"); + } + mapping.put("schemaVersion", "quote-template-mapping-v1"); + JSONObject fields = mapping.getJSONObject("fields"); + int points = fields == null ? 0 : fields.size(); + ver.setMappingJson(mapping.toJSONString()); + ver.setMappingPointCount(points); + JSONArray unmapped = mapping.getJSONArray("unmappedRequired"); + InquiryQuoteTemplate template = getById(ver.getTemplateId()); + if (unmapped != null && !unmapped.isEmpty() && template != null && "auto".equals(template.getMode())) { + ver.setParseStatus("mapping_incomplete"); + ver.setParseMessage("映射已保存,尚有未映射必填:" + unmapped.toJSONString()); + } else { + ver.setParseStatus("parsed"); + ver.setParseMessage("映射已确认,共 " + points + " 个映射点"); + } + ver.setUpdateBy(operator); + ver.setUpdateTime(new Date()); + versionMapper.updateById(ver); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void enableTemplate(String templateId, String versionId, String operator) { + InquiryQuoteTemplate template = getById(templateId); + if (template == null) { + throw new IllegalArgumentException("模板不存在"); + } + InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); + if (ver == null || !templateId.equals(ver.getTemplateId())) { + throw new IllegalArgumentException("版本不属于该模板"); + } + if ("failed".equals(ver.getParseStatus())) { + throw new IllegalStateException("解析失败的版本不能启用"); + } + if ("auto".equals(template.getMode()) && "mapping_incomplete".equals(ver.getParseStatus())) { + throw new IllegalStateException("自动模板须完成必填映射后才能启用"); + } + // 同模板其它版本取消启用 + List all = listVersions(templateId); + for (InquiryQuoteTemplateVersion v : all) { + if (v.getEnabled() != null && v.getEnabled() == 1) { + v.setEnabled(0); + v.setUpdateTime(new Date()); + versionMapper.updateById(v); + } + } + ver.setEnabled(1); + ver.setUpdateBy(operator); + ver.setUpdateTime(new Date()); + versionMapper.updateById(ver); + + template.setCurrentVersion(ver.getVersionNo()); + template.setStatus("enabled"); + template.setUpdateBy(operator); + template.setUpdateTime(new Date()); + updateById(template); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void disableTemplate(String templateId, String operator) { + InquiryQuoteTemplate template = getById(templateId); + if (template == null) { + throw new IllegalArgumentException("模板不存在"); + } + template.setStatus("disabled"); + template.setUpdateBy(operator); + template.setUpdateTime(new Date()); + updateById(template); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void setDefault(String templateId, boolean isDefault, String operator) { + InquiryQuoteTemplate template = getById(templateId); + if (template == null) { + throw new IllegalArgumentException("模板不存在"); + } + if (isDefault) { + List sameBiz = list(new QueryWrapper() + .eq("biz_type", template.getBizType()) + .eq("is_default", 1)); + for (InquiryQuoteTemplate t : sameBiz) { + if (!t.getId().equals(templateId)) { + t.setIsDefault(0); + t.setUpdateTime(new Date()); + updateById(t); + } + } + template.setIsDefault(1); + } else { + template.setIsDefault(0); + } + template.setUpdateBy(operator); + template.setUpdateTime(new Date()); + updateById(template); + } + + @Override + public Path buildSamplePreview(String versionId) throws Exception { + InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId); + if (ver == null) { + throw new IllegalArgumentException("版本不存在"); + } + if (!"excel".equals(ver.getFormat())) { + throw new IllegalStateException("仅 Excel 支持样例回填预览"); + } + Path source = resolveObjectPath(ver.getObjectKey()); + JSONObject contract = QuoteTemplateExcelSupport.loadContract(resolveContractPath()); + JSONObject mapping; + try { + mapping = StringUtils.isNotBlank(ver.getMappingJson()) + ? JSON.parseObject(ver.getMappingJson()) + : new JSONObject(); + } catch (Exception e) { + throw new IllegalStateException( + "mappingJson 非法(需合法 JSON)。请重新上传解析或执行 mapping 修复脚本: " + e.getMessage(), e); + } + JSONObject facts = contract.getJSONObject("samplePreviewFacts"); + if (facts == null) { + facts = new JSONObject(); + } + Path previewDir = Paths.get(uploadPath, BIZ_PATH, "preview"); + Files.createDirectories(previewDir); + Path target = previewDir.resolve(ver.getId() + "-preview.xlsx"); + QuoteTemplateExcelSupport.fillWorkbook(source, target, mapping, facts); + return target; + } + + @Override + public Path resolveObjectPath(String objectKey) { + if (StringUtils.isBlank(objectKey)) { + throw new IllegalArgumentException("objectKey 空"); + } + Path p = Paths.get(objectKey); + if (p.isAbsolute() && Files.isRegularFile(p)) { + return p; + } + // Jeecg local 常返回相对 upload 的路径;minio 时需另下载体——本期以 local 为主 + if (StringUtils.isNotBlank(uploadPath)) { + Path under = Paths.get(uploadPath, objectKey.replace("/", java.io.File.separator)); + if (Files.isRegularFile(under)) { + return under; + } + // 有时 objectKey 已含盘符或 upload 前缀 + under = Paths.get(uploadPath).resolve(objectKey); + if (Files.isRegularFile(under)) { + return under; + } + } + if (Files.isRegularFile(Paths.get(objectKey))) { + return Paths.get(objectKey); + } + throw new IllegalStateException("找不到模板文件: " + objectKey + "(uploadType=" + uploadType + ")"); + } + + @Override + public List> listEnabledForAgent(boolean enabledOnly) { + QueryWrapper qw = new QueryWrapper<>(); + if (enabledOnly) { + qw.eq("status", "enabled"); + } + qw.eq("mode", "auto"); + qw.orderByDesc("is_default").orderByAsc("biz_type").orderByAsc("name"); + List> out = new ArrayList<>(); + for (InquiryQuoteTemplate t : list(qw)) { + InquiryQuoteTemplateVersion ver = findEnabledOrLatest(t.getId()); + if (ver == null || (enabledOnly && (ver.getEnabled() == null || ver.getEnabled() != 1))) { + continue; + } + Map m = new LinkedHashMap<>(); + m.put("templateId", t.getId()); + m.put("name", t.getName()); + m.put("bizType", t.getBizType()); + m.put("category", t.getCategory()); + m.put("keywords", parseKeywords(t.getKeywordsJson())); + m.put("isDefault", t.getIsDefault() != null && t.getIsDefault() == 1); + m.put("versionNo", ver.getVersionNo()); + m.put("versionId", ver.getId()); + m.put("objectKey", ver.getObjectKey()); + m.put("fileName", ver.getFileName()); + m.put("sha256", ver.getSha256()); + m.put("mappingJson", ver.getMappingJson()); + out.add(m); + } + return out; + } + + private InquiryQuoteTemplateVersion findEnabledOrLatest(String templateId) { + InquiryQuoteTemplateVersion enabled = versionMapper.selectOne(new QueryWrapper() + .eq("template_id", templateId) + .eq("enabled", 1) + .last("LIMIT 1")); + if (enabled != null) { + return enabled; + } + return versionMapper.selectOne(new QueryWrapper() + .eq("template_id", templateId) + .orderByDesc("version_no") + .last("LIMIT 1")); + } + + private int nextVersionNo(String templateId) { + InquiryQuoteTemplateVersion latest = versionMapper.selectOne(new QueryWrapper() + .eq("template_id", templateId) + .orderByDesc("version_no") + .last("LIMIT 1")); + return latest == null || latest.getVersionNo() == null ? 1 : latest.getVersionNo() + 1; + } + + private Path resolveContractPath() { + if (StringUtils.isNotBlank(contractPath)) { + return Paths.get(contractPath); + } + // 开发机常见:进程 cwd 在 inquiry-api 或其子目录 + Path[] candidates = new Path[]{ + Paths.get("contracts", "quote-template-mapping-v1.json"), + Paths.get("..", "contracts", "quote-template-mapping-v1.json"), + Paths.get("..", "..", "contracts", "quote-template-mapping-v1.json") + }; + for (Path c : candidates) { + if (Files.isRegularFile(c)) { + return c.toAbsolutePath().normalize(); + } + } + return null; + } + + private static List parseKeywords(String json) { + if (StringUtils.isBlank(json)) { + return Collections.emptyList(); + } + try { + return JSON.parseArray(json, String.class); + } catch (Exception e) { + return Collections.singletonList(json); + } + } + + private static List extractUnmapped(String mappingJson) { + if (StringUtils.isBlank(mappingJson)) { + return Collections.emptyList(); + } + try { + JSONArray arr = JSON.parseObject(mappingJson).getJSONArray("unmappedRequired"); + return arr == null ? Collections.emptyList() : arr.toJavaList(String.class); + } catch (Exception e) { + return Collections.emptyList(); + } + } + + private static String mapParseStatusLabel(String status) { + if ("parsed".equals(status)) { + return "已解析"; + } + if ("mapping_incomplete".equals(status)) { + return "映射未完成"; + } + if ("failed".equals(status)) { + return "失败"; + } + if ("pending".equals(status)) { + return "解析中"; + } + return status == null ? "" : status; + } + + private static String stripExt(String name) { + if (name == null) { + return ""; + } + int i = name.lastIndexOf('.'); + return i > 0 ? name.substring(0, i) : name; + } + + private static String defaultCategory(String bizType) { + if ("空运".equals(bizType)) { + return "空运到港报价单模板"; + } + if ("陆运".equals(bizType)) { + return "国内拼车"; + } + return "海运报价单模板"; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java new file mode 100644 index 0000000..9318bf4 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java @@ -0,0 +1,380 @@ +package org.jeecg.modules.inquiry.template; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellReference; +import org.apache.poi.xssf.usermodel.XSSFCell; +import org.apache.poi.xssf.usermodel.XSSFColor; +import org.springframework.core.io.ClassPathResource; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +/** + * 报价模板 Excel 解析与按映射回填。 + *

+ * 黄区=固定不覆盖;标签词典自动建议字段→单元格;样例/冻结事实写入映射点。 + * 禁止:发明价格;不在此做 Graph / 六态。 + * 线程:跟随调用方(上传请求或 Worker 委托的 HTTP 线程),无全局锁。 + */ +@Slf4j +public final class QuoteTemplateExcelSupport { + + private static final Set YELLOW_RGB = new HashSet<>(Arrays.asList( + "FFFFFF00", "FFFF00", "FFFFEB9C", "FFFFC000", "FFFFFF99", + "FFFFCC00", "FFFFD966", "FFFFF2CC", "FFFFE699" + )); + + private QuoteTemplateExcelSupport() { + } + + /** + * 加载 contracts 合同(优先 classpath,其次仓库相对路径由调用方传入)。 + */ + public static JSONObject loadContract(Path optionalRepoContract) { + try { + if (optionalRepoContract != null && Files.isRegularFile(optionalRepoContract)) { + byte[] bytes = Files.readAllBytes(optionalRepoContract); + String text = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + return JSON.parseObject(text); + } + } catch (Exception e) { + log.warn("读取仓库合同失败,尝试 classpath: {}", e.getMessage()); + } + try { + ClassPathResource res = new ClassPathResource("contracts/quote-template-mapping-v1.json"); + if (res.exists()) { + try (InputStream in = res.getInputStream()) { + byte[] buf = new byte[4096]; + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + int n; + while ((n = in.read(buf)) >= 0) { + bos.write(buf, 0, n); + } + return JSON.parseObject(new String(bos.toByteArray(), java.nio.charset.StandardCharsets.UTF_8)); + } + } + } catch (Exception e) { + log.warn("classpath 合同不可用: {}", e.getMessage()); + } + // 最小内置:避免上传直接失败 + JSONObject fallback = new JSONObject(); + fallback.put("labelDictionary", new JSONObject()); + fallback.put("samplePreviewFacts", new JSONObject()); + fallback.put("systemFields", new JSONArray()); + return fallback; + } + + /** + * 解析 xlsx:黄区列表、标签候选、自动映射建议。 + * + * @return mapping 草案(含 fields / fixedYellowCells / candidates / unmappedRequired) + */ + public static JSONObject parseWorkbook(Path xlsxPath, JSONObject contract) throws Exception { + Map dict = toStringMap(contract.getJSONObject("labelDictionary")); + JSONArray systemFields = contract.getJSONArray("systemFields"); + Set requiredHints = new HashSet<>(); + if (systemFields != null) { + for (int i = 0; i < systemFields.size(); i++) { + JSONObject f = systemFields.getJSONObject(i); + if (f != null && Boolean.TRUE.equals(f.getBoolean("requiredHint"))) { + requiredHints.add(f.getString("code")); + } + } + } + + JSONObject mapping = new JSONObject(true); + mapping.put("schemaVersion", "quote-template-mapping-v1"); + JSONObject fields = new JSONObject(true); + JSONArray yellow = new JSONArray(); + JSONArray candidates = new JSONArray(); + Set mappedCodes = new HashSet<>(); + + try (InputStream in = Files.newInputStream(xlsxPath); Workbook wb = WorkbookFactory.create(in)) { + Sheet sheet = wb.getNumberOfSheets() > 0 ? wb.getSheetAt(0) : null; + if (sheet == null) { + mapping.put("parseError", "empty_workbook"); + return mapping; + } + String sheetName = sheet.getSheetName(); + mapping.put("sheet", sheetName); + + DataFormatter fmt = new DataFormatter(); + for (Row row : sheet) { + if (row == null) { + continue; + } + for (Cell cell : row) { + if (cell == null) { + continue; + } + String ref = sheetName + "!" + new CellReference(cell).formatAsString(); + boolean isYellow = isYellowFixed(cell); + if (isYellow) { + yellow.add(ref); + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (text.isEmpty()) { + continue; + } + String normalized = text.replace(":", ":").trim(); + String code = matchLabel(normalized, dict); + String fillRef = null; + if (code != null) { + fillRef = resolveFillTarget(sheet, cell, sheetName, text, fmt); + if (fillRef != null && !mappedCodes.contains(code)) { + fields.put(code, fillRef); + mappedCodes.add(code); + } + } + JSONObject cand = new JSONObject(true); + cand.put("cell", ref); + cand.put("text", text.length() > 80 ? text.substring(0, 80) : text); + cand.put("yellowFixed", isYellow); + cand.put("suggestedField", code); + cand.put("suggestedFill", fillRef); + candidates.add(cand); + } + } + } + + mapping.put("fields", fields); + mapping.put("fixedYellowCells", yellow); + mapping.put("candidates", candidates); + JSONArray unmapped = new JSONArray(); + for (String req : requiredHints) { + if (!mappedCodes.contains(req)) { + unmapped.add(req); + } + } + mapping.put("unmappedRequired", unmapped); + return mapping; + } + + /** + * 按 mapping.fields / fee_rows 写入事实;先清空映射覆盖的可填区(跳过黄区)。 + */ + public static void fillWorkbook(Path sourceXlsx, Path targetXlsx, JSONObject mapping, JSONObject facts) throws Exception { + Set yellow = new HashSet<>(); + JSONArray yArr = mapping.getJSONArray("fixedYellowCells"); + if (yArr != null) { + for (int i = 0; i < yArr.size(); i++) { + yellow.add(yArr.getString(i)); + } + } + try (InputStream in = Files.newInputStream(sourceXlsx); Workbook wb = WorkbookFactory.create(in)) { + JSONObject fields = mapping.getJSONObject("fields"); + if (fields != null) { + for (String code : fields.keySet()) { + String cellRef = fields.getString(code); + if (StringUtils.isBlank(cellRef) || yellow.contains(cellRef)) { + continue; + } + Object val = facts.get(code); + if (val == null) { + clearCell(wb, cellRef); + continue; + } + if ("fee_rows".equals(code)) { + continue; + } + writeCell(wb, cellRef, String.valueOf(val)); + } + } + writeFeeRows(wb, mapping, facts, yellow); + Files.createDirectories(targetXlsx.getParent()); + try (OutputStream out = Files.newOutputStream(targetXlsx)) { + wb.write(out); + } + } + } + + private static void writeFeeRows(Workbook wb, JSONObject mapping, JSONObject facts, Set yellow) { + JSONObject feeRows = mapping.getJSONObject("fee_rows"); + if (feeRows == null) { + return; + } + Object raw = facts.get("fee_rows"); + if (!(raw instanceof JSONArray) && !(raw instanceof List)) { + return; + } + JSONArray rows = raw instanceof JSONArray ? (JSONArray) raw : JSON.parseArray(JSON.toJSONString(raw)); + String anchor = feeRows.getString("anchor"); + JSONObject columns = feeRows.getJSONObject("columns"); + if (StringUtils.isBlank(anchor) || columns == null || rows == null || rows.isEmpty()) { + return; + } + ParsedRef anchorRef = ParsedRef.parse(anchor); + Sheet sheet = wb.getSheet(anchorRef.sheet); + if (sheet == null) { + sheet = wb.getSheetAt(0); + } + int startRow = anchorRef.row; + for (int i = 0; i < rows.size(); i++) { + JSONObject rowObj = rows.getJSONObject(i); + int r = startRow + i; + writeFeeCol(sheet, r, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName()); + writeFeeCol(sheet, r, columns.getString("amount"), rowObj.getString("amount"), yellow, sheet.getSheetName()); + if (columns.containsKey("remark")) { + writeFeeCol(sheet, r, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName()); + } + } + } + + private static void writeFeeCol(Sheet sheet, int rowIdx, String colLetter, String value, Set yellow, String sheetName) { + if (StringUtils.isBlank(colLetter)) { + return; + } + int col = CellReference.convertColStringToIndex(colLetter.trim()); + String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString(); + if (yellow.contains(ref)) { + return; + } + Row row = sheet.getRow(rowIdx); + if (row == null) { + row = sheet.createRow(rowIdx); + } + Cell cell = row.getCell(col); + if (cell == null) { + cell = row.createCell(col); + } + cell.setCellValue(value == null ? "" : value); + } + + private static void clearCell(Workbook wb, String cellRef) { + writeCell(wb, cellRef, ""); + } + + private static void writeCell(Workbook wb, String cellRef, String value) { + ParsedRef ref = ParsedRef.parse(cellRef); + Sheet sheet = wb.getSheet(ref.sheet); + if (sheet == null && wb.getNumberOfSheets() > 0) { + sheet = wb.getSheetAt(0); + } + if (sheet == null) { + return; + } + Row row = sheet.getRow(ref.row); + if (row == null) { + row = sheet.createRow(ref.row); + } + Cell cell = row.getCell(ref.col); + if (cell == null) { + cell = row.createCell(ref.col); + } + cell.setCellValue(value == null ? "" : value); + } + + private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) { + // 同一格「标签:值」 + int idx = Math.max(text.indexOf(':'), text.indexOf(':')); + if (idx >= 0 && idx < text.length() - 1 && StringUtils.isNotBlank(text.substring(idx + 1).trim())) { + // 值已在标签格内:映射到本格(生成时会整格重写为纯值,运营需知悉) + return sheetName + "!" + new CellReference(labelCell).formatAsString(); + } + // 右侧相邻非黄空格或任意右侧格 + Row row = labelCell.getRow(); + int nextCol = labelCell.getColumnIndex() + 1; + Cell right = row.getCell(nextCol); + if (right == null) { + right = row.createCell(nextCol); + } + return sheetName + "!" + new CellReference(right).formatAsString(); + } + + private static String matchLabel(String normalized, Map dict) { + if (dict == null || dict.isEmpty()) { + return null; + } + String key = normalized; + if (dict.containsKey(key)) { + return dict.get(key); + } + // 去冒号再试 + String noColon = key.replace(":", "").trim(); + for (Map.Entry e : dict.entrySet()) { + String dk = e.getKey().replace(":", ":").replace(":", "").trim(); + if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) { + if (dk.length() >= 2 && noColon.length() >= 2) { + return e.getValue(); + } + } + } + return null; + } + + private static boolean isYellowFixed(Cell cell) { + CellStyle style = cell.getCellStyle(); + if (style == null) { + return false; + } + short fill = style.getFillForegroundColor(); + if (fill == IndexedColors.YELLOW.getIndex() + || fill == IndexedColors.LIGHT_YELLOW.getIndex() + || fill == IndexedColors.GOLD.getIndex() + || fill == IndexedColors.LEMON_CHIFFON.getIndex()) { + return true; + } + if (cell instanceof XSSFCell) { + XSSFColor color = ((XSSFCell) cell).getCellStyle().getFillForegroundXSSFColor(); + if (color != null) { + byte[] rgb = color.getRGB(); + if (rgb != null && rgb.length >= 3) { + String hex = String.format("%02X%02X%02X", rgb[0] & 0xff, rgb[1] & 0xff, rgb[2] & 0xff); + if (YELLOW_RGB.contains(hex) || YELLOW_RGB.contains("FF" + hex)) { + return true; + } + // 近似黄:高 R/G、低 B + int r = rgb[0] & 0xff, g = rgb[1] & 0xff, b = rgb[2] & 0xff; + if (r > 200 && g > 180 && b < 160) { + return true; + } + } + } + } + return false; + } + + private static Map toStringMap(JSONObject obj) { + Map map = new LinkedHashMap<>(); + if (obj == null) { + return map; + } + for (String k : obj.keySet()) { + map.put(k, obj.getString(k)); + } + return map; + } + + /** sheet!A1 解析。 */ + private static final class ParsedRef { + final String sheet; + final int row; + final int col; + + private ParsedRef(String sheet, int row, int col) { + this.sheet = sheet; + this.row = row; + this.col = col; + } + + static ParsedRef parse(String cellRef) { + String sheet = "Sheet1"; + String addr = cellRef; + int bang = cellRef.indexOf('!'); + if (bang > 0) { + sheet = cellRef.substring(0, bang); + addr = cellRef.substring(bang + 1); + } + CellReference cr = new CellReference(addr); + return new ParsedRef(sheet, cr.getRow(), cr.getCol()); + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json new file mode 100644 index 0000000..afa8d5b --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json @@ -0,0 +1,145 @@ +{ + "schemaVersion": "quote-template-mapping-v1", + "description": "报价模板常变版:原 Excel 几乎不改;黄色=固定区;映射点=系统字段→单元格;千问只辅助映射不发明价格。", + "rules": { + "yellowMeansFixed": true, + "yellowNote": "黄色填充单元格为版式固定内容,生成与预览时永不覆盖。", + "clearFillableBeforeWrite": true, + "layoutAuthority": "uploaded_excel", + "noUnifiedColumns": true, + "llmRole": "suggest_field_to_cell_mapping_only", + "priceSource": "frozen_quote_facts_only", + "versionFreeze": "generated_quote_binds_templateId_plus_templateVersion" + }, + "modes": { + "auto": "可自动生成报价文件", + "manual": "仅下载给人工填写,不进自动生成" + }, + "mappingJsonShape": { + "schemaVersion": "quote-template-mapping-v1", + "sheet": "主工作表名(可空,默认第一个)", + "fields": { + "customer_name": "SheetName!C3", + "quote_no": "SheetName!F4" + }, + "fee_rows": { + "anchor": "SheetName!A15", + "columns": { + "name": "B", + "amount": "D", + "remark": "F" + }, + "styleCopyRows": 1 + }, + "fixedYellowCells": ["SheetName!A1", "SheetName!B2"], + "candidates": [], + "unmappedRequired": [] + }, + "systemFields": [ + { "code": "customer_name", "label": "客户名称", "requiredHint": true }, + { "code": "contact_name", "label": "联系人", "requiredHint": false }, + { "code": "contact_phone", "label": "电话", "requiredHint": false }, + { "code": "quote_no", "label": "报价单编号", "requiredHint": true }, + { "code": "quote_date", "label": "报价日期", "requiredHint": true }, + { "code": "valid_until", "label": "有效期至", "requiredHint": false }, + { "code": "currency", "label": "币别", "requiredHint": false }, + { "code": "sales_name", "label": "业务员", "requiredHint": false }, + { "code": "pol", "label": "起运港", "requiredHint": false }, + { "code": "pod", "label": "目的港", "requiredHint": false }, + { "code": "origin", "label": "始发地", "requiredHint": false }, + { "code": "destination", "label": "目的地", "requiredHint": false }, + { "code": "commodity", "label": "品名", "requiredHint": false }, + { "code": "quantity", "label": "货物数量", "requiredHint": false }, + { "code": "container_type_qty", "label": "箱型箱量", "requiredHint": false }, + { "code": "fcl_or_lcl", "label": "整柜或拼柜", "requiredHint": false }, + { "code": "trade_terms", "label": "贸易条款", "requiredHint": false }, + { "code": "cargo_ready_date", "label": "货好时间", "requiredHint": false }, + { "code": "pieces", "label": "件数", "requiredHint": false }, + { "code": "weight_kg", "label": "重量", "requiredHint": false }, + { "code": "volume_cbm", "label": "体积", "requiredHint": false }, + { "code": "total_amount", "label": "报价合计", "requiredHint": false }, + { "code": "fee_rows", "label": "费用明细行", "requiredHint": false } + ], + "labelDictionary": { + "客户名称": "customer_name", + "客户": "customer_name", + "客户:": "customer_name", + "联系人": "contact_name", + "联系人:": "contact_name", + "电话": "contact_phone", + "电话:": "contact_phone", + "报价单编号": "quote_no", + "报价编号": "quote_no", + "报价日期": "quote_date", + "询价日期": "quote_date", + "有效期至": "valid_until", + "报价有效期": "valid_until", + "币别": "currency", + "业务员": "sales_name", + "起运港": "pol", + "起 运 港": "pol", + "目的港": "pod", + "目 的 港": "pod", + "始发地": "origin", + "始发站": "origin", + "目的地": "destination", + "品名": "commodity", + "品 名": "commodity", + "货物数量": "quantity", + "箱型箱量": "container_type_qty", + "整柜或拼柜": "fcl_or_lcl", + "贸易条款": "trade_terms", + "货好时间": "cargo_ready_date", + "件数": "pieces", + "件 数": "pieces", + "重量": "weight_kg", + "重 量": "weight_kg", + "体积": "volume_cbm", + "体 积": "volume_cbm" + }, + "samplePreviewFacts": { + "customer_name": "ABC Trading Co.", + "contact_name": "张三", + "contact_phone": "13800000000", + "quote_no": "QT-202607020001", + "quote_date": "2026-07-02", + "valid_until": "2026-07-05", + "currency": "USD", + "sales_name": "张敏", + "pol": "CNSHA", + "pod": "USLAX", + "origin": "深圳", + "destination": "洛杉矶", + "commodity": "通用货物", + "quantity": "2", + "container_type_qty": "40HQ x 2", + "fcl_or_lcl": "FCL", + "trade_terms": "FOB", + "cargo_ready_date": "2026-07-10", + "pieces": "10", + "weight_kg": "1200", + "volume_cbm": "8.5", + "total_amount": "3680.00", + "fee_rows": [ + { "name": "O/F 海运费", "amount": "2900", "remark": "" }, + { "name": "THC + DOC + SEAL", "amount": "780", "remark": "" } + ] + }, + "categories": { + "海运": ["海运报价单模板"], + "空运": ["空运到港报价单模板", "空运到门报价单模板"], + "陆运": [ + "双清或中越", + "国内整车", + "国内拼车", + "跨境整车", + "跨境集拼", + "中港", + "凭祥保税仓&普仓仓储" + ] + }, + "seedModeHints": { + "manualKeywords": ["人工报价", "双清", "保税仓", "仓储"], + "autoDefault": true + } +} diff --git a/inquiry-backend/src/views/inquiry/account/account.api.ts b/inquiry-backend/src/views/inquiry/account/account.api.ts new file mode 100644 index 0000000..abed053 --- /dev/null +++ b/inquiry-backend/src/views/inquiry/account/account.api.ts @@ -0,0 +1,61 @@ +/** + * 账号管理 API:走 Jeecg 系统用户,不另起 inquiry 账号表。 + * 企微 ID 暂写入 workNo(工号),与现网 Jeecg SysUser 字段对齐;无独立 wecom 列时避免改系统实体。 + */ +import { + changePassword, + deleteUser, + getAllRolesListNoByTenant, + getUserRoles, + list as listUsers, + saveOrUpdateUser, +} from '/@/views/system/user/user.api'; + +export { + changePassword, + deleteUser, + getAllRolesListNoByTenant, + getUserRoles, + listUsers, + saveOrUpdateUser, +}; + +export type AccountRow = { + id: string; + username: string; + name: string; + wecomUserId: string; + roleId: string; + roleLabel: string; + roleTag: string; + phoneMasked: string; + lastLogin: string; + status: 'active' | 'disabled'; +}; + +const ROLE_TAG_CYCLE = ['tag-violet', 'tag-blue', 'tag-teal', 'tag-amber']; + +export function roleTagFor(index: number) { + return ROLE_TAG_CYCLE[index % ROLE_TAG_CYCLE.length]; +} + +/** Jeecg status:1 正常 → active;其余视为禁用。 */ +export function mapUserToAccount( + u: any, + roleId = '', + roleLabel = '未绑定角色', + roleTag = 'tag-blue', +): AccountRow { + return { + id: String(u.id || ''), + username: u.username || '', + name: u.realname || u.username || '', + wecomUserId: u.workNo || u.wecomId || u.thirdId || '', + roleId, + roleLabel, + roleTag, + phoneMasked: u.phone || '未填写', + lastLogin: u.lastLoginTime || u.createTime || '—', + status: Number(u.status) === 1 ? 'active' : 'disabled', + }; +} diff --git a/inquiry-backend/src/views/inquiry/account/index.vue b/inquiry-backend/src/views/inquiry/account/index.vue index 9d36657..3cbc642 100644 --- a/inquiry-backend/src/views/inquiry/account/index.vue +++ b/inquiry-backend/src/views/inquiry/account/index.vue @@ -1,15 +1,27 @@