From ad136a9d53a09f8c17d5b42223919e503e197df5 Mon Sep 17 00:00:00 2001 From: fengruixiang <> Date: Wed, 21 Oct 2026 14:13:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=97=AD=E7=8E=AF=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=20loop.ps1=E3=80=81=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E3=80=81=E6=95=B0=E6=8D=AE=E5=BA=93=E8=A1=A5=E4=B8=81=E4=B8=8E?= =?UTF-8?q?=20S1/S8=20=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitattributes | 3 + .gitignore | 9 + README.md | 97 ++++++ config/abp/appsettings.Loop.json | 40 +++ config/ixserver/overrides.psd1 | 45 +++ config/loop.psd1 | 45 +++ db/abp/loop_patch.sql | 54 ++++ db/ixserver/loop_patch.sql | 101 ++++++ loop.ps1 | 535 +++++++++++++++++++++++++++++++ scenarios/s1-ig-text-echo.ps1 | 44 +++ scenarios/s8-human-chain.ps1 | 87 +++++ tools/CreateLoopDb.cs | 55 ++++ 12 files changed, 1115 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/abp/appsettings.Loop.json create mode 100644 config/ixserver/overrides.psd1 create mode 100644 config/loop.psd1 create mode 100644 db/abp/loop_patch.sql create mode 100644 db/ixserver/loop_patch.sql create mode 100644 loop.ps1 create mode 100644 scenarios/s1-ig-text-echo.ps1 create mode 100644 scenarios/s8-human-chain.ps1 create mode 100644 tools/CreateLoopDb.cs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..01105d4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto +# 注意:PowerShell 5.1 把无 BOM 的 UTF-8 脚本当 ANSI(GBK)读,中文会变乱码甚至把字符串截断。 +# .ps1 / .psd1 一律带 UTF-8 BOM 保存。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9f79f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# 编译产物、日志、上传文件、数据库备份:全部可由 loop.ps1 再生 +run/ + +# iCONAbpFX 是 staging 的拷贝(真实后台用户、client 配置),任何形式的库文件都不进 git +*.bak +*.mdf +*.ldf + +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbc9818 --- /dev/null +++ b/README.md @@ -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 的 `` 上写 `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 明文不出现在这个仓库里。 diff --git a/config/abp/appsettings.Loop.json b/config/abp/appsettings.Loop.json new file mode 100644 index 0000000..3e08f5e --- /dev/null +++ b/config/abp/appsettings.Loop.json @@ -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" + } +} diff --git a/config/ixserver/overrides.psd1 b/config/ixserver/overrides.psd1 new file mode 100644 index 0000000..89b34b2 --- /dev/null +++ b/config/ixserver/overrides.psd1 @@ -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}' + } +} diff --git a/config/loop.psd1 b/config/loop.psd1 new file mode 100644 index 0000000..9e6ff59 --- /dev/null +++ b/config/loop.psd1 @@ -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' +} diff --git a/db/abp/loop_patch.sql b/db/abp/loop_patch.sql new file mode 100644 index 0000000..55be6c4 --- /dev/null +++ b/db/abp/loop_patch.sql @@ -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'); diff --git a/db/ixserver/loop_patch.sql b/db/ixserver/loop_patch.sql new file mode 100644 index 0000000..5320264 --- /dev/null +++ b/db/ixserver/loop_patch.sql @@ -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'; diff --git a/loop.ps1 b/loop.ps1 new file mode 100644 index 0000000..ed28918 --- /dev/null +++ b/loop.ps1 @@ -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\.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 + } + } +} diff --git a/scenarios/s1-ig-text-echo.ps1 b/scenarios/s1-ig-text-echo.ps1 new file mode 100644 index 0000000..fd55db2 --- /dev/null +++ b/scenarios/s1-ig-text-echo.ps1 @@ -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 diff --git a/scenarios/s8-human-chain.ps1 b/scenarios/s8-human-chain.ps1 new file mode 100644 index 0000000..28d0806 --- /dev/null +++ b/scenarios/s8-human-chain.ps1 @@ -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 diff --git a/tools/CreateLoopDb.cs b/tools/CreateLoopDb.cs new file mode 100644 index 0000000..c829fe0 --- /dev/null +++ b/tools/CreateLoopDb.cs @@ -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 "); + 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; + } +}