xueyiming 1 день назад
Родитель
Сommit
d1906a32ff

+ 4 - 0
.gitignore

@@ -19,6 +19,10 @@ build/
 logs/
 tests/*
 !tests/__init__.py
+!tests/api/
+tests/api/*
+!tests/api/__init__.py
+!tests/api/test_demand_feedback.py
 !tests/supply_agent/
 !tests/supply_infra/
 tests/supply_infra/*

+ 81 - 0
alembic/versions/20260730_06_add_demand_feedback.py

@@ -0,0 +1,81 @@
+"""add demand summary feedback records
+
+Revision ID: 20260730_06
+Revises: 20260730_05
+Create Date: 2026-07-30
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260730_06"
+down_revision: str | None = "20260730_05"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "demand_feedback",
+        sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+        sa.Column("client_request_id", sa.String(length=64), nullable=False),
+        sa.Column(
+            "target_type",
+            sa.String(length=32),
+            nullable=False,
+            comment="反馈对象:demand / video / hit_content",
+        ),
+        sa.Column("biz_dt", sa.String(length=32), nullable=False),
+        sa.Column("demand_grade_id", sa.BigInteger(), nullable=False),
+        sa.Column("video_id", sa.String(length=64), nullable=True),
+        sa.Column("demand_video_expansion_id", sa.BigInteger(), nullable=True),
+        sa.Column("feedback_action", sa.String(length=32), nullable=False),
+        sa.Column("reason_code", sa.String(length=64), nullable=True),
+        sa.Column("content", sa.Text(), nullable=True),
+        sa.Column("target_snapshot_json", sa.Text(), nullable=False),
+        sa.Column("feedback_user_id", sa.Integer(), nullable=False),
+        sa.Column(
+            "feedback_user_name_snapshot",
+            sa.String(length=128),
+            nullable=False,
+        ),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint(
+            "client_request_id",
+            name="uk_demand_feedback_request",
+        ),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "idx_demand_feedback_demand",
+        "demand_feedback",
+        ["demand_grade_id", "created_at"],
+    )
+    op.create_index(
+        "idx_demand_feedback_video",
+        "demand_feedback",
+        ["demand_grade_id", "video_id", "created_at"],
+    )
+    op.create_index(
+        "idx_demand_feedback_expansion",
+        "demand_feedback",
+        ["demand_video_expansion_id", "created_at"],
+    )
+    op.create_index(
+        "idx_demand_feedback_user",
+        "demand_feedback",
+        ["feedback_user_id", "created_at"],
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("idx_demand_feedback_user", table_name="demand_feedback")
+    op.drop_index("idx_demand_feedback_expansion", table_name="demand_feedback")
+    op.drop_index("idx_demand_feedback_video", table_name="demand_feedback")
+    op.drop_index("idx_demand_feedback_demand", table_name="demand_feedback")
+    op.drop_table("demand_feedback")

+ 51 - 0
alembic/versions/20260730_07_add_feedback_username.py

@@ -0,0 +1,51 @@
+"""add feedback username snapshot
+
+Revision ID: 20260730_07
+Revises: 20260730_06
+Create Date: 2026-07-30
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260730_07"
+down_revision: str | None = "20260730_06"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column(
+        "demand_feedback",
+        sa.Column(
+            "feedback_username_snapshot",
+            sa.String(length=64),
+            nullable=False,
+            server_default="",
+            comment="反馈人登录账号快照",
+        ),
+    )
+    op.execute(
+        sa.text(
+            """
+            UPDATE demand_feedback AS feedback
+            JOIN auth_user AS user_account
+              ON user_account.id = feedback.feedback_user_id
+            SET feedback.feedback_username_snapshot = user_account.username
+            WHERE feedback.feedback_username_snapshot = ''
+            """
+        )
+    )
+    op.alter_column(
+        "demand_feedback",
+        "feedback_username_snapshot",
+        existing_type=sa.String(length=64),
+        server_default=None,
+    )
+
+
+def downgrade() -> None:
+    op.drop_column("demand_feedback", "feedback_username_snapshot")

+ 53 - 1
api/app.py

@@ -4,7 +4,9 @@ from __future__ import annotations
 from contextlib import asynccontextmanager
 from pathlib import Path
 
-from fastapi import FastAPI, HTTPException, Query
+from typing import Literal
+
+from fastapi import FastAPI, HTTPException, Query, Request, status
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
@@ -14,6 +16,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
 from api.auth_middleware import AuthenticationMiddleware
 from api.routers.auth import router as auth_router
 from api.routers.pipeline import router as pipeline_router
+from api.schemas.demand_feedback import CreateDemandFeedbackBody
 from api.services.agent_catalog import (
     get_agent_detail,
     update_agent_document_injection,
@@ -23,6 +26,13 @@ from api.services.category_tree import build_category_tree
 from api.services.demand_belong_category import list_demand_belong_categories
 from api.services.demand_grade import list_demand_grades
 from api.services.demand_grade_videos import list_videos_for_demand_grade
+from api.services.demand_feedback import (
+    FeedbackRequestConflictError,
+    FeedbackTargetConflictError,
+    FeedbackTargetNotFoundError,
+    create_demand_feedback,
+    list_demand_feedback,
+)
 from api.services.demand_videos import list_videos_for_demand_belong
 from api.services.oss_logs import list_agent_oss_logs, list_demand_belong_oss_logs
 from api.services.scheduler import (
@@ -260,6 +270,48 @@ def video_discovery_demand(demand_grade_id: int) -> dict:
     return result
 
 
+@app.post(
+    "/api/video-discovery/feedback",
+    status_code=status.HTTP_201_CREATED,
+)
+def submit_video_discovery_feedback(
+    body: CreateDemandFeedbackBody,
+    request: Request,
+) -> dict:
+    """Append feedback for one demand, video or hit-content record."""
+    try:
+        return create_demand_feedback(body, request.state.current_user)
+    except FeedbackTargetNotFoundError as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except (FeedbackTargetConflictError, FeedbackRequestConflictError) as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc
+
+
+@app.get("/api/video-discovery/feedback")
+def video_discovery_feedback_history(
+    target_type: Literal["demand", "video", "hit_content"],
+    demand_grade_id: int = Query(gt=0),
+    video_id: str | None = Query(default=None, max_length=64),
+    demand_video_expansion_id: int | None = Query(default=None, gt=0),
+    limit: int = Query(default=50, ge=1, le=100),
+    offset: int = Query(default=0, ge=0),
+) -> dict:
+    """Return feedback records and feedback people for one target."""
+    try:
+        return list_demand_feedback(
+            target_type=target_type,
+            demand_grade_id=demand_grade_id,
+            video_id=video_id,
+            demand_video_expansion_id=demand_video_expansion_id,
+            limit=limit,
+            offset=offset,
+        )
+    except FeedbackTargetNotFoundError as exc:
+        raise HTTPException(status_code=404, detail=str(exc)) from exc
+    except FeedbackTargetConflictError as exc:
+        raise HTTPException(status_code=409, detail=str(exc)) from exc
+
+
 @app.get("/api/demand-belong-oss-logs")
 def demand_belong_oss_logs() -> dict:
     """Return demand_belong_category_agent oss_logs ordered by create_time desc."""

+ 2 - 0
api/auth_middleware.py

@@ -21,6 +21,8 @@ _NORMAL_USER_PATHS = {
     ("GET", "/api/category-tree"),
     ("GET", "/api/demand-grade"),
     ("GET", "/api/video-discovery/demands"),
+    ("GET", "/api/video-discovery/feedback"),
+    ("POST", "/api/video-discovery/feedback"),
 }
 _NORMAL_USER_PATTERNS = (
     re.compile(r"^/api/video-discovery/demands/\d+$"),

+ 79 - 0
api/schemas/demand_feedback.py

@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field, field_validator, model_validator
+
+FeedbackTargetType = Literal["demand", "video", "hit_content"]
+FeedbackAction = Literal["support", "oppose", "correct", "supplement"]
+
+REASON_CODES_BY_TARGET: dict[str, set[str]] = {
+    "demand": {
+        "not_a_demand",
+        "wrong_grade",
+        "wrong_category",
+        "unclear_wording",
+        "wrong_reason",
+        "missing_demand",
+        "other",
+    },
+    "video": {
+        "irrelevant",
+        "weak_relevance",
+        "unavailable",
+        "duplicate",
+        "wrong_info",
+        "missing_video",
+        "other",
+    },
+    "hit_content": {
+        "not_hit",
+        "wrong_point_type",
+        "inaccurate_wording",
+        "wrong_reason",
+        "duplicate",
+        "missing_content",
+        "other",
+    },
+}
+
+
+class CreateDemandFeedbackBody(BaseModel):
+    client_request_id: str = Field(min_length=8, max_length=64)
+    target_type: FeedbackTargetType
+    demand_grade_id: int = Field(gt=0)
+    video_id: str | None = Field(default=None, max_length=64)
+    demand_video_expansion_id: int | None = Field(default=None, gt=0)
+    feedback_action: FeedbackAction
+    reason_code: str | None = Field(default=None, max_length=64)
+    content: str | None = Field(default=None, max_length=1000)
+
+    @field_validator("client_request_id", "video_id", "reason_code", "content")
+    @classmethod
+    def strip_text(cls, value: str | None) -> str | None:
+        if value is None:
+            return None
+        stripped = value.strip()
+        return stripped or None
+
+    @model_validator(mode="after")
+    def validate_target_and_content(self) -> "CreateDemandFeedbackBody":
+        if self.target_type == "demand":
+            if self.video_id is not None or self.demand_video_expansion_id is not None:
+                raise ValueError("需求反馈不能包含视频或命中内容 ID")
+        elif self.target_type == "video":
+            if not self.video_id or self.demand_video_expansion_id is not None:
+                raise ValueError("视频反馈必须且只能包含视频 ID")
+        elif not self.video_id or self.demand_video_expansion_id is None:
+            raise ValueError("命中内容反馈必须包含视频 ID 和命中内容 ID")
+
+        if self.feedback_action == "oppose" and not self.reason_code:
+            raise ValueError("不认可反馈必须选择问题原因")
+        if self.feedback_action in {"correct", "supplement"} and not self.content:
+            raise ValueError("纠错或补充反馈必须填写说明")
+        if (
+            self.reason_code is not None
+            and self.reason_code not in REASON_CODES_BY_TARGET[self.target_type]
+        ):
+            raise ValueError("问题原因不适用于当前反馈对象")
+        return self

+ 265 - 0
api/services/demand_feedback.py

@@ -0,0 +1,265 @@
+"""Human feedback for demand-summary records."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from pydantic import ValidationError
+from sqlalchemy.exc import IntegrityError
+
+from api.schemas.demand_feedback import CreateDemandFeedbackBody
+from api.services.video_discovery import _parse_video_ids
+from supply_infra.db.models.demand_feedback import DemandFeedback
+from supply_infra.db.repositories.demand_feedback_repo import DemandFeedbackRepository
+from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
+from supply_infra.db.repositories.demand_video_expansion_repo import (
+    DemandVideoExpansionRepository,
+)
+from supply_infra.db.repositories.multi_demand_video_detail_repo import (
+    MultiDemandVideoDetailRepository,
+)
+from supply_infra.db.session import get_session
+
+_SOURCE_VIDEO_GRADES = frozenset({"B", "C", "D"})
+
+
+class FeedbackTargetNotFoundError(Exception):
+    """Raised when the requested demand, video or hit content does not exist."""
+
+
+class FeedbackTargetConflictError(Exception):
+    """Raised when target identifiers do not belong to the same record."""
+
+
+class FeedbackRequestConflictError(Exception):
+    """Raised when an idempotency key is reused for a different request."""
+
+
+def _feedback_summary(count: int) -> dict[str, int]:
+    return {"count": int(count)}
+
+
+def _serialize_feedback(row: DemandFeedback) -> dict[str, Any]:
+    try:
+        target_snapshot = json.loads(row.target_snapshot_json)
+    except (TypeError, ValueError):
+        target_snapshot = {}
+    return {
+        "id": int(row.id),
+        "target_type": row.target_type,
+        "biz_dt": row.biz_dt,
+        "demand_grade_id": int(row.demand_grade_id),
+        "video_id": row.video_id,
+        "demand_video_expansion_id": (
+            int(row.demand_video_expansion_id)
+            if row.demand_video_expansion_id is not None
+            else None
+        ),
+        "feedback_action": row.feedback_action,
+        "reason_code": row.reason_code,
+        "content": row.content,
+        "target_snapshot": target_snapshot,
+        "feedback_user": {
+            "id": int(row.feedback_user_id),
+            "username": row.feedback_username_snapshot,
+            "display_name": row.feedback_user_name_snapshot,
+        },
+        "created_at": row.created_at.isoformat() if row.created_at else None,
+    }
+
+
+def _same_request(
+    row: DemandFeedback,
+    body: CreateDemandFeedbackBody,
+    feedback_user_id: int,
+) -> bool:
+    return (
+        row.feedback_user_id == feedback_user_id
+        and row.target_type == body.target_type
+        and row.demand_grade_id == body.demand_grade_id
+        and row.video_id == body.video_id
+        and row.demand_video_expansion_id == body.demand_video_expansion_id
+        and row.feedback_action == body.feedback_action
+        and row.reason_code == body.reason_code
+        and row.content == body.content
+    )
+
+
+def _resolve_target(
+    session: Any,
+    body: CreateDemandFeedbackBody,
+) -> tuple[Any, dict[str, Any]]:
+    grade = DemandGradeRepository(session).get_by_id(body.demand_grade_id)
+    if grade is None:
+        raise FeedbackTargetNotFoundError("需求不存在")
+
+    snapshot: dict[str, Any] = {
+        "demand_name": grade.demand_name,
+        "grade": grade.grade,
+    }
+    if body.target_type == "demand":
+        snapshot["reason"] = grade.reason
+        return grade, snapshot
+
+    video_id = body.video_id or ""
+    grade_code = str(grade.grade or "").upper()
+    expansion_repo = DemandVideoExpansionRepository(session)
+    if grade_code in _SOURCE_VIDEO_GRADES:
+        video_exists = video_id in _parse_video_ids(grade.video_list)
+        video_source = "pool"
+    else:
+        video_exists = expansion_repo.has_video(
+            biz_dt=str(grade.biz_dt),
+            source_demand_grade_id=int(grade.id),
+            video_id=video_id,
+        )
+        video_source = "expansion"
+    if not video_exists:
+        raise FeedbackTargetConflictError("视频不属于当前需求")
+
+    detail = MultiDemandVideoDetailRepository(session).list_by_vids([video_id]).get(
+        video_id
+    )
+    snapshot.update(
+        {
+            "video_id": video_id,
+            "video_title": detail.title if detail else None,
+            "video_source": video_source,
+        }
+    )
+    if body.target_type == "video":
+        return grade, snapshot
+
+    expansion = expansion_repo.get_active_by_id(body.demand_video_expansion_id or 0)
+    if expansion is None:
+        raise FeedbackTargetNotFoundError("命中内容不存在")
+    if (
+        int(expansion.source_demand_grade_id) != int(grade.id)
+        or str(expansion.biz_dt) != str(grade.biz_dt)
+        or str(expansion.video_id) != video_id
+    ):
+        raise FeedbackTargetConflictError("命中内容不属于当前需求和视频")
+    snapshot.update(
+        {
+            "point_type": expansion.point_type,
+            "expanded_text": expansion.expanded_text,
+            "point_desc": expansion.point_desc,
+            "reason": expansion.reason,
+        }
+    )
+    return grade, snapshot
+
+
+def create_demand_feedback(
+    body: CreateDemandFeedbackBody,
+    current_user: dict[str, Any],
+) -> dict[str, Any]:
+    feedback_user_id = int(current_user["id"])
+    feedback_username = str(current_user["username"])
+    feedback_user_name = str(
+        current_user.get("display_name") or current_user.get("username") or feedback_user_id
+    )
+    with get_session() as session:
+        repo = DemandFeedbackRepository(session)
+        existing = repo.get_by_client_request_id(body.client_request_id)
+        if existing is not None:
+            if not _same_request(existing, body, feedback_user_id):
+                raise FeedbackRequestConflictError("请求标识已用于其他反馈")
+            _, video_counts, expansion_counts = repo.count_for_demand(
+                body.demand_grade_id
+            )
+            count = (
+                repo.count_demands([body.demand_grade_id]).get(body.demand_grade_id, 0)
+                if body.target_type == "demand"
+                else video_counts.get(body.video_id or "", 0)
+                if body.target_type == "video"
+                else expansion_counts.get(body.demand_video_expansion_id or 0, 0)
+            )
+            return {
+                "item": _serialize_feedback(existing),
+                "feedback_summary": _feedback_summary(count),
+            }
+
+        grade, target_snapshot = _resolve_target(session, body)
+        feedback = DemandFeedback(
+            client_request_id=body.client_request_id,
+            target_type=body.target_type,
+            biz_dt=str(grade.biz_dt),
+            demand_grade_id=int(grade.id),
+            video_id=body.video_id,
+            demand_video_expansion_id=body.demand_video_expansion_id,
+            feedback_action=body.feedback_action,
+            reason_code=body.reason_code,
+            content=body.content,
+            target_snapshot_json=json.dumps(
+                target_snapshot,
+                ensure_ascii=False,
+                separators=(",", ":"),
+            ),
+            feedback_user_id=feedback_user_id,
+            feedback_user_name_snapshot=feedback_user_name[:128],
+            feedback_username_snapshot=feedback_username[:64],
+        )
+        try:
+            repo.add(feedback)
+            session.refresh(feedback)
+        except IntegrityError:
+            session.rollback()
+            existing = repo.get_by_client_request_id(body.client_request_id)
+            if existing is None or not _same_request(existing, body, feedback_user_id):
+                raise FeedbackRequestConflictError("请求标识已用于其他反馈") from None
+            feedback = existing
+
+        rows, total = repo.list_for_target(
+            target_type=body.target_type,
+            demand_grade_id=body.demand_grade_id,
+            video_id=body.video_id,
+            demand_video_expansion_id=body.demand_video_expansion_id,
+            limit=1,
+            offset=0,
+        )
+        del rows
+        return {
+            "item": _serialize_feedback(feedback),
+            "feedback_summary": _feedback_summary(total),
+        }
+
+
+def list_demand_feedback(
+    *,
+    target_type: str,
+    demand_grade_id: int,
+    video_id: str | None,
+    demand_video_expansion_id: int | None,
+    limit: int,
+    offset: int,
+) -> dict[str, Any]:
+    try:
+        body = CreateDemandFeedbackBody(
+            client_request_id="history-query",
+            target_type=target_type,
+            demand_grade_id=demand_grade_id,
+            video_id=video_id,
+            demand_video_expansion_id=demand_video_expansion_id,
+            feedback_action="support",
+        )
+    except ValidationError as exc:
+        raise FeedbackTargetConflictError("反馈目标参数不完整") from exc
+    with get_session() as session:
+        repo = DemandFeedbackRepository(session)
+        rows, total = repo.list_for_target(
+            target_type=target_type,
+            demand_grade_id=demand_grade_id,
+            video_id=video_id,
+            demand_video_expansion_id=demand_video_expansion_id,
+            limit=limit,
+            offset=offset,
+        )
+        if total == 0:
+            _resolve_target(session, body)
+        return {
+            "items": [_serialize_feedback(row) for row in rows],
+            "total": total,
+            "limit": limit,
+            "offset": offset,
+        }

+ 29 - 2
api/services/video_discovery.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 import json
 from typing import Any
 
+from supply_infra.db.repositories.demand_feedback_repo import DemandFeedbackRepository
 from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
 from supply_infra.db.repositories.demand_video_expansion_repo import (
     DemandVideoExpansionRepository,
@@ -68,6 +69,7 @@ def _serialize_video(
     video_id: str,
     detail: Any | None,
     points: list[dict[str, Any]],
+    feedback_count: int = 0,
 ) -> dict[str, Any]:
     url1 = _normalize_url(detail.url1) if detail else None
     url2 = _normalize_url(detail.url2) if detail else None
@@ -79,6 +81,7 @@ def _serialize_video(
         "video_detail_url": _VIDEO_DETAIL_URL_TEMPLATE.format(vid=video_id),
         "point_count": len(points),
         "points": points,
+        "feedback_summary": {"count": int(feedback_count)},
     }
 
 
@@ -142,6 +145,7 @@ def _serialize_demand(
     row: Any,
     category_names: dict[int, str],
     video_count: int,
+    feedback_count: int = 0,
 ) -> dict[str, Any]:
     category_ids = _parse_int_list(row.category_ids)
     return {
@@ -163,6 +167,7 @@ def _serialize_demand(
         "strategies": _parse_string_list(row.strategies),
         "reason": row.reason,
         "video_count": video_count,
+        "feedback_summary": {"count": int(feedback_count)},
     }
 
 
@@ -210,6 +215,9 @@ def list_video_discovery_demands(biz_dt: str | None = None) -> dict[str, Any]:
         video_counts = DemandVideoExpansionRepository(
             session
         ).count_distinct_videos_by_demand_grade(str(resolved_biz_dt))
+        feedback_counts = DemandFeedbackRepository(session).count_demands(
+            [int(row.id) for row in rows]
+        )
         return {
             "biz_dt": str(resolved_biz_dt),
             "items": [
@@ -217,6 +225,7 @@ def list_video_discovery_demands(biz_dt: str | None = None) -> dict[str, Any]:
                     row,
                     category_names,
                     _source_video_count(row, video_counts),
+                    feedback_counts.get(int(row.id), 0),
                 )
                 for row in rows
             ],
@@ -237,12 +246,20 @@ def get_video_discovery_demand(demand_grade_id: int) -> dict[str, Any] | None:
 
         category_names = _category_name_map(session, [grade])
         grade_code = str(grade.grade or "").upper()
+        demand_feedback_count, video_feedback_counts, point_feedback_counts = (
+            DemandFeedbackRepository(session).count_for_demand(demand_grade_id)
+        )
 
         if grade_code in _SOURCE_VIDEO_GRADES:
             video_ids = _parse_video_ids(grade.video_list)
             details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
             videos = [
-                _serialize_video(video_id, details.get(video_id), [])
+                _serialize_video(
+                    video_id,
+                    details.get(video_id),
+                    [],
+                    video_feedback_counts.get(video_id, 0),
+                )
                 for video_id in video_ids
             ]
         else:
@@ -262,10 +279,14 @@ def get_video_discovery_demand(demand_grade_id: int) -> dict[str, Any] | None:
                     points_by_video[video_id] = []
                 points_by_video[video_id].append(
                     {
+                        "id": int(row.id),
                         "point_type": row.point_type,
                         "expanded_text": row.expanded_text,
                         "point_desc": row.point_desc,
                         "reason": row.reason,
+                        "feedback_summary": {
+                            "count": point_feedback_counts.get(int(row.id), 0)
+                        },
                     }
                 )
 
@@ -275,11 +296,17 @@ def get_video_discovery_demand(demand_grade_id: int) -> dict[str, Any] | None:
                     video_id,
                     details.get(video_id),
                     points_by_video[video_id],
+                    video_feedback_counts.get(video_id, 0),
                 )
                 for video_id in video_ids
             ]
 
-        demand = _serialize_demand(grade, category_names, len(videos))
+        demand = _serialize_demand(
+            grade,
+            category_names,
+            len(videos),
+            demand_feedback_count,
+        )
         demand["point_count"] = sum(video["point_count"] for video in videos)
         demand["videos"] = videos
         demand["video_source"] = (

+ 311 - 0
prd/08-需求汇总反馈机制设计.md

@@ -0,0 +1,311 @@
+# 需求汇总反馈机制设计
+
+## 1. 目标与范围
+
+在现有“需求汇总”页面的三层记录上增加独立反馈入口:
+
+1. 需求:`demand_grade` 的当日判断记录。
+2. 视频:当前需求下的一条拓展命中视频或需求池源视频。
+3. 命中内容:`demand_video_expansion` 中的一条目的点、关键点或灵感点。
+
+本期反馈是新增证据,不直接修改需求等级、视频命中关系或命中内容。
+
+## 2. 核心设计结论
+
+### 2.1 使用一张统一反馈表
+
+三类反馈共享提交人、审计时间和文字说明。统一表便于:
+
+- 汇总一个需求下的全部反馈;
+- 建立统一待处理队列;
+- 后续将人工反馈作为下一业务日的输入证据;
+- 保持追加式历史,避免修改原反馈导致审计信息丢失。
+
+### 2.2 反馈绑定当日记录
+
+需求反馈绑定 `demand_grade.id`,而不是只绑定 `demand_name`。同一需求在不同业务日的等级、原因、关联视频都可能变化,必须知道用户针对哪个版本反馈。
+
+同时保存 `biz_dt` 和目标快照,方便跨日查询及源数据变化后的历史还原。
+
+### 2.3 三类目标的稳定身份
+
+| 反馈目标 | 稳定定位字段 | 说明 |
+|---|---|---|
+| 需求 | `demand_grade_id` | 对当日需求判断反馈 |
+| 视频 | `demand_grade_id + video_id` | 同一视频对不同需求的相关性不同 |
+| 命中内容 | `demand_video_expansion_id` | 精确定位单条目的点、关键点或灵感点 |
+
+当前命中内容接口没有返回 `demand_video_expansion.id`,实现前需要为 `VideoDiscoveryPoint` 增加 `id`。
+
+## 3. 反馈记录表
+
+建议表名:`demand_feedback`
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `id` | BIGINT | 是 | 自增主键 |
+| `client_request_id` | VARCHAR(64) | 是 | 客户端提交幂等键,唯一 |
+| `target_type` | VARCHAR(32) | 是 | `demand` / `video` / `hit_content` |
+| `biz_dt` | VARCHAR(32) | 是 | 目标所属业务日 |
+| `demand_grade_id` | BIGINT | 是 | 三类反馈都必须归属一个需求 |
+| `video_id` | VARCHAR(64) | 否 | 视频、命中内容反馈必填 |
+| `demand_video_expansion_id` | BIGINT | 否 | 命中内容反馈必填 |
+| `feedback_action` | VARCHAR(32) | 是 | `support` / `oppose` / `correct` / `supplement` |
+| `reason_code` | VARCHAR(64) | 否 | 按目标类型选择的问题原因 |
+| `content` | TEXT | 否 | 用户补充说明;纠错、补充时必填 |
+| `target_snapshot_json` | TEXT | 是 | 服务端生成的目标展示快照 |
+| `feedback_user_id` | INT | 是 | 反馈人 `auth_user.id` |
+| `feedback_user_name_snapshot` | VARCHAR(128) | 是 | 反馈人名称快照 |
+| `feedback_username_snapshot` | VARCHAR(64) | 是 | 反馈人登录账号 `username` 快照 |
+| `created_at` | DATETIME | 是 | 创建时间 |
+| `updated_at` | DATETIME | 是 | 更新时间 |
+
+推荐索引:
+
+```sql
+UNIQUE KEY uk_demand_feedback_request (client_request_id),
+KEY idx_demand_feedback_demand (demand_grade_id, created_at),
+KEY idx_demand_feedback_video (demand_grade_id, video_id, created_at),
+KEY idx_demand_feedback_expansion (demand_video_expansion_id, created_at),
+KEY idx_demand_feedback_user (feedback_user_id, created_at)
+```
+
+目标字段校验由服务层执行:
+
+- `demand`:`video_id`、`demand_video_expansion_id` 必须为空;
+- `video`:`video_id` 必填,`demand_video_expansion_id` 为空;
+- `hit_content`:三个定位字段都必填;
+- 命中内容必须确实属于传入的需求和视频;
+- `biz_dt`、展示文本和其他快照全部由服务端根据目标生成,不信任客户端传值。
+
+不建议对三个业务目标设置级联删除。反馈是审计证据,即使目标以后失效,也要依靠快照保留当时上下文。
+
+### 3.1 目标快照示例
+
+```json
+{
+  "demand_name": "老年人智能手机使用",
+  "grade": "A",
+  "video_title": "教父母使用手机的五个技巧",
+  "video_source": "expansion",
+  "point_type": "purpose",
+  "expanded_text": "降低老年人使用智能设备的门槛",
+  "point_desc": "面向子女和老人解释基础操作",
+  "reason": "该内容直接覆盖当前需求"
+}
+```
+
+快照仅保存目标提交时已有的业务字段,不保存密码、会话、IP 等认证信息。反馈人由服务端从当前登录会话取得,客户端不能指定或覆盖。
+
+不增加 `has_feedback`、`is_feedback` 等“是否反馈”字段。某条业务记录是否存在反馈、反馈数量是多少,都直接由 `demand_feedback` 记录实时查询或聚合得出,避免业务记录和反馈表状态不一致。
+
+## 4. 反馈选项
+
+第一层统一动作保持简单:
+
+| 动作 | 页面文案 | 使用场景 |
+|---|---|---|
+| `support` | 认可 | 判断、关联或内容准确 |
+| `oppose` | 不认可 | 判断、关联或内容不成立 |
+| `correct` | 纠错 | 数据、类型、文案或原因有明确错误 |
+| `supplement` | 补充 | 增加需求、视频、依据或说明 |
+
+第二层原因随目标变化:
+
+| 目标 | 建议原因 |
+|---|---|
+| 需求 | 需求不成立、等级不合适、分类不准确、需求表述不清、判断原因不准确、缺少需求 |
+| 视频 | 与需求不相关、相关性弱、视频失效、视频重复、标题或信息错误、缺少相关视频 |
+| 命中内容 | 内容未命中、点位类型错误、表述不准确、命中原因不准确、内容重复、缺少命中内容 |
+
+交互校验:
+
+- “认可”可直接提交,也可补充说明;
+- “不认可”必须选择原因;
+- “纠错”和“补充”必须填写说明;
+- 说明限制 1,000 字,前后端同时校验。
+
+## 5. 前端页面设计
+
+### 5.1 入口位置
+
+沿用现有三栏工作台,不新开反馈页面:
+
+```text
+┌──────────────────┬────────────────────────┬────────────────────────┐
+│ 01 选择需求       │ 02 需求拓展视频          │ 03 命中视频内容          │
+│                  │                        │                        │
+│ 需求卡片           │ 当前需求说明   [反馈]    │ 当前视频                │
+│ 名称 / 等级 / 视频数│                        │                        │
+│ [反馈 2]           │ 视频卡片 1       [反馈]  │ 目的点卡片 1      [反馈] │
+│                  │ 视频卡片 2       [反馈]  │ 关键点卡片 2      [反馈] │
+└──────────────────┴────────────────────────┴────────────────────────┘
+```
+
+- 需求反馈:放在第二栏“当前寻找需求”标题区域,确保反馈对象是当前选中的完整需求记录。
+- 视频反馈:每条视频卡片右侧增加“反馈”,与选择视频动作分开。
+- 命中内容反馈:每条点位卡片右侧增加“反馈”,保留现有复制按钮。
+- 如需提示反馈情况,只显示由反馈记录聚合得到的 `反馈 n`,不显示或保存“已反馈”状态;徽标不使用颜色表达业务好坏。
+
+当前需求卡和视频卡整块都是 `<button>`。实现视频行内反馈前,要重构为容器加两个并列按钮,避免交互元素嵌套。
+
+### 5.2 统一反馈抽屉
+
+点击任意入口后,从右侧打开统一抽屉:
+
+```text
+反馈 · 命中内容                                      ×
+────────────────────────────────────────────────────
+需求:老年人智能手机使用
+视频:教父母使用手机的五个技巧
+目的点:降低老年人使用智能设备的门槛
+
+你的判断
+[认可] [不认可] [纠错] [补充]
+
+问题原因
+[内容未命中] [点位类型错误] [原因不准确] [...]
+
+补充说明
+┌──────────────────────────────────────────────────┐
+│ 请说明判断依据或建议的正确内容                    │
+└──────────────────────────────────────────────────┘
+0 / 1000
+
+                                [取消] [提交反馈]
+```
+
+交互要求:
+
+- 打开抽屉时固定保存目标上下文,切换左侧需求不能改变正在填写的反馈对象;
+- 提交期间按钮显示加载态并禁止重复点击;
+- 成功后关闭抽屉,重新获取对应记录的反馈数量并显示轻量成功提示;
+- 失败时保留用户已填写内容;
+- 使用 `client_request_id` 防止网络重试产生重复记录;
+- Esc 可关闭,关闭未提交内容时二次确认;
+- 移动端使用底部全屏面板。
+
+### 5.3 反馈历史
+
+MVP 在记录旁只展示该目标的反馈总数,不记录或展示“当前用户是否反馈过”。
+
+点击数量徽标可查看该目标的反馈历史。每条历史记录展示反馈人、反馈动作、原因、说明和时间。
+
+## 6. API 设计
+
+### 6.1 扩展现有查询响应
+
+避免页面对每条记录发起一次反馈查询。在现有两个接口中批量返回反馈摘要:
+
+- `GET /api/video-discovery/demands`
+- `GET /api/video-discovery/demands/{demand_grade_id}`
+
+需求、视频和命中内容增加:
+
+```json
+{
+  "feedback_summary": {
+    "count": 2
+  }
+}
+```
+
+命中内容同时增加:
+
+```json
+{
+  "id": 456,
+  "point_type": "purpose",
+  "expanded_text": "..."
+}
+```
+
+### 6.2 提交反馈
+
+`POST /api/video-discovery/feedback`
+
+```json
+{
+  "client_request_id": "0198f85d-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
+  "target_type": "hit_content",
+  "demand_grade_id": 123,
+  "video_id": "7360000000000000000",
+  "demand_video_expansion_id": 456,
+  "feedback_action": "oppose",
+  "reason_code": "not_hit",
+  "content": "该内容只提到设备设置,没有覆盖老人学习使用的需求。"
+}
+```
+
+成功返回 `201`:
+
+```json
+{
+  "item": {
+    "id": 789,
+    "created_at": "2026-07-30T15:20:00"
+  },
+  "feedback_summary": {
+    "count": 3
+  }
+}
+```
+
+服务端响应:
+
+- `400`:字段组合或反馈内容不合法;
+- `404`:目标不存在;
+- `409`:目标上下文不一致;
+- 使用相同 `client_request_id` 重试时返回第一次创建的结果,不重复写入。
+
+### 6.3 查看历史
+
+`GET /api/video-discovery/feedback`
+
+查询参数使用与提交相同的目标定位字段,并支持 `cursor`、`limit`。普通用户返回自己的记录,管理员返回全部记录。
+
+## 7. 权限
+
+| 能力 | 普通用户 | 管理员 |
+|---|---:|---:|
+| 提交反馈 | 是 | 是 |
+| 查看反馈记录和反馈人 | 是 | 是 |
+| 直接修改业务结果 | 否 | 否,需走独立受控流程 |
+
+需要在 `AuthenticationMiddleware` 中放行普通用户的反馈 `GET` 和 `POST` 路由。
+
+## 8. 实现拆分
+
+### 第一阶段:可提交、可追溯
+
+1. 新增 Alembic migration、SQLAlchemy model 和 repository。
+2. 命中内容查询返回 `demand_video_expansion.id`。
+3. 新增提交与历史查询 API。
+4. 现有需求和详情接口批量附加反馈摘要。
+5. 新增统一反馈抽屉,并在需求、视频、命中内容三层接入。
+6. 增加字段组合校验、幂等提交和权限测试。
+
+### 第二阶段:反馈闭环
+
+1. 将反馈转换为下一业务日可消费的证据事件。
+2. 展示反馈对后续需求判断的实际影响。
+
+## 9. 验收标准
+
+- 页面上的每条需求、每条视频和每条命中内容都有明确反馈入口;
+- 提交后数据库能唯一还原提交人、业务日、需求、视频及命中内容;
+- 同一视频在不同需求下的反馈不会串联;
+- 源数据更新后,历史反馈仍能通过目标快照解释;
+- 普通用户不能看到其他用户的反馈正文和身份;
+- 重复点击或请求重试不会生成重复记录;
+- 反馈不会直接覆盖 `demand_grade`、`demand_video_expansion` 或视频详情;
+- 列表和详情加载反馈摘要时不产生逐条 N+1 查询。
+
+## 10. 实施前需确认的产品决策
+
+建议按以下默认值实施:
+
+1. 所有已登录用户都可以提交反馈;
+2. 反馈追加保存,不允许直接编辑;需要修改时再次提交,历史仍保留;
+3. 第一阶段展示反馈记录和反馈人,不维护“是否反馈”状态;
+4. 反馈是证据,不自动改变当天需求等级或命中结果。

+ 2 - 0
supply_infra/db/models/__init__.py

@@ -6,6 +6,7 @@ from supply_infra.db.models.auth_user import AuthUser
 from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
 from supply_infra.db.models.demand_belong_pool_rel import DemandBelongPoolRel
+from supply_infra.db.models.demand_feedback import DemandFeedback
 from supply_infra.db.models.demand_grade import DemandGrade
 from supply_infra.db.models.demand_grade_category_rel import DemandGradeCategoryRel
 from supply_infra.db.models.demand_grade_plan import (
@@ -42,6 +43,7 @@ __all__ = [
     "CategoryTreeWeight",
     "DemandBelongCategory",
     "DemandBelongPoolRel",
+    "DemandFeedback",
     "DemandGrade",
     "DemandGradeCategoryRel",
     "DemandGradePlan",

+ 107 - 0
supply_infra/db/models/demand_feedback.py

@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class DemandFeedback(Base):
+    """需求汇总页面的人工反馈记录。"""
+
+    __tablename__ = "demand_feedback"
+    __table_args__ = (
+        UniqueConstraint(
+            "client_request_id",
+            name="uk_demand_feedback_request",
+        ),
+        Index(
+            "idx_demand_feedback_demand",
+            "demand_grade_id",
+            "created_at",
+        ),
+        Index(
+            "idx_demand_feedback_video",
+            "demand_grade_id",
+            "video_id",
+            "created_at",
+        ),
+        Index(
+            "idx_demand_feedback_expansion",
+            "demand_video_expansion_id",
+            "created_at",
+        ),
+        Index(
+            "idx_demand_feedback_user",
+            "feedback_user_id",
+            "created_at",
+        ),
+    )
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+    client_request_id: Mapped[str] = mapped_column(String(64), nullable=False)
+    target_type: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        comment="反馈对象:demand / video / hit_content",
+    )
+    biz_dt: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        comment="目标所属业务日",
+    )
+    demand_grade_id: Mapped[int] = mapped_column(
+        BigInteger,
+        nullable=False,
+        comment="目标 demand_grade.id",
+    )
+    video_id: Mapped[str | None] = mapped_column(
+        String(64),
+        nullable=True,
+        comment="视频反馈或命中内容反馈的视频 id",
+    )
+    demand_video_expansion_id: Mapped[int | None] = mapped_column(
+        BigInteger,
+        nullable=True,
+        comment="命中内容 demand_video_expansion.id",
+    )
+    feedback_action: Mapped[str] = mapped_column(
+        String(32),
+        nullable=False,
+        comment="support / oppose / correct / supplement",
+    )
+    reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    content: Mapped[str | None] = mapped_column(Text, nullable=True)
+    target_snapshot_json: Mapped[str] = mapped_column(
+        Text,
+        nullable=False,
+        comment="提交时由服务端生成的目标快照",
+    )
+    feedback_user_id: Mapped[int] = mapped_column(
+        Integer,
+        nullable=False,
+        comment="反馈人 auth_user.id",
+    )
+    feedback_user_name_snapshot: Mapped[str] = mapped_column(
+        String(128),
+        nullable=False,
+        comment="反馈人名称快照",
+    )
+    feedback_username_snapshot: Mapped[str] = mapped_column(
+        String(64),
+        nullable=False,
+        comment="反馈人登录账号快照",
+    )
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+    )

+ 2 - 0
supply_infra/db/repositories/__init__.py

@@ -14,6 +14,7 @@ from supply_infra.db.repositories.demand_belong_category_repo import (
 from supply_infra.db.repositories.demand_belong_pool_rel_repo import (
     DemandBelongPoolRelRepository,
 )
+from supply_infra.db.repositories.demand_feedback_repo import DemandFeedbackRepository
 from supply_infra.db.repositories.demand_grade_category_rel_repo import (
     DemandGradeCategoryRelRepository,
 )
@@ -50,6 +51,7 @@ __all__ = [
     "CategoryTreeWeightRepository",
     "DemandBelongCategoryRepository",
     "DemandBelongPoolRelRepository",
+    "DemandFeedbackRepository",
     "DemandGradeCategoryRelRepository",
     "DemandGradeRepository",
     "DemandGradePlanRepository",

+ 105 - 0
supply_infra/db/repositories/demand_feedback_repo.py

@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+from sqlalchemy import func, select
+
+from supply_infra.db.models.demand_feedback import DemandFeedback
+from supply_infra.db.repositories.base import BaseRepository
+
+
+class DemandFeedbackRepository(BaseRepository[DemandFeedback]):
+    """需求、视频与命中内容反馈 repository。"""
+
+    model = DemandFeedback
+
+    def get_by_client_request_id(self, client_request_id: str) -> DemandFeedback | None:
+        stmt = select(DemandFeedback).where(
+            DemandFeedback.client_request_id == client_request_id
+        )
+        return self.session.scalars(stmt).first()
+
+    def count_demands(self, demand_grade_ids: list[int]) -> dict[int, int]:
+        if not demand_grade_ids:
+            return {}
+        stmt = (
+            select(
+                DemandFeedback.demand_grade_id,
+                func.count(DemandFeedback.id),
+            )
+            .where(
+                DemandFeedback.target_type == "demand",
+                DemandFeedback.demand_grade_id.in_(demand_grade_ids),
+            )
+            .group_by(DemandFeedback.demand_grade_id)
+        )
+        return {
+            int(demand_grade_id): int(count or 0)
+            for demand_grade_id, count in self.session.execute(stmt).all()
+        }
+
+    def count_for_demand(
+        self,
+        demand_grade_id: int,
+    ) -> tuple[int, dict[str, int], dict[int, int]]:
+        stmt = (
+            select(
+                DemandFeedback.target_type,
+                DemandFeedback.video_id,
+                DemandFeedback.demand_video_expansion_id,
+                func.count(DemandFeedback.id),
+            )
+            .where(DemandFeedback.demand_grade_id == int(demand_grade_id))
+            .group_by(
+                DemandFeedback.target_type,
+                DemandFeedback.video_id,
+                DemandFeedback.demand_video_expansion_id,
+            )
+        )
+        demand_count = 0
+        video_counts: dict[str, int] = {}
+        expansion_counts: dict[int, int] = {}
+        for target_type, video_id, expansion_id, count in self.session.execute(stmt).all():
+            value = int(count or 0)
+            if target_type == "demand":
+                demand_count += value
+            elif target_type == "video" and video_id:
+                video_counts[str(video_id)] = value
+            elif target_type == "hit_content" and expansion_id is not None:
+                expansion_counts[int(expansion_id)] = value
+        return demand_count, video_counts, expansion_counts
+
+    def list_for_target(
+        self,
+        *,
+        target_type: str,
+        demand_grade_id: int,
+        video_id: str | None,
+        demand_video_expansion_id: int | None,
+        limit: int,
+        offset: int,
+    ) -> tuple[list[DemandFeedback], int]:
+        filters = [
+            DemandFeedback.target_type == target_type,
+            DemandFeedback.demand_grade_id == int(demand_grade_id),
+        ]
+        if target_type in {"video", "hit_content"}:
+            filters.append(DemandFeedback.video_id == video_id)
+        else:
+            filters.append(DemandFeedback.video_id.is_(None))
+        if target_type == "hit_content":
+            filters.append(
+                DemandFeedback.demand_video_expansion_id
+                == int(demand_video_expansion_id or 0)
+            )
+        else:
+            filters.append(DemandFeedback.demand_video_expansion_id.is_(None))
+
+        total_stmt = select(func.count(DemandFeedback.id)).where(*filters)
+        total = int(self.session.scalar(total_stmt) or 0)
+        rows_stmt = (
+            select(DemandFeedback)
+            .where(*filters)
+            .order_by(DemandFeedback.created_at.desc(), DemandFeedback.id.desc())
+            .offset(offset)
+            .limit(limit)
+        )
+        return list(self.session.scalars(rows_stmt).all()), total

+ 22 - 0
supply_infra/db/repositories/demand_video_expansion_repo.py

@@ -17,6 +17,28 @@ class DemandVideoExpansionRepository(BaseRepository[DemandVideoExpansion]):
 
     model = DemandVideoExpansion
 
+    def get_active_by_id(self, expansion_id: int) -> DemandVideoExpansion | None:
+        stmt = select(DemandVideoExpansion).where(
+            DemandVideoExpansion.id == int(expansion_id),
+            DemandVideoExpansion.is_delete == 0,
+        )
+        return self.session.scalars(stmt).first()
+
+    def has_video(
+        self,
+        *,
+        biz_dt: str,
+        source_demand_grade_id: int,
+        video_id: str,
+    ) -> bool:
+        stmt = select(DemandVideoExpansion.id).where(
+            DemandVideoExpansion.biz_dt == biz_dt,
+            DemandVideoExpansion.source_demand_grade_id == int(source_demand_grade_id),
+            DemandVideoExpansion.video_id == video_id,
+            DemandVideoExpansion.is_delete == 0,
+        )
+        return self.session.scalar(stmt.limit(1)) is not None
+
     def list_by_demand_grade(
         self, biz_dt: str, source_demand_grade_id: int
     ) -> list[DemandVideoExpansion]:

+ 1 - 0
tests/api/__init__.py

@@ -0,0 +1 @@
+"""API tests."""

+ 172 - 0
tests/api/test_demand_feedback.py

@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+from datetime import datetime
+from types import SimpleNamespace
+
+import pytest
+from pydantic import ValidationError
+
+from api.schemas.demand_feedback import CreateDemandFeedbackBody
+from api.services import demand_feedback as feedback_service
+from supply_infra.db.models.demand_feedback import DemandFeedback
+
+
+def _body(**changes) -> CreateDemandFeedbackBody:
+    values = {
+        "client_request_id": "request-123",
+        "target_type": "demand",
+        "demand_grade_id": 10,
+        "feedback_action": "support",
+    }
+    values.update(changes)
+    return CreateDemandFeedbackBody(**values)
+
+
+def test_feedback_body_validates_target_fields() -> None:
+    with pytest.raises(ValidationError):
+        _body(target_type="video")
+    with pytest.raises(ValidationError):
+        _body(
+            target_type="hit_content",
+            video_id="vid-1",
+        )
+    with pytest.raises(ValidationError):
+        _body(video_id="vid-1")
+
+    body = _body(target_type="video", video_id="vid-1")
+    assert body.video_id == "vid-1"
+
+
+def test_feedback_body_validates_action_content_and_reason() -> None:
+    with pytest.raises(ValidationError):
+        _body(feedback_action="oppose")
+    with pytest.raises(ValidationError):
+        _body(feedback_action="correct")
+    with pytest.raises(ValidationError):
+        _body(reason_code="irrelevant")
+
+    body = _body(
+        feedback_action="correct",
+        reason_code="wrong_reason",
+        content="  正确原因应引用后验样本。  ",
+    )
+    assert body.content == "正确原因应引用后验样本。"
+
+
+def test_feedback_model_has_person_but_no_review_or_boolean_state() -> None:
+    columns = DemandFeedback.__table__.columns
+    assert "feedback_user_id" in columns
+    assert "feedback_user_name_snapshot" in columns
+    assert "feedback_username_snapshot" in columns
+    assert "review_status" not in columns
+    assert "has_feedback" not in columns
+    assert "is_feedback" not in columns
+
+
+def test_create_feedback_uses_server_target_and_current_user(monkeypatch) -> None:
+    grade = SimpleNamespace(
+        id=10,
+        biz_dt="20260730",
+        demand_name="老年人智能手机使用",
+        grade="A",
+        reason="需求有明确内容意图",
+        video_list=None,
+    )
+    expansion = SimpleNamespace(
+        id=30,
+        biz_dt="20260730",
+        source_demand_grade_id=10,
+        video_id="vid-1",
+        point_type="purpose",
+        expanded_text="降低老人使用智能设备的门槛",
+        point_desc="解释基础操作",
+        reason="覆盖当前需求",
+    )
+    detail = SimpleNamespace(title="教父母使用手机")
+    saved: list[DemandFeedback] = []
+
+    class Session:
+        def refresh(self, row) -> None:
+            row.id = 99
+            row.created_at = datetime(2026, 7, 30, 15, 20)
+
+        def rollback(self) -> None:
+            raise AssertionError("unexpected rollback")
+
+    class SessionContext:
+        def __enter__(self):
+            return Session()
+
+        def __exit__(self, *_args):
+            return False
+
+    class GradeRepo:
+        def __init__(self, _session):
+            pass
+
+        def get_by_id(self, grade_id):
+            return grade if grade_id == 10 else None
+
+    class ExpansionRepo:
+        def __init__(self, _session):
+            pass
+
+        def has_video(self, **kwargs):
+            return kwargs["video_id"] == "vid-1"
+
+        def get_active_by_id(self, expansion_id):
+            return expansion if expansion_id == 30 else None
+
+    class DetailRepo:
+        def __init__(self, _session):
+            pass
+
+        def list_by_vids(self, _video_ids):
+            return {"vid-1": detail}
+
+    class FeedbackRepo:
+        def __init__(self, _session):
+            pass
+
+        def get_by_client_request_id(self, _request_id):
+            return None
+
+        def add(self, row):
+            saved.append(row)
+            return row
+
+        def list_for_target(self, **_kwargs):
+            return saved, len(saved)
+
+    monkeypatch.setattr(feedback_service, "get_session", lambda: SessionContext())
+    monkeypatch.setattr(feedback_service, "DemandGradeRepository", GradeRepo)
+    monkeypatch.setattr(feedback_service, "DemandVideoExpansionRepository", ExpansionRepo)
+    monkeypatch.setattr(feedback_service, "MultiDemandVideoDetailRepository", DetailRepo)
+    monkeypatch.setattr(feedback_service, "DemandFeedbackRepository", FeedbackRepo)
+
+    result = feedback_service.create_demand_feedback(
+        _body(
+            target_type="hit_content",
+            video_id="vid-1",
+            demand_video_expansion_id=30,
+            feedback_action="oppose",
+            reason_code="not_hit",
+            content="没有覆盖老人学习使用的需求。",
+        ),
+        {
+            "id": 7,
+            "username": "zhangsan",
+            "display_name": "张三",
+        },
+    )
+
+    assert result["feedback_summary"] == {"count": 1}
+    assert result["item"]["feedback_user"] == {
+        "id": 7,
+        "username": "zhangsan",
+        "display_name": "张三",
+    }
+    assert result["item"]["target_snapshot"]["expanded_text"] == "降低老人使用智能设备的门槛"
+    assert saved[0].feedback_user_id == 7
+    assert saved[0].feedback_user_name_snapshot == "张三"
+    assert saved[0].feedback_username_snapshot == "zhangsan"

+ 43 - 0
web/src/api/videoDiscovery.ts

@@ -1,8 +1,17 @@
 import type {
+  CreateDemandFeedbackInput,
+  CreateDemandFeedbackResponse,
+  DemandFeedbackHistoryResponse,
+  DemandFeedbackTarget,
   VideoDiscoveryDemandDetail,
   VideoDiscoveryDemandResponse,
 } from '../types/videoDiscovery'
 
+async function errorMessage(response: Response, fallback: string): Promise<string> {
+  const payload = await response.json().catch(() => null) as { detail?: string } | null
+  return payload?.detail || fallback
+}
+
 export async function fetchVideoDiscoveryDemands(
   bizDt?: string | null,
 ): Promise<VideoDiscoveryDemandResponse> {
@@ -23,3 +32,37 @@ export async function fetchVideoDiscoveryDemand(
   }
   return res.json()
 }
+
+export async function createDemandFeedback(
+  input: CreateDemandFeedbackInput,
+): Promise<CreateDemandFeedbackResponse> {
+  const res = await fetch('/api/video-discovery/feedback', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(input),
+  })
+  if (!res.ok) {
+    throw new Error(await errorMessage(res, `提交反馈失败: ${res.status} ${res.statusText}`))
+  }
+  return res.json()
+}
+
+export async function fetchDemandFeedback(
+  target: DemandFeedbackTarget,
+): Promise<DemandFeedbackHistoryResponse> {
+  const query = new URLSearchParams({
+    target_type: target.target_type,
+    demand_grade_id: String(target.demand_grade_id),
+    limit: '50',
+    offset: '0',
+  })
+  if (target.video_id) query.set('video_id', target.video_id)
+  if (target.demand_video_expansion_id) {
+    query.set('demand_video_expansion_id', String(target.demand_video_expansion_id))
+  }
+  const res = await fetch(`/api/video-discovery/feedback?${query.toString()}`)
+  if (!res.ok) {
+    throw new Error(await errorMessage(res, `加载反馈失败: ${res.status} ${res.statusText}`))
+  }
+  return res.json()
+}

+ 620 - 0
web/src/components/DemandFeedbackDrawer.vue

@@ -0,0 +1,620 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import {
+  createDemandFeedback,
+  fetchDemandFeedback,
+} from '../api/videoDiscovery'
+import type {
+  DemandFeedbackItem,
+  DemandFeedbackTarget,
+  FeedbackAction,
+  FeedbackSummary,
+} from '../types/videoDiscovery'
+
+const props = defineProps<{
+  open: boolean
+  target: DemandFeedbackTarget | null
+}>()
+
+const emit = defineEmits<{
+  close: []
+  submitted: [summary: FeedbackSummary]
+}>()
+
+const ACTIONS: Array<{ value: FeedbackAction; label: string }> = [
+  { value: 'support', label: '认可' },
+  { value: 'oppose', label: '不认可' },
+  { value: 'correct', label: '纠错' },
+  { value: 'supplement', label: '补充' },
+]
+
+const REASONS: Record<string, Array<{ value: string; label: string }>> = {
+  demand: [
+    { value: 'not_a_demand', label: '需求不成立' },
+    { value: 'wrong_grade', label: '等级不合适' },
+    { value: 'wrong_category', label: '分类不准确' },
+    { value: 'unclear_wording', label: '需求表述不清' },
+    { value: 'wrong_reason', label: '判断原因不准确' },
+    { value: 'missing_demand', label: '缺少需求' },
+    { value: 'other', label: '其他' },
+  ],
+  video: [
+    { value: 'irrelevant', label: '与需求不相关' },
+    { value: 'weak_relevance', label: '相关性弱' },
+    { value: 'unavailable', label: '视频失效' },
+    { value: 'duplicate', label: '视频重复' },
+    { value: 'wrong_info', label: '标题或信息错误' },
+    { value: 'missing_video', label: '缺少相关视频' },
+    { value: 'other', label: '其他' },
+  ],
+  hit_content: [
+    { value: 'not_hit', label: '内容未命中' },
+    { value: 'wrong_point_type', label: '点位类型错误' },
+    { value: 'inaccurate_wording', label: '表述不准确' },
+    { value: 'wrong_reason', label: '命中原因不准确' },
+    { value: 'duplicate', label: '内容重复' },
+    { value: 'missing_content', label: '缺少命中内容' },
+    { value: 'other', label: '其他' },
+  ],
+}
+
+const action = ref<FeedbackAction | null>(null)
+const reasonCode = ref('')
+const content = ref('')
+const submitting = ref(false)
+const submitError = ref<string | null>(null)
+const history = ref<DemandFeedbackItem[]>([])
+const historyTotal = ref(0)
+const historyLoading = ref(false)
+const historyError = ref<string | null>(null)
+
+const targetLabel = computed(() => {
+  if (props.target?.target_type === 'demand') return '需求'
+  if (props.target?.target_type === 'video') return '视频'
+  return '命中内容'
+})
+
+const reasonOptions = computed(() => REASONS[props.target?.target_type ?? 'demand'])
+const dirty = computed(() => Boolean(action.value || reasonCode.value || content.value.trim()))
+const canSubmit = computed(() => {
+  if (!action.value || !props.target || submitting.value) return false
+  if (action.value === 'oppose' && !reasonCode.value) return false
+  if (
+    (action.value === 'correct' || action.value === 'supplement')
+    && !content.value.trim()
+  ) return false
+  return true
+})
+
+watch(
+  [() => props.open, () => props.target],
+  ([open]) => {
+    if (!open || !props.target) return
+    action.value = null
+    reasonCode.value = ''
+    content.value = ''
+    submitError.value = null
+    void loadHistory()
+  },
+)
+
+watch(action, (value) => {
+  if (value === 'support') reasonCode.value = ''
+})
+
+async function loadHistory() {
+  if (!props.target) return
+  historyLoading.value = true
+  historyError.value = null
+  try {
+    const response = await fetchDemandFeedback(props.target)
+    history.value = response.items ?? []
+    historyTotal.value = response.total ?? 0
+  } catch (error) {
+    historyError.value = error instanceof Error ? error.message : String(error)
+  } finally {
+    historyLoading.value = false
+  }
+}
+
+function requestId(): string {
+  if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
+  return `${Date.now()}-${Math.random().toString(16).slice(2)}`
+}
+
+async function submit() {
+  if (!canSubmit.value || !props.target || !action.value) return
+  submitting.value = true
+  submitError.value = null
+  try {
+    const response = await createDemandFeedback({
+      client_request_id: requestId(),
+      target_type: props.target.target_type,
+      demand_grade_id: props.target.demand_grade_id,
+      video_id: props.target.video_id,
+      demand_video_expansion_id: props.target.demand_video_expansion_id,
+      feedback_action: action.value,
+      reason_code: reasonCode.value || undefined,
+      content: content.value.trim() || undefined,
+    })
+    emit('submitted', response.feedback_summary)
+    emit('close')
+  } catch (error) {
+    submitError.value = error instanceof Error ? error.message : String(error)
+  } finally {
+    submitting.value = false
+  }
+}
+
+function close() {
+  if (submitting.value) return
+  if (dirty.value && !window.confirm('反馈尚未提交,确定关闭吗?')) return
+  emit('close')
+}
+
+function onKeydown(event: KeyboardEvent) {
+  if (event.key === 'Escape' && props.open) close()
+}
+
+function actionLabel(value: FeedbackAction): string {
+  return ACTIONS.find((item) => item.value === value)?.label ?? value
+}
+
+function reasonLabel(value: string | null): string {
+  if (!value) return ''
+  return reasonOptions.value.find((item) => item.value === value)?.label ?? value
+}
+
+function formatTime(value: string | null): string {
+  if (!value) return '—'
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime())) return value
+  return new Intl.DateTimeFormat('zh-CN', {
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+  }).format(date)
+}
+
+onMounted(() => window.addEventListener('keydown', onKeydown))
+onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="feedback-fade">
+      <div v-if="open && target" class="feedback-overlay" @click.self="close">
+        <aside class="feedback-drawer" role="dialog" aria-modal="true" :aria-label="`${targetLabel}反馈`">
+          <header class="drawer-head">
+            <div>
+              <span>反馈 · {{ targetLabel }}</span>
+              <h2>{{ target.title }}</h2>
+            </div>
+            <button type="button" aria-label="关闭反馈" @click="close">×</button>
+          </header>
+
+          <div class="drawer-body">
+            <section class="target-context" aria-label="反馈对象">
+              <div v-for="item in target.context" :key="item.label">
+                <span>{{ item.label }}</span>
+                <strong>{{ item.value }}</strong>
+              </div>
+            </section>
+
+            <section class="form-section">
+              <div class="section-title">
+                <strong>你的判断</strong>
+                <span>必填</span>
+              </div>
+              <div class="action-options">
+                <button
+                  v-for="item in ACTIONS"
+                  :key="item.value"
+                  type="button"
+                  :class="{ active: action === item.value }"
+                  @click="action = item.value"
+                >
+                  {{ item.label }}
+                </button>
+              </div>
+            </section>
+
+            <section v-if="action && action !== 'support'" class="form-section">
+              <div class="section-title">
+                <strong>问题原因</strong>
+                <span>{{ action === 'oppose' ? '必填' : '选填' }}</span>
+              </div>
+              <div class="reason-options">
+                <button
+                  v-for="item in reasonOptions"
+                  :key="item.value"
+                  type="button"
+                  :class="{ active: reasonCode === item.value }"
+                  @click="reasonCode = reasonCode === item.value ? '' : item.value"
+                >
+                  {{ item.label }}
+                </button>
+              </div>
+            </section>
+
+            <section class="form-section">
+              <div class="section-title">
+                <strong>补充说明</strong>
+                <span>{{ action === 'correct' || action === 'supplement' ? '必填' : '选填' }}</span>
+              </div>
+              <textarea
+                v-model="content"
+                maxlength="1000"
+                rows="4"
+                placeholder="请说明判断依据或建议的正确内容"
+              />
+              <div class="text-count">{{ content.length }} / 1000</div>
+            </section>
+
+            <p v-if="submitError" class="feedback-error">{{ submitError }}</p>
+
+            <section class="history-section">
+              <div class="section-title">
+                <strong>反馈记录</strong>
+                <span>{{ historyTotal }} 条</span>
+              </div>
+              <div v-if="historyLoading" class="history-state">正在加载反馈记录…</div>
+              <div v-else-if="historyError" class="history-state error">{{ historyError }}</div>
+              <div v-else-if="history.length" class="history-list">
+                <article v-for="item in history" :key="item.id">
+                  <header>
+                    <strong>
+                      {{ item.feedback_user.display_name }}
+                      <small>@{{ item.feedback_user.username }}</small>
+                    </strong>
+                    <time>{{ formatTime(item.created_at) }}</time>
+                  </header>
+                  <div class="history-tags">
+                    <span>{{ actionLabel(item.feedback_action) }}</span>
+                    <span v-if="item.reason_code">{{ reasonLabel(item.reason_code) }}</span>
+                  </div>
+                  <p v-if="item.content">{{ item.content }}</p>
+                </article>
+              </div>
+              <div v-else class="history-state">暂无反馈记录</div>
+            </section>
+          </div>
+
+          <footer class="drawer-actions">
+            <button type="button" class="cancel" @click="close">取消</button>
+            <button type="button" class="submit" :disabled="!canSubmit" @click="submit">
+              {{ submitting ? '提交中…' : '提交反馈' }}
+            </button>
+          </footer>
+        </aside>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.feedback-overlay {
+  position: fixed;
+  z-index: 1000;
+  inset: 0;
+  display: flex;
+  justify-content: flex-end;
+  background: rgba(18, 28, 22, 0.34);
+  backdrop-filter: blur(2px);
+}
+
+.feedback-drawer {
+  width: min(470px, 100vw);
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  color: #17211b;
+  box-shadow: -18px 0 48px rgba(18, 38, 25, 0.16);
+}
+
+.drawer-head {
+  min-height: 82px;
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 18px 20px 15px;
+  border-bottom: 1px solid #e5eae6;
+}
+
+.drawer-head span {
+  color: #397354;
+  font-size: 10px;
+  font-weight: 800;
+  letter-spacing: 0.12em;
+}
+
+.drawer-head h2 {
+  margin: 5px 0 0;
+  font-family: Georgia, 'Songti SC', serif;
+  font-size: 18px;
+  line-height: 1.35;
+}
+
+.drawer-head button {
+  width: 30px;
+  height: 30px;
+  flex: 0 0 auto;
+  border: 1px solid #dce3dd;
+  border-radius: 8px;
+  background: #f8faf8;
+  color: #68736b;
+  font-size: 19px;
+  cursor: pointer;
+}
+
+.drawer-body {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  padding: 16px 20px 24px;
+}
+
+.target-context {
+  display: grid;
+  gap: 7px;
+  padding: 12px 13px;
+  border: 1px solid #dce6de;
+  border-radius: 10px;
+  background: #f4f8f5;
+}
+
+.target-context div {
+  display: grid;
+  grid-template-columns: 64px minmax(0, 1fr);
+  gap: 8px;
+}
+
+.target-context span {
+  color: #829087;
+  font-size: 10px;
+}
+
+.target-context strong {
+  overflow-wrap: anywhere;
+  color: #334239;
+  font-size: 11px;
+  line-height: 1.45;
+}
+
+.form-section,
+.history-section {
+  margin-top: 18px;
+}
+
+.section-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 9px;
+}
+
+.section-title strong {
+  font-size: 12px;
+}
+
+.section-title span {
+  color: #8a958d;
+  font-size: 9px;
+}
+
+.action-options {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 7px;
+}
+
+.action-options button,
+.reason-options button {
+  border: 1px solid #d8dfd9;
+  border-radius: 8px;
+  background: #fff;
+  color: #58645c;
+  font-size: 11px;
+  cursor: pointer;
+}
+
+.action-options button {
+  height: 36px;
+  font-weight: 700;
+}
+
+.reason-options {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 7px;
+}
+
+.reason-options button {
+  min-height: 30px;
+  padding: 5px 9px;
+}
+
+.action-options button:hover,
+.reason-options button:hover,
+.action-options button.active,
+.reason-options button.active {
+  border-color: #3d7656;
+  background: #e9f2ec;
+  color: #275b40;
+}
+
+textarea {
+  width: 100%;
+  box-sizing: border-box;
+  resize: vertical;
+  padding: 10px 11px;
+  border: 1px solid #d8dfd9;
+  border-radius: 9px;
+  outline: none;
+  color: #26332a;
+  font: inherit;
+  font-size: 12px;
+  line-height: 1.55;
+}
+
+textarea:focus {
+  border-color: #6b957c;
+  box-shadow: 0 0 0 3px rgba(53, 110, 78, 0.1);
+}
+
+.text-count {
+  margin-top: 4px;
+  color: #98a19a;
+  font-size: 9px;
+  text-align: right;
+}
+
+.feedback-error {
+  margin: 12px 0 0;
+  padding: 9px 10px;
+  border-radius: 8px;
+  background: #fff0ec;
+  color: #a8462c;
+  font-size: 11px;
+}
+
+.history-section {
+  padding-top: 17px;
+  border-top: 1px solid #e8ece9;
+}
+
+.history-list {
+  display: grid;
+  gap: 8px;
+}
+
+.history-list article {
+  padding: 10px 11px;
+  border: 1px solid #e1e6e2;
+  border-radius: 9px;
+  background: #fbfcfb;
+}
+
+.history-list header {
+  display: flex;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.history-list header strong {
+  font-size: 11px;
+}
+
+.history-list header small {
+  margin-left: 5px;
+  color: #8b958e;
+  font-size: 9px;
+  font-weight: 500;
+}
+
+.history-list time {
+  color: #919a93;
+  font-size: 9px;
+}
+
+.history-tags {
+  display: flex;
+  gap: 5px;
+  margin-top: 7px;
+}
+
+.history-tags span {
+  padding: 2px 6px;
+  border-radius: 999px;
+  background: #e7efe9;
+  color: #39604a;
+  font-size: 9px;
+  font-weight: 700;
+}
+
+.history-list p {
+  margin: 7px 0 0;
+  color: #5f6b63;
+  font-size: 11px;
+  line-height: 1.5;
+  white-space: pre-wrap;
+}
+
+.history-state {
+  padding: 12px;
+  border-radius: 8px;
+  background: #f7f9f7;
+  color: #89928b;
+  font-size: 10px;
+  text-align: center;
+}
+
+.history-state.error {
+  color: #a8462c;
+}
+
+.drawer-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 9px;
+  padding: 13px 20px;
+  border-top: 1px solid #e3e8e4;
+  background: #fbfcfb;
+}
+
+.drawer-actions button {
+  height: 36px;
+  padding: 0 16px;
+  border-radius: 8px;
+  font-size: 11px;
+  font-weight: 800;
+  cursor: pointer;
+}
+
+.drawer-actions .cancel {
+  border: 1px solid #d4dcd6;
+  background: #fff;
+  color: #68736b;
+}
+
+.drawer-actions .submit {
+  border: 1px solid #315f47;
+  background: #315f47;
+  color: #fff;
+}
+
+.drawer-actions .submit:disabled {
+  border-color: #cdd5cf;
+  background: #cdd5cf;
+  cursor: not-allowed;
+}
+
+.feedback-fade-enter-active,
+.feedback-fade-leave-active {
+  transition: opacity 0.18s ease;
+}
+
+.feedback-fade-enter-active .feedback-drawer,
+.feedback-fade-leave-active .feedback-drawer {
+  transition: transform 0.18s ease;
+}
+
+.feedback-fade-enter-from,
+.feedback-fade-leave-to {
+  opacity: 0;
+}
+
+.feedback-fade-enter-from .feedback-drawer,
+.feedback-fade-leave-to .feedback-drawer {
+  transform: translateX(24px);
+}
+
+@media (max-width: 640px) {
+  .feedback-drawer {
+    width: 100%;
+  }
+}
+</style>

+ 63 - 0
web/src/types/videoDiscovery.ts

@@ -1,5 +1,12 @@
 export type VideoPointType = 'purpose' | 'key' | 'inspiration' | string
 
+export type FeedbackTargetType = 'demand' | 'video' | 'hit_content'
+export type FeedbackAction = 'support' | 'oppose' | 'correct' | 'supplement'
+
+export interface FeedbackSummary {
+  count: number
+}
+
 export interface VideoDiscoveryDemand {
   id: number
   demand_name: string
@@ -15,13 +22,16 @@ export interface VideoDiscoveryDemand {
   strategies: string[]
   reason: string | null
   video_count: number
+  feedback_summary: FeedbackSummary
 }
 
 export interface VideoDiscoveryPoint {
+  id: number
   point_type: VideoPointType
   expanded_text: string
   point_desc: string | null
   reason: string
+  feedback_summary: FeedbackSummary
 }
 
 export interface VideoDiscoveryVideo {
@@ -32,6 +42,7 @@ export interface VideoDiscoveryVideo {
   video_detail_url: string | null
   point_count: number
   points: VideoDiscoveryPoint[]
+  feedback_summary: FeedbackSummary
 }
 
 export interface VideoDiscoveryDemandDetail extends VideoDiscoveryDemand {
@@ -44,3 +55,55 @@ export interface VideoDiscoveryDemandResponse {
   biz_dt: string | null
   items: VideoDiscoveryDemand[]
 }
+
+export interface DemandFeedbackTarget {
+  target_type: FeedbackTargetType
+  demand_grade_id: number
+  video_id?: string
+  demand_video_expansion_id?: number
+  title: string
+  context: Array<{ label: string; value: string }>
+  feedback_summary: FeedbackSummary
+}
+
+export interface DemandFeedbackItem {
+  id: number
+  target_type: FeedbackTargetType
+  biz_dt: string
+  demand_grade_id: number
+  video_id: string | null
+  demand_video_expansion_id: number | null
+  feedback_action: FeedbackAction
+  reason_code: string | null
+  content: string | null
+  target_snapshot: Record<string, unknown>
+  feedback_user: {
+    id: number
+    username: string
+    display_name: string
+  }
+  created_at: string | null
+}
+
+export interface DemandFeedbackHistoryResponse {
+  items: DemandFeedbackItem[]
+  total: number
+  limit: number
+  offset: number
+}
+
+export interface CreateDemandFeedbackInput {
+  client_request_id: string
+  target_type: FeedbackTargetType
+  demand_grade_id: number
+  video_id?: string
+  demand_video_expansion_id?: number
+  feedback_action: FeedbackAction
+  reason_code?: string
+  content?: string
+}
+
+export interface CreateDemandFeedbackResponse {
+  item: DemandFeedbackItem
+  feedback_summary: FeedbackSummary
+}

+ 273 - 76
web/src/views/VideoDiscoveryView.vue

@@ -4,9 +4,12 @@ import {
   fetchVideoDiscoveryDemand,
   fetchVideoDiscoveryDemands,
 } from '../api/videoDiscovery'
+import DemandFeedbackDrawer from '../components/DemandFeedbackDrawer.vue'
 import { formatPosteriorDiff } from '../types/category'
 import { pickDecodeUrl, videoDetailUrl } from '../utils/videoLinks'
 import type {
+  DemandFeedbackTarget,
+  FeedbackSummary,
   VideoDiscoveryDemand,
   VideoDiscoveryDemandDetail,
   VideoDiscoveryPoint,
@@ -39,6 +42,7 @@ const videoSearch = ref('')
 const pointFilter = ref<PointFilter>('all')
 const selectedVid = ref<string | null>(null)
 const copiedKey = ref<string | null>(null)
+const feedbackTarget = ref<DemandFeedbackTarget | null>(null)
 const visibleDemandLimit = ref(80)
 let detailRequest = 0
 let copiedTimer = 0
@@ -287,6 +291,80 @@ function videoPointCount(video: VideoDiscoveryVideo, type: string): number {
   return video.points.filter((point) => point.point_type === type).length
 }
 
+function openDemandFeedback() {
+  if (!selectedDemand.value) return
+  feedbackTarget.value = {
+    target_type: 'demand',
+    demand_grade_id: selectedDemand.value.id,
+    title: selectedDemand.value.demand_name,
+    context: [
+      { label: '需求', value: selectedDemand.value.demand_name },
+      { label: '业务日', value: formatDate(selectedDemand.value.biz_dt) },
+      { label: '等级', value: selectedDemand.value.grade },
+    ],
+    feedback_summary: selectedDemand.value.feedback_summary,
+  }
+}
+
+function openVideoFeedback(video: VideoDiscoveryVideo) {
+  if (!selectedDemand.value) return
+  feedbackTarget.value = {
+    target_type: 'video',
+    demand_grade_id: selectedDemand.value.id,
+    video_id: video.vid,
+    title: video.title || `视频 ${video.vid}`,
+    context: [
+      { label: '需求', value: selectedDemand.value.demand_name },
+      { label: '视频', value: video.title || `视频 ${video.vid}` },
+      { label: '视频 ID', value: video.vid },
+    ],
+    feedback_summary: video.feedback_summary,
+  }
+}
+
+function openPointFeedback(point: VideoDiscoveryPoint) {
+  if (!selectedDemand.value || !selectedVideo.value) return
+  feedbackTarget.value = {
+    target_type: 'hit_content',
+    demand_grade_id: selectedDemand.value.id,
+    video_id: selectedVideo.value.vid,
+    demand_video_expansion_id: point.id,
+    title: point.expanded_text,
+    context: [
+      { label: '需求', value: selectedDemand.value.demand_name },
+      {
+        label: '视频',
+        value: selectedVideo.value.title || `视频 ${selectedVideo.value.vid}`,
+      },
+      { label: pointMeta(point.point_type).label, value: point.expanded_text },
+    ],
+    feedback_summary: point.feedback_summary,
+  }
+}
+
+function applyFeedbackSummary(summary: FeedbackSummary) {
+  const target = feedbackTarget.value
+  if (!target) return
+  if (target.target_type === 'demand') {
+    const demand = demands.value.find((item) => item.id === target.demand_grade_id)
+    if (demand) demand.feedback_summary = summary
+    if (detail.value?.id === target.demand_grade_id) {
+      detail.value.feedback_summary = summary
+    }
+    return
+  }
+  const video = detail.value?.videos.find((item) => item.vid === target.video_id)
+  if (!video) return
+  if (target.target_type === 'video') {
+    video.feedback_summary = summary
+    return
+  }
+  const point = video.points.find(
+    (item) => item.id === target.demand_video_expansion_id,
+  )
+  if (point) point.feedback_summary = summary
+}
+
 async function copyText(text: string, key: string) {
   try {
     await navigator.clipboard.writeText(text)
@@ -480,13 +558,20 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
               <span class="brief-label">当前寻找需求</span>
               <h3>{{ selectedDemand.demand_name }}</h3>
             </div>
-            <button
-              type="button"
-              class="copy-button"
-              @click="copyText(selectedDemand.demand_name, 'demand')"
-            >
-              {{ copiedKey === 'demand' ? '已复制' : '复制需求' }}
-            </button>
+            <div class="brief-actions">
+              <button
+                type="button"
+                class="copy-button"
+                @click="copyText(selectedDemand.demand_name, 'demand')"
+              >
+                {{ copiedKey === 'demand' ? '已复制' : '复制需求' }}
+              </button>
+              <button type="button" class="feedback-trigger" @click="openDemandFeedback">
+                反馈<span v-if="selectedDemand.feedback_summary.count">
+                  {{ selectedDemand.feedback_summary.count }}
+                </span>
+              </button>
+            </div>
           </div>
 
           <p v-if="selectedDemand.reason" class="brief-reason">
@@ -559,59 +644,72 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
           </div>
 
           <div v-if="filteredVideos.length" class="video-list">
-            <button
+            <article
               v-for="(video, index) in filteredVideos"
               :key="video.vid"
-              type="button"
               class="video-card"
               :class="{ selected: video.vid === selectedVid }"
-              @click="selectedVid = video.vid"
             >
-              <span class="video-index">{{ String(index + 1).padStart(2, '0') }}</span>
-              <div class="video-main">
-                <div class="video-title-row">
-                  <strong>{{ video.title || `视频 ${video.vid}` }}</strong>
-                  <span v-if="!isSourceOnlyGrade">{{ video.point_count }} 个内容点</span>
-                </div>
-                <code>{{ video.vid }}</code>
-                <div class="video-link-row">
-                  <a
-                    v-if="decodeUrlFor(video)"
-                    :href="decodeUrlFor(video)!"
-                    class="video-link"
-                    target="_blank"
-                    rel="noopener noreferrer"
-                    @click.stop
-                  >
-                    解构结果
-                  </a>
-                  <a
-                    :href="detailUrlFor(video)"
-                    class="video-link"
-                    target="_blank"
-                    rel="noopener noreferrer"
-                    @click.stop
-                  >
-                    视频详情
-                  </a>
-                </div>
-                <div v-if="!isSourceOnlyGrade" class="video-point-badges">
-                  <span v-if="videoPointCount(video, 'purpose')" class="point-badge purpose">
-                    ◎ 目的 {{ videoPointCount(video, 'purpose') }}
-                  </span>
-                  <span v-if="videoPointCount(video, 'key')" class="point-badge key">
-                    ◆ 关键 {{ videoPointCount(video, 'key') }}
-                  </span>
-                  <span
-                    v-if="videoPointCount(video, 'inspiration')"
-                    class="point-badge inspiration"
-                  >
-                    ✦ 灵感 {{ videoPointCount(video, 'inspiration') }}
-                  </span>
+              <button
+                type="button"
+                class="video-select"
+                @click="selectedVid = video.vid"
+              >
+                <span class="video-index">{{ String(index + 1).padStart(2, '0') }}</span>
+                <div class="video-main">
+                  <div class="video-title-row">
+                    <strong>{{ video.title || `视频 ${video.vid}` }}</strong>
+                    <span v-if="!isSourceOnlyGrade">{{ video.point_count }} 个内容点</span>
+                  </div>
+                  <code>{{ video.vid }}</code>
+                  <div class="video-link-row">
+                    <a
+                      v-if="decodeUrlFor(video)"
+                      :href="decodeUrlFor(video)!"
+                      class="video-link"
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      @click.stop
+                    >
+                      解构结果
+                    </a>
+                    <a
+                      :href="detailUrlFor(video)"
+                      class="video-link"
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      @click.stop
+                    >
+                      视频详情
+                    </a>
+                  </div>
+                  <div v-if="!isSourceOnlyGrade" class="video-point-badges">
+                    <span v-if="videoPointCount(video, 'purpose')" class="point-badge purpose">
+                      ◎ 目的 {{ videoPointCount(video, 'purpose') }}
+                    </span>
+                    <span v-if="videoPointCount(video, 'key')" class="point-badge key">
+                      ◆ 关键 {{ videoPointCount(video, 'key') }}
+                    </span>
+                    <span
+                      v-if="videoPointCount(video, 'inspiration')"
+                      class="point-badge inspiration"
+                    >
+                      ✦ 灵感 {{ videoPointCount(video, 'inspiration') }}
+                    </span>
+                  </div>
                 </div>
-              </div>
-              <span class="video-arrow">›</span>
-            </button>
+                <span class="video-arrow">›</span>
+              </button>
+              <button
+                type="button"
+                class="feedback-trigger video-feedback"
+                @click="openVideoFeedback(video)"
+              >
+                反馈<span v-if="video.feedback_summary.count">
+                  {{ video.feedback_summary.count }}
+                </span>
+              </button>
+            </article>
           </div>
           <div v-else class="empty-state">
             <span class="empty-mark">○</span>
@@ -701,7 +799,7 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
                 <span class="point-count">{{ group.points.length }}</span>
               </header>
 
-              <article v-for="(point, index) in group.points" :key="index" class="point-card">
+              <article v-for="(point, index) in group.points" :key="point.id" class="point-card">
                 <div class="point-number">{{ index + 1 }}</div>
                 <div>
                   <strong>{{ point.expanded_text }}</strong>
@@ -710,19 +808,31 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
                     <span>命中原因</span>{{ point.reason }}
                   </p>
                 </div>
-                <button
-                  type="button"
-                  title="复制内容点"
-                  :aria-label="`复制内容点:${point.expanded_text}`"
-                  @click="
-                    copyText(
-                      point.expanded_text,
-                      `${group.type}-${index}`,
-                    )
-                  "
-                >
-                  {{ copiedKey === `${group.type}-${index}` ? '✓' : '+' }}
-                </button>
+                <div class="point-actions">
+                  <button
+                    type="button"
+                    class="point-copy"
+                    title="复制内容点"
+                    :aria-label="`复制内容点:${point.expanded_text}`"
+                    @click="
+                      copyText(
+                        point.expanded_text,
+                        `${group.type}-${point.id}`,
+                      )
+                    "
+                  >
+                    {{ copiedKey === `${group.type}-${point.id}` ? '✓' : '+' }}
+                  </button>
+                  <button
+                    type="button"
+                    class="feedback-trigger point-feedback"
+                    @click="openPointFeedback(point)"
+                  >
+                    反馈<span v-if="point.feedback_summary.count">
+                      {{ point.feedback_summary.count }}
+                    </span>
+                  </button>
+                </div>
               </article>
             </section>
           </div>
@@ -744,6 +854,13 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
         </div>
       </aside>
     </main>
+
+    <DemandFeedbackDrawer
+      :open="feedbackTarget !== null"
+      :target="feedbackTarget"
+      @close="feedbackTarget = null"
+      @submitted="applyFeedbackSummary"
+    />
   </div>
 </template>
 
@@ -1264,7 +1381,6 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
 
 .copy-button {
   height: 29px;
-  margin-left: auto;
   padding: 0 9px;
   flex: 0 0 auto;
   border: 1px solid #cbd6cd;
@@ -1276,6 +1392,48 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
   cursor: pointer;
 }
 
+.brief-actions {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-left: auto;
+}
+
+.feedback-trigger {
+  min-height: 29px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+  padding: 0 9px;
+  border: 1px solid #b9cbbd;
+  border-radius: 7px;
+  background: #edf4ef;
+  color: #315f47;
+  font-size: 10px;
+  font-weight: 800;
+  white-space: nowrap;
+  cursor: pointer;
+}
+
+.feedback-trigger:hover {
+  border-color: #6d977d;
+  background: #e3eee6;
+}
+
+.feedback-trigger span {
+  min-width: 15px;
+  height: 15px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 4px;
+  border-radius: 999px;
+  background: #315f47;
+  color: #fff;
+  font-size: 8px;
+}
+
 .brief-reason {
   display: -webkit-box;
   margin: 10px 0;
@@ -1370,21 +1528,40 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
 
 .video-card {
   width: 100%;
-  display: grid;
-  grid-template-columns: 28px minmax(0, 1fr) 16px;
+  display: flex;
   align-items: center;
-  gap: 10px;
-  padding: 11px 10px;
+  gap: 4px;
+  padding: 3px 5px 3px 3px;
   border: 1px solid transparent;
   border-bottom-color: #ebefeb;
   border-radius: 10px;
   background: transparent;
   color: inherit;
   text-align: left;
-  cursor: pointer;
   transition: 0.15s ease;
 }
 
+.video-select {
+  min-width: 0;
+  flex: 1;
+  display: grid;
+  grid-template-columns: 28px minmax(0, 1fr) 16px;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 5px 8px 7px;
+  border: 0;
+  background: transparent;
+  color: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+
+.video-feedback {
+  min-height: 28px;
+  flex: 0 0 auto;
+  padding: 0 7px;
+}
+
 .video-card:hover {
   border-color: #dfe7e0;
   background: #fafbfa;
@@ -1676,7 +1853,7 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
 
 .point-card {
   display: grid;
-  grid-template-columns: 20px minmax(0, 1fr) 23px;
+  grid-template-columns: 20px minmax(0, 1fr) auto;
   align-items: start;
   gap: 8px;
   padding: 9px 7px;
@@ -1719,7 +1896,14 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
   font-weight: 800;
 }
 
-.point-card button {
+.point-actions {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  gap: 5px;
+}
+
+.point-card .point-copy {
   width: 22px;
   height: 22px;
   display: grid;
@@ -1732,6 +1916,19 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
   cursor: pointer;
 }
 
+.point-card .point-feedback {
+  min-height: 23px;
+  padding: 0 6px;
+  font-size: 9px;
+}
+
+.point-card .point-feedback span {
+  min-width: 13px;
+  height: 13px;
+  padding: 0 3px;
+  font-size: 7px;
+}
+
 .page-state,
 .inline-state,
 .empty-state {