☕ NEW! 完成新手任務即可參加抽獎!LINE 星巴克禮券等你拿,名額有限!        🎉 推廣活動:邀請好友註冊 DevLearn,累積推薦抽 LINE 星巴克禮券! 活動詳情 →        🔥 活動期間 2026/4/1 - 5/31 |已有 0 人參加       
專案實戰 中級

實戰:部落格 CMS 系統

專案概覽

💡 比喻:經營一本雜誌 CMS(內容管理系統)就像經營一本雜誌:

  • 編輯器 = 記者寫稿的桌子
  • 分類與標籤 = 雜誌的不同專欄
  • 留言系統 = 讀者投書專區
  • SEO = 讓更多人在書店找到你的雜誌
  • 管理後台 = 總編輯的辦公室

專案結構

BlogCMS/
├── Controllers/
│   ├── BlogController.cs      ← 前台文章展示
│   ├── AdminController.cs     ← 後台管理
│   └── CommentController.cs   ← 留言管理
├── Services/
│   ├── IPostService.cs        ← 文章服務介面
│   ├── PostService.cs         ← 文章服務實作
│   ├── IImageService.cs       ← 圖片服務介面
│   └── ImageService.cs        ← 圖片服務實作
├── Models/
│   ├── Post.cs                ← 文章 Model
│   ├── Tag.cs                 ← 標籤 Model
│   └── Comment.cs             ← 留言 Model
├── ViewModels/
│   ├── PostEditorVM.cs        ← 編輯器 ViewModel
│   └── PostListVM.cs          ← 文章列表 ViewModel
└── wwwroot/
    └── uploads/               ← 圖片上傳目錄

Markdown 編輯器整合

// 文章 Model(支援 Markdown) // Post model with Markdown support
public class Post // 文章類別
{
    public int Id { get; set; } // 主鍵
    [Required(ErrorMessage = "標題必填")] // 驗證:標題必填
    [StringLength(200)] // 限制長度
    public string Title { get; set; } = ""; // 文章標題

    [Required] // 必填
    public string Slug { get; set; } = ""; // URL 友善的代稱

    [Required(ErrorMessage = "內容必填")] // 驗證:內容必填
    public string MarkdownContent { get; set; } = ""; // Markdown 原始內容

    public string HtmlContent { get; set; } = ""; // 轉換後的 HTML

    public string? Excerpt { get; set; } // 文章摘要
    public string? FeaturedImage { get; set; } // 精選圖片

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // 建立時間
    public DateTime? PublishedAt { get; set; } // 發佈時間
    public bool IsPublished { get; set; } = false; // 是否已發佈

    public int CategoryId { get; set; } // 分類 ID
    public Category? Category { get; set; } // 導覽屬性:分類

    public List<PostTag> PostTags { get; set; } = new(); // 多對多:文章標籤
    public List<Comment> Comments { get; set; } = new(); // 一對多:留言
    public int ViewCount { get; set; } = 0; // 瀏覽次數
}

// Slug 產生器 // Slug generator utility
public static class SlugGenerator // Slug 產生器靜態類別
{
    public static string Generate(string title) // 從標題產生 Slug
    {
        var slug = title.ToLower().Trim(); // 轉小寫並去除頭尾空白
        slug = Regex.Replace(slug, @"[^\w\u4e00-\u9fff\s-]", ""); // 移除特殊字元(保留中文)
        slug = Regex.Replace(slug, @"[\s]+", "-"); // 空白替換為連字號
        slug = Regex.Replace(slug, @"-+", "-"); // 多個連字號合併
        slug = slug.Trim('-'); // 去除頭尾連字號
        return slug; // 回傳 Slug
    }
}

// Markdown 轉 HTML 服務(使用 Markdig) // Markdown to HTML service
public class MarkdownService // Markdown 轉換服務
{
    private readonly MarkdownPipeline _pipeline; // Markdig 管線

    public MarkdownService() // 建構函式
    {
        _pipeline = new MarkdownPipelineBuilder() // 建立管線建構器
            .UseAdvancedExtensions() // 啟用進階擴充(表格、任務列表等)
            .UseEmojiAndSmiley() // 支援 Emoji
            .UseSyntaxHighlighting() // 程式碼語法高亮
            .Build(); // 建置管線
    }

    public string ToHtml(string markdown) // Markdown 轉 HTML 方法
    {
        if (string.IsNullOrEmpty(markdown)) return ""; // 空內容回傳空字串
        return Markdown.ToHtml(markdown, _pipeline); // 使用 Markdig 轉換
    }

    public string ToPlainText(string markdown, int maxLength = 200) // 產生摘要
    {
        var html = ToHtml(markdown); // 先轉 HTML
        var text = Regex.Replace(html, "<[^>]+>", ""); // 移除 HTML 標籤
        text = WebUtility.HtmlDecode(text); // 解碼 HTML 實體
        return text.Length > maxLength // 如果超過長度限制
            ? text[..maxLength] + "..." // 截斷並加省略號
            : text; // 否則回傳完整文字
    }
}

前端整合 EasyMDE 編輯器

// PostEditorVM:傳給 View 的 ViewModel // Editor ViewModel
public class PostEditorVM // 文章編輯器 ViewModel
{
    public int? Id { get; set; } // 文章 ID(新增時為 null)
    [Required(ErrorMessage = "標題必填")] // 驗證
    public string Title { get; set; } = ""; // 文章標題
    [Required(ErrorMessage = "內容必填")] // 驗證
    public string MarkdownContent { get; set; } = ""; // Markdown 內容
    public int CategoryId { get; set; } // 分類 ID
    public string TagIds { get; set; } = ""; // 標籤 ID(逗號分隔)
    public IFormFile? FeaturedImage { get; set; } // 精選圖片上傳
    public bool IsPublished { get; set; } // 是否立即發佈
    public List<Category> AvailableCategories { get; set; } = new(); // 可選分類
    public List<Tag> AvailableTags { get; set; } = new(); // 可選標籤
}

文章 CRUD + 分類標籤

多對多關係:文章與標籤

// 標籤 Model // Tag model
public class Tag // 標籤類別
{
    public int Id { get; set; } // 主鍵
    public string Name { get; set; } = ""; // 標籤名稱
    public string Slug { get; set; } = ""; // URL 代稱
    public List<PostTag> PostTags { get; set; } = new(); // 多對多關聯
}

// 多對多中間表 // Many-to-many join table
public class PostTag // 文章標籤關聯表
{
    public int PostId { get; set; } // 文章 ID
    public Post Post { get; set; } = null!; // 導覽屬性:文章
    public int TagId { get; set; } // 標籤 ID
    public Tag Tag { get; set; } = null!; // 導覽屬性:標籤
}

// DbContext 設定多對多關係 // Configure many-to-many in DbContext
protected override void OnModelCreating(ModelBuilder builder) // 設定模型
{
    base.OnModelCreating(builder); // 呼叫基底方法

    builder.Entity<PostTag>() // 設定 PostTag 實體
        .HasKey(pt => new { pt.PostId, pt.TagId }); // 複合主鍵

    builder.Entity<PostTag>() // 設定 Post 端的關係
        .HasOne(pt => pt.Post) // 一篇文章
        .WithMany(p => p.PostTags) // 有多個標籤關聯
        .HasForeignKey(pt => pt.PostId); // 外鍵是 PostId

    builder.Entity<PostTag>() // 設定 Tag 端的關係
        .HasOne(pt => pt.Tag) // 一個標籤
        .WithMany(t => t.PostTags) // 有多個文章關聯
        .HasForeignKey(pt => pt.TagId); // 外鍵是 TagId

    builder.Entity<Post>() // 設定 Post 的 Slug 索引
        .HasIndex(p => p.Slug) // 對 Slug 建立索引
        .IsUnique(); // 設定為唯一索引
}

文章 Service 完整實作

// 文章 Service // Post service
public class PostService : IPostService // 實作文章服務
{
    private readonly AppDbContext _context; // 資料庫上下文
    private readonly MarkdownService _markdown; // Markdown 服務

    public PostService(AppDbContext context, MarkdownService markdown) // 建構函式
    {
        _context = context; // 儲存 DbContext
        _markdown = markdown; // 儲存 Markdown 服務
    }

    public async Task<Post> CreatePostAsync(PostEditorVM vm) // 建立文章方法
    {
        var post = new Post // 建立文章物件
        {
            Title = vm.Title, // 設定標題
            Slug = SlugGenerator.Generate(vm.Title), // 自動產生 Slug
            MarkdownContent = vm.MarkdownContent, // 儲存 Markdown 原始碼
            HtmlContent = _markdown.ToHtml(vm.MarkdownContent), // 轉換為 HTML
            Excerpt = _markdown.ToPlainText(vm.MarkdownContent), // 產生摘要
            CategoryId = vm.CategoryId, // 設定分類
            IsPublished = vm.IsPublished, // 設定是否發佈
            PublishedAt = vm.IsPublished ? DateTime.UtcNow : null, // 發佈時間
        };

        // 處理標籤 // Handle tags
        if (!string.IsNullOrEmpty(vm.TagIds)) // 如果有選標籤
        {
            var tagIds = vm.TagIds.Split(',') // 分割標籤 ID 字串
                .Select(int.Parse) // 轉為整數
                .ToList(); // 轉為清單
            post.PostTags = tagIds.Select(tid => new PostTag // 建立關聯
            {
                TagId = tid // 設定標籤 ID
            }).ToList(); // 轉為清單
        }

        _context.Posts.Add(post); // 加入追蹤
        await _context.SaveChangesAsync(); // 儲存到資料庫
        return post; // 回傳建立的文章
    }

    public async Task<List<Post>> GetPublishedPostsAsync( // 取得已發佈文章
        int page, int pageSize, string? category = null, string? tag = null) // 支援篩選
    {
        var query = _context.Posts // 從文章資料表開始
            .Include(p => p.Category) // 載入分類
            .Include(p => p.PostTags).ThenInclude(pt => pt.Tag) // 載入標籤
            .Where(p => p.IsPublished) // 只取已發佈的
            .AsQueryable(); // 轉為可查詢物件

        if (!string.IsNullOrEmpty(category)) // 如果有指定分類
            query = query.Where(p => p.Category!.Slug == category); // 篩選分類

        if (!string.IsNullOrEmpty(tag)) // 如果有指定標籤
            query = query.Where(p => p.PostTags.Any(pt => pt.Tag.Slug == tag)); // 篩選標籤

        return await query // 執行查詢
            .OrderByDescending(p => p.PublishedAt) // 依發佈時間降序
            .Skip((page - 1) * pageSize) // 分頁跳過
            .Take(pageSize) // 取指定筆數
            .ToListAsync(); // 回傳結果
    }

    public async Task<Post?> GetPostBySlugAsync(string slug) // 依 Slug 取得文章
    {
        var post = await _context.Posts // 查詢文章
            .Include(p => p.Category) // 載入分類
            .Include(p => p.PostTags).ThenInclude(pt => pt.Tag) // 載入標籤
            .Include(p => p.Comments.Where(c => c.IsApproved)) // 載入已審核的留言
            .FirstOrDefaultAsync(p => p.Slug == slug && p.IsPublished); // 找到符合的文章

        if (post != null) // 如果找到文章
        {
            post.ViewCount++; // 增加瀏覽次數
            await _context.SaveChangesAsync(); // 儲存瀏覽次數
        }

        return post; // 回傳文章
    }
}

留言系統

// 留言 Model // Comment model
public class Comment // 留言類別
{
    public int Id { get; set; } // 主鍵
    [Required(ErrorMessage = "請輸入留言內容")] // 驗證
    [StringLength(1000, ErrorMessage = "留言最多 1000 字")] // 長度限制
    public string Content { get; set; } = ""; // 留言內容

    [Required(ErrorMessage = "請輸入暱稱")] // 驗證
    [StringLength(50)] // 長度限制
    public string AuthorName { get; set; } = ""; // 留言者暱稱

    [EmailAddress] // Email 驗證
    public string? AuthorEmail { get; set; } // 留言者 Email(選填)

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // 留言時間
    public bool IsApproved { get; set; } = false; // 是否已審核

