延續「文章列表」與「新增文章」,本篇實現修改與刪除,完成文章管理的 CRUD 閉環。重點包括:關聯實體刪除順序、TryUpdateModel 部分更新防參數綁定攻擊、附件清理邏輯。
資料關係:CommonModel (1) ←→ (1) Article,CommonModel (1) ←→ (N) Attachment。
若先刪 CommonModel,外鍵約束會導致錯誤;必須先刪除子實體。
public class CommonModelRepository : BaseRepository, ICommonModelRepository { // 使用 new 關鍵字隱藏基類方法,實現級聯刪除附件 public new bool Delete(CommonModel model, bool isSave = true) { if (model.Attachment != null && model.Attachment.Any()) Context.Attachments.RemoveRange(model.Attachment); Context.CommonModels.Remove(model); return isSave ? Context.SaveChanges() > 0 : true; } }
public JsonResult Delete(int id)
{
var article = _articleService.Find(id);
if (article == null) return Json(false);
var attachmentList = article.CommonModel.Attachment?.ToList();
var commonModel = article.CommonModel;
// 1. 先刪 Article(觸發 FK 級聯或手動處理)
if (!_articleService.Delete(article)) return Json(false);
// 2. 刪除實體檔案
foreach (var att in attachmentList)
{
string physicalPath = Server.MapPath(att.FilePath);
if (System.IO.File.Exists(physicalPath)) System.IO.File.Delete(physicalPath);
}
// 3. 刪除 CommonModel(Repository 已處理 Attachment 記錄)
_commonModelService.Delete(commonModel);
return Json(true);
}
public ActionResult Edit(int id)
{
var article = _articleService.Find(id);
if (article == null) return HttpNotFound();
return View(article);
}
@model Ninesky.Models.Article
@section Scripts {
<script src="~/Scripts/KindEditor/kindeditor-min.js"></script>
<script>
KindEditor.ready(function (K) {
// 內容編輯器
window.editor = K.create('#Content', {
height: '500px',
uploadJson: '@Url.Action("Upload", "Attachment")',
fileManagerJson: '@Url.Action("FileManagerJson", "Attachment", new { id = Model.CommonModel.ModelID })',
allowFileManager: true
});
// 首頁圖片選擇器
var editor2 = K.editor({
fileManagerJson: '@Url.Action("FileManagerJson", "Attachment", new { id = Model.CommonModel.ModelID })'
});
K('#btnPicSelect').click(function () {
editor2.loadPlugin('filemanager', function () {
editor2.plugin.filemanagerDialog({
viewType: 'VIEW', dirName: 'image',
clickFn: function (url, title) {
$.ajax({
type: "POST", url: '@Url.Action("CreateThumbnail", "Attachment")',
data: { originalPicture: url }, async: false,
success: function (data) {
if (data) {
K('#CommonModel_DefaultPicUrl').val(data);
K('#imgPreview').attr("src", data);
} else alert("縮圖生成失敗");
editor2.hideDialog();
}
});
}
});
});
});
});
</script>
}
@using (Html.BeginForm())
{
@Html.AntiForgeryToken()
<div class="form-horizontal">
<h4>修改文章</h4> <hr />
@Html.ValidationSummary(true)
<div class="form-group">
@Html.LabelFor(m => m.CommonModel.CategoryID, new { @class = "control-label col-sm-2" })
<div class="col-sm-10">
<input id="CommonModel_CategoryID" name="CommonModel.CategoryID"
data-options="url:'@Url.Action("JsonTree", "Category", new { model="Article" })'"
class="easyui-combotree" style="height:34px;width:280px;"
value="@Model.CommonModel.CategoryID" />
@Html.ValidationMessageFor(m => m.CommonModel.CategoryID)
</div>
</div>
<div class="form-group">
@Html.LabelFor(m => m.CommonModel.Title, new { @class = "control-label col-sm-2" })
<div class="col-sm-10">
@Html.TextBoxFor(m => m.CommonModel.Title, new { @class = "form-control" })
@Html.ValidationMessageFor(m => m.CommonModel.Title)
</div>
</div>
@* Author, Source, Intro, Content 同樣處理 *@
<div class="form-group">
@Html.LabelFor(m => m.CommonModel.DefaultPicUrl, new { @class = "control-label col-sm-2" })
<div class="col-sm-10">
<img id="imgPreview" class="thumbnail" src="@Model.CommonModel.DefaultPicUrl" />
@Html.HiddenFor(m => m.CommonModel.DefaultPicUrl)
<a id="btnPicSelect" class="easyui-linkbutton">選擇…</a>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<input type="submit" value="儲存" class="btn btn-default" />
</div>
</div>
</div>
}
[HttpPost]
[ValidateInput(false)] // 允許 HTML 內容
[ValidateAntiForgeryToken] // 防 CSRF
public ActionResult Edit()
{
// 1. 從路由取得 ID
int id = int.Parse(ControllerContext.RouteData.GetRequiredString("id"));
var article = _articleService.Find(id);
if (article == null) return HttpNotFound();
// 2. 白名單部分更新:僅允許修改指定欄位
TryUpdateModel(article, new[] { "Author", "Source", "Intro", "Content" });
TryUpdateModel(article.CommonModel, "CommonModel", new[] { "CategoryID", "Title", "DefaultPicUrl" });
if (ModelState.IsValid)
{
if (_articleService.Update(article))
{
// 3. 附件清理:檢查上傳但未使用的附件
var attService = new AttachmentService();
var uploads = attService.FindList(article.CommonModel.ModelID, User.Identity.Name, string.Empty, true).ToList();
foreach (var att in uploads)
{
string fileUrl = Url.Content(att.FilePath);
bool isUsed = (article.CommonModel.DefaultPicUrl?.Contains(fileUrl) ?? false)
|| article.Content.Contains(fileUrl);
if (isUsed)
{
att.ModelID = article.ModelID; // 歸屬給文章
attService.Update(att);
}
else
{
// 刪除實體檔案與資料庫記錄
System.IO.File.Delete(Server.MapPath(att.FilePath));
attService.Delete(att);
}
}
return View("EditSuccess", article);
}
}
return View(article);
}
| 技術點 | 說明 | 代碼位置 |
|---|---|---|
ValidateInput(false) | 關閉請求驗證,允許富文本 HTML 提交 | Edit Action |
ValidateAntiForgeryToken | 防跨站請求偽造,配合 View @Html.AntiForgeryToken() | Edit Action |
TryUpdateModel 白名單 | 僅綁定允許修改的屬性,防止惡意提交 CategoryID 等敏感欄位 | Edit Action |
| 前綴綁定 | TryUpdateModel(model.CommonModel, "CommonModel", ...) 對應 View 中 name="CommonModel.Title" | Edit Action |
| 路由取 ID | ControllerContext.RouteData.GetRequiredString("id") 避免 Action 參數被竄改 | Edit Action |
| 附件歸屬判斷 | 檢查 DefaultPicUrl 與 Content 是否包含附件 URL | Edit Action |
與「全部文章列表」類似,但:
inputer = User.Identity.NameonDblClickRow 雙擊進入編輯、edit()/del() 函式文章管理 CRUD 至此完結。核心收穫:
TryUpdateModel + 白名單是防參數綁定攻擊的標準做法下一階段可延伸:版本控制、發布工作流、全文搜尋、多語系支援。