Compare commits

...

2 Commits

Author SHA1 Message Date
fengruixiang ba133c4fb5 chore(docker): 本地开发栈补齐 PAYG 配置与 .bak 架构补齐脚本
- docker-compose.local.yml: 新增 AppConfig__TwoC__TenantId 与
    IsMockupPayment,隨付即用(PAYG)建单需校验 2C 池租户,否则报
    "TwoC.TenantId is not configured"
  - 新增 local-schema-catchup.sql:AML-local-dev.bak 快照缺
    __EFMigrationsHistory,DbMigrator 无从增量,补上 PAYG /
    AgentUserSetting / SplitOrderType 三个迁移的 DDL,并回补
    IsDeleted 的 DEFAULT 0 约束(全部带存在性判断,可重复执行)
  - 文档 docker-compose-local-dev.md 更名为 readme.md
2026-07-22 11:12:01 +08:00
fengruixiang b2b41e59e4 chore(docker): 本地开发栈迁至 monorepo 根目录并适配跨仓库构建上下文
docker-compose-local-dev/ 从 AML_Backend 子仓库移到父仓库根目录,与
  docs/AML_Backend/ 下的旧副本合并为唯一一份,避免两处各自漂移。

  因为这套栈现在位于 AML_Backend 仓库之外,构建上下文必须跨仓库指向:
  - context 改为 ../AML_Backend;dockerfile 用 ../docker-compose-local-dev/
    Dockerfile.local,从上下文外引用(BuildKit 支持)
  - appsettings.local.json 已不在构建上下文内,原来的
    COPY docker-compose-local-dev/appsettings.local.json 会失效,改用命名附加
    上下文传入:compose 侧 additional_contexts: {localcfg: .},Dockerfile 侧
    COPY --from=localcfg(host 与 migrator 两个 stage 各一处)

  同时补入旧副本没有的 seed-root-ou.sql。AML-local-dev.bak 由 *.bak 规则忽略。
  镜像已重建并实测:恢复 .bak + 跑种子后,门户端点 GetEditionList /
  GetPlanList 均返回 200。

  Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
    COPY docker-compose-local-dev/appsettings.local.json 会失效,改用命名附加
    上下文传入:compose 侧 additional_contexts: {localcfg: .},Dockerfile 侧
    COPY --from=localcfg(host 与 migrator 两个 stage 各一处)

  同时补入旧副本没有的 seed-root-ou.sql。AML-local-dev.bak 由 *.bak 规则忽略。
  镜像已重建并实测:恢复 .bak + 跑种子后,门户端点 GetEditionList /
  GetPlanList 均返回 200。
2026-07-21 15:50:55 +08:00
8 changed files with 266 additions and 34 deletions

View File

@ -1,19 +0,0 @@
{
"mcpServers": {
"mssql": {
"command": "uvx",
"args": ["mcp-server-mssql"],
"env": {
"MSSQL_CONNECTION_STRING": "Server=localhost;Database=YourDatabase;User Id=YourUser;Password=YourPassword;TrustServerCertificate=True"
},
"disabled": false,
"autoApprove": []
},
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"],
"disabled": false,
"autoApprove": []
}
}
}

View File