    public int PostId { get; set; } // 所屬文章 ID
    public Post? Post { get; set; } // 導覽屬性:文章

    public int? ParentId { get; set; } // 父留言 ID(回覆用)
    public Comment? Parent { get; set; } // 導覽屬性:父留言
    public List<Comment> Replies { get; set; } = new(); // 子留言清單
}

// 留言 Service // Comment service
public class CommentService // 留言服務類別
{
    private readonly AppDbContext _context; // 資料庫上下文

    public CommentService(AppDbContext context) // 建構函式
    {
        _context = context; // 儲存 DbContext
    }

    public async Task<Comment> AddCommentAsync(Comment comment) // 新增留言
    {
        // 簡單的垃圾留言過濾 // Simple spam filter
        var spamWords = new[] { "casino", "viagra", "loan" }; // 垃圾關鍵字清單
        var contentLower = comment.Content.ToLower(); // 轉小寫比對
        if (spamWords.Any(w => contentLower.Contains(w))) // 檢查是否含垃圾字
        {
            throw new InvalidOperationException("留言內容包含不允許的字詞"); // 拒絕垃圾留言
        }

        comment.IsApproved = false; // 預設未審核
        comment.CreatedAt = DateTime.UtcNow; // 設定留言時間
        _context.Comments.Add(comment); // 加入追蹤
        await _context.SaveChangesAsync(); // 儲存到資料庫
        return comment; // 回傳留言
    }

    public async Task<List<Comment>> GetCommentsForPostAsync(int postId) // 取得文章留言
    {
        return await _context.Comments // 查詢留言
            .Where(c => c.PostId == postId && c.IsApproved) // 篩選已審核的
            .Where(c => c.ParentId == null) // 只取頂層留言
            .Include(c => c.Replies.Where(r => r.IsApproved)) // 載入已審核的回覆
            .OrderByDescending(c => c.CreatedAt) // 依時間降序
            .ToListAsync(); // 回傳結果
    }

    public async Task ApproveCommentAsync(int commentId) // 審核留言
    {
        var comment = await _context.Comments.FindAsync(commentId); // 找到留言
        if (comment != null) // 如果找到
        {
            comment.IsApproved = true; // 設定為已審核
            await _context.SaveChangesAsync(); // 儲存
        }
    }
}

圖片上傳與管理

