浏览代码

fix(s0): tenant-scope work order control settings

- prevent cross-tenant work order control updates (scope by tenant before Id lookup, 404 on miss)
- enforce one configuration per tenant
- remove Domain as a required visibility gate; GET works without it
- auto-load tenant configuration in UI on mount, reload after save
- drop non-persisted company/factory required fields that blocked saving
- align unique index annotation with the actual (tenant_id, Domain) index
- add tenant regression coverage

chore: bump version Web 2.4.325 / server 1.0.395
YY968XX 1 天之前
父节点
当前提交
9ba4295998

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.324",
+	"version": "2.4.325",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 5 - 2
Web/src/views/aidop/s0/api/s0ManufacturingApi.ts

@@ -613,7 +613,8 @@ export interface S0WorkOrdControlRow {
 
 export interface S0WorkOrdControlUpsert {
 	id?: number | null;
-	domain: string;
+	/** 工厂域:历史兼容业务字段,可留空;隔离边界只有 tenant_id。 */
+	domain?: string | null;
 	enteringPer?: number | null;
 	paintingPer?: number | null;
 	issuedInterval?: number | null;
@@ -633,7 +634,9 @@ export interface S0WorkOrdControlUpsert {
 const workOrdControlBase = `${mfg}/work-ord-control`;
 
 export const s0MfgWorkOrdControlApi = {
-	getOne: (params: { domain: string }) => service.get<S0WorkOrdControlRow | null>(workOrdControlBase, { params }).then(unwrap),
+	/** 不传 domain 即取当前租户的配置(页面初始加载走这条路径)。 */
+	getOne: (params?: { domain?: string }) =>
+		service.get<S0WorkOrdControlRow | null>(workOrdControlBase, { params: params ?? {} }).then(unwrap),
 	upsert: (body: S0WorkOrdControlUpsert) => service.post<S0WorkOrdControlRow>(`${workOrdControlBase}/upsert`, body).then(unwrap),
 };
 

+ 20 - 55
Web/src/views/aidop/s0/manufacturing/WorkOrderControlParams.vue

@@ -1,35 +1,10 @@
 <template>
 	<AidopDemoShell :title="pageTitle" subtitle="S0 / Manufacturing / 工单控制参数">
-		<el-form ref="formRef" :model="form" :rules="rules" label-width="220px" class="form" @submit.prevent>
+		<el-form ref="formRef" :model="form" label-width="220px" class="form" @submit.prevent>
 			<el-row :gutter="16">
 				<el-col :span="12">
-					<el-form-item label="公司" prop="companyRefId">
-						<el-select v-model="form.companyRefId" filterable placeholder="请选择公司" style="width: 100%">
-							<el-option
-								v-for="item in companyOptions"
-								:key="item.id"
-								:label="item.name || item.code || `${item.id}`"
-								:value="item.id"
-							/>
-						</el-select>
-					</el-form-item>
-				</el-col>
-				<el-col :span="12">
-					<el-form-item label="工厂" prop="factoryRefId">
-						<el-select v-model="form.factoryRefId" filterable placeholder="请选择工厂" style="width: 100%">
-							<el-option
-								v-for="item in factoriesForCompany(form.companyRefId)"
-								:key="item.id"
-								:label="item.name || item.code || `${item.id}`"
-								:value="item.id"
-							/>
-						</el-select>
-					</el-form-item>
-				</el-col>
-
-				<el-col :span="12">
-					<el-form-item label="工厂域" prop="domain">
-						<el-input v-model="form.domain" clearable placeholder="例如:S0-F001" />
+					<el-form-item label="工厂域">
+						<el-input v-model="form.domain" clearable placeholder="可留空(历史兼容字段)" />
 					</el-form-item>
 				</el-col>
 				<el-col :span="12">
@@ -85,33 +60,33 @@
 
 			<div class="actions">
 				<el-button type="primary" :loading="saving || loading" @click="save">保存</el-button>
-				<el-button :disabled="saving || loading" @click="reload">重新加载</el-button>
+				<el-button :disabled="saving || loading" @click="loadConfig">重新加载</el-button>
 			</div>
 		</el-form>
 	</AidopDemoShell>
 </template>
 
 <script setup lang="ts" name="aidopS0MfgWorkOrderControl">
-import { computed, onMounted, reactive, ref, watch } from 'vue';
+import { computed, onMounted, reactive, ref } from 'vue';
 import { useRoute } from 'vue-router';
-import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
+import { ElMessage, type FormInstance } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
-import { useS0MfgOrgScope } from '../composables/useS0MfgOrgScope';
 import { s0MfgWorkOrdControlApi, type S0WorkOrdControlUpsert } from '../api/s0ManufacturingApi';
 
+// 业务模型(S0-WORKORD-CONTROL-TENANT-UX-FIX):每个租户一套工单控制参数。
+// 隔离边界只有 tenant_id;工厂域是可留空的历史兼容字段,不参与加载与可见性。
+// 原「公司 / 工厂」两个 required 下拉既不进 payload 也不落库,且本租户无 Type=501 工厂
+// 导致校验必然失败、页面永远存不下,已整体移除。
+
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '工单控制参数');
 
-const { companyOptions, loadOrgs, factoriesForCompany } = useS0MfgOrgScope();
-
 const loading = ref(false);
 const saving = ref(false);
 const formRef = ref<FormInstance>();
 
 const form = reactive({
 	id: undefined as number | undefined,
-	companyRefId: undefined,
-	factoryRefId: undefined,
 	domain: '',
 	enteringPer: undefined as number | undefined,
 	paintingPer: undefined as number | undefined,
@@ -125,26 +100,13 @@ const form = reactive({
 	isConfirm: false,
 });
 
-watch(
-	() => form.companyRefId,
-	() => {
-		if (!factoriesForCompany(form.companyRefId).some((x) => x.id === form.factoryRefId)) form.factoryRefId = undefined;
-	},
-);
-
-const rules: FormRules = {
-	companyRefId: [{ required: true, message: '请选择公司', trigger: 'change' }],
-	factoryRefId: [{ required: true, message: '请选择工厂', trigger: 'change' }],
-	domain: [{ required: true, message: '请填写工厂域', trigger: 'blur' }],
-};
-
-async function reload() {
-	const domain = form.domain.trim();
-	if (!domain) return;
+/** 当前租户的配置;不依赖用户手输工厂域。 */
+async function loadConfig() {
 	loading.value = true;
 	try {
-		const data = await s0MfgWorkOrdControlApi.getOne({ domain });
+		const data = await s0MfgWorkOrdControlApi.getOne();
 		form.id = data?.id;
+		form.domain = data?.domain ?? '';
 		form.enteringPer = (data?.enteringPer ?? undefined) as any;
 		form.paintingPer = (data?.paintingPer ?? undefined) as any;
 		form.issuedInterval = (data?.issuedInterval ?? undefined) as any;
@@ -166,7 +128,8 @@ async function save() {
 	try {
 		const payload: S0WorkOrdControlUpsert = {
 			id: form.id ?? null,
-			domain: form.domain.trim(),
+			// 工厂域可留空;不再伪造 companyRefId / factoryRefId(后端 DTO 无此字段、不落库)。
+			domain: form.domain.trim() || null,
 			enteringPer: form.enteringPer ?? null,
 			paintingPer: form.paintingPer ?? null,
 			issuedInterval: form.issuedInterval ?? null,
@@ -181,13 +144,15 @@ async function save() {
 		const saved = await s0MfgWorkOrdControlApi.upsert(payload);
 		form.id = saved.id;
 		ElMessage.success('已保存');
+		// 保存 → 立即回读当前租户配置,保证「保存后刷新仍可见」。
+		await loadConfig();
 	} finally {
 		saving.value = false;
 	}
 }
 
 onMounted(async () => {
-	await loadOrgs();
+	await loadConfig();
 });
 </script>
 

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.394</AssemblyVersion>
-    <FileVersion>1.0.394</FileVersion>
-    <Version>1.0.394</Version>
+    <AssemblyVersion>1.0.395</AssemblyVersion>
+    <FileVersion>1.0.395</FileVersion>
+    <Version>1.0.395</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 115 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Tenant/S0WorkOrdControlTenantTests.cs

@@ -0,0 +1,115 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Dto.S0.Manufacturing;
+using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
+using SqlSugar;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S0.Tenant;
+
+/// <summary>
+/// S0-WORKORD-CONTROL-TENANT-UX-FIX 防回归契约。
+///
+/// 守住四条:
+///  1. Upsert 按 Id 定位必须先 tenant scope,禁止裸 GetByIdAsync(P0 越租户更新漏洞);
+///  2. Id 不属于当前租户时必须 NotFound,不得落到 Update 分支;
+///  3. GET / Upsert 的 Domain 均为可选,页面加载不依赖用户手输工厂域;
+///  4. 唯一索引必须以 TenantId 打头,否则 CodeFirst 重建后会退化为跨租户唯一。
+///
+/// 与同目录其他契约测试一致:用源码静态扫描,不需要真实 DB / HTTP 上下文。
+/// 真实的双租户读写隔离由集成冒烟(两个租户 token 实调)覆盖。
+/// </summary>
+public class S0WorkOrdControlTenantTests
+{
+    private static string ControllerSource()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+
+        var path = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0",
+            "Manufacturing", "AdoS0WorkOrdControlController.cs");
+        Assert.True(File.Exists(path), $"未找到 {path}");
+        return File.ReadAllText(path);
+    }
+
+    /// <summary>去掉注释行,避免注释里的说明文字造成误判。</summary>
+    private static string StripComments(string src)
+    {
+        var withoutBlock = Regex.Replace(src, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
+        var lines = withoutBlock.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal));
+        return string.Join('\n', lines);
+    }
+
+    /// <summary>P0:不得使用未加租户条件的按 Id 查询。</summary>
+    [Fact]
+    public void Upsert_MustNotUseUnscopedGetById()
+    {
+        var src = StripComments(ControllerSource());
+
+        Assert.DoesNotContain("GetByIdAsync", src);
+        Assert.DoesNotContain("GetSingleAsync", src);
+
+        // 按 Id 定位必须建立在 ScopedTo(tenantId) 之上。
+        Assert.Matches(new Regex(@"ScopedTo\(tenantId\)\s*\r?\n?\s*\.Where\(x => x\.Id == dto\.Id"), src);
+    }
+
+    /// <summary>P0:Id 命中失败必须 404,绝不继续走到 Update。</summary>
+    [Fact]
+    public void Upsert_MustReturnNotFoundWhenIdBelongsToAnotherTenant()
+    {
+        var src = StripComments(ControllerSource());
+        Assert.Contains("return NotFound();", src);
+
+        var scopedIdIdx = src.IndexOf("x.Id == dto.Id", StringComparison.Ordinal);
+        var notFoundIdx = src.IndexOf("return NotFound();", StringComparison.Ordinal);
+        Assert.True(scopedIdIdx >= 0 && notFoundIdx > scopedIdIdx,
+            "按 Id 的租户内查询之后必须紧跟 NotFound 分支。");
+
+        var updateIdx = src.IndexOf("AsUpdateable", StringComparison.Ordinal);
+        Assert.True(updateIdx > notFoundIdx, "NotFound 必须早于任何 Update 调用。");
+    }
+
+    /// <summary>每个 action 仍必须显式解析租户。</summary>
+    [Fact]
+    public void AllActions_ResolveTenantFirst()
+    {
+        var src = StripComments(ControllerSource());
+        var actions = Regex.Matches(src, @"public async Task<IActionResult> (\w+)\(").Count;
+        var resolves = Regex.Matches(src, @"AdoS0TenantScope\.TryResolveRequired").Count;
+        Assert.True(actions > 0);
+        Assert.Equal(actions, resolves);
+    }
+
+    /// <summary>Domain 必须可选:GET 不传也能取到当前租户配置。</summary>
+    [Fact]
+    public void DomainMustBeOptional_OnBothDtos()
+    {
+        var get = typeof(AdoS0WorkOrdControlGetDto).GetProperty(nameof(AdoS0WorkOrdControlGetDto.Domain))!;
+        var upsert = typeof(AdoS0WorkOrdControlUpsertDto).GetProperty(nameof(AdoS0WorkOrdControlUpsertDto.Domain))!;
+
+        Assert.Equal(typeof(string), get.PropertyType);
+        Assert.Equal(typeof(string), upsert.PropertyType);
+
+        Assert.Empty(get.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.RequiredAttribute), true));
+        Assert.Empty(upsert.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.RequiredAttribute), true));
+
+        // 查询里 Domain 只能作为可选筛选(WhereIF),不得是硬条件。
+        var src = StripComments(ControllerSource());
+        Assert.Contains("WhereIF(!string.IsNullOrWhiteSpace(domain)", src);
+    }
+
+    /// <summary>唯一索引必须以 TenantId 打头,否则跨租户抢占同一 Domain。</summary>
+    [Fact]
+    public void UniqueIndexMustLeadWithTenantId()
+    {
+        var index = typeof(AdoS0WorkOrdControl)
+            .GetCustomAttributes(typeof(SugarIndexAttribute), inherit: true)
+            .OfType<SugarIndexAttribute>()
+            .Single();
+
+        Assert.Equal(nameof(AdoS0WorkOrdControl.TenantId), index.IndexFields.Keys.First());
+        Assert.Contains(nameof(AdoS0WorkOrdControl.Domain), index.IndexFields.Keys);
+        Assert.True(index.IsUnique);
+    }
+}

+ 23 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0WorkOrdControlController.cs

@@ -5,6 +5,12 @@ namespace Admin.NET.Plugin.AiDOP.Controllers.S0.Manufacturing;
 
 /// <summary>
 /// 工单控制参数设置(WorkOrdControl):单记录配置表单。
+///
+/// 业务模型(S0-WORKORD-CONTROL-TENANT-UX-FIX):**每个租户一套配置**。
+///  - tenant_id 是唯一隔离边界;Domain 只是历史兼容业务字段,不承担可见性/安全职责。
+///  - GET 不要求传 Domain:不传即返回当前租户的配置,页面打开即可自动回填。
+///  - Upsert 按 Id 定位时必须先 tenant scope;Id 属于其他租户一律 404,绝不更新。
+///  - 单配置约束:未命中 Id 时按租户定位既有记录,不会因 Domain 不同而无限新建。
 /// </summary>
 [ApiController]
 [Route("api/s0/manufacturing/work-ord-control")]
@@ -22,9 +28,11 @@ public class AdoS0WorkOrdControlController : ControllerBase
     public async Task<IActionResult> GetOneAsync([FromQuery] AdoS0WorkOrdControlGetDto q)
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
-        var domain = q.Domain.Trim();
+
+        // 租户优先:Domain 仅作可选业务筛选,留空即返回当前租户配置(页面无需先手输工厂域)。
+        var domain = q.Domain?.Trim();
         var item = await _rep.ScopedTo(tenantId)
-            .Where(x => x.Domain == domain)
+            .WhereIF(!string.IsNullOrWhiteSpace(domain), x => x.Domain == domain)
             .OrderBy(x => x.Id)
             .FirstAsync();
 
@@ -36,26 +44,30 @@ public class AdoS0WorkOrdControlController : ControllerBase
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var now = DateTime.Now;
-        var domain = dto.Domain.Trim();
+        var domain = dto.Domain?.Trim();
 
         AdoS0WorkOrdControl? entity = null;
         if (dto.Id is > 0)
-            entity = await _rep.GetByIdAsync(dto.Id.Value);
-
-        if (entity == null)
         {
+            // P0:先 tenant scope 再按 Id 命中。Id 属于其他租户时查不到 → 404,绝不落到 Update 分支。
             entity = await _rep.ScopedTo(tenantId)
-                .Where(x => x.Domain == domain)
-                .OrderBy(x => x.Id)
+                .Where(x => x.Id == dto.Id!.Value)
                 .FirstAsync();
+            if (entity == null) return NotFound();
         }
 
+        // 单配置约束:一个租户最多一条当前配置,不因 Domain 不同而无限新建。
+        entity ??= await _rep.ScopedTo(tenantId)
+            .OrderBy(x => x.Id)
+            .FirstAsync();
+
         if (entity == null)
         {
             entity = new AdoS0WorkOrdControl
             {
                 TenantId = tenantId,
-                Domain = domain,
+                // Domain 可空:留空存空串(列非空),不猜 8010 / S0-F001 等历史值。
+                Domain = domain ?? string.Empty,
                 EnteringPer = dto.EnteringPer,
                 PaintingPer = dto.PaintingPer,
                 IssuedInterval = dto.IssuedInterval,
@@ -75,7 +87,8 @@ public class AdoS0WorkOrdControlController : ControllerBase
             return Ok(entity);
         }
 
-        entity.Domain = domain;
+        // 保值:dto 未传 Domain 时不得抹掉既有历史值。
+        if (!string.IsNullOrWhiteSpace(domain)) entity.Domain = domain;
         entity.EnteringPer = dto.EnteringPer;
         entity.PaintingPer = dto.PaintingPer;
         entity.IssuedInterval = dto.IssuedInterval;

+ 10 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S0/Manufacturing/AdoS0ManufacturingDtos.cs

@@ -667,23 +667,27 @@ public class AdoS0MfgWorkOrderControlUpsertDto
 }
 
 /// <summary>
-/// 工单控制参数设置(WorkOrdControl):按 Domain 查询单记录。
+/// 工单控制参数设置(WorkOrdControl):每个租户一套配置。
+///
+/// 业务模型(S0-WORKORD-CONTROL-TENANT-UX-FIX):
+/// 隔离边界只有 tenant_id;Domain 降级为历史兼容业务字段,可空,
+/// 不参与可见性判定,页面初始加载也不依赖它。
 /// </summary>
 public class AdoS0WorkOrdControlGetDto
 {
-    [Required(ErrorMessage = "工厂域不能为空")]
+    /// <summary>工厂域(可选业务筛选,留空表示取当前租户的配置)。</summary>
     [MaxLength(50)]
-    public string Domain { get; set; } = string.Empty;
+    public string? Domain { get; set; }
 }
 
 public class AdoS0WorkOrdControlUpsertDto
 {
-    /// <summary>RecID → Id;存在则按 Id 更新。</summary>
+    /// <summary>RecID → Id;存在则按 Id 更新(Id 必须属于当前租户,否则 404)。</summary>
     public long? Id { get; set; }
 
-    [Required(ErrorMessage = "工厂域不能为空")]
+    /// <summary>工厂域(可选历史兼容字段,留空则沿用既有值)。</summary>
     [MaxLength(50)]
-    public string Domain { get; set; } = string.Empty;
+    public string? Domain { get; set; }
 
     public decimal? EnteringPer { get; set; }
     public decimal? PaintingPer { get; set; }

+ 6 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Manufacturing/AdoS0WorkOrdControl.cs

@@ -1,10 +1,14 @@
 namespace Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
 
 /// <summary>
-/// 工单控制参数(WorkOrdControl 语义):按 Domain 维护的单记录配置。
+/// 工单控制参数(WorkOrdControl 语义):每个租户一套的单记录配置。
+///
+/// 唯一索引必须以 tenant_id 打头:DB 中 uk_WorkOrdControl_domain 实际就是
+/// (tenant_id, Domain),此前注解只声明 Domain 单列,一旦 CodeFirst 重建索引
+/// 会退化为跨租户唯一(A 租户占用某 Domain 后 B 租户再也建不了),故补齐对齐。
 /// </summary>
 [SugarTable("WorkOrdControl", "S0 工单控制参数设置")]
-[SugarIndex("uk_WorkOrdControl_domain", nameof(Domain), OrderByType.Asc, IsUnique = true)]
+[SugarIndex("uk_WorkOrdControl_domain", nameof(TenantId), OrderByType.Asc, nameof(Domain), OrderByType.Asc, IsUnique = true)]
 public class AdoS0WorkOrdControl : ITenantIdFilter
 {
     [SugarColumn(ColumnDescription = "工单控制参数主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]