@ -6,17 +6,19 @@
# - Targets .NET 6 (the projects are net6.0; the CI Dockerfile still says 5.0). # - Targets .NET 6 (the projects are net6.0; the CI Dockerfile still says 5.0).
# - Does not COPY the non-existent src/access-token.bin. # - Does not COPY the non-existent src/access-token.bin.
# - Produces two runtime targets: `host` (the API) and `migrator` (data init). # - Produces two runtime targets: `host` (the API) and `migrator` (data init).
# - Bakes docker-compose-local-dev/appsettings.local.json over appsettings.json. # - Bakes appsettings.local.json over appsettings.json.
# The repo's appsettings.json now only carries a NacosConfig section and pulls # The repo's appsettings.json now only carries a NacosConfig section and pulls
# the real settings from a Nacos server (192.168.1.120:8848) that is not # the real settings from a Nacos server (192.168.1.120:8848) that is not
# reachable in local dev. Program.cs only loads Nacos when a NacosConfig # reachable in local dev. Program.cs only loads Nacos when a NacosConfig
# section exists, so replacing appsettings.json with a Nacos-free, fully # section exists, so replacing appsettings.json with a Nacos-free, fully
# self-contained config makes the app start without Nacos. # self-contained config makes the app start without Nacos.
# #
# Build context = repository root (AML_Backend); this Dockerfile lives one level # Build context = the AML_Backend repo root. This Dockerfile lives OUTSIDE that
# down in docker-compose-local-dev/. BuildKit uses the sibling # repo, in the monorepo-root docker-compose-local-dev/, so appsettings.local.json
# docker-compose-local-dev/Dockerfile.local.dockerignore (not the repo-root # is not reachable from the main context; compose passes that directory in as the
# .dockerignore), which keeps appsettings.json and docker-compose-local-dev/. # named additional context `localcfg` (see docker-compose.local.yml). BuildKit
# uses the sibling Dockerfile.local.dockerignore (not AML_Backend/.dockerignore),
# which keeps appsettings.json.
############################ ############################
# Restore + build (whole solution available) # Restore + build (whole solution available)
@ -75,7 +77,7 @@ WORKDIR /app
COPY --from=publish-host /app/host ./ COPY --from=publish-host /app/host ./
# Local dev: replace the Nacos-only appsettings.json with a full self-contained # Local dev: replace the Nacos-only appsettings.json with a full self-contained
# config (no NacosConfig section -> the app does not try to reach Nacos). # config (no NacosConfig section -> the app does not try to reach Nacos).
COPY docker-compose-local-dev/appsettings.local.json ./appsettings.json COPY --from=localcfg appsettings.local.json ./appsettings.json
EXPOSE 44331 EXPOSE 44331
ENTRYPOINT ["dotnet", "iCON.Abp.FX.HttpApi.Host.dll"] ENTRYPOINT ["dotnet", "iCON.Abp.FX.HttpApi.Host.dll"]
@ -89,7 +91,7 @@ WORKDIR /app
COPY --from=publish-migrator /app/migrator ./ COPY --from=publish-migrator /app/migrator ./
# Same Nacos-free local config as the host (the migrator's own appsettings.json # Same Nacos-free local config as the host (the migrator's own appsettings.json
# is Nacos-only too); reuse the host's seed Data / wwwroot content. # is Nacos-only too); reuse the host's seed Data / wwwroot content.
COPY docker-compose-local-dev/appsettings.local.json ./appsettings.json COPY --from=localcfg appsettings.local.json ./appsettings.json
COPY --from=publish-host /app/host/Data ./Data COPY --from=publish-host /app/host/Data ./Data
COPY --from=publish-host /app/host/wwwroot ./wwwroot COPY --from=publish-host /app/host/wwwroot ./wwwroot
ENTRYPOINT ["dotnet", "iCON.Abp.FX.DbMigrator.dll"] ENTRYPOINT ["dotnet", "iCON.Abp.FX.DbMigrator.dll"]

View File