// 圖片上傳 Service // Image upload service
public class ImageService : IImageService // 實作圖片服務
{
    private readonly IWebHostEnvironment _env; // 網站環境
    private readonly long _maxFileSize = 5 * 1024 * 1024; // 最大 5MB
    private readonly string[] _allowedExts = { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; // 允許的副檔名

    public ImageService(IWebHostEnvironment env) // 建構函式
    {
        _env = env; // 儲存環境參考
    }

    public async Task<string> UploadImageAsync(IFormFile file) // 上傳圖片方法
    {
        // 驗證檔案大小 // Validate file size
        if (file.Length == 0) throw new ArgumentException("檔案是空的"); // 檔案不可為空
        if (file.Length > _maxFileSize) throw new ArgumentException("檔案大小不可超過 5MB"); // 大小限制

        // 驗證副檔名 // Validate extension
        var ext = Path.GetExtension(file.FileName).ToLower(); // 取得副檔名
        if (!_allowedExts.Contains(ext)) // 檢查副檔名
            throw new ArgumentException($"不支援的檔案格式:{ext}"); // 格式不允許

        // 產生唯一檔名 // Generate unique filename
        var fileName = $"{Guid.NewGuid()}{ext}"; // 用 GUID 產生唯一檔名
        var uploadDir = Path.Combine(_env.WebRootPath, "uploads"); // 上傳目錄路徑

        if (!Directory.Exists(uploadDir)) // 如果目錄不存在
            Directory.CreateDirectory(uploadDir); // 建立上傳目錄

        var filePath = Path.Combine(uploadDir, fileName); // 完整檔案路徑

        // 儲存檔案 // Save file
        using var stream = new FileStream(filePath, FileMode.Create); // 建立檔案串流
        await file.CopyToAsync(stream); // 複製上傳檔案到磁碟

        return $"/uploads/{fileName}"; // 回傳相對路徑
    }

    public void DeleteImage(string imageUrl) // 刪除圖片方法
    {
        if (string.IsNullOrEmpty(imageUrl)) return; // 空路徑直接返回
        var filePath = Path.Combine(_env.WebRootPath, imageUrl.TrimStart('/')); // 組合完整路徑
        if (File.Exists(filePath)) File.Delete(filePath); // 如果存在就刪除
    }
}

// Markdown 編輯器的圖片上傳 API // Image upload API for editor
[HttpPost("/api/upload/image")] // 圖片上傳端點
[Authorize] // 需要登入
public async Task<IActionResult> UploadImage(IFormFile file) // 上傳 Action
{
    try // 嘗試上傳
    {
        var url = await _imageService.UploadImageAsync(file); // 呼叫上傳服務
        return Ok(new { url }); // 回傳圖片 URL(給 EasyMDE 用)
    }
    catch (ArgumentException ex) // 捕捉驗證錯誤
    {
        return BadRequest(new { error = ex.Message }); // 回傳 400 錯誤
    }
}

SEO 優化 (meta tags, sitemap, robots.txt)

// SEO 元資料 ViewModel // SEO metadata ViewModel
public class SeoMetadata // SEO 元資料類別
{
    public string Title { get; set; } = ""; // 頁面標題
    public string Description { get; set; } = ""; // 描述
    public string? OgImage { get; set; } // Open Graph 圖片
    public string? CanonicalUrl { get; set; } // 標準網址
    public string? Author { get; set; } // 作者
}

// Sitemap 產生器 // Sitemap generator
[Route("sitemap.xml")] // 設定路由
public async Task<IActionResult> Sitemap() // Sitemap Action
{
    var posts = await _context.Posts // 查詢所有已發佈文章
        .Where(p => p.IsPublished) // 只取已發佈的
        .Select(p => new { p.Slug, p.PublishedAt }) // 只取需要的欄位
        .ToListAsync(); // 執行查詢

    var sb = new StringBuilder(); // 建立字串建構器
    sb.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); // XML 宣告
    sb.AppendLine("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">"); // Sitemap 根元素

    // 首頁 // Homepage
    sb.AppendLine("  <url>"); // URL 元素開始
    sb.AppendLine($"    <loc>{Request.Scheme}://{Request.Host}/</loc>"); // 首頁網址
    sb.AppendLine("    <changefreq>daily</changefreq>"); // 更新頻率:每天
    sb.AppendLine("    <priority>1.0</priority>"); // 優先度:最高
    sb.AppendLine("  </url>"); // URL 元素結束

    foreach (var post in posts) // 逐一加入文章
    {
        sb.AppendLine("  <url>"); // URL 元素開始
        sb.AppendLine($"    <loc>{Request.Scheme}://{Request.Host}/blog/{post.Slug}</loc>"); // 文章網址
        sb.AppendLine($"    <lastmod>{post.PublishedAt:yyyy-MM-dd}</lastmod>"); // 最後修改日
        sb.AppendLine("    <changefreq>monthly</changefreq>"); // 更新頻率:每月
        sb.AppendLine("    <priority>0.8</priority>"); // 優先度
        sb.AppendLine("  </url>"); // URL 元素結束
    }

    sb.AppendLine("</urlset>"); // 根元素結束
    return Content(sb.ToString(), "application/xml"); // 回傳 XML
}

// robots.txt // robots.txt endpoint
[Route("robots.txt")] // 設定路由
public IActionResult Robots() // robots.txt Action
{
    var content = $"User-agent: *\n" + // 所有搜尋引擎
                  $"Allow: /\n" + // 允許存取所有頁面
                  $"Disallow: /admin/\n" + // 禁止存取管理後台
                  $"Disallow: /api/\n" + // 禁止存取 API
                  $"Sitemap: {Request.Scheme}://{Request.Host}/sitemap.xml"; // Sitemap 位置
    return Content(content, "text/plain"); // 回傳純文字
}

RSS Feed 產生

// RSS Feed 產生器 // RSS feed generator
[Route("feed.xml")] // 設定路由
public async Task<IActionResult> RssFeed() // RSS Feed Action
{
    var posts = await _context.Posts // 查詢文章
        .Where(p => p.IsPublished) // 已發佈的
        .OrderByDescending(p => p.PublishedAt) // 依發佈時間降序
        .Take(20) // 取最新 20 篇
        .ToListAsync(); // 執行查詢

    var sb = new StringBuilder(); // 建立字串建構器
    sb.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); // XML 宣告
    sb.AppendLine("<rss version=\"2.0\">"); // RSS 根元素
    sb.AppendLine("  <channel>"); // 頻道開始
    sb.AppendLine("    <title>我的部落格</title>"); // 頻道標題
    sb.AppendLine($"    <link>{Request.Scheme}://{Request.Host}</link>"); // 網站網址
    sb.AppendLine("    <description>技術部落格</description>"); // 頻道描述
    sb.AppendLine($"    <language>zh-TW</language>"); // 語言

