diff --git a/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQuery.cs b/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQuery.cs
new file mode 100644
index 00000000..cd06659f
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQuery.cs
@@ -0,0 +1,31 @@
+#nullable enable
+using System.ComponentModel.DataAnnotations;
+
+namespace WalkingTec.Mvvm.Core.Analysis
+{
+ ///
+ /// 儲存的 Analysis 查詢設定(維度、度量、篩選條件),支援 private/public 共享。
+ ///
+ public class AnalysisSavedQuery : BasePoco
+ {
+ [Required]
+ [StringLength(100, ErrorMessage = "Validate.{0}stringmax{1}")]
+ public string Name { get; set; } = string.Empty;
+
+ /// 對應的 ListVM FullName(在 AnalysisVmRegistry 白名單中查找)。
+ [Required]
+ [StringLength(500)]
+ public string ListVmType { get; set; } = string.Empty;
+
+ /// JSON-serialized AnalysisQueryRequest(dims、msrs、filters、hierarchies)。
+ [Required]
+ public string ConfigJson { get; set; } = string.Empty;
+
+ /// 擁有者的 ITCode(登入代碼)。
+ [StringLength(50)]
+ public string? OwnerCode { get; set; }
+
+ /// 是否公開(其他使用者可載入,但不能刪除)。
+ public bool IsPublic { get; set; } = false;
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQueryDtos.cs b/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQueryDtos.cs
new file mode 100644
index 00000000..ec6870a7
--- /dev/null
+++ b/src/WalkingTec.Mvvm.Core/Analysis/AnalysisSavedQueryDtos.cs
@@ -0,0 +1,35 @@
+#nullable enable
+using System;
+using System.ComponentModel.DataAnnotations;
+
+namespace WalkingTec.Mvvm.Core.Analysis
+{
+ ///
+ /// 儲存查詢請求,包含查詢設定與顯示名稱。
+ ///
+ public class SaveQueryRequest
+ {
+ [Required]
+ [StringLength(100)]
+ public string Name { get; set; } = string.Empty;
+
+ public bool IsPublic { get; set; } = false;
+
+ [Required]
+ public AnalysisQueryRequest Config { get; set; } = new();
+ }
+
+ ///
+ /// 儲存查詢清單項目 DTO(不含 ConfigJson,節省傳輸量)。
+ ///
+ public class SavedQuerySummaryDto
+ {
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string ListVmType { get; set; } = string.Empty;
+ public string OwnerCode { get; set; } = string.Empty;
+ public bool IsPublic { get; set; }
+ public bool IsOwner { get; set; }
+ public DateTime? CreatedAt { get; set; }
+ }
+}
diff --git a/src/WalkingTec.Mvvm.Core/DataContext.cs b/src/WalkingTec.Mvvm.Core/DataContext.cs
index 934e9599..ebca25d7 100644
--- a/src/WalkingTec.Mvvm.Core/DataContext.cs
+++ b/src/WalkingTec.Mvvm.Core/DataContext.cs
@@ -22,6 +22,7 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
+using WalkingTec.Mvvm.Core.Analysis;
using WalkingTec.Mvvm.Core.Extensions;
using WalkingTec.Mvvm.Core.Models;
using WalkingTec.Mvvm.Core.Support.Json;
@@ -53,6 +54,7 @@ public partial class FrameworkContext : EmptyContext, IDataContext
// public DbSet Elsa_WorkflowExecutionLogRecords { get; set; }
// public DbSet Elsa_WorkflowInstances { get; set; }
public DbSet FrameworkRefreshTokens { get; set; } = null!;
+ public DbSet AnalysisSavedQueries { get; set; } = null!;
///
/// FrameworkContext
@@ -448,6 +450,7 @@ public string? TenantCode
public string? Version { get; set; }
public CS ConnectionString { get; set; } = null!;
+ public DbSet AnalysisSavedQueries { get; set; } = null!;
///
diff --git a/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs b/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs
index 9f59b84e..b64367e5 100644
--- a/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs
+++ b/src/WalkingTec.Mvvm.Mvc/_AnalysisController.cs
@@ -290,6 +290,127 @@ public async Task PivotExport([FromBody] AnalysisPivotRequest? re
"analysis_pivot.xlsx");
}
+ // ─── Saved Queries ─────────────────────────────────────────────────
+
+ ///
+ /// GET /_analysis/savedqueries?listVmType=Foo.BarListVM
+ /// 回傳目前使用者的私有查詢 + 所有公開查詢(依建立時間降序)。
+ ///
+ [HttpGet("savedqueries")]
+ [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)]
+ public IActionResult ListSavedQueries([FromQuery] string listVmType)
+ {
+ if (string.IsNullOrWhiteSpace(listVmType))
+ return BadRequest("listVmType is required.");
+
+ var userCode = Wtm?.LoginUserInfo?.ITCode ?? string.Empty;
+
+ var rows = Wtm!.DC.Set()
+ .Where(q => q.ListVmType == listVmType && (q.OwnerCode == userCode || q.IsPublic))
+ .OrderByDescending(q => q.CreateTime)
+ .Select(q => new SavedQuerySummaryDto
+ {
+ Id = q.ID,
+ Name = q.Name,
+ ListVmType = q.ListVmType,
+ OwnerCode = q.OwnerCode ?? string.Empty,
+ IsPublic = q.IsPublic,
+ IsOwner = q.OwnerCode == userCode,
+ CreatedAt = q.CreateTime
+ })
+ .ToList();
+
+ return Ok(rows);
+ }
+
+ ///
+ /// POST /_analysis/savedqueries
+ /// 儲存一個查詢設定,回傳新建立的記錄 ID。
+ ///
+ [HttpPost("savedqueries")]
+ [ProducesResponseType(typeof(object), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
+ public IActionResult SaveQuery([FromBody] SaveQueryRequest? req)
+ {
+ if (req == null) return BadRequest("Request body is required.");
+ if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("查詢名稱不可為空。");
+ if (string.IsNullOrWhiteSpace(req.Config?.ListVmType)) return BadRequest("Config.ListVmType is required.");
+
+ try { _registry.Resolve(req.Config.ListVmType); }
+ catch (AnalysisVmNotFoundException ex) { return NotFound(ex.Message); }
+
+ var userCode = Wtm?.LoginUserInfo?.ITCode ?? string.Empty;
+ var configJson = JsonSerializer.Serialize(req.Config, _camelCase);
+
+ var entity = new AnalysisSavedQuery
+ {
+ Name = req.Name.Trim(),
+ ListVmType = req.Config.ListVmType,
+ ConfigJson = configJson,
+ OwnerCode = userCode,
+ IsPublic = req.IsPublic,
+ CreateTime = DateTime.Now,
+ CreateBy = userCode
+ };
+
+ Wtm!.DC.Set().Add(entity);
+ Wtm.DC.SaveChanges();
+
+ _logger.LogInformation("Analysis saved query created Id={Id} Name={Name} ListVm={ListVm} Owner={Owner} IsPublic={IsPublic}",
+ entity.ID, entity.Name, entity.ListVmType, entity.OwnerCode, entity.IsPublic);
+
+ return CreatedAtAction(nameof(GetSavedQuery), new { id = entity.ID },
+ new { id = entity.ID, name = entity.Name });
+ }
+
+ ///
+ /// GET /_analysis/savedqueries/{id}
+ /// 載入指定 ID 的查詢設定(回傳 AnalysisQueryRequest)。
+ ///
+ [HttpGet("savedqueries/{id:guid}")]
+ [ProducesResponseType(typeof(AnalysisQueryRequest), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public IActionResult GetSavedQuery(Guid id)
+ {
+ var entity = Wtm!.DC.Set().FirstOrDefault(q => q.ID == id);
+ if (entity == null) return NotFound();
+
+ var userCode = Wtm.LoginUserInfo?.ITCode ?? string.Empty;
+ if (!entity.IsPublic && entity.OwnerCode != userCode) return Forbid();
+
+ AnalysisQueryRequest? config;
+ try { config = JsonSerializer.Deserialize(entity.ConfigJson, _camelCase); }
+ catch (JsonException) { return BadRequest("儲存的查詢格式無效。"); }
+
+ if (config == null) return BadRequest("儲存的查詢格式無效。");
+
+ return new JsonResult(config, _camelCase);
+ }
+
+ ///
+ /// DELETE /_analysis/savedqueries/{id}
+ /// 刪除儲存的查詢(只有擁有者可刪除)。
+ ///
+ [HttpDelete("savedqueries/{id:guid}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public IActionResult DeleteSavedQuery(Guid id)
+ {
+ var entity = Wtm!.DC.Set().FirstOrDefault(q => q.ID == id);
+ if (entity == null) return NotFound();
+
+ var userCode = Wtm.LoginUserInfo?.ITCode ?? string.Empty;
+ if (entity.OwnerCode != userCode) return Forbid();
+
+ Wtm.DC.Set().Remove(entity);
+ Wtm.DC.SaveChanges();
+
+ _logger.LogInformation("Analysis saved query deleted Id={Id} Owner={Owner}", id, userCode);
+ return NoContent();
+ }
+
// ─── Helpers ───────────────────────────────────────────────────────
///
diff --git a/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisControllerTests.cs b/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisControllerTests.cs
index c1feb19f..dd58c035 100644
--- a/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisControllerTests.cs
+++ b/test/WalkingTec.Mvvm.Core.Test/Analysis/AnalysisControllerTests.cs
@@ -2410,6 +2410,7 @@ public async Task Export_happy_path_writes_ActionLog()
StringAssert.Contains(logMsg, "Export", "ActionLog 應記錄 ActionName 包含 Export");
}
+
[TestMethod]
public async Task Query_validation_failure_does_not_write_ActionLog()
{
@@ -2432,5 +2433,149 @@ public async Task Query_validation_failure_does_not_write_ActionLog()
Assert.AreEqual(0, actionLogger.Messages.Count,
"驗證失敗時不應寫入 ActionLog");
}
+
+ // ─── Saved Queries CRUD & 權限測試 ───────────────────────────────────────
+
+ [TestMethod]
+ public void SaveQuery_creates_new_record()
+ {
+ var controller = CreateController();
+ controller.Wtm.LoginUserInfo.ITCode = "testuser";
+
+ var req = new SaveQueryRequest
+ {
+ Name = "My Sales Query",
+ IsPublic = true,
+ Config = new AnalysisQueryRequest
+ {
+ ListVmType = typeof(SaleRecordListVM).FullName,
+ Dimensions = new List { "Region" },
+ Measures = new List { new MeasureRequest { Field = "Amount", Func = AggregateFunc.Sum } }
+ }
+ };
+
+ var result = controller.SaveQuery(req) as CreatedAtActionResult;
+ Assert.IsNotNull(result);
+
+ // Verify DB
+ var query = controller.Wtm.DC.Set().FirstOrDefault();
+ Assert.IsNotNull(query);
+ Assert.AreEqual("My Sales Query", query.Name);
+ Assert.AreEqual(typeof(SaleRecordListVM).FullName, query.ListVmType);
+ Assert.AreEqual("testuser", query.OwnerCode);
+ Assert.IsTrue(query.IsPublic);
+ }
+
+ [TestMethod]
+ public void ListSavedQueries_returns_public_and_owned_queries()
+ {
+ var controller = CreateController();
+ controller.Wtm.LoginUserInfo.ITCode = "userA";
+
+ var vmType = typeof(SaleRecordListVM).FullName;
+ var configJson = "{}";
+
+ controller.Wtm.DC.Set().AddRange(
+ new AnalysisSavedQuery { Name = "A_Private", ListVmType = vmType, OwnerCode = "userA", IsPublic = false, ConfigJson = configJson },
+ new AnalysisSavedQuery { Name = "A_Public", ListVmType = vmType, OwnerCode = "userA", IsPublic = true, ConfigJson = configJson },
+ new AnalysisSavedQuery { Name = "B_Private", ListVmType = vmType, OwnerCode = "userB", IsPublic = false, ConfigJson = configJson },
+ new AnalysisSavedQuery { Name = "B_Public", ListVmType = vmType, OwnerCode = "userB", IsPublic = true, ConfigJson = configJson }
+ );
+ controller.Wtm.DC.SaveChanges();
+
+ var result = controller.ListSavedQueries(vmType) as OkObjectResult;
+ Assert.IsNotNull(result);
+
+ var list = result.Value as IEnumerable;
+ Assert.IsNotNull(list);
+
+ var names = list.Select(x => x.Name).ToList();
+ Assert.IsTrue(names.Contains("A_Private"), "UserA 應能看到自己的 Private 查詢");
+ Assert.IsTrue(names.Contains("A_Public"));
+ Assert.IsFalse(names.Contains("B_Private"), "UserA 不應看到 UserB 的 Private 查詢");
+ Assert.IsTrue(names.Contains("B_Public"), "UserA 應能看到 UserB 的 Public 查詢");
+ }
+
+ [TestMethod]
+ public void GetSavedQuery_returns_config_for_authorized_user()
+ {
+ var controller = CreateController();
+ controller.Wtm.LoginUserInfo.ITCode = "userA";
+ var vmType = typeof(SaleRecordListVM).FullName;
+
+ var id = Guid.NewGuid();
+ controller.Wtm.DC.Set().Add(
+ new AnalysisSavedQuery
+ {
+ ID = id,
+ Name = "Test",
+ ListVmType = vmType,
+ OwnerCode = "userB",
+ IsPublic = true,
+ ConfigJson = "{\"listVmType\":\"" + vmType + "\"}"
+ }
+ );
+ controller.Wtm.DC.SaveChanges();
+
+ var result = controller.GetSavedQuery(id) as JsonResult;
+ Assert.IsNotNull(result, "存取公開查詢應回傳 200");
+ var config = result.Value as AnalysisQueryRequest;
+ Assert.IsNotNull(config);
+ Assert.AreEqual(vmType, config.ListVmType);
+ }
+
+ [TestMethod]
+ public void GetSavedQuery_returns_403_for_unauthorized_user()
+ {
+ var controller = CreateController();
+ controller.Wtm.LoginUserInfo.ITCode = "userA";
+
+ var id = Guid.NewGuid();
+ controller.Wtm.DC.Set().Add(
+ new AnalysisSavedQuery { ID = id, Name = "Test", ListVmType = "VM", OwnerCode = "userB", IsPublic = false, ConfigJson = "{}" }
+ );
+ controller.Wtm.DC.SaveChanges();
+
+ var result = controller.GetSavedQuery(id) as ForbidResult;
+ Assert.IsNotNull(result, "存取他人的私有查詢應回傳 403 Forbid");
+ }
+
+ [TestMethod]
+ public void DeleteSavedQuery_removes_record_if_owner()
+ {
+ var controller = CreateController();
+ controller.Wtm.LoginUserInfo.ITCode = "userA";
+
+ var id = Guid.NewGuid();
+ controller.Wtm.DC.Set().Add(
+ new AnalysisSavedQuery { ID = id, Name = "Test", ListVmType = "VM", OwnerCode = "userA", ConfigJson = "{}" }
+ );
+ controller.Wtm.DC.SaveChanges();
+
+ var result = controller.DeleteSavedQuery(id) as NoContentResult;
+ Assert.IsNotNull(result);
+
+ var query = controller.Wtm.DC.Set().FirstOrDefault(q => q.ID == id);
+ Assert.IsNull(query, "查詢應被刪除");
+ }
+
+ [TestMethod]
+ public void DeleteSavedQuery_returns_403_if_not_owner()
+ {
+ var controller = CreateController();
+ controller.Wtm.LoginUserInfo.ITCode = "userA";
+
+ var id = Guid.NewGuid();
+ controller.Wtm.DC.Set().Add(
+ new AnalysisSavedQuery { ID = id, Name = "Test", ListVmType = "VM", OwnerCode = "userB", ConfigJson = "{}" }
+ );
+ controller.Wtm.DC.SaveChanges();
+
+ var result = controller.DeleteSavedQuery(id) as ForbidResult;
+ Assert.IsNotNull(result, "刪除他人查詢應回傳 403 Forbid");
+
+ var query = controller.Wtm.DC.Set().FirstOrDefault(q => q.ID == id);
+ Assert.IsNotNull(query, "查詢不應被刪除");
+ }
}
}