@ -304,6 +304,13 @@
"RetryTimes": 0, "RetryTimes": 0,
"RetryMinutes": 0 "RetryMinutes": 0
}, },
"ClearExpiredPaygOrderJob": {
"Enabled": true,
"RunOnStart": true,
"IntervalSeconds": 300,
"RetryTimes": 0,
"RetryMinutes": 0
},
"TenantTobeExpiredRemindJob": { "TenantTobeExpiredRemindJob": {
"Enabled": true, "Enabled": true,
"RunOnStart": true, "RunOnStart": true,
@ -386,7 +393,7 @@
"RequestTokenPath": "connect/token?__tenant=Portal", "RequestTokenPath": "connect/token?__tenant=Portal",
"RequestTokenParams": "grant_type=password,response_type=token,username=Portal@iconsz.com,password=1qaz@WSX,scope=FX,client_id=PortalClient,client_secret=1qaz@WSX", "RequestTokenParams": "grant_type=password,response_type=token,username=Portal@iconsz.com,password=1qaz@WSX,scope=FX,client_id=PortalClient,client_secret=1qaz@WSX",
"AbpTokenExpiredSeconds": "3600", "AbpTokenExpiredSeconds": "3600",
"PortalTenantId": "3A07C0C9-50E2-638F-C2BB-61A4DA42B8D4", "PortalTenantId": "3A087539-4DCE-150D-A3EE-9AAD1F533D02",
"ExpireOrderMins": 5, "ExpireOrderMins": 5,
"IsMockupPayment": true, "IsMockupPayment": true,
"SalesEmailAddress": "307736951@qq.com", "SalesEmailAddress": "307736951@qq.com",
@ -432,8 +439,22 @@
} }
], ],
"jQSeparatedEditions": { "jQSeparatedEditions": {
"EditionIds": [ "3A1A2969-6200-7B6C-1FE2-F4001F2F17E1" ], "EditionIds": [ "3A1A296B-DAB4-4B94-B267-4424683B8916" ],
"EditionNames": [ "CPA" ] "EditionNames": [ "CPA" ]
},
"OfflineSubscribeOrderMail": {
"Cc": [],
"Bcc": []
}
},
"TwoC": {
"TenantId": null,
"LinkValidHours": 72,
"ExpireOrderMins": 60,
"IsMockupPayment": true,
"OfflineOrderMail": {
"Cc": [],
"Bcc": []
} }
}, },
"BusinessDefaultData": { "BusinessDefaultData": {

View File

@ -56,10 +56,14 @@ services:
db-migrator: db-migrator:
build: build:
# Context is the AML_Backend repo root (one level up); this compose file and # This stack lives at the monorepo root, OUTSIDE the AML_Backend repo, so the
# the Dockerfile live in docker-compose-local-dev/. # build context points into that subproject and the Dockerfile is referenced
context: .. # from here. appsettings.local.json is no longer inside the context, so it is
dockerfile: docker-compose-local-dev/Dockerfile.local # passed in as a named additional context (`localcfg`) instead.
context: ../AML_Backend
dockerfile: ../docker-compose-local-dev/Dockerfile.local
additional_contexts:
localcfg: .
target: migrator target: migrator
image: aml-dbmigrator:local image: aml-dbmigrator:local
container_name: aml-dbmigrator container_name: aml-dbmigrator
@ -73,8 +77,10 @@ services:
httpapi-host: httpapi-host:
build: build:
context: .. context: ../AML_Backend
dockerfile: docker-compose-local-dev/Dockerfile.local dockerfile: ../docker-compose-local-dev/Dockerfile.local
additional_contexts:
localcfg: .
target: host target: host
image: aml-httpapi-host:local image: aml-httpapi-host:local
container_name: aml-httpapi-host container_name: aml-httpapi-host
@ -100,6 +106,21 @@ services:
App__SelfUrl: "http://localhost:44331" App__SelfUrl: "http://localhost:44331"
AuthServer__Authority: "http://localhost:44331" AuthServer__Authority: "http://localhost:44331"
AuthServer__RequireHttpsMetadata: "false" AuthServer__RequireHttpsMetadata: "false"
# 内部 token 请求AbpTokenService → connect/token本地 host 仅监听 HTTP
# 覆盖 appsettings 的 https 默认值,否则 TenantRenewal / 建租户等内部调用会 SSL 握手失败。
AppConfig__General__ApiLocalhostUrl: "http://localhost:44331/"
# 在线支付appsettings.local.json 里 Portal.IsMockupPayment=true会让 CreateOrder 建单后
# 立即自造一次 QFPay 回调把订单置为已支付 —— 这样前端永远走不到收银台。置 false 才能测
# 真实支付链(下单 → 收银台 → PaymentWebhook → 订单转已支付)。
# 若只想跳过支付、快速拿到已开通的租户,改回 "true" 即可。
AppConfig__Portal__IsMockupPayment: "false"
# 隨付即用(PAYG / CreatePayAsYouGoOrder):支付成功后把检测链接挂到 2C 池租户下,
# 建单时就会校验此项,未配置直接报 "TwoC.TenantId is not configured"。
# 取本地库 SaasTenants 里名为 "2C" 的租户 Id。
AppConfig__TwoC__TenantId: "3A1C1F4F-32A8-26B9-743D-5B12DE3B49E8"
# PAYG 在线单的模拟支付true 会在建单后自造一次成功回调(跳过收银台)。
# 与上面订阅单的 IsMockupPayment 同理,要测真实收银台链路就置 false。
AppConfig__TwoC__IsMockupPayment: "false"
# RabbitMQ: use the bundled broker service and run the background consumer. # RabbitMQ: use the bundled broker service and run the background consumer.
AppConfig__RabbitMQConfig__HostName: "rabbitmq" AppConfig__RabbitMQConfig__HostName: "rabbitmq"
AppConfig__RabbitMQConfig__EnableConsumer: "true" AppConfig__RabbitMQConfig__EnableConsumer: "true"

View File

@ -0,0 +1,106 @@
/* ─────────────────────────────────────────────────────────────
AML-local-dev.bak
.bak __EFMigrationsHistoryDbMigrator
/ 500
DDL
20260718121851_PaygOfflineOrderManage (PAYG)线
20260719120000_AddAgentUserSettingAndPaymentMethod +
20260720000000_SplitOrderTypeAddOrderSource
IsDeleted DEFAULT 0 .bak
EF INSERT "Cannot insert the value NULL into column 'IsDeleted'"
docker exec -i aml-mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P 'Aml@Local2026' -C -d AbpAML -i /dev/stdin < local-schema-catchup.sql
*/
SET NOCOUNT ON;
/* ── 1) IsDeleted 默认值约束(.bak 还原丢失)───────────────── */
DECLARE @sql nvarchar(max) = N'';
SELECT @sql = @sql + N'ALTER TABLE [' + t.name + N'] ADD CONSTRAINT [DF_' + t.name + N'_IsDeleted] DEFAULT 0 FOR [IsDeleted];' + CHAR(10)
FROM sys.tables t
JOIN sys.columns c ON c.object_id = t.object_id AND c.name = 'IsDeleted'
WHERE c.default_object_id = 0;
EXEC sp_executesql @sql;
/* ── 2) PaygOfflineOrderManage ─────────────────────────────── */
IF COL_LENGTH('AMLPortal_Orders', 'ContactEmail') IS NULL
ALTER TABLE AMLPortal_Orders ADD ContactEmail nvarchar(max) NULL;
IF COL_LENGTH('AMLPortal_Orders', 'SubjectName') IS NULL
ALTER TABLE AMLPortal_Orders ADD SubjectName nvarchar(max) NULL;
IF COL_LENGTH('AMLPortal_Orders', 'SubjectType') IS NULL
ALTER TABLE AMLPortal_Orders ADD SubjectType nvarchar(max) NULL;
IF OBJECT_ID('AML_ConsumerLinks') IS NOT NULL AND COL_LENGTH('AML_ConsumerLinks', 'PaidOrderId') IS NULL
ALTER TABLE AML_ConsumerLinks ADD PaidOrderId uniqueidentifier NULL;
IF OBJECT_ID('AML_OrderProcessLogs') IS NULL
CREATE TABLE AML_OrderProcessLogs (
Id uniqueidentifier NOT NULL CONSTRAINT PK_AML_OrderProcessLogs PRIMARY KEY,
OrderKind int NOT NULL,
OrderId uniqueidentifier NOT NULL,
Action nvarchar(max) NULL,
Remark nvarchar(max) NULL,
OperatorName nvarchar(max) NULL,
ExtraProperties nvarchar(max) NULL,
ConcurrencyStamp nvarchar(40) NULL,
CreationTime datetime2 NOT NULL,
CreatorId uniqueidentifier NULL,
LastModificationTime datetime2 NULL,
LastModifierId uniqueidentifier NULL,
IsDeleted bit NOT NULL CONSTRAINT DF_AML_OrderProcessLogs_IsDeleted DEFAULT 0,
DeleterId uniqueidentifier NULL,
DeletionTime datetime2 NULL
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AML_OrderProcessLogs_OrderId')
CREATE INDEX IX_AML_OrderProcessLogs_OrderId ON AML_OrderProcessLogs(OrderId);
/* ── 3) AddAgentUserSettingAndPaymentMethod ────────────────── */
IF OBJECT_ID('AMLPortal_AgentUserSettings') IS NULL
CREATE TABLE AMLPortal_AgentUserSettings (
Id uniqueidentifier NOT NULL CONSTRAINT PK_AMLPortal_AgentUserSettings PRIMARY KEY,
AgentUserId uniqueidentifier NOT NULL,
CountryCode nvarchar(max) NULL,
ExtraProperties nvarchar(max) NULL,
ConcurrencyStamp nvarchar(40) NULL,
CreationTime datetime2 NOT NULL,
CreatorId uniqueidentifier NULL,
LastModificationTime datetime2 NULL,
LastModifierId uniqueidentifier NULL,
IsDeleted bit NOT NULL CONSTRAINT DF_AMLPortal_AgentUserSettings_IsDeleted DEFAULT 0,
DeleterId uniqueidentifier NULL,
DeletionTime datetime2 NULL
);
IF OBJECT_ID('AMLPortal_AgentUserPaymentMethods') IS NULL
CREATE TABLE AMLPortal_AgentUserPaymentMethods (
Id uniqueidentifier NOT NULL CONSTRAINT PK_AMLPortal_AgentUserPaymentMethods PRIMARY KEY,
AgentUserId uniqueidentifier NOT NULL,
PaymentGateway int NOT NULL, -- PaymentGatewayEnums100=线下 / 200=QFPay
ExtraProperties nvarchar(max) NULL,
ConcurrencyStamp nvarchar(40) NULL,
CreationTime datetime2 NOT NULL,
CreatorId uniqueidentifier NULL,
LastModificationTime datetime2 NULL,
LastModifierId uniqueidentifier NULL,
IsDeleted bit NOT NULL CONSTRAINT DF_AMLPortal_AgentUserPaymentMethods_IsDeleted DEFAULT 0,
DeleterId uniqueidentifier NULL,
DeletionTime datetime2 NULL
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AMLPortal_AgentUserSettings_AgentUserId')
CREATE INDEX IX_AMLPortal_AgentUserSettings_AgentUserId ON AMLPortal_AgentUserSettings(AgentUserId);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AMLPortal_AgentUserPaymentMethods_AgentUserId')
CREATE INDEX IX_AMLPortal_AgentUserPaymentMethods_AgentUserId ON AMLPortal_AgentUserPaymentMethods(AgentUserId);
/* ── 4) SplitOrderTypeAddOrderSource ───────────────────────── */
IF COL_LENGTH('AMLPortal_Orders', 'OrderSource') IS NULL
BEGIN
ALTER TABLE AMLPortal_Orders ADD OrderSource int NOT NULL CONSTRAINT DF_AMLPortal_Orders_OrderSource DEFAULT 1;
-- 存量线下手工补录单OrderType=300(OfflineManual) → 续费(200) + 来源后台手工补录(2)
-- 走动态 SQL同一批次里新加的列静态语句在编译期就会报「列名无效」
EXEC(N'UPDATE AMLPortal_Orders SET OrderType = 200, OrderSource = 2 WHERE OrderType = 300;');
END
PRINT '本地库架构补齐完成';

View File

@ -0,0 +1,101 @@
-- ============================================================================
-- 本地开发种子root OU + 通知收件用户
--
-- 用途
-- appsettings.local.json 的 AppConfig:Portal:EditionOUMappings 里EditionId="root"
-- 映射到 OU `3A0971B1-75B7-C43A-F39D-3CED32E0DF30`。该 ID 来自 dev 环境(这份配置是
-- Nacos 改造前的 dev 快照),本地种子库里并没有这个 OU。
--
-- 不跑这个脚本会怎样
-- 门户订单支付后自动建租户会失败,且报错极具误导性:
-- CustomTenantController.CreateAsync 最后一步「发邮件给 root OU」
-- → _organizationUnitRepository.FindAsync(ouid) 返回 null
-- → GetMembersAsync(null) 在 EF 表达式求值时抛 NullReferenceException
-- → 接口 500 → OrderService.CreateTenant 捕获 → 整个事务回滚
-- 现象是:订单已支付(PaymentStatus=200),但 Orders.TargetTenantID 为 NULL、
-- SaasTenants 里查无此租户AMLPortal_TenantEventQueues 留下一条 Status=300(重试中)。
-- 日志里只有 "An exception was thrown while attempting to evaluate a LINQ query
-- parameter expression",很难联想到是缺一条 OU 数据。
-- GetParentAgentList 里也有同一段 root OU 逻辑,同样依赖它。)
--
-- 何时执行
-- 任何一次重建本地库之后,都要跑一遍:
-- · `docker compose -f docker-compose.local.yml down -v`EF 迁移 + 代码种子重建)
-- · 或 RESTORE `AML-local-dev.bak`(目前本地库的实际数据来源)
-- 两条路都不会带出这条 OU它既不在代码种子里属环境数据那份 .bak 里也没有。
--
-- 执行方式
-- docker exec -i aml-mssql /opt/mssql-tools18/bin/sqlcmd \
-- -S localhost -U sa -P "Aml@Local2026" -C -d AbpAML \
-- -i /dev/stdin < seed-root-ou.sql
--
-- 或先 docker cp 进容器再 -i 指定路径。
--
-- 幂等:可重复执行,已存在则跳过。
-- ============================================================================
SET NOCOUNT ON;
-- 收件邮箱:租户创建成功的通知会真实发送到这里(经 iCS(stag) → smtp.yandex.com
-- 不走 appsettings 里的 Abp.Mailing.Smtp那个 127.0.0.1:25 本地并不存在)。
-- 想换收件人改这里即可。
DECLARE @NotifyEmail nvarchar(256) = N'f-r-x@163.com';
DECLARE @UserName nvarchar(256) = N'local-sales-admin';
-- 必须与 appsettings.local.json 中 EditionOUMappings[EditionId="root"].OUIDs[0] 一致
DECLARE @OuId uniqueidentifier = '3A0971B1-75B7-C43A-F39D-3CED32E0DF30';
DECLARE @UserId uniqueidentifier;
BEGIN TRAN;
-- 1) root OUhost 级TenantId / ParentId 均为 NULL
-- Code 只需在 host 范围内不与现有 OU 冲突;种子数据已占用 00001~00003。
IF NOT EXISTS (SELECT 1 FROM AbpOrganizationUnits WHERE Id = @OuId)
BEGIN
INSERT INTO AbpOrganizationUnits
(Id, TenantId, ParentId, Code, DisplayName, ExtraProperties, ConcurrencyStamp, CreationTime, IsDeleted)
VALUES
(@OuId, NULL, NULL, N'00099', N'Local Root OU (sales admin)', N'{}',
CONVERT(nvarchar(40), NEWID()), GETDATE(), 0);
PRINT '[seed] AbpOrganizationUnits: root OU created';
END
ELSE
PRINT '[seed] AbpOrganizationUnits: root OU already exists, skipped';
-- 2) 通知收件用户host 级,不设密码 —— 仅用于被 GetMembersAsync 查到并取 Email/UserName
SELECT @UserId = Id FROM AbpUsers WHERE TenantId IS NULL AND NormalizedEmail = UPPER(@NotifyEmail);
IF @UserId IS NULL
BEGIN
SET @UserId = NEWID();
INSERT INTO AbpUsers
(Id, TenantId, UserName, NormalizedUserName, Name, Email, NormalizedEmail, EmailConfirmed,
PasswordHash, SecurityStamp, IsExternal, PhoneNumberConfirmed, TwoFactorEnabled,
LockoutEnabled, AccessFailedCount, ExtraProperties, ConcurrencyStamp, CreationTime, IsDeleted)
VALUES
(@UserId, NULL, @UserName, UPPER(@UserName), N'Local Sales Admin', @NotifyEmail, UPPER(@NotifyEmail), 1,
NULL, CONVERT(nvarchar(40), NEWID()), 0, 0, 0,
0, 0, N'{}', CONVERT(nvarchar(40), NEWID()), GETDATE(), 0);
PRINT '[seed] AbpUsers: notify user created';
END
ELSE
PRINT '[seed] AbpUsers: notify user already exists, skipped';
-- 3) 用户挂到 OU 下FindAllParentAgentsFromOU 靠这层关联找人)
IF NOT EXISTS (SELECT 1 FROM AbpUserOrganizationUnits WHERE UserId = @UserId AND OrganizationUnitId = @OuId)
BEGIN
INSERT INTO AbpUserOrganizationUnits (UserId, OrganizationUnitId, TenantId, CreationTime)
VALUES (@UserId, @OuId, NULL, GETDATE());
PRINT '[seed] AbpUserOrganizationUnits: membership created';
END
ELSE
PRINT '[seed] AbpUserOrganizationUnits: membership already exists, skipped';
COMMIT;
-- 验证:应返回恰好一行
SELECT u.UserName, u.Email, ou.DisplayName, ou.Code
FROM AbpUsers u
JOIN AbpUserOrganizationUnits uo ON uo.UserId = u.Id
JOIN AbpOrganizationUnits ou ON ou.Id = uo.OrganizationUnitId
WHERE ou.Id = @OuId;