    foreach (var post in posts) // 逐一加入文章
    {
        sb.AppendLine("    <item>"); // 項目開始
        sb.AppendLine($"      <title>{WebUtility.HtmlEncode(post.Title)}</title>"); // 文章標題
        sb.AppendLine($"      <link>{Request.Scheme}://{Request.Host}/blog/{post.Slug}</link>"); // 文章連結
        sb.AppendLine($"      <description>{WebUtility.HtmlEncode(post.Excerpt ?? "")}</description>"); // 摘要
        sb.AppendLine($"      <pubDate>{post.PublishedAt:R}</pubDate>"); // 發佈日期(RFC 822)
        sb.AppendLine($"      <guid>{Request.Scheme}://{Request.Host}/blog/{post.Slug}</guid>"); // 唯一識別碼
        sb.AppendLine("    </item>"); // 項目結束
    }

    sb.AppendLine("  </channel>"); // 頻道結束
    sb.AppendLine("</rss>"); // RSS 結束
    return Content(sb.ToString(), "application/rss+xml"); // 回傳 RSS XML
}

管理員後台

// 管理後台 Controller // Admin controller
[Authorize(Roles = "Admin")] // 整個 Controller 限定 Admin
[Route("admin")] // 路由前綴
public class AdminController : Controller // 管理後台 Controller
{
    private readonly IPostService _postService; // 文章服務
    private readonly CommentService _commentService; // 留言服務

    public AdminController(IPostService postService, CommentService commentService) // 建構函式
    {
        _postService = postService; // 儲存文章服務
        _commentService = commentService; // 儲存留言服務
    }

    // 後台首頁:儀表板 // Dashboard
    [HttpGet("")] // admin/
    public async Task<IActionResult> Dashboard() // 儀表板 Action
    {
        var stats = new DashboardVM // 建立儀表板 ViewModel
        {
            TotalPosts = await _postService.GetTotalPostCountAsync(), // 文章總數
            PublishedPosts = await _postService.GetPublishedCountAsync(), // 已發佈數
            DraftPosts = await _postService.GetDraftCountAsync(), // 草稿數
            PendingComments = await _commentService.GetPendingCountAsync(), // 待審核留言
            TotalViews = await _postService.GetTotalViewsAsync(), // 總瀏覽次數
        };
        return View(stats); // 回傳儀表板
    }

    // 文章管理列表 // Post management list
    [HttpGet("posts")] // admin/posts
    public async Task<IActionResult> Posts(int page = 1) // 文章管理 Action
    {
        var posts = await _postService.GetAllPostsAsync(page, 20); // 取得所有文章(含草稿)
        return View(posts); // 回傳文章列表
    }

    // 待審核留言 // Pending comments
    [HttpGet("comments/pending")] // admin/comments/pending
    public async Task<IActionResult> PendingComments() // 待審核留言 Action
    {
        var comments = await _commentService.GetPendingCommentsAsync(); // 取得待審核留言
        return View(comments); // 回傳留言列表
    }

    // 審核留言 // Approve comment
    [HttpPost("comments/{id}/approve")] // admin/comments/5/approve
    public async Task<IActionResult> ApproveComment(int id) // 審核 Action
    {
        await _commentService.ApproveCommentAsync(id); // 審核通過
        TempData["Success"] = "留言已審核通過"; // 成功訊息
        return RedirectToAction(nameof(PendingComments)); // 導回待審核頁
    }
}

// 儀表板 ViewModel // Dashboard ViewModel
public class DashboardVM // 儀表板資料
{
    public int TotalPosts { get; set; } // 文章總數
    public int PublishedPosts { get; set; } // 已發佈數
    public int DraftPosts { get; set; } // 草稿數
    public int PendingComments { get; set; } // 待審核留言數
    public int TotalViews { get; set; } // 總瀏覽次數
}

🤔 我這樣寫為什麼會錯?

❌ 錯誤 1:Slug 沒有做唯一性檢查

// ❌ 錯誤:直接用標題產生 Slug,沒檢查重複 // Mistake: no uniqueness check
var slug = SlugGenerator.Generate(post.Title); // 如果兩篇文章同名就爆了
post.Slug = slug; // 直接存入

// ✅ 正確:檢查重複並加上後綴 // Correct: check and append suffix
public async Task<string> GenerateUniqueSlugAsync(string title) // 產生唯一 Slug
{
    var slug = SlugGenerator.Generate(title); // 先產生基本 Slug
    var baseSlug = slug; // 保存原始 Slug
    var counter = 1; // 計數器

    while (await _context.Posts.AnyAsync(p => p.Slug == slug)) // 如果已存在
    {
        slug = $"{baseSlug}-{counter}"; // 加上數字後綴
        counter++; // 計數器加一
    }
    return slug; // 回傳唯一的 Slug
}

❌ 錯誤 2:圖片上傳沒有驗證內容

// ❌ 錯誤:只檢查副檔名 // Mistake: only checking extension
if (file.FileName.EndsWith(".jpg")) // 只看副檔名
{
    // 有人可以把 .exe 改名為 .jpg 上傳! // Someone could rename .exe to .jpg!
}

// ✅ 正確:也要檢查檔案內容 // Correct: also check file content
public bool IsValidImage(IFormFile file) // 驗證是否為真正的圖片
{
    var ext = Path.GetExtension(file.FileName).ToLower(); // 檢查副檔名
    if (!_allowedExts.Contains(ext)) return false; // 副檔名不對就拒絕

    using var reader = new BinaryReader(file.OpenReadStream()); // 讀取檔案內容
    var headerBytes = reader.ReadBytes(4); // 讀取前 4 個位元組
    var header = BitConverter.ToString(headerBytes); // 轉為 16 進位字串

    return header.StartsWith("FF-D8") // JPEG 檔頭
        || header.StartsWith("89-50-4E-47") // PNG 檔頭
        || header.StartsWith("47-49-46"); // GIF 檔頭
}

❌ 錯誤 3:留言沒有防止 XSS 攻擊

// ❌ 錯誤:直接顯示留言內容 // Mistake: rendering raw comment content
@Html.Raw(comment.Content) // 如果留言包含 <script> 標籤就完蛋了!

// ✅ 正確:一定要 HTML 編碼 // Correct: always HTML encode
@comment.Content // Razor 預設會自動編碼
// 或者手動編碼 // Or manually encode
@Html.Encode(comment.Content) // 手動 HTML 編碼

📋 本章重點

功能 關鍵技術 注意事項
Markdown 編輯 Markdig + EasyMDE 儲存原始碼和 HTML
多對多標籤 PostTag 中間表 設定複合主鍵
圖片上傳 IFormFile + GUID 檔名 驗證副檔名和內容
SEO Sitemap + meta tags Slug 要唯一
留言 審核機制 + 防 XSS 永遠不用 Html.Raw

🎯 下一步:來建構 RESTful API 微服務!

💡 大家的想法 · 0

載入中...
💬 即時聊天室 🟢 0 人在線
😀 😎 🤓 💻 🎮 🎸 🔥
➕ 新問題
📋 我的工單
💬 LINE 社群
🔒
需要註冊才能使用此功能
註冊帳號即可解鎖測驗、遊戲、簽到、筆記下載等所有功能,完全免費!
免費註冊