feat: 闭环测试环境 loop.ps1、配置、数据库补丁与 S1/S8 场景
- loop.ps1:deploy / up / down / restart / reset / reset-abp / status / logs,WMI 起进程 - config:端口与闭环专用假凭据、iXserver Web.config 覆盖项、ABP appsettings.Loop.json - db:iXserverLoop 与 iCONAbpFX 的闭环补丁(凭据换假值、外部地址进黑洞并自检) - tools/CreateLoopDb.cs:按部署出来的 Model.dll 建库 - scenarios:S1(IG → 第三方 echo)、S8(SCRM 人工链路) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
commit
ad136a9d53
|
|
@ -0,0 +1,3 @@
|
|||
* text=auto
|
||||
# 注意:PowerShell 5.1 把无 BOM 的 UTF-8 脚本当 ANSI(GBK)读,中文会变乱码甚至把字符串截断。
|
||||
# .ps1 / .psd1 一律带 UTF-8 BOM 保存。
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# 编译产物、日志、上传文件、数据库备份:全部可由 loop.ps1 再生
|
||||
run/
|
||||
|
||||
# iCONAbpFX 是 staging 的拷贝(真实后台用户、client 配置),任何形式的库文件都不进 git
|
||||
*.bak
|
||||
*.mdf
|
||||
*.ldf
|
||||
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
# iXserverLoopEnv:模拟闭环测试环境
|
||||
|
||||
在 FG-W11-ICON-DEV 一台机器上把五个角色跑起来,让一条 IG 消息从入站到第三方回复、再回到 IG 全程走真 HTTP。
|
||||
方案与取舍见 iXserver 仓库的 `docs/构建模拟闭环测试环境.html`。
|
||||
|
||||
| 角色 | 端口 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| ABP(授权 + 消息模板) | https 44332 | `C:\Projects\HKTBAbp` 发布到 `run\abp` |
|
||||
| iXserver API(IIS Express) | 8081,WebSocket 33346 | `C:\Projects\iXserver` 整解决方案编译后拷到 `run\ixserver` |
|
||||
| 第三方 chatbot 客户端 | 5056 | `iXserver\Tools\ThirdPartyChatbotTestClient` |
|
||||
| IG 后端模拟 | 5070 | `iXserver\Tools\IgMockServer` |
|
||||
| SCRM 客服系统模拟 | 5080 | `iXserver\Tools\ScrmMockServer` |
|
||||
|
||||
所有端口只绑本机。iXserver 的旧接口和 WebSocket **没有鉴权**,绝不能对外开放 8081 / 33346。
|
||||
|
||||
## 这个仓库装什么
|
||||
|
||||
只装「环境」:`loop.ps1`、`config\`、`db\`、`tools\`、`scenarios\`、`deployed.json`。
|
||||
程序本身不在这里——两个模拟服务的代码在 iXserver 的 `Tools\`(它们模拟的是 iXserver 自己的协议,
|
||||
要和协议改动同一个提交);编译产物、日志、上传文件在被忽略的 `run\`。
|
||||
|
||||
**以 Mac 上的 `~/projects/iXserverLoopEnv` 为准**,用 remote-dev-box 的 `sync.sh` 推到 winbox 的
|
||||
`C:\Projects\iXserverLoopEnv`。提交在 Mac 上做,winbox 上只跑。
|
||||
|
||||
## 第一次
|
||||
|
||||
```powershell
|
||||
.\loop.ps1 reset-abp -Init # iCONAbpFX 拷完后:换 setsail secret 与 admin 密码,留底
|
||||
.\loop.ps1 deploy # 编译 iXserver 整解决方案 + 发布 ABP(慢)
|
||||
.\loop.ps1 reset # 建 iXserverLoop:EDMX 建表 → FeatureTest 种子 → loop_patch,留底
|
||||
.\loop.ps1 up
|
||||
```
|
||||
|
||||
之后日常:
|
||||
|
||||
```powershell
|
||||
.\loop.ps1 status
|
||||
.\loop.ps1 deploy -Only ixserver # 改了 iXserver 代码
|
||||
.\loop.ps1 deploy -Only ixserver -ConfigOnly # 只改了 config\ixserver\overrides.psd1
|
||||
.\loop.ps1 reset -FromBackup # 库回到干净状态(秒级)
|
||||
.\loop.ps1 logs -Only ixserver -Tail 100
|
||||
.\loop.ps1 down
|
||||
```
|
||||
|
||||
## 场景
|
||||
|
||||
`scenarios\` 下每个脚本独立可跑,通过退出 0、失败退出 1 并打印两侧记录。前提是 `up` 后五个角色都健康。
|
||||
|
||||
```powershell
|
||||
.\scenarios\s1-ig-text-echo.ps1 # IG 文本 → iXserver → 第三方 chatbot echo → IG 收到
|
||||
.\scenarios\s8-human-chain.ps1 # SCRM 开工 → 新 IG 用户 → 人工工单推送 → 指派 / 回复 → IG 收到 → 结束 → 下班
|
||||
```
|
||||
|
||||
S8 结束时会把服务号切回机器人、在线人数归零,所以 S1 / S8 先后跑互不影响;要停在人工状态看页面加 `-KeepShift`。
|
||||
|
||||
从 Mac 看几个页面用 SSH 本地端口转发:
|
||||
|
||||
```bash
|
||||
ssh -N -L 5056:127.0.0.1:5056 -L 5070:127.0.0.1:5070 -L 5080:127.0.0.1:5080 FG-W11-ICON-DEV
|
||||
```
|
||||
|
||||
## 几个容易踩的点
|
||||
|
||||
- **库是 iXserverLoop,不是 iXsererTest。** FeatureTest 每个方法都 drop 重建 iXsererTest,
|
||||
反过来闭环 API 的连接池连着它会让 FeatureTest 无限卡在删库上。
|
||||
- **API 从 `run\ixserver` 跑,不从 `API\` 源码目录跑。** 编译 FeatureTest 会刷新 `API\bin` 里的模块 dll,
|
||||
正在跑的站点会被回收、甚至换成依赖不全的版本。
|
||||
- **`reset` 把建库与 `loop_patch.sql` 绑在一起。** 种子里有真格式的 FB token 与 api.commas.hk 等真实 webhook,
|
||||
补丁把它们全换成假值 / 黑洞,并在末尾自检;不提供「只建不补」。
|
||||
- **`OAuth_Authority` 必须逐字等于本地 ABP 签出 token 的 `iss`。** IdentityServer4 没设 IssuerUri,
|
||||
iss 取请求的 Host,所以全链路统一用 `https://localhost:44332`,换成 127.0.0.1 就全部 401。
|
||||
- **签名密钥不在库里**,在 ABP 进程工作目录的 `tempkey.jwk`。deploy 删掉仓库里那把开发密钥,
|
||||
首次 `up` 时 ABP 自己生成一把,留存到 `run\abp-key\`,之后重新部署沿用同一把。
|
||||
- **备份在 SQL 实例的默认备份目录**(`*.loop.bak`),不在 `run\backup\`:SQL 服务账号对 `C:\Projects` 不一定有写权限。
|
||||
- **进程用 WMI(Win32_Process.Create)起**,不用 Start-Process:经 SSH 起的进程会随会话一起被杀。
|
||||
pid 记在 `run\pids\`,`down` 按进程树杀。
|
||||
- **VS Code 的自动端口转发会抢闭环端口。** 在 winbox 上用 VS Code Remote-SSH 连 Mac 时,它从终端输出里认
|
||||
`localhost:端口`,然后在 winbox 本机占住同一个端口转发回 Mac,闭环角色就报「端口被 Code 占着」。
|
||||
Mac 的 `~/.vscode-server/data/Machine/settings.json` 已设 `remote.autoForwardPorts=false`、
|
||||
`remote.restoreForwardedPorts=false`;已经转发出去的要在 VS Code「端口」面板里停掉。
|
||||
- **WMI 起的进程只有系统环境变量。** `TEMP` 是 `C:\Windows\TEMP`,ASP.NET 编译临时目录也会退回系统目录,
|
||||
没写权限,首个请求就 500。`loop.ps1` 给每个角色设了 `TEMP`/`TMP` 指到 `run\data\temp`,
|
||||
并在 Web.config 的 `<compilation>` 上写 `tempDirectory`。
|
||||
- **ABP 的 HTTPS 证书用的是 IIS Express 自带的 localhost 证书**(LocalMachine\My,到 2030 年,已在当前用户受信任根里)。
|
||||
winbox 的 ASP.NET dev 证书 2026-05 已过期;用 IIS Express 这张,iXserver(.NET Framework)连 ABP 不用另外信任、也不弹框。
|
||||
- **ABP 的各个 DbContext 按名字找连接串,找不到退回 `Default`**,而仓库 `appsettings.json` 的 `Default` 指向公司内网的库。
|
||||
`appsettings.Loop.json` 把所有连接串都盖成本机 iCONAbpFX。
|
||||
- PowerShell 5.1 的两个坑已经在 `loop.ps1` 里绕开:原生命令往 stderr 写一行就抛异常(统一走 `Invoke-Native`);
|
||||
`Get-Content` 默认按 GBK 读无 BOM 的 UTF-8(拷配置用 `Copy-Item`,脚本文件一律带 BOM)。
|
||||
- `deployed.json` 记录每次部署时两个源码仓库的 commit 与未提交文件。闭环结果离开「当时部署的是哪个版本」没法解读,
|
||||
跑场景的记录里引用它。注意 winbox 上 iXserver 仓库的 HEAD 不随 `sync.sh` 前进(只推文件),
|
||||
`commit` 字段可能停在很久以前,以 `uncommitted` 清单和 Mac 上的提交为准。
|
||||
|
||||
## 闭环专用凭据(假值,可公开)
|
||||
|
||||
见 `config\loop.psd1` 的 `Secrets`:setsail 与 FX_App 的 client secret、ABP admin 密码、所有渠道 token 的替身。
|
||||
staging 的 secret 明文不出现在这个仓库里。
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"App": {
|
||||
"SelfUrl": "https://localhost:44332"
|
||||
},
|
||||
// 各模块的 DbContext 按自己的名字找连接串,找不到退回 Default;仓库 appsettings.json 里的
|
||||
// Default / Mssql_218 等指向公司内网的库。全部盖成本机 iCONAbpFX,闭环绝不连到别的库
|
||||
"ConnectionStrings": {
|
||||
"Loop": "Server=(local)\\SQLEXPRESS;Database=iCONAbpFX;Uid=dev;Pwd=123456;",
|
||||
"Default": "Server=(local)\\SQLEXPRESS;Database=iCONAbpFX;Uid=dev;Pwd=123456;",
|
||||
"Mysql_IconStaging": "",
|
||||
"Mssql_FengLocal": "Server=(local)\\SQLEXPRESS;Database=iCONAbpFX;Uid=dev;Pwd=123456;",
|
||||
"Mssql_218": "Server=(local)\\SQLEXPRESS;Database=iCONAbpFX;Uid=dev;Pwd=123456;"
|
||||
},
|
||||
"DBConfig": {
|
||||
"DBType": "mssql",
|
||||
"ConnectionStringName": "Loop"
|
||||
},
|
||||
"AuthServer": {
|
||||
"Authority": "https://localhost:44332",
|
||||
"RequireHttpsMetadata": "false"
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
// winbox 上的 ASP.NET dev 证书 2026-05 已过期;改用 IIS Express 自带的 localhost 证书(到 2030 年、
|
||||
// 已在当前用户的受信任根里),这样 iXserver(.NET Framework)连 ABP 不用另外信任证书、也不弹确认框
|
||||
"Https": {
|
||||
"Url": "https://localhost:44332",
|
||||
"Certificate": { "Subject": "localhost", "Store": "My", "Location": "LocalMachine" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"FileMediaResourceService": {
|
||||
"StoreFilePath": "C:\\Projects\\iXserverLoopEnv\\run\\data\\abp-files",
|
||||
"StoreUrlPath": "https://localhost:44332/files/"
|
||||
},
|
||||
"iXserverAgentCSApplication": {
|
||||
"BaseUrl": "http://localhost:8081",
|
||||
"SocketServer": "ws://localhost:33346"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# deploy 时盖到 run\ixserver\Web.config 上的键(方案附录 A)。只覆盖这里列出的,其余沿用 API\Web.config。
|
||||
# {RunData} 由 loop.ps1 换成 run\data 的绝对路径;{AbpPort} 等换成 config\loop.psd1 里的端口。
|
||||
@{
|
||||
# 只替换 provider connection string 里的 data source / initial catalog / 凭据,metadata 部分不动
|
||||
ConnectionString = @{
|
||||
Name = 'iXsocialEntities'
|
||||
DataSource = '(local)\SQLEXPRESS'
|
||||
InitialCatalog = 'iXserverLoop'
|
||||
UserId = 'dev'
|
||||
Password = '123456'
|
||||
}
|
||||
|
||||
AppSettings = @{
|
||||
# ---- OAuth / ABP:iss 必须逐字等于本地 ABP 签出 token 的 iss,统一用 localhost,不带结尾斜杠
|
||||
'OAuth_Authority' = 'https://localhost:{AbpPort}'
|
||||
'Abp_TokenManager_TokenEndPoint' = 'https://localhost:{AbpPort}/connect/token'
|
||||
'Abp_TokenManager_RequestParas' = 'grant_type=password,response_type=token,username={AbpAdminUser},password={AbpAdminPassword},scope=FX,client_id=FX_App,client_secret={FxAppClientSecret}'
|
||||
'Abp_APIUrl' = 'https://localhost:{AbpPort}/'
|
||||
'AbpServiceAccountChatMessageProvider_API_GreetingUrl' = 'https://localhost:{AbpPort}/api/chat-service-account/messages/get-greeting-messages-by-code?chatServiceAccountCode={0}&cultureName={1}'
|
||||
'AbpServiceAccountChatMessageProvider_API_GoodbyUrl' = 'https://localhost:{AbpPort}/api/chat-service-account/messages/get-goodby-messages-by-code?chatServiceAccountCode={0}&cultureName={1}'
|
||||
|
||||
# ---- IG 出站打到 IG 模拟(依赖 InstagramMessagerApi 的 Instagram_GraphApiBaseUrl)
|
||||
'Instagram_GraphApiBaseUrl' = 'http://127.0.0.1:{IgMockPort}/v16.0'
|
||||
|
||||
# ---- 第三方 chatbot
|
||||
'ThirdPartyChatbot_EnforceAuthorization' = 'true'
|
||||
'ThirdPartyChatbot_WebhookRetryEnabled' = 'false' # S3 时改成 true 再 deploy -ConfigOnly
|
||||
'Curl_ExePath' = 'C:\Windows\System32\curl.exe'
|
||||
|
||||
# ---- 落盘目录:一律落在 run\data,不沿用 D:\...
|
||||
'LogFileLocation' = '{RunData}\API_Log\'
|
||||
'TempFolder' = '{RunData}\API_TempFolder\'
|
||||
'FileFolder' = '{RunData}\API_FileFolder\'
|
||||
'DbTimeLogLocation' = '{RunData}\API_Log_DB\'
|
||||
'AutoChatJsonSetting_JsonStoreDir' = '{RunData}\TicketJsonDir'
|
||||
|
||||
# ---- 自身地址:上传文件的访问地址要能从本机打开
|
||||
'ApiUrl' = 'http://localhost:{IxserverPort}/'
|
||||
'UploadFilesVirtualFolder' = 'http://localhost:{IxserverPort}/FileData/'
|
||||
'SocketServerUrl' = 'ws://localhost:{WebSocketPort}'
|
||||
'SocketWebSiteUrl' = 'http://localhost:{IxserverPort}'
|
||||
'APIChatbotWebHook' = 'http://localhost:{IxserverPort}/ai/service/webhook'
|
||||
'WebSocketPort' = '{WebSocketPort}'
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# 闭环环境的总配置:端口、源码位置、闭环专用的假凭据。loop.ps1 读它。
|
||||
# 改端口时,iXserver 的 overrides.psd1、db\ixserver\loop_patch.sql 里写死的地址要一起改。
|
||||
@{
|
||||
# 源码仓库(winbox 上的路径)。deploy 从这里编译
|
||||
IxserverRepo = 'C:\Projects\iXserver'
|
||||
AbpRepo = 'C:\Projects\HKTBAbp'
|
||||
|
||||
Sql = @{
|
||||
Server = '(local)\SQLEXPRESS'
|
||||
User = 'dev'
|
||||
Password = '123456'
|
||||
IxserverDb = 'iXserverLoop'
|
||||
AbpDb = 'iCONAbpFX'
|
||||
}
|
||||
|
||||
# 端口避开 FeatureTest 的 9090–9092,以及被 VS Code 占着的 44331 / 5055 / 33336
|
||||
Ports = @{
|
||||
Ixserver = 8081
|
||||
WebSocket = 33346 # iXserver 内嵌 SuperSocket,端口来自 Web.config 的 WebSocketPort;33336 被 VS Code 端口转发占着
|
||||
Abp = 44332
|
||||
Chatbot = 5056
|
||||
IgMock = 5070
|
||||
ScrmMock = 5080
|
||||
}
|
||||
|
||||
# 闭环专用的假凭据:与 staging / 生产无关,可以进 git
|
||||
Secrets = @{
|
||||
SetsailClientId = 'setsail'
|
||||
SetsailClientSecret = 'loop-setsail-secret' # db\abp\loop_patch.sql 写的就是它的 SHA256
|
||||
FxAppClientSecret = 'loop-fxapp-secret' # 同上;iXserver 用 FX_App + admin 密码换 token 取模板
|
||||
AbpAdminUser = 'admin'
|
||||
AbpAdminPassword = 'Loop@123456' # db\abp\loop_patch.sql 写的就是它的哈希
|
||||
FakeChannelToken = 'loop-fake-token' # 所有服务号的 FB / IG / Line / WA token 都换成它
|
||||
}
|
||||
|
||||
# 被测服务号(FeatureTest 种子里的 IG 演示号)
|
||||
ServiceCode = 'epro_ig_demo'
|
||||
IgAccount = '17841458722619988'
|
||||
IgPageId = '568932443471849'
|
||||
CompanyCode = 'SM_HKTB_IG'
|
||||
AdminCode = 'HKTB'
|
||||
|
||||
# 第三方客户端收到投递后自动回复:echo / 任意固定文本;留空则只能在页面上手动回
|
||||
ChatbotAutoReply = 'echo'
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
-- iCONAbpFX 的闭环补丁:库从 staging 拷来后执行一次(loop.ps1 reset-abp -Init),之后 BACKUP 到实例默认备份目录留底。
|
||||
--
|
||||
-- 签名密钥不在库里:ABP 用 AddDeveloperSigningCredential,从宿主目录的 tempkey.jwk 读,
|
||||
-- 没有就生成。loop.ps1 deploy 会删掉发布目录里的 tempkey.*,让本机生成一把只在 run\abp 里的新密钥,
|
||||
-- 所以闭环签出的 token 在 staging / 生产都验不过。(方案 v4 §2.1 以为密钥入库,实际不是。)
|
||||
--
|
||||
-- 这里只换三样凭据,让闭环不必知道 staging 的明文:
|
||||
-- setsail 的 client secret → loop-setsail-secret
|
||||
-- FX_App 的 client secret → loop-fxapp-secret
|
||||
-- admin 的密码(iXserver 用 FX_App + password 授权取模板)→ Loop@123456
|
||||
-- 明文都在 config\loop.psd1 里,是闭环专用的假值。
|
||||
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
------------------------------------------------------------------------------------------------
|
||||
-- 1. setsail 的 secret:IdentityServer4 存的是 Base64(SHA256(UTF8(secret)))
|
||||
------------------------------------------------------------------------------------------------
|
||||
DECLARE @setsail UNIQUEIDENTIFIER = (SELECT Id FROM dbo.IdentityServerClients WHERE ClientId = N'setsail');
|
||||
IF @setsail IS NULL THROW 50001, N'iCONAbpFX 里没有 setsail client,库是不是从 staging 拷的?', 1;
|
||||
|
||||
DELETE FROM dbo.IdentityServerClientSecrets WHERE ClientId = @setsail;
|
||||
INSERT INTO dbo.IdentityServerClientSecrets (ClientId, Type, Value, Description, Expiration)
|
||||
VALUES (@setsail, N'SharedSecret', N'ooFrp78vixE5Hq+OfnE3FXBedZPUiT8mjNzICr2EB80=', N'loop env', NULL);
|
||||
|
||||
-- FX_App 同理
|
||||
DECLARE @fxapp UNIQUEIDENTIFIER = (SELECT Id FROM dbo.IdentityServerClients WHERE ClientId = N'FX_App');
|
||||
IF @fxapp IS NULL THROW 50003, N'iCONAbpFX 里没有 FX_App client', 1;
|
||||
|
||||
DELETE FROM dbo.IdentityServerClientSecrets WHERE ClientId = @fxapp;
|
||||
INSERT INTO dbo.IdentityServerClientSecrets (ClientId, Type, Value, Description, Expiration)
|
||||
VALUES (@fxapp, N'SharedSecret', N'4wsAVCVjGIuA3sm8w1VV1yvrlD9+nrONjlWXabX6cJw=', N'loop env', NULL);
|
||||
|
||||
------------------------------------------------------------------------------------------------
|
||||
-- 2. admin 的密码:ASP.NET Core Identity V3 格式(HMACSHA256,10000 次,16 字节盐)
|
||||
-- 盐是固定的 'loop-env-salt-01',补丁可重复执行、结果不变
|
||||
------------------------------------------------------------------------------------------------
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.AbpUsers WHERE UserName = N'admin' AND TenantId IS NULL)
|
||||
THROW 50002, N'iCONAbpFX 里没有 host 的 admin 用户', 1;
|
||||
|
||||
UPDATE dbo.AbpUsers
|
||||
SET PasswordHash = N'AQAAAAEAACcQAAAAEGxvb3AtZW52LXNhbHQtMDHzcKLvk4EOtgWerM+7p4SzG/uo/xPnsQw7mAP1Jo6Gvw==',
|
||||
SecurityStamp = CONVERT(NVARCHAR(36), NEWID()),
|
||||
AccessFailedCount = 0,
|
||||
LockoutEnd = NULL
|
||||
WHERE UserName = N'admin' AND TenantId IS NULL;
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
SELECT c.ClientId, c.Enabled, c.AccessTokenLifetime,
|
||||
(SELECT COUNT(*) FROM dbo.IdentityServerClientSecrets s WHERE s.ClientId = c.Id) AS secrets
|
||||
FROM dbo.IdentityServerClients c
|
||||
WHERE c.ClientId IN (N'setsail', N'FX_App');
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
-- iXserverLoop 的闭环补丁:在 FeatureTest 种子(test_db_init_script.sql)之上执行。
|
||||
-- loop.ps1 reset 里与建库、灌种子绑在一起执行,不提供「只建不补」的路径(方案 §8 #6):
|
||||
-- 种子里是真格式的 FB token 和真实的外部 webhook,不补就起 API,消息会漏到外面。
|
||||
--
|
||||
-- 端口与 config\loop.psd1 一致:第三方客户端 5056。改端口要同步改这里。
|
||||
-- 需要 SQL Server 2016+(JSON_MODIFY)。winbox 上是 SQL Server 2022 Express。
|
||||
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DECLARE @fakeToken NVARCHAR(50) = N'loop-fake-token';
|
||||
DECLARE @blackhole NVARCHAR(100) = N'http://127.0.0.1:9/loop-blackhole'; -- 9 = discard,本机没人听,立即拒绝连接
|
||||
DECLARE @chatbot NVARCHAR(200) = N'http://localhost:5056/webhook?mode=ticket-with-source&service_code=epro_ig_demo';
|
||||
|
||||
------------------------------------------------------------------------------------------------
|
||||
-- 1. 所有服务号的真凭据换成假值(列 + all_setting 里的同名字段)
|
||||
------------------------------------------------------------------------------------------------
|
||||
UPDATE dbo.service_account_config SET fb_token = @fakeToken WHERE fb_token IS NOT NULL AND fb_token <> N'';
|
||||
UPDATE dbo.service_account_config SET wc_app_secret = @fakeToken WHERE wc_app_secret IS NOT NULL AND wc_app_secret <> N'';
|
||||
UPDATE dbo.service_account_config SET wc_account_token = @fakeToken WHERE wc_account_token IS NOT NULL AND wc_account_token <> N'';
|
||||
UPDATE dbo.service_account_config SET wc_api_token = @fakeToken WHERE wc_api_token IS NOT NULL AND wc_api_token <> N'';
|
||||
UPDATE dbo.service_account_config SET wc_AESKey = @fakeToken WHERE wc_AESKey IS NOT NULL AND wc_AESKey <> N'';
|
||||
UPDATE dbo.service_account_config SET line_channel_secret = @fakeToken WHERE line_channel_secret IS NOT NULL AND line_channel_secret <> N'';
|
||||
UPDATE dbo.service_account_config SET line_long_live_access_token = @fakeToken WHERE line_long_live_access_token IS NOT NULL AND line_long_live_access_token <> N'';
|
||||
UPDATE dbo.service_account_config SET emma_token = @fakeToken WHERE emma_token IS NOT NULL AND emma_token <> N'';
|
||||
|
||||
-- JSON_MODIFY 在 lax 模式下会给不存在的路径新建键,所以每条都先判断路径存在
|
||||
UPDATE dbo.service_account_config SET all_setting = JSON_MODIFY(all_setting, '$.InstagramSetting.FanPageToken', @fakeToken)
|
||||
WHERE ISJSON(all_setting) = 1 AND JSON_VALUE(all_setting, '$.InstagramSetting.FanPageToken') IS NOT NULL;
|
||||
UPDATE dbo.service_account_config SET all_setting = JSON_MODIFY(all_setting, '$.EmmaSetting.Token', @fakeToken)
|
||||
WHERE ISJSON(all_setting) = 1 AND JSON_VALUE(all_setting, '$.EmmaSetting.Token') IS NOT NULL;
|
||||
UPDATE dbo.service_account_config SET all_setting = JSON_MODIFY(all_setting, '$.LineSetting.ChannelSecret', @fakeToken)
|
||||
WHERE ISJSON(all_setting) = 1 AND JSON_VALUE(all_setting, '$.LineSetting.ChannelSecret') IS NOT NULL;
|
||||
UPDATE dbo.service_account_config SET all_setting = JSON_MODIFY(all_setting, '$.LineSetting.LongLiveAccessToken', @fakeToken)
|
||||
WHERE ISJSON(all_setting) = 1 AND JSON_VALUE(all_setting, '$.LineSetting.LongLiveAccessToken') IS NOT NULL;
|
||||
|
||||
------------------------------------------------------------------------------------------------
|
||||
-- 2. 所有服务号指向外部的 chatbot 地址一律打进黑洞(api.commas.hk、hktbchatbot.com 等)
|
||||
------------------------------------------------------------------------------------------------
|
||||
UPDATE dbo.service_account_config SET all_setting = JSON_MODIFY(all_setting, '$.AISetting.AIProviderWebhook.Webhook', @blackhole)
|
||||
WHERE ISJSON(all_setting) = 1 AND JSON_VALUE(all_setting, '$.AISetting.AIProviderWebhook.Webhook') IS NOT NULL;
|
||||
UPDATE dbo.service_account_config SET all_setting = JSON_MODIFY(all_setting, '$.AutoResponeSetting.CustomChatbotWebhook', @blackhole)
|
||||
WHERE ISJSON(all_setting) = 1 AND JSON_VALUE(all_setting, '$.AutoResponeSetting.CustomChatbotWebhook') IS NOT NULL;
|
||||
UPDATE dbo.service_account_config SET all_setting = JSON_MODIFY(all_setting, '$.AutoResponeSetting.OfflineChatbotWebhook', @blackhole)
|
||||
WHERE ISJSON(all_setting) = 1 AND JSON_VALUE(all_setting, '$.AutoResponeSetting.OfflineChatbotWebhook') IS NOT NULL;
|
||||
UPDATE dbo.service_account_config SET ai_setting = JSON_MODIFY(ai_setting, '$.APIUrl', @blackhole)
|
||||
WHERE ISJSON(ai_setting) = 1 AND JSON_VALUE(ai_setting, '$.APIUrl') IS NOT NULL;
|
||||
-- 旧版 ai_setting.DataJson 是「字符串里套 JSON」,里面还有一份 AIProviderWebhook.Webhook。
|
||||
-- DataJson 动辄超过 4000 字,JSON_VALUE 会返回 NULL,所以用 OPENJSON 取整段
|
||||
UPDATE c SET ai_setting = JSON_MODIFY(c.ai_setting, '$.DataJson', JSON_MODIFY(j.DataJson, '$.AIProviderWebhook.Webhook', @blackhole))
|
||||
FROM dbo.service_account_config c
|
||||
CROSS APPLY OPENJSON(c.ai_setting) WITH (DataJson NVARCHAR(MAX) '$.DataJson') j
|
||||
WHERE ISJSON(c.ai_setting) = 1 AND ISJSON(j.DataJson) = 1
|
||||
AND JSON_VALUE(j.DataJson, '$.AIProviderWebhook.Webhook') IS NOT NULL;
|
||||
|
||||
------------------------------------------------------------------------------------------------
|
||||
-- 3. 被测服务号 epro_ig_demo:chatbot 指向本地第三方客户端,模板走 ABP
|
||||
-- 切人工、客服在线、额度池不写在这里,由 SCRM 模拟调接口完成(方案 §4)
|
||||
------------------------------------------------------------------------------------------------
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.service_account_config WHERE code = N'epro_ig_demo' AND ISJSON(all_setting) = 1)
|
||||
THROW 50001, N'种子里找不到 epro_ig_demo 或它的 all_setting 不是 JSON', 1;
|
||||
|
||||
UPDATE dbo.service_account_config
|
||||
SET all_setting = JSON_MODIFY(JSON_MODIFY(all_setting,
|
||||
'$.AISetting.AIProviderWebhook.Webhook', @chatbot),
|
||||
'$.ChatMessageSetting.CurrentProviderKey', N'AbpServiceAccountChatMessageProvider')
|
||||
WHERE code = N'epro_ig_demo';
|
||||
|
||||
------------------------------------------------------------------------------------------------
|
||||
-- 4. 第三方 client 登记:两张表都要有 setsail(方案 §2 链路 c)。ip_whitelist 留空 = 不限来源
|
||||
------------------------------------------------------------------------------------------------
|
||||
DELETE FROM dbo.third_party_chatbot_client WHERE client_id = N'setsail';
|
||||
DELETE FROM dbo.oauth_client WHERE client_id = N'setsail';
|
||||
INSERT INTO dbo.oauth_client (id, client_id, name, allowed_scopes, ip_whitelist, enabled, create_datetime, update_datetime)
|
||||
VALUES (1001, N'setsail', N'setsail (loop)', N'ixserver_chatbot', NULL, 1, GETDATE(), GETDATE());
|
||||
INSERT INTO dbo.third_party_chatbot_client (id, client_id, service_codes, create_datetime, update_datetime)
|
||||
VALUES (1001, N'setsail', N'epro_ig_demo', GETDATE(), GETDATE());
|
||||
|
||||
------------------------------------------------------------------------------------------------
|
||||
-- 5. 自检:补完之后库里不应再有任何真 token 或外部 chatbot 地址
|
||||
------------------------------------------------------------------------------------------------
|
||||
DECLARE @leaks INT = (
|
||||
SELECT COUNT(*) FROM dbo.service_account_config
|
||||
WHERE (fb_token IS NOT NULL AND fb_token NOT IN (N'', @fakeToken))
|
||||
OR all_setting LIKE N'%commas.hk/%Webhook%'
|
||||
OR all_setting LIKE N'%hktbchatbot.com%'
|
||||
OR ai_setting LIKE N'%commas.hk/%Webhook%'
|
||||
OR ai_setting LIKE N'%hktbchatbot.com%');
|
||||
IF @leaks > 0
|
||||
THROW 50002, N'补丁后仍有服务号带着真 token 或外部 chatbot 地址,检查种子里是否出现了新字段', 1;
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
SELECT code,
|
||||
JSON_VALUE(all_setting, '$.AISetting.IsEnable') AS ai_enable,
|
||||
JSON_VALUE(all_setting, '$.AISetting.AIProviderWebhook.Webhook') AS webhook,
|
||||
JSON_VALUE(all_setting, '$.ChatMessageSetting.CurrentProviderKey') AS message_provider,
|
||||
JSON_VALUE(all_setting, '$.InstagramSetting.InstagramAccount') AS ig_account,
|
||||
JSON_VALUE(all_setting, '$.InstagramSetting.FanPageToken') AS ig_token
|
||||
FROM dbo.service_account_config WHERE code = N'epro_ig_demo';
|
||||
|
|
@ -0,0 +1,535 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
模拟闭环测试环境的编排:部署、起停、重置、状态。方案见 iXserver 仓库 docs\构建模拟闭环测试环境.html。
|
||||
|
||||
.DESCRIPTION
|
||||
五个角色(依赖顺序):abp → ixserver → chatbot → igmock → scrm
|
||||
deploy 从源码仓库编译,产物拷进 run\,写 deployed.json
|
||||
up 按依赖顺序起,逐个等健康检查
|
||||
down 倒序停
|
||||
restart down + up
|
||||
reset 重建 iXserverLoop(EDMX 建表 → FeatureTest 种子 → loop_patch)并备份;-FromBackup 直接还原
|
||||
reset-abp iCONAbpFX 还原到留底备份;-Init 首次打补丁并留底
|
||||
status 各角色进程、端口、健康
|
||||
logs 看某个角色的日志尾巴
|
||||
|
||||
.EXAMPLE
|
||||
.\loop.ps1 deploy
|
||||
.\loop.ps1 deploy -Only ixserver -ConfigOnly # 只把 overrides.psd1 重新盖到 Web.config
|
||||
.\loop.ps1 reset-abp -Init # 拷完 iCONAbpFX 后执行一次
|
||||
.\loop.ps1 reset
|
||||
.\loop.ps1 up
|
||||
.\loop.ps1 status
|
||||
.\loop.ps1 logs -Only ixserver
|
||||
#>
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[ValidateSet('deploy', 'up', 'down', 'restart', 'reset', 'reset-abp', 'status', 'logs')]
|
||||
[string]$Command = 'status',
|
||||
|
||||
# 只操作这些角色:abp, ixserver, chatbot, igmock, scrm
|
||||
[string[]]$Only,
|
||||
|
||||
[switch]$SkipBuild, # deploy:不编译,直接拷现有产物
|
||||
[switch]$ConfigOnly, # deploy:只重写配置(ixserver 的 Web.config / abp 的 appsettings.Loop.json)
|
||||
[switch]$FromBackup, # reset:从上次留底的备份还原,不重建
|
||||
[switch]$Init, # reset-abp:首次打补丁并留底
|
||||
[int]$Tail = 60 # logs:显示多少行
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version 2
|
||||
|
||||
$Root = $PSScriptRoot
|
||||
$Cfg = Import-PowerShellDataFile (Join-Path $Root 'config\loop.psd1')
|
||||
$Run = Join-Path $Root 'run'
|
||||
$Dirs = @{
|
||||
Ixserver = Join-Path $Run 'ixserver'
|
||||
Abp = Join-Path $Run 'abp'
|
||||
AbpKey = Join-Path $Run 'abp-key'
|
||||
Data = Join-Path $Run 'data'
|
||||
Logs = Join-Path $Run 'logs'
|
||||
Pids = Join-Path $Run 'pids'
|
||||
Tools = Join-Path $Run 'tools'
|
||||
}
|
||||
foreach ($d in $Dirs.Values) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d | Out-Null } }
|
||||
|
||||
$AllRoles = @('abp', 'ixserver', 'chatbot', 'igmock', 'scrm')
|
||||
$Roles = if ($Only) { $AllRoles | Where-Object { $Only -contains $_ } } else { $AllRoles }
|
||||
|
||||
# 健康检查要连本机 ABP 的 dev 证书;只影响本 PowerShell 进程
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
[Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||
|
||||
# ============================================================================ 小工具
|
||||
|
||||
function Write-Step([string]$text) { Write-Host "==> $text" -ForegroundColor Cyan }
|
||||
function Write-Ok([string]$text) { Write-Host " $text" -ForegroundColor Green }
|
||||
function Write-Warn2([string]$text) { Write-Host " $text" -ForegroundColor Yellow }
|
||||
|
||||
function Expand-Template([string]$text) {
|
||||
$map = @{
|
||||
'{RunData}' = $Dirs.Data
|
||||
'{IxserverPort}' = $Cfg.Ports.Ixserver
|
||||
'{WebSocketPort}' = $Cfg.Ports.WebSocket
|
||||
'{AbpPort}' = $Cfg.Ports.Abp
|
||||
'{IgMockPort}' = $Cfg.Ports.IgMock
|
||||
'{AbpAdminUser}' = $Cfg.Secrets.AbpAdminUser
|
||||
'{AbpAdminPassword}' = $Cfg.Secrets.AbpAdminPassword
|
||||
'{FxAppClientSecret}' = $Cfg.Secrets.FxAppClientSecret
|
||||
}
|
||||
foreach ($k in $map.Keys) { $text = $text.Replace($k, [string]$map[$k]) }
|
||||
return $text
|
||||
}
|
||||
|
||||
<#
|
||||
PS 5.1 在 ErrorActionPreference=Stop 下,原生命令往 stderr 写一行(2>&1、2>$null 都一样)就抛异常,
|
||||
后面的 $LASTEXITCODE 判断和输出收集都轮不到。原生命令一律经这里调:临时放宽,stderr 并进输出转成字符串,
|
||||
退出码照常留在 $LASTEXITCODE 里由调用方判断。
|
||||
#>
|
||||
function Invoke-Native([scriptblock]$block) {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
& $block 2>&1 | ForEach-Object { "$_" }
|
||||
}
|
||||
|
||||
function Find-MSBuild {
|
||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
||||
if (Test-Path $vswhere) {
|
||||
$found = & $vswhere -latest -requires Microsoft.Component.MSBuild -find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1
|
||||
if ($found) { return $found }
|
||||
}
|
||||
throw '找不到 MSBuild(需要 VS 2022 / Build Tools)'
|
||||
}
|
||||
|
||||
function Invoke-Sql {
|
||||
param([string]$Database = 'master', [string]$Query, [string]$File, [switch]$Utf16)
|
||||
$sqlArgs = @('-S', $Cfg.Sql.Server, '-U', $Cfg.Sql.User, '-P', $Cfg.Sql.Password, '-d', $Database, '-b', '-I', '-W', '-h', '-1')
|
||||
if ($File) {
|
||||
# UTF-16 带 BOM 的文件(FeatureTest 种子)sqlcmd 自己认;其余按 UTF-8 读
|
||||
if (-not $Utf16) { $sqlArgs += @('-f', '65001') }
|
||||
$sqlArgs += @('-i', $File)
|
||||
}
|
||||
else { $sqlArgs += @('-Q', $Query) }
|
||||
$out = Invoke-Native { & sqlcmd @sqlArgs }
|
||||
if ($LASTEXITCODE -ne 0) { throw "sqlcmd 失败($Database):`n$($out -join "`n")" }
|
||||
return $out
|
||||
}
|
||||
|
||||
function Get-BackupPath([string]$name) {
|
||||
# 写到实例的默认备份目录:SQL 服务账号对 C:\Projects 下不一定有写权限,不为此去改 ACL
|
||||
$dir = (Invoke-Sql -Query "SET NOCOUNT ON; SELECT CONVERT(NVARCHAR(400), SERVERPROPERTY('InstanceDefaultBackupPath'))" | Select-Object -First 1).Trim()
|
||||
return (Join-Path $dir "$name.loop.bak")
|
||||
}
|
||||
|
||||
function Test-Port([int]$port) {
|
||||
return [bool](Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
function Wait-Until([string]$what, [scriptblock]$check, [int]$timeoutSec = 120) {
|
||||
$deadline = (Get-Date).AddSeconds($timeoutSec)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try { if (& $check) { return } } catch { }
|
||||
Start-Sleep -Milliseconds 800
|
||||
}
|
||||
throw "$what 在 $timeoutSec 秒内没有就绪"
|
||||
}
|
||||
|
||||
# 任何 HTTP 应答(含 4xx/5xx)都算「起来了」;连不上才算没起
|
||||
function Test-HttpAlive([string]$url) {
|
||||
try {
|
||||
$req = [Net.HttpWebRequest]::Create($url)
|
||||
$req.Timeout = 5000
|
||||
$resp = $req.GetResponse(); $resp.Close(); return $true
|
||||
}
|
||||
catch [Net.WebException] {
|
||||
if ($_.Exception.Response) { $_.Exception.Response.Close(); return $true }
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RolePid([string]$role) {
|
||||
$file = Join-Path $Dirs.Pids "$role.pid"
|
||||
if (-not (Test-Path $file)) { return $null }
|
||||
$id = [int](Get-Content $file -Raw)
|
||||
if (Get-Process -Id $id -ErrorAction SilentlyContinue) { return $id }
|
||||
Remove-Item $file -Force
|
||||
return $null
|
||||
}
|
||||
|
||||
<#
|
||||
经 SSH 起的进程会随会话的 job object 一起被杀;用 WMI 的 Win32_Process.Create 起,
|
||||
进程挂在 WmiPrvSE 下面,SSH 断开也不受影响。输出经 cmd 重定向进 run\logs\<role>.log。
|
||||
#>
|
||||
function Start-Role([string]$role, [string]$workDir, [string]$commandLine, [hashtable]$envVars) {
|
||||
$log = Join-Path $Dirs.Logs "$role.log"
|
||||
if (Test-Path $log) { Move-Item $log "$log.prev" -Force }
|
||||
# WMI 起的进程只有系统环境变量,TEMP 是 C:\Windows\TEMP(当前用户不一定可写),统一指到 run\data\temp
|
||||
$temp = Join-Path $Dirs.Data 'temp'
|
||||
if (-not (Test-Path $temp)) { New-Item -ItemType Directory -Path $temp | Out-Null }
|
||||
$prefix = "set `"TEMP=$temp`" && set `"TMP=$temp`" && "
|
||||
if ($envVars) { foreach ($k in $envVars.Keys) { $prefix += "set `"$k=$($envVars[$k])`" && " } }
|
||||
$full = "cmd.exe /c `"$prefix$commandLine > `"$log`" 2>&1`""
|
||||
$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $full; CurrentDirectory = $workDir }
|
||||
if ($result.ReturnValue -ne 0) { throw "$role 启动失败:Win32_Process.Create 返回 $($result.ReturnValue)" }
|
||||
Set-Content -Path (Join-Path $Dirs.Pids "$role.pid") -Value $result.ProcessId
|
||||
return $result.ProcessId
|
||||
}
|
||||
|
||||
function Stop-Role([string]$role) {
|
||||
$id = Get-RolePid $role
|
||||
if (-not $id) { Write-Ok "$role 没在跑"; return }
|
||||
Invoke-Native { & taskkill.exe /PID $id /T /F } | Out-Null
|
||||
$global:LASTEXITCODE = 0
|
||||
Remove-Item (Join-Path $Dirs.Pids "$role.pid") -Force -ErrorAction SilentlyContinue
|
||||
Write-Ok "$role 已停(pid $id)"
|
||||
}
|
||||
|
||||
function Get-NodeExe {
|
||||
$node = Get-Command node -ErrorAction SilentlyContinue
|
||||
if (-not $node) { throw '找不到 node(需要 Node ≥ 18)' }
|
||||
return $node.Source
|
||||
}
|
||||
|
||||
# ============================================================================ 各角色定义
|
||||
|
||||
function Get-RoleSpec([string]$role) {
|
||||
$p = $Cfg.Ports
|
||||
$tools = Join-Path $Cfg.IxserverRepo 'Tools'
|
||||
switch ($role) {
|
||||
'abp' {
|
||||
return @{
|
||||
Port = $p.Abp; WorkDir = $Dirs.Abp
|
||||
Command = "dotnet iCON.Abp.FX.HttpApi.Host.dll"
|
||||
Env = @{ ASPNETCORE_ENVIRONMENT = 'Loop' }
|
||||
Health = { Test-HttpAlive "https://localhost:$($Cfg.Ports.Abp)/.well-known/openid-configuration" }
|
||||
Timeout = 180
|
||||
}
|
||||
}
|
||||
'ixserver' {
|
||||
$iis = Join-Path $env:ProgramFiles 'IIS Express\iisexpress.exe'
|
||||
return @{
|
||||
Port = $p.Ixserver; WorkDir = $Dirs.Ixserver
|
||||
Command = "`"$iis`" /path:`"$($Dirs.Ixserver)`" /port:$($p.Ixserver) /clr:v4.0 /systray:false"
|
||||
# 首个请求才触发 Application_Start,内嵌的 WebSocket 也是那时起的,所以两样都等
|
||||
Health = { (Test-HttpAlive "http://localhost:$($Cfg.Ports.Ixserver)/") -and (Test-Port $Cfg.Ports.WebSocket) }
|
||||
Timeout = 240
|
||||
}
|
||||
}
|
||||
'chatbot' {
|
||||
$s = $Cfg.Secrets
|
||||
$cmd = "`"$(Get-NodeExe)`" `"$tools\ThirdPartyChatbotTestClient\server.js`" --no-tunnel --port $($p.Chatbot)" +
|
||||
" --api-base http://localhost:$($p.Ixserver) --service-code $($Cfg.ServiceCode)" +
|
||||
" --token-url http://localhost:$($p.Ixserver)/api/oauth/token --client-id $($s.SetsailClientId) --client-secret $($s.SetsailClientSecret)"
|
||||
if ($Cfg.ChatbotAutoReply) { $cmd += " --auto-reply $($Cfg.ChatbotAutoReply)" }
|
||||
return @{ Port = $p.Chatbot; WorkDir = $Root; Command = $cmd; Health = { Test-HttpAlive "http://127.0.0.1:$($Cfg.Ports.Chatbot)/_api/state" }; Timeout = 30 }
|
||||
}
|
||||
'igmock' {
|
||||
$cmd = "`"$(Get-NodeExe)`" `"$tools\IgMockServer\server.js`" --port $($p.IgMock) --ixserver http://localhost:$($p.Ixserver)" +
|
||||
" --ig-account $($Cfg.IgAccount) --page-id $($Cfg.IgPageId) --expect-token $($Cfg.Secrets.FakeChannelToken)"
|
||||
return @{ Port = $p.IgMock; WorkDir = $Root; Command = $cmd; Health = { Test-HttpAlive "http://127.0.0.1:$($Cfg.Ports.IgMock)/_api/state" }; Timeout = 30 }
|
||||
}
|
||||
'scrm' {
|
||||
$script = "$tools\ScrmMockServer\server.js"
|
||||
if (-not (Test-Path $script)) { return $null } # P7 之前还没有
|
||||
$cmd = "`"$(Get-NodeExe)`" `"$script`" --port $($p.ScrmMock) --ixserver http://localhost:$($p.Ixserver)" +
|
||||
" --ws ws://localhost:$($p.WebSocket) --admin-code $($Cfg.AdminCode) --company-code $($Cfg.CompanyCode) --service-code $($Cfg.ServiceCode)"
|
||||
return @{ Port = $p.ScrmMock; WorkDir = $Root; Command = $cmd; Health = { Test-HttpAlive "http://127.0.0.1:$($Cfg.Ports.ScrmMock)/_api/state" }; Timeout = 30 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================ deploy
|
||||
|
||||
function Set-WebConfigOverrides {
|
||||
$overrides = Import-PowerShellDataFile (Join-Path $Root 'config\ixserver\overrides.psd1')
|
||||
$path = Join-Path $Dirs.Ixserver 'Web.config'
|
||||
$xml = New-Object Xml.XmlDocument
|
||||
$xml.PreserveWhitespace = $true
|
||||
$xml.Load($path)
|
||||
|
||||
$cs = $overrides.ConnectionString
|
||||
$node = $xml.SelectSingleNode("/configuration/connectionStrings/add[@name='$($cs.Name)']")
|
||||
if (-not $node) { throw "Web.config 里没有连接串 $($cs.Name)" }
|
||||
$value = $node.GetAttribute('connectionString')
|
||||
$value = $value -replace '(?i)data source=[^;"&]*', "data source=$($cs.DataSource)"
|
||||
$value = $value -replace '(?i)initial catalog=[^;"&]*', "initial catalog=$($cs.InitialCatalog)"
|
||||
$value = $value -replace '(?i)user id=[^;"&]*', "user id=$($cs.UserId)"
|
||||
$value = $value -replace '(?i)password=[^;"&]*', "password=$($cs.Password)"
|
||||
$node.SetAttribute('connectionString', $value)
|
||||
|
||||
$appSettings = $xml.SelectSingleNode('/configuration/appSettings')
|
||||
foreach ($key in $overrides.AppSettings.Keys) {
|
||||
$v = Expand-Template $overrides.AppSettings[$key]
|
||||
$add = $appSettings.SelectSingleNode("add[@key='$key']")
|
||||
if (-not $add) {
|
||||
$add = $xml.CreateElement('add')
|
||||
$add.SetAttribute('key', $key)
|
||||
[void]$appSettings.AppendChild($add)
|
||||
}
|
||||
$add.SetAttribute('value', $v)
|
||||
}
|
||||
# WMI 起的进程拿不到用户自己的 %TEMP%,ASP.NET 退回系统的 Temporary ASP.NET Files 又没写权限,
|
||||
# 首个请求就 500。编译临时目录显式放进 run\data,不去改系统目录的 ACL
|
||||
$compilation = $xml.SelectSingleNode('/configuration/system.web/compilation')
|
||||
if (-not $compilation) { throw 'Web.config 里没有 system.web/compilation' }
|
||||
$compilation.SetAttribute('tempDirectory', (Join-Path $Dirs.Data 'aspnet-temp'))
|
||||
|
||||
$xml.Save($path)
|
||||
|
||||
foreach ($sub in 'API_Log', 'API_TempFolder', 'API_FileFolder', 'API_Log_DB', 'TicketJsonDir', 'aspnet-temp') {
|
||||
$d = Join-Path $Dirs.Data $sub
|
||||
if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d | Out-Null }
|
||||
}
|
||||
Write-Ok "Web.config 已按 overrides.psd1 覆盖($($overrides.AppSettings.Count) 个键 + 连接串 → $($cs.InitialCatalog))"
|
||||
}
|
||||
|
||||
function Deploy-Ixserver {
|
||||
$repo = $Cfg.IxserverRepo
|
||||
if (-not $ConfigOnly) {
|
||||
if (-not $SkipBuild) {
|
||||
Write-Step "编译 iXserver 整个解决方案(模块的 OutputPath 指向 API\bin,只编 API 会缺模块)"
|
||||
$msbuild = Find-MSBuild
|
||||
$buildLog = Join-Path $Dirs.Logs 'build-ixserver.log'
|
||||
& $msbuild (Join-Path $repo 'iXsocial.sln') /t:Build /p:Configuration=Debug /m /nologo /v:minimal "/flp:LogFile=$buildLog;Encoding=UTF-8;Verbosity=normal" | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "iXserver 编译失败,见 $buildLog" }
|
||||
}
|
||||
Write-Step '拷贝 API → run\ixserver'
|
||||
& robocopy.exe (Join-Path $repo 'API') $Dirs.Ixserver /MIR /XD obj .vs /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -ge 8) { throw "robocopy 失败(退出码 $LASTEXITCODE)" }
|
||||
$global:LASTEXITCODE = 0
|
||||
foreach ($dll in 'iCON.iXserver.Module.ThirdPartyChatbot.BLL.dll', 'iCON.iXserver.Module.ThirdPartyChatbot.API.dll', 'iCON.iXserver.Module.AI.dll', 'InstagramChatChannel.dll') {
|
||||
if (-not (Test-Path (Join-Path $Dirs.Ixserver "bin\$dll"))) { throw "run\ixserver\bin 里缺 $dll(CLAUDE.md 第三节:模块要在宿主 bin 里)" }
|
||||
}
|
||||
}
|
||||
Set-WebConfigOverrides
|
||||
}
|
||||
|
||||
function Deploy-Abp {
|
||||
$hostDir = Join-Path $Cfg.AbpRepo 'src\iCON.Abp.FX.HttpApi.Host'
|
||||
if (-not $ConfigOnly) {
|
||||
Write-Step '发布 ABP(net5.0)→ run\abp'
|
||||
$buildLog = Join-Path $Dirs.Logs 'build-abp.log'
|
||||
& dotnet publish (Join-Path $hostDir 'iCON.Abp.FX.HttpApi.Host.csproj') -c Release -o $Dirs.Abp --nologo "-flp:LogFile=$buildLog;Encoding=UTF-8;Verbosity=normal" | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "ABP 发布失败,见 $buildLog" }
|
||||
# 仓库里那把开发密钥不用:删掉,让本机生成(或沿用上次生成的)一把只在这台机器上的
|
||||
Remove-Item (Join-Path $Dirs.Abp 'tempkey.*') -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path (Join-Path $Dirs.AbpKey 'tempkey.jwk')) { Copy-Item (Join-Path $Dirs.AbpKey 'tempkey.*') $Dirs.Abp -Force }
|
||||
}
|
||||
# 原样拷贝:PS 5.1 的 Get-Content 默认按 GBK 读无 BOM 的 UTF-8,中文注释会乱码并吞掉换行
|
||||
Copy-Item (Join-Path $Root 'config\abp\appsettings.Loop.json') (Join-Path $Dirs.Abp 'appsettings.Loop.json') -Force
|
||||
Write-Ok 'appsettings.Loop.json 已就位(ASPNETCORE_ENVIRONMENT=Loop)'
|
||||
}
|
||||
|
||||
# git 在还没有提交的仓库上 rev-parse HEAD 会失败,记成空即可
|
||||
function Invoke-Git([string]$repo) {
|
||||
$gitArgs = $args # scriptblock 里的 $args 是它自己的(空的),先存下来
|
||||
$out = Invoke-Native { & git -C $repo @gitArgs }
|
||||
if ($LASTEXITCODE -ne 0) { $out = $null }
|
||||
$global:LASTEXITCODE = 0
|
||||
return $out
|
||||
}
|
||||
|
||||
function Get-RepoState([string]$repo) {
|
||||
if (-not (Test-Path (Join-Path $repo '.git'))) { return @{ repo = $repo; note = '不是 git 仓库' } }
|
||||
return [ordered]@{
|
||||
repo = $repo
|
||||
branch = (Invoke-Git $repo rev-parse --abbrev-ref HEAD)
|
||||
commit = (Invoke-Git $repo rev-parse HEAD)
|
||||
# winbox 的工作区是 sync.sh 推过来的,有未提交改动在所难免,如实记下(方案 §2.1)
|
||||
uncommitted = @(Invoke-Git $repo status --porcelain)
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Deployed([string[]]$deployedRoles) {
|
||||
$file = Join-Path $Root 'deployed.json'
|
||||
$state = [ordered]@{}
|
||||
if (Test-Path $file) {
|
||||
$old = Get-Content $file -Raw | ConvertFrom-Json
|
||||
foreach ($p in $old.PSObject.Properties) { $state[$p.Name] = $p.Value }
|
||||
}
|
||||
$now = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz')
|
||||
if ($deployedRoles -contains 'ixserver') { $state['ixserver'] = [ordered]@{ deployedAt = $now; configOnly = [bool]$ConfigOnly; source = (Get-RepoState $Cfg.IxserverRepo) } }
|
||||
if ($deployedRoles -contains 'abp') { $state['abp'] = [ordered]@{ deployedAt = $now; configOnly = [bool]$ConfigOnly; source = (Get-RepoState $Cfg.AbpRepo) } }
|
||||
$state['loopEnv'] = Get-RepoState $Root
|
||||
$state | ConvertTo-Json -Depth 6 | Set-Content -Path $file -Encoding UTF8
|
||||
Write-Ok "部署来源已记入 deployed.json"
|
||||
}
|
||||
|
||||
function Invoke-Deploy {
|
||||
$targets = @($Roles | Where-Object { $_ -in 'ixserver', 'abp' })
|
||||
if (-not $targets) { Write-Ok 'Node 工具直接从 iXserver\Tools 运行,不需要部署'; return }
|
||||
$restart = @($targets | Where-Object { Get-RolePid $_ })
|
||||
foreach ($r in $restart) { Stop-Role $r } # 跑着的进程锁着 dll
|
||||
if ($targets -contains 'abp') { Deploy-Abp }
|
||||
if ($targets -contains 'ixserver') { Deploy-Ixserver }
|
||||
Write-Deployed $targets
|
||||
if ($restart) { Write-Step "重新拉起部署前在跑的:$($restart -join ', ')"; Invoke-Up $restart }
|
||||
}
|
||||
|
||||
# ============================================================================ up / down / status
|
||||
|
||||
function Invoke-Up([string[]]$which) {
|
||||
$sql = Get-Service 'MSSQL$SQLEXPRESS' -ErrorAction SilentlyContinue
|
||||
if (-not $sql -or $sql.Status -ne 'Running') { throw 'SQL Server Express(MSSQL$SQLEXPRESS)没在运行' }
|
||||
foreach ($role in $which) {
|
||||
$spec = Get-RoleSpec $role
|
||||
if (-not $spec) { Write-Warn2 "$role 还没有实现,跳过"; continue }
|
||||
if (Get-RolePid $role) { Write-Ok "$role 已在跑"; continue }
|
||||
if (Test-Port $spec.Port) {
|
||||
$owner = (Get-NetTCPConnection -State Listen -LocalPort $spec.Port | Select-Object -First 1).OwningProcess
|
||||
throw "$role 的端口 $($spec.Port) 被别的进程占着(pid $owner $((Get-Process -Id $owner).ProcessName)),先处理再起"
|
||||
}
|
||||
Write-Step "启动 $role(:$($spec.Port))"
|
||||
$id = Start-Role $role $spec.WorkDir $spec.Command $(if ($spec.ContainsKey('Env')) { $spec.Env })
|
||||
try {
|
||||
Wait-Until $role $spec.Health $spec.Timeout
|
||||
}
|
||||
catch {
|
||||
Write-Warn2 "日志尾巴(run\logs\$role.log):"
|
||||
Get-Content (Join-Path $Dirs.Logs "$role.log") -Tail 30 -ErrorAction SilentlyContinue | Out-Host
|
||||
throw
|
||||
}
|
||||
Write-Ok "$role 就绪(pid $id)"
|
||||
if ($role -eq 'abp' -and -not (Test-Path (Join-Path $Dirs.AbpKey 'tempkey.jwk')) -and (Test-Path (Join-Path $Dirs.Abp 'tempkey.jwk'))) {
|
||||
Copy-Item (Join-Path $Dirs.Abp 'tempkey.*') $Dirs.AbpKey -Force
|
||||
Write-Ok '本机签名密钥已留存到 run\abp-key(之后重新部署沿用同一把)'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Down([string[]]$which) {
|
||||
[array]::Reverse($which)
|
||||
foreach ($role in $which) { Stop-Role $role }
|
||||
}
|
||||
|
||||
function Invoke-Status {
|
||||
$rows = foreach ($role in $AllRoles) {
|
||||
$spec = Get-RoleSpec $role
|
||||
if (-not $spec) { [pscustomobject]@{ Role = $role; Pid = ''; Port = ''; Listening = ''; Healthy = '未实现' }; continue }
|
||||
$id = Get-RolePid $role
|
||||
$healthy = $false
|
||||
try { $healthy = [bool](& $spec.Health) } catch { }
|
||||
[pscustomobject]@{ Role = $role; Pid = $id; Port = $spec.Port; Listening = (Test-Port $spec.Port); Healthy = $healthy }
|
||||
}
|
||||
$rows | Format-Table -AutoSize | Out-Host
|
||||
$deployed = Join-Path $Root 'deployed.json'
|
||||
if (Test-Path $deployed) {
|
||||
$d = Get-Content $deployed -Raw | ConvertFrom-Json
|
||||
foreach ($name in 'ixserver', 'abp') {
|
||||
if ($d.PSObject.Properties.Name -contains $name) {
|
||||
$s = $d.$name.source
|
||||
Write-Host (" {0,-9} 部署于 {1} {2}@{3} 未提交 {4} 个" -f $name, $d.$name.deployedAt, $s.branch, ([string]$s.commit).Substring(0, [Math]::Min(10, ([string]$s.commit).Length)), @($s.uncommitted).Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================ reset
|
||||
|
||||
function Remove-Database([string]$db) {
|
||||
Invoke-Sql -Query "IF DB_ID(N'$db') IS NOT NULL BEGIN ALTER DATABASE [$db] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [$db]; END" | Out-Null
|
||||
}
|
||||
|
||||
function Restore-Database([string]$db, [string]$bak) {
|
||||
if (-not (Test-Path $bak)) { throw "没有留底备份 $bak" }
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-Sql -Query "IF DB_ID(N'$db') IS NOT NULL ALTER DATABASE [$db] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; RESTORE DATABASE [$db] FROM DISK = N'$bak' WITH REPLACE; ALTER DATABASE [$db] SET MULTI_USER;" | Out-Null
|
||||
Write-Ok "$db 已从 $bak 还原($([int]$sw.Elapsed.TotalMilliseconds) ms)"
|
||||
}
|
||||
|
||||
function Backup-Database([string]$db, [string]$bak) {
|
||||
Invoke-Sql -Query "BACKUP DATABASE [$db] TO DISK = N'$bak' WITH INIT, FORMAT" | Out-Null
|
||||
Write-Ok "$db 已留底 → $bak"
|
||||
}
|
||||
|
||||
<# 现编一个小 exe,用部署出来的 Model.dll 按 EDMX 建库(tools\CreateLoopDb.cs) #>
|
||||
function New-IxserverSchema {
|
||||
$bin = Join-Path $Dirs.Ixserver 'bin'
|
||||
if (-not (Test-Path (Join-Path $bin 'Model.dll'))) { throw '还没有部署 iXserver(run\ixserver\bin\Model.dll 不存在),先 deploy' }
|
||||
$toolDir = Join-Path $Dirs.Tools 'CreateLoopDb'
|
||||
if (Test-Path $toolDir) { Remove-Item $toolDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $toolDir | Out-Null
|
||||
Copy-Item (Join-Path $bin 'Model.dll'), (Join-Path $bin 'EntityFramework*.dll') $toolDir
|
||||
|
||||
$csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
|
||||
$exe = Join-Path $toolDir 'CreateLoopDb.exe'
|
||||
$out = Invoke-Native { & $csc /nologo /target:exe "/out:$exe" "/reference:$toolDir\Model.dll" "/reference:$toolDir\EntityFramework.dll" /reference:System.Data.Entity.dll (Join-Path $Root 'tools\CreateLoopDb.cs') }
|
||||
if ($LASTEXITCODE -ne 0) { throw "编译 CreateLoopDb 失败:`n$($out -join "`n")" }
|
||||
|
||||
# exe.config 取自部署后的 Web.config:连接串(已指向 iXserverLoop)、EF 配置节、程序集重定向
|
||||
$web = New-Object Xml.XmlDocument; $web.Load((Join-Path $Dirs.Ixserver 'Web.config'))
|
||||
$cfg = New-Object Xml.XmlDocument
|
||||
$root = $cfg.AppendChild($cfg.CreateElement('configuration'))
|
||||
foreach ($name in 'configSections', 'connectionStrings', 'entityFramework', 'runtime') {
|
||||
$n = $web.SelectSingleNode("/configuration/$name")
|
||||
if ($n) { [void]$root.AppendChild($cfg.ImportNode($n, $true)) }
|
||||
}
|
||||
# configSections 里别的节(log4net 等)这里用不到,只留 entityFramework,免得缺程序集报错
|
||||
$sections = $cfg.SelectSingleNode('/configuration/configSections')
|
||||
if ($sections) { foreach ($s in @($sections.ChildNodes)) { if ($s.NodeType -eq 'Element' -and $s.GetAttribute('name') -ne 'entityFramework') { [void]$sections.RemoveChild($s) } } }
|
||||
$cfg.Save("$exe.config")
|
||||
|
||||
$out = Invoke-Native { & $exe $bin }
|
||||
if ($LASTEXITCODE -ne 0) { throw "按 EDMX 建库失败:`n$($out -join "`n")" }
|
||||
}
|
||||
|
||||
function Invoke-Reset {
|
||||
$db = $Cfg.Sql.IxserverDb
|
||||
$bak = Get-BackupPath $db
|
||||
$wasRunning = [bool](Get-RolePid 'ixserver')
|
||||
# EF 连接池连着库会让删库 / 还原失败,先停 API
|
||||
if ($wasRunning) { Stop-Role 'ixserver' }
|
||||
|
||||
if ($FromBackup) {
|
||||
Restore-Database $db $bak
|
||||
}
|
||||
else {
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
Write-Step "重建 $db"
|
||||
Remove-Database $db
|
||||
New-IxserverSchema
|
||||
Write-Ok '表结构已按 EDMX 建好'
|
||||
$seed = Join-Path $Cfg.IxserverRepo 'FeatureTest\AppData\test_db_init_script.sql'
|
||||
Invoke-Sql -Database $db -File $seed -Utf16 | Out-Null
|
||||
Write-Ok '已灌 FeatureTest 种子'
|
||||
# 补丁与建库绑在一起:种子里有真 token、真外部 webhook,不补不许起(方案 §8 #6)
|
||||
Invoke-Sql -Database $db -File (Join-Path $Root 'db\ixserver\loop_patch.sql') | Out-Host
|
||||
Write-Ok "loop_patch 已执行(共 $([int]$sw.Elapsed.TotalSeconds) 秒)"
|
||||
Backup-Database $db $bak
|
||||
}
|
||||
if ($wasRunning) { Invoke-Up @('ixserver') }
|
||||
}
|
||||
|
||||
function Invoke-ResetAbp {
|
||||
$db = $Cfg.Sql.AbpDb
|
||||
$bak = Get-BackupPath $db
|
||||
$wasRunning = [bool](Get-RolePid 'abp')
|
||||
if ($wasRunning) { Stop-Role 'abp' }
|
||||
if ($Init) {
|
||||
Write-Step "给 $db 打闭环补丁(换 setsail secret 与 admin 密码)"
|
||||
Invoke-Sql -Database $db -File (Join-Path $Root 'db\abp\loop_patch.sql') | Out-Host
|
||||
Backup-Database $db $bak
|
||||
}
|
||||
else {
|
||||
Restore-Database $db $bak
|
||||
}
|
||||
if ($wasRunning) { Invoke-Up @('abp') }
|
||||
}
|
||||
|
||||
# ============================================================================ 入口
|
||||
|
||||
switch ($Command) {
|
||||
'deploy' { Invoke-Deploy }
|
||||
'up' { Invoke-Up $Roles; Invoke-Status }
|
||||
'down' { Invoke-Down @($Roles) }
|
||||
'restart' { Invoke-Down @($Roles); Invoke-Up $Roles; Invoke-Status }
|
||||
'reset' { Invoke-Reset }
|
||||
'reset-abp' { Invoke-ResetAbp }
|
||||
'status' { Invoke-Status }
|
||||
'logs' {
|
||||
foreach ($role in $Roles) {
|
||||
Write-Step "run\logs\$role.log"
|
||||
Get-Content (Join-Path $Dirs.Logs "$role.log") -Tail $Tail -ErrorAction SilentlyContinue | Out-Host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<#
|
||||
S1:IG 用户发一条文本 → iXserver → 第三方 chatbot(echo 自动回复)→ iXserver → IG 用户收到 echo。
|
||||
前提:.\loop.ps1 up 四个角色都健康,chatbot 以 --auto-reply echo 运行(config\loop.psd1 的 ChatbotAutoReply)。
|
||||
通过退出 0,失败退出 1 并打印两侧的记录。用法:.\scenarios\s1-ig-text-echo.ps1 [-TimeoutSec 60]
|
||||
#>
|
||||
param([int]$TimeoutSec = 60)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$Cfg = Import-PowerShellDataFile (Join-Path $PSScriptRoot '..\config\loop.psd1')
|
||||
$ig = "http://127.0.0.1:$($Cfg.Ports.IgMock)"
|
||||
$cb = "http://127.0.0.1:$($Cfg.Ports.Chatbot)"
|
||||
|
||||
function Fail([string]$why) {
|
||||
Write-Host "S1 失败:$why" -ForegroundColor Red
|
||||
Write-Host '--- IG 模拟记录'
|
||||
(Invoke-RestMethod "$ig/_api/state").events | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress } | Out-Host
|
||||
Write-Host '--- chatbot 记录(最近 6 条)'
|
||||
(Invoke-RestMethod "$cb/_api/state").events | Select-Object -Last 6 | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress } | Out-Host
|
||||
exit 1
|
||||
}
|
||||
|
||||
Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null
|
||||
$text = 'loop-s1 ' + [guid]::NewGuid().ToString('N').Substring(0, 8)
|
||||
$sent = Invoke-RestMethod "$ig/_api/send" -Method Post -ContentType 'application/json; charset=utf-8' -Body (@{ kind = 'text'; text = $text } | ConvertTo-Json)
|
||||
if ($sent.event.response.status -ne 200) { Fail "iXserver 收 webhook 返回 $($sent.event.response.status)" }
|
||||
|
||||
# 出站是异步的:轮询 IG 模拟的 outbound,带上限
|
||||
$expected = "echo: $text"
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
$hit = $null
|
||||
while (-not $hit -and (Get-Date) -lt $deadline) {
|
||||
$hit = @((Invoke-RestMethod "$ig/_api/state").events | Where-Object { $_.kind -eq 'outbound' -and $_.summary -eq $expected }) | Select-Object -First 1
|
||||
if (-not $hit) { Start-Sleep -Milliseconds 500 }
|
||||
}
|
||||
if (-not $hit) { Fail "$TimeoutSec 秒内 IG 用户没有收到「$expected」" }
|
||||
if (@($hit.issues).Count) { Fail "出站请求有问题:$($hit.issues -join ';')" }
|
||||
if (-not $hit.tokenPresent) { Fail '出站请求没带 access_token' }
|
||||
|
||||
$reply = @((Invoke-RestMethod "$cb/_api/state").events | Where-Object { $_.kind -eq 'reply' -and $_.message -eq $expected }) | Select-Object -First 1
|
||||
if (-not $reply) { Fail 'chatbot 侧没有对应的回复记录' }
|
||||
if ($reply.response.status -ne 201) { Fail "send-txt 返回 $($reply.response.status)" }
|
||||
|
||||
Write-Host "S1 通过:「$text」→ ticket $($reply.ticketId) → IG 收到「$expected」" -ForegroundColor Green
|
||||
exit 0
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<#
|
||||
S8:人工链路本身(不经第三方 chatbot)。
|
||||
SCRM 开工(SetSetting IsEnable=false + UpdateCSAvailable 3)→ 客服上线(UserReg + 额度池)
|
||||
→ 新 IG 用户发消息 → 人工工单,SCRM 在 WebSocket 上收到推送 → 指派 → 打开(验证信息 + 已读)
|
||||
→ 以客服身份回复 → IG 用户收到 → 结束工单 → SCRM 下班(切回机器人、在线 0),不影响 S1。
|
||||
前提:.\loop.ps1 up 五个角色都健康。每次用新的 IGSID,保证建出新工单。
|
||||
通过退出 0,失败退出 1 并打印记录。用法:.\scenarios\s8-human-chain.ps1 [-TimeoutSec 60] [-KeepShift]
|
||||
#>
|
||||
param([int]$TimeoutSec = 60, [switch]$KeepShift)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$Cfg = Import-PowerShellDataFile (Join-Path $PSScriptRoot '..\config\loop.psd1')
|
||||
$ig = "http://127.0.0.1:$($Cfg.Ports.IgMock)"
|
||||
$sc = "http://127.0.0.1:$($Cfg.Ports.ScrmMock)"
|
||||
|
||||
function Post([string]$url, $body) {
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes(($body | ConvertTo-Json -Compress -Depth 6))
|
||||
try { return Invoke-RestMethod $url -Method Post -ContentType 'application/json; charset=utf-8' -Body $bytes }
|
||||
catch {
|
||||
# 动作失败时模拟方回 502,正文里带着每一步的记录
|
||||
$resp = $_.Exception.Response
|
||||
if (-not $resp) { throw }
|
||||
$reader = New-Object IO.StreamReader($resp.GetResponseStream(), [Text.Encoding]::UTF8)
|
||||
return ($reader.ReadToEnd() | ConvertFrom-Json)
|
||||
}
|
||||
}
|
||||
|
||||
function Fail([string]$why) {
|
||||
Write-Host "S8 失败:$why" -ForegroundColor Red
|
||||
Write-Host '--- SCRM 模拟记录(最近 15 条)'
|
||||
(Invoke-RestMethod "$sc/_api/state").events | Select-Object -First 15 | ForEach-Object { ($_ | ConvertTo-Json -Depth 8 -Compress) } | Out-Host
|
||||
Write-Host '--- IG 模拟记录'
|
||||
(Invoke-RestMethod "$ig/_api/state").events | ForEach-Object { "{0} {1} {2}" -f $_.kind, $_.igsid, $_.summary } | Out-Host
|
||||
if (-not $KeepShift) { Post "$sc/_api/shift" @{ on = $false } | Out-Null }
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Step([string]$action, $body) {
|
||||
$r = Post "$sc/_api/$action" $body
|
||||
if (-not $r.ok) { Fail "$action 未成功:$($r.error) $((@($r.calls) | ForEach-Object { $_.api + ' → ' + ($_.response.body.msg) }) -join ';')" }
|
||||
return $r
|
||||
}
|
||||
|
||||
function Wait-For([string]$what, [scriptblock]$probe) {
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$hit = & $probe
|
||||
if ($hit) { return $hit }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
Fail "$TimeoutSec 秒内没等到:$what"
|
||||
}
|
||||
|
||||
$ws = (Invoke-RestMethod "$sc/_api/state").state.ws
|
||||
if ($ws.status -ne 'logged-in') { Fail "SCRM 模拟的 WebSocket 没登录($($ws.status) $($ws.lastError))" }
|
||||
|
||||
Invoke-RestMethod "$sc/_api/clear" -Method Post | Out-Null
|
||||
Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null
|
||||
|
||||
Step 'shift' @{ on = $true } | Out-Null
|
||||
Step 'agent-online' @{} | Out-Null
|
||||
|
||||
$igsid = 'loop-s8-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
|
||||
$sent = Post "$ig/_api/send" @{ kind = 'text'; igsid = $igsid; text = 'S8 想找人工' }
|
||||
if ($sent.event.response.status -ne 200) { Fail "iXserver 收 webhook 返回 $($sent.event.response.status)" }
|
||||
|
||||
# 推送报文没有统一类型字段,按 enduser_openid = IGSID 认出这张新工单
|
||||
$ticketId = Wait-For 'SCRM 收到这位 IG 用户的工单推送' {
|
||||
$push = @((Invoke-RestMethod "$sc/_api/state").events | Where-Object { $_.kind -eq 'push' -and (($_.parsed | ConvertTo-Json -Depth 8 -Compress) -match [regex]::Escape($igsid)) }) | Select-Object -First 1
|
||||
if ($push) { $push.ticketIds | Select-Object -First 1 }
|
||||
}
|
||||
|
||||
Step 'assign' @{ ticket_id = $ticketId } | Out-Null
|
||||
Step 'open' @{ ticket_id = $ticketId } | Out-Null
|
||||
$replyText = 'S8 客服回复 ' + [guid]::NewGuid().ToString('N').Substring(0, 6)
|
||||
Step 'reply' @{ ticket_id = $ticketId; text = $replyText } | Out-Null
|
||||
|
||||
$out = Wait-For "IG 用户收到「$replyText」" {
|
||||
@((Invoke-RestMethod "$ig/_api/state").events | Where-Object { $_.kind -eq 'outbound' -and $_.igsid -eq $igsid -and $_.summary -eq $replyText }) | Select-Object -First 1
|
||||
}
|
||||
if (@($out.issues).Count) { Fail "出站请求有问题:$($out.issues -join ';')" }
|
||||
|
||||
Step 'complete' @{ ticket_id = $ticketId } | Out-Null
|
||||
if (-not $KeepShift) { Step 'shift' @{ on = $false } | Out-Null }
|
||||
|
||||
Write-Host "S8 通过:$igsid → 人工工单 $ticketId → SCRM 收到推送、指派、回复 → IG 收到「$replyText」→ 已结束" -ForegroundColor Green
|
||||
exit 0
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// 按 iXserver 的 EDMX 建一个空库,与 FeatureTest 的 TestBase.Setup 同一条路(Database.Create)。
|
||||
// 这样表结构永远跟当前部署的 Model.dll 一致,不用另外维护一份 schema.sql。
|
||||
//
|
||||
// loop.ps1 reset 用 .NET Framework 自带的 csc 现编现用:
|
||||
// 引用 run\ixserver\bin 里的 Model.dll / EntityFramework.dll,连接串由同名 .exe.config 给。
|
||||
// Model 的实体还引用了 bin 里别的程序集(WebChat.Message 等),所以第一个参数传 bin 目录,从那里解析依赖。
|
||||
// 库已存在时直接失败——删库由 loop.ps1 负责,这里不做任何破坏性操作。
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
static class CreateLoopDb
|
||||
{
|
||||
static int Main(string[] args)
|
||||
{
|
||||
if (args.Length != 1 || !Directory.Exists(args[0]))
|
||||
{
|
||||
Console.Error.WriteLine("usage: CreateLoopDb <run\\ixserver\\bin>");
|
||||
return 64;
|
||||
}
|
||||
var bin = args[0];
|
||||
AppDomain.CurrentDomain.AssemblyResolve += (sender, e) =>
|
||||
{
|
||||
var path = Path.Combine(bin, new AssemblyName(e.Name).Name + ".dll");
|
||||
return File.Exists(path) ? Assembly.LoadFrom(path) : null;
|
||||
};
|
||||
try
|
||||
{
|
||||
return Create();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 单独一个方法:Main 被 JIT 时还没挂上 AssemblyResolve,不能在 Main 里直接碰 Model 的类型
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
static int Create()
|
||||
{
|
||||
using (var context = new Model.iXsocialEntities())
|
||||
{
|
||||
if (context.Database.Exists())
|
||||
{
|
||||
Console.Error.WriteLine("database already exists, drop it first");
|
||||
return 2;
|
||||
}
|
||||
context.Database.Create();
|
||||
}
|
||||
Console.WriteLine("created");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue