diff --git a/db/schema.sql b/db/schema.sql
index de881bd..031c3fa 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -906,3 +906,118 @@ CREATE INDEX IF NOT EXISTS "idx_game_views_dedupe" ON "game_views" ("game_id", "
-- 5) games 비정규화 방문수 카운터
ALTER TABLE "games" ADD COLUMN IF NOT EXISTS "view_count" integer DEFAULT 0 NOT NULL;
CREATE INDEX IF NOT EXISTS "idx_games_view_count" ON "games" ("view_count");
+
+-- ---------------------------------------------------------------------------
+-- W3-3 포스팅 보드: post_categories / posts / unity_feed_sources / unity_feed_items
+-- (권위 DDL — docs/board-ddl.sql 와 동일)
+-- ---------------------------------------------------------------------------
+
+-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
+CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
+CREATE TABLE IF NOT EXISTS "post_categories" (
+ "id" bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
+ "name" character varying(80) NOT NULL,
+ "slug" character varying(80) NOT NULL,
+ "sort_order" integer DEFAULT 0 NOT NULL,
+ "is_active" boolean DEFAULT true NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ PRIMARY KEY ("id")
+);
+ALTER SEQUENCE "post_categories_id_seq" OWNED BY "post_categories"."id";
+CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
+ ON "post_categories" ("slug");
+
+-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
+CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
+CREATE TABLE IF NOT EXISTS "posts" (
+ "id" bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
+ "category_id" bigint NOT NULL,
+ "author_user_id" bigint NOT NULL,
+ "title" character varying(200) NOT NULL,
+ "body_markdown" text NOT NULL,
+ "body_sanitized_html" text NOT NULL,
+ "link_url" character varying(2048),
+ "og_title" character varying(300),
+ "og_description" character varying(600),
+ "og_image_url" character varying(2048),
+ "og_site_name" character varying(200),
+ "og_fetched_at" timestamp with time zone,
+ "status" character varying(20) DEFAULT 'PUBLISHED' NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "deleted_at" timestamp with time zone,
+ "is_delete" boolean DEFAULT false NOT NULL,
+ PRIMARY KEY ("id")
+);
+ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
+ ALTER TABLE "posts"
+ ADD CONSTRAINT "posts_category_id_fkey"
+ FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
+ ALTER TABLE "posts"
+ ADD CONSTRAINT "posts_author_user_id_fkey"
+ FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
+ ALTER TABLE "posts"
+ ADD CONSTRAINT "posts_status_check"
+ CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
+ END IF;
+END
+$$;
+CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
+ ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
+CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
+ ON "posts" ("category_id", "created_at" DESC, "id" DESC)
+ WHERE "is_delete" = false AND "status" = 'PUBLISHED';
+
+-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
+CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
+CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
+ "id" bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
+ "name" character varying(120) NOT NULL,
+ "feed_url" character varying(2048) NOT NULL,
+ "is_active" boolean DEFAULT true NOT NULL,
+ "last_polled_at" timestamp with time zone,
+ "last_seen_guid" character varying(512),
+ "last_error" character varying(500),
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ PRIMARY KEY ("id")
+);
+ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
+CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
+ ON "unity_feed_sources" ("feed_url");
+
+-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
+CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
+CREATE TABLE IF NOT EXISTS "unity_feed_items" (
+ "id" bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
+ "source_id" bigint NOT NULL,
+ "guid" character varying(512) NOT NULL,
+ "title" character varying(500) NOT NULL,
+ "link_url" character varying(2048) NOT NULL,
+ "published_at" timestamp with time zone,
+ "is_acknowledged" boolean DEFAULT false NOT NULL,
+ "detected_at" timestamp with time zone DEFAULT now() NOT NULL,
+ PRIMARY KEY ("id")
+);
+ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
+ ALTER TABLE "unity_feed_items"
+ ADD CONSTRAINT "unity_feed_items_source_id_fkey"
+ FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
+ END IF;
+END
+$$;
+CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
+ ON "unity_feed_items" ("source_id", "guid");
+CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
+ ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
+ WHERE "is_acknowledged" = false;
diff --git a/docs/board-ddl.sql b/docs/board-ddl.sql
new file mode 100644
index 0000000..2815f5d
--- /dev/null
+++ b/docs/board-ddl.sql
@@ -0,0 +1,112 @@
+-- W3-3 포스팅 보드. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
+-- posts / post_categories / unity_feed_sources / unity_feed_items. 추가만, 파괴 없음.
+
+-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
+CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
+CREATE TABLE IF NOT EXISTS "post_categories" (
+ "id" bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
+ "name" character varying(80) NOT NULL,
+ "slug" character varying(80) NOT NULL,
+ "sort_order" integer DEFAULT 0 NOT NULL,
+ "is_active" boolean DEFAULT true NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ PRIMARY KEY ("id")
+);
+ALTER SEQUENCE "post_categories_id_seq" OWNED BY "post_categories"."id";
+CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
+ ON "post_categories" ("slug");
+
+-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
+CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
+CREATE TABLE IF NOT EXISTS "posts" (
+ "id" bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
+ "category_id" bigint NOT NULL,
+ "author_user_id" bigint NOT NULL,
+ "title" character varying(200) NOT NULL,
+ "body_markdown" text NOT NULL,
+ "body_sanitized_html" text NOT NULL,
+ "link_url" character varying(2048),
+ "og_title" character varying(300),
+ "og_description" character varying(600),
+ "og_image_url" character varying(2048),
+ "og_site_name" character varying(200),
+ "og_fetched_at" timestamp with time zone,
+ "status" character varying(20) DEFAULT 'PUBLISHED' NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "deleted_at" timestamp with time zone,
+ "is_delete" boolean DEFAULT false NOT NULL,
+ PRIMARY KEY ("id")
+);
+ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
+ ALTER TABLE "posts"
+ ADD CONSTRAINT "posts_category_id_fkey"
+ FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
+ ALTER TABLE "posts"
+ ADD CONSTRAINT "posts_author_user_id_fkey"
+ FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
+ ALTER TABLE "posts"
+ ADD CONSTRAINT "posts_status_check"
+ CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
+ END IF;
+END
+$$;
+CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
+ ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
+CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
+ ON "posts" ("category_id", "created_at" DESC, "id" DESC)
+ WHERE "is_delete" = false AND "status" = 'PUBLISHED';
+
+-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
+CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
+CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
+ "id" bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
+ "name" character varying(120) NOT NULL,
+ "feed_url" character varying(2048) NOT NULL,
+ "is_active" boolean DEFAULT true NOT NULL,
+ "last_polled_at" timestamp with time zone,
+ "last_seen_guid" character varying(512),
+ "last_error" character varying(500),
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ PRIMARY KEY ("id")
+);
+ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
+CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
+ ON "unity_feed_sources" ("feed_url");
+
+-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
+CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
+CREATE TABLE IF NOT EXISTS "unity_feed_items" (
+ "id" bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
+ "source_id" bigint NOT NULL,
+ "guid" character varying(512) NOT NULL,
+ "title" character varying(500) NOT NULL,
+ "link_url" character varying(2048) NOT NULL,
+ "published_at" timestamp with time zone,
+ "is_acknowledged" boolean DEFAULT false NOT NULL,
+ "detected_at" timestamp with time zone DEFAULT now() NOT NULL,
+ PRIMARY KEY ("id")
+);
+ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
+ ALTER TABLE "unity_feed_items"
+ ADD CONSTRAINT "unity_feed_items_source_id_fkey"
+ FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
+ END IF;
+END
+$$;
+CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
+ ON "unity_feed_items" ("source_id", "guid");
+CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
+ ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
+ WHERE "is_acknowledged" = false;
diff --git a/pom.xml b/pom.xml
index 05112c9..c135592 100644
--- a/pom.xml
+++ b/pom.xml
@@ -84,6 +84,16 @@
spring-boot-starter-testtest
+
+ org.commonmark
+ commonmark
+ 0.22.0
+
+
+ org.jsoup
+ jsoup
+ 1.17.2
+
diff --git a/src/main/java/com/pandoli365/bibimbap/config/SchedulingConfig.java b/src/main/java/com/pandoli365/bibimbap/config/SchedulingConfig.java
new file mode 100644
index 0000000..86b2388
--- /dev/null
+++ b/src/main/java/com/pandoli365/bibimbap/config/SchedulingConfig.java
@@ -0,0 +1,9 @@
+package com.pandoli365.bibimbap.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@Configuration
+@EnableScheduling
+public class SchedulingConfig {
+}
diff --git a/src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java b/src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java
new file mode 100644
index 0000000..5aa359a
--- /dev/null
+++ b/src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java
@@ -0,0 +1,164 @@
+package com.pandoli365.bibimbap.controller;
+
+import com.pandoli365.bibimbap.data.PostCategoryData;
+import com.pandoli365.bibimbap.mapper.PostCategoriesMapper;
+import com.pandoli365.bibimbap.security.CsrfTokens;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Controller
+public class PostAdminController {
+
+ private final PostCategoriesMapper postCategoriesMapper;
+
+ public PostAdminController(PostCategoriesMapper postCategoriesMapper) {
+ this.postCategoriesMapper = postCategoriesMapper;
+ }
+
+ @GetMapping("/admin/post-categories")
+ public String postCategoriesPage(HttpServletRequest request, Model model) {
+ model.addAttribute("categories", postCategoriesMapper.listActive());
+ model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
+ return "admin-post-categories";
+ }
+
+ @PostMapping("/admin/post-categories")
+ @Transactional
+ public ResponseEntity