瀏覽代碼

增加需求反馈机制

xueyiming 1 天之前
父節點
當前提交
d1906a32ff

+ 4 - 0
.gitignore

@@ -19,6 +19,10 @@ build/
 logs/
 logs/
 tests/*
 tests/*
 !tests/__init__.py
 !tests/__init__.py
+!tests/api/
+tests/api/*
+!tests/api/__init__.py
+!tests/api/test_demand_feedback.py
 !tests/supply_agent/
 !tests/supply_agent/
 !tests/supply_infra/
 !tests/supply_infra/
 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 contextlib import asynccontextmanager
 from pathlib import Path
 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.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
@@ -14,6 +16,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
 from api.auth_middleware import AuthenticationMiddleware
 from api.auth_middleware import AuthenticationMiddleware
 from api.routers.auth import router as auth_router
 from api.routers.auth import router as auth_router
 from api.routers.pipeline import router as pipeline_router
 from api.routers.pipeline import router as pipeline_router
+from api.schemas.demand_feedback import CreateDemandFeedbackBody
 from api.services.agent_catalog import (
 from api.services.agent_catalog import (
     get_agent_detail,
     get_agent_detail,
     update_agent_document_injection,
     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_belong_category import list_demand_belong_categories
 from api.services.demand_grade import list_demand_grades
 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_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.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.oss_logs import list_agent_oss_logs, list_demand_belong_oss_logs
 from api.services.scheduler import (
 from api.services.scheduler import (
@@ -260,6 +270,48 @@ def video_discovery_demand(demand_grade_id: int) -> dict:
     return result
     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")
 @app.get("/api/demand-belong-oss-logs")
 def demand_belong_oss_logs() -> dict:
 def demand_belong_oss_logs() -> dict:
     """Return demand_belong_category_agent oss_logs ordered by create_time desc."""
     """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/category-tree"),
     ("GET", "/api/demand-grade"),
     ("GET", "/api/demand-grade"),
     ("GET", "/api/video-discovery/demands"),
     ("GET", "/api/video-discovery/demands"),
+    ("GET", "/api/video-discovery/feedback"),
+    ("POST", "/api/video-discovery/feedback"),
 }
 }
 _NORMAL_USER_PATTERNS = (
 _NORMAL_USER_PATTERNS = (
     re.compile(r"^/api/video-discovery/demands/\d+$"),
     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
 import json
 from typing import Any
 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_grade_repo import DemandGradeRepository
 from supply_infra.db.repositories.demand_video_expansion_repo import (
 from supply_infra.db.repositories.demand_video_expansion_repo import (
     DemandVideoExpansionRepository,
     DemandVideoExpansionRepository,
@@ -68,6 +69,7 @@ def _serialize_video(
     video_id: str,
     video_id: str,
     detail: Any | None,
     detail: Any | None,
     points: list[dict[str, Any]],
     points: list[dict[str, Any]],
+    feedback_count: int = 0,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     url1 = _normalize_url(detail.url1) if detail else None
     url1 = _normalize_url(detail.url1) if detail else None
     url2 = _normalize_url(detail.url2) 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),
         "video_detail_url": _VIDEO_DETAIL_URL_TEMPLATE.format(vid=video_id),
         "point_count": len(points),
         "point_count": len(points),
         "points": points,
         "points": points,
+        "feedback_summary": {"count": int(feedback_count)},
     }
     }
 
 
 
 
@@ -142,6 +145,7 @@ def _serialize_demand(
     row: Any,
     row: Any,
     category_names: dict[int, str],
     category_names: dict[int, str],
     video_count: int,
     video_count: int,
+    feedback_count: int = 0,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     category_ids = _parse_int_list(row.category_ids)
     category_ids = _parse_int_list(row.category_ids)
     return {
     return {
@@ -163,6 +167,7 @@ def _serialize_demand(
         "strategies": _parse_string_list(row.strategies),
         "strategies": _parse_string_list(row.strategies),
         "reason": row.reason,
         "reason": row.reason,
         "video_count": video_count,
         "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(
         video_counts = DemandVideoExpansionRepository(
             session
             session
         ).count_distinct_videos_by_demand_grade(str(resolved_biz_dt))
         ).count_distinct_videos_by_demand_grade(str(resolved_biz_dt))
+        feedback_counts = DemandFeedbackRepository(session).count_demands(
+            [int(row.id) for row in rows]
+        )
         return {
         return {
             "biz_dt": str(resolved_biz_dt),
             "biz_dt": str(resolved_biz_dt),
             "items": [
             "items": [
@@ -217,6 +225,7 @@ def list_video_discovery_demands(biz_dt: str | None = None) -> dict[str, Any]:
                     row,
                     row,
                     category_names,
                     category_names,
                     _source_video_count(row, video_counts),
                     _source_video_count(row, video_counts),
+                    feedback_counts.get(int(row.id), 0),
                 )
                 )
                 for row in rows
                 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])
         category_names = _category_name_map(session, [grade])
         grade_code = str(grade.grade or "").upper()
         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:
         if grade_code in _SOURCE_VIDEO_GRADES:
             video_ids = _parse_video_ids(grade.video_list)
             video_ids = _parse_video_ids(grade.video_list)
             details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
             details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
             videos = [
             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
                 for video_id in video_ids
             ]
             ]
         else:
         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] = []
                 points_by_video[video_id].append(
                 points_by_video[video_id].append(
                     {
                     {
+                        "id": int(row.id),
                         "point_type": row.point_type,
                         "point_type": row.point_type,
                         "expanded_text": row.expanded_text,
                         "expanded_text": row.expanded_text,
                         "point_desc": row.point_desc,
                         "point_desc": row.point_desc,
                         "reason": row.reason,
                         "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,
                     video_id,
                     details.get(video_id),
                     details.get(video_id),
                     points_by_video[video_id],
                     points_by_video[video_id],
+                    video_feedback_counts.get(video_id, 0),
                 )
                 )
                 for video_id in video_ids
                 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["point_count"] = sum(video["point_count"] for video in videos)
         demand["videos"] = videos
         demand["videos"] = videos
         demand["video_source"] = (
         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.category_tree_weight import CategoryTreeWeight
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
 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_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 import DemandGrade
 from supply_infra.db.models.demand_grade_category_rel import DemandGradeCategoryRel
 from supply_infra.db.models.demand_grade_category_rel import DemandGradeCategoryRel
 from supply_infra.db.models.demand_grade_plan import (
 from supply_infra.db.models.demand_grade_plan import (
@@ -42,6 +43,7 @@ __all__ = [
     "CategoryTreeWeight",
     "CategoryTreeWeight",
     "DemandBelongCategory",
     "DemandBelongCategory",
     "DemandBelongPoolRel",
     "DemandBelongPoolRel",
+    "DemandFeedback",
     "DemandGrade",
     "DemandGrade",
     "DemandGradeCategoryRel",
     "DemandGradeCategoryRel",
     "DemandGradePlan",
     "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 (
 from supply_infra.db.repositories.demand_belong_pool_rel_repo import (
     DemandBelongPoolRelRepository,
     DemandBelongPoolRelRepository,
 )
 )
+from supply_infra.db.repositories.demand_feedback_repo import DemandFeedbackRepository
 from supply_infra.db.repositories.demand_grade_category_rel_repo import (
 from supply_infra.db.repositories.demand_grade_category_rel_repo import (
     DemandGradeCategoryRelRepository,
     DemandGradeCategoryRelRepository,
 )
 )
@@ -50,6 +51,7 @@ __all__ = [
     "CategoryTreeWeightRepository",
     "CategoryTreeWeightRepository",
     "DemandBelongCategoryRepository",
     "DemandBelongCategoryRepository",
     "DemandBelongPoolRelRepository",
     "DemandBelongPoolRelRepository",
+    "DemandFeedbackRepository",
     "DemandGradeCategoryRelRepository",
     "DemandGradeCategoryRelRepository",
     "DemandGradeRepository",
     "DemandGradeRepository",
     "DemandGradePlanRepository",
     "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
     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(
     def list_by_demand_grade(
         self, biz_dt: str, source_demand_grade_id: int
         self, biz_dt: str, source_demand_grade_id: int
     ) -> list[DemandVideoExpansion]:
     ) -> 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 {
 import type {
+  CreateDemandFeedbackInput,
+  CreateDemandFeedbackResponse,
+  DemandFeedbackHistoryResponse,
+  DemandFeedbackTarget,
   VideoDiscoveryDemandDetail,
   VideoDiscoveryDemandDetail,
   VideoDiscoveryDemandResponse,
   VideoDiscoveryDemandResponse,
 } from '../types/videoDiscovery'
 } 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(
 export async function fetchVideoDiscoveryDemands(
   bizDt?: string | null,
   bizDt?: string | null,
 ): Promise<VideoDiscoveryDemandResponse> {
 ): Promise<VideoDiscoveryDemandResponse> {
@@ -23,3 +32,37 @@ export async function fetchVideoDiscoveryDemand(
   }
   }
   return res.json()
   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 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 {
 export interface VideoDiscoveryDemand {
   id: number
   id: number
   demand_name: string
   demand_name: string
@@ -15,13 +22,16 @@ export interface VideoDiscoveryDemand {
   strategies: string[]
   strategies: string[]
   reason: string | null
   reason: string | null
   video_count: number
   video_count: number
+  feedback_summary: FeedbackSummary
 }
 }
 
 
 export interface VideoDiscoveryPoint {
 export interface VideoDiscoveryPoint {
+  id: number
   point_type: VideoPointType
   point_type: VideoPointType
   expanded_text: string
   expanded_text: string
   point_desc: string | null
   point_desc: string | null
   reason: string
   reason: string
+  feedback_summary: FeedbackSummary
 }
 }
 
 
 export interface VideoDiscoveryVideo {
 export interface VideoDiscoveryVideo {
@@ -32,6 +42,7 @@ export interface VideoDiscoveryVideo {
   video_detail_url: string | null
   video_detail_url: string | null
   point_count: number
   point_count: number
   points: VideoDiscoveryPoint[]
   points: VideoDiscoveryPoint[]
+  feedback_summary: FeedbackSummary
 }
 }
 
 
 export interface VideoDiscoveryDemandDetail extends VideoDiscoveryDemand {
 export interface VideoDiscoveryDemandDetail extends VideoDiscoveryDemand {
@@ -44,3 +55,55 @@ export interface VideoDiscoveryDemandResponse {
   biz_dt: string | null
   biz_dt: string | null
   items: VideoDiscoveryDemand[]
   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,
   fetchVideoDiscoveryDemand,
   fetchVideoDiscoveryDemands,
   fetchVideoDiscoveryDemands,
 } from '../api/videoDiscovery'
 } from '../api/videoDiscovery'
+import DemandFeedbackDrawer from '../components/DemandFeedbackDrawer.vue'
 import { formatPosteriorDiff } from '../types/category'
 import { formatPosteriorDiff } from '../types/category'
 import { pickDecodeUrl, videoDetailUrl } from '../utils/videoLinks'
 import { pickDecodeUrl, videoDetailUrl } from '../utils/videoLinks'
 import type {
 import type {
+  DemandFeedbackTarget,
+  FeedbackSummary,
   VideoDiscoveryDemand,
   VideoDiscoveryDemand,
   VideoDiscoveryDemandDetail,
   VideoDiscoveryDemandDetail,
   VideoDiscoveryPoint,
   VideoDiscoveryPoint,
@@ -39,6 +42,7 @@ const videoSearch = ref('')
 const pointFilter = ref<PointFilter>('all')
 const pointFilter = ref<PointFilter>('all')
 const selectedVid = ref<string | null>(null)
 const selectedVid = ref<string | null>(null)
 const copiedKey = ref<string | null>(null)
 const copiedKey = ref<string | null>(null)
+const feedbackTarget = ref<DemandFeedbackTarget | null>(null)
 const visibleDemandLimit = ref(80)
 const visibleDemandLimit = ref(80)
 let detailRequest = 0
 let detailRequest = 0
 let copiedTimer = 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
   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) {
 async function copyText(text: string, key: string) {
   try {
   try {
     await navigator.clipboard.writeText(text)
     await navigator.clipboard.writeText(text)
@@ -480,13 +558,20 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
               <span class="brief-label">当前寻找需求</span>
               <span class="brief-label">当前寻找需求</span>
               <h3>{{ selectedDemand.demand_name }}</h3>
               <h3>{{ selectedDemand.demand_name }}</h3>
             </div>
             </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>
           </div>
 
 
           <p v-if="selectedDemand.reason" class="brief-reason">
           <p v-if="selectedDemand.reason" class="brief-reason">
@@ -559,59 +644,72 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
           </div>
           </div>
 
 
           <div v-if="filteredVideos.length" class="video-list">
           <div v-if="filteredVideos.length" class="video-list">
-            <button
+            <article
               v-for="(video, index) in filteredVideos"
               v-for="(video, index) in filteredVideos"
               :key="video.vid"
               :key="video.vid"
-              type="button"
               class="video-card"
               class="video-card"
               :class="{ selected: video.vid === selectedVid }"
               :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>
-              </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>
           <div v-else class="empty-state">
           <div v-else class="empty-state">
             <span class="empty-mark">○</span>
             <span class="empty-mark">○</span>
@@ -701,7 +799,7 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
                 <span class="point-count">{{ group.points.length }}</span>
                 <span class="point-count">{{ group.points.length }}</span>
               </header>
               </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 class="point-number">{{ index + 1 }}</div>
                 <div>
                 <div>
                   <strong>{{ point.expanded_text }}</strong>
                   <strong>{{ point.expanded_text }}</strong>
@@ -710,19 +808,31 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
                     <span>命中原因</span>{{ point.reason }}
                     <span>命中原因</span>{{ point.reason }}
                   </p>
                   </p>
                 </div>
                 </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>
               </article>
             </section>
             </section>
           </div>
           </div>
@@ -744,6 +854,13 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
         </div>
         </div>
       </aside>
       </aside>
     </main>
     </main>
+
+    <DemandFeedbackDrawer
+      :open="feedbackTarget !== null"
+      :target="feedbackTarget"
+      @close="feedbackTarget = null"
+      @submitted="applyFeedbackSummary"
+    />
   </div>
   </div>
 </template>
 </template>
 
 
@@ -1264,7 +1381,6 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
 
 
 .copy-button {
 .copy-button {
   height: 29px;
   height: 29px;
-  margin-left: auto;
   padding: 0 9px;
   padding: 0 9px;
   flex: 0 0 auto;
   flex: 0 0 auto;
   border: 1px solid #cbd6cd;
   border: 1px solid #cbd6cd;
@@ -1276,6 +1392,48 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
   cursor: pointer;
   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 {
 .brief-reason {
   display: -webkit-box;
   display: -webkit-box;
   margin: 10px 0;
   margin: 10px 0;
@@ -1370,21 +1528,40 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
 
 
 .video-card {
 .video-card {
   width: 100%;
   width: 100%;
-  display: grid;
-  grid-template-columns: 28px minmax(0, 1fr) 16px;
+  display: flex;
   align-items: center;
   align-items: center;
-  gap: 10px;
-  padding: 11px 10px;
+  gap: 4px;
+  padding: 3px 5px 3px 3px;
   border: 1px solid transparent;
   border: 1px solid transparent;
   border-bottom-color: #ebefeb;
   border-bottom-color: #ebefeb;
   border-radius: 10px;
   border-radius: 10px;
   background: transparent;
   background: transparent;
   color: inherit;
   color: inherit;
   text-align: left;
   text-align: left;
-  cursor: pointer;
   transition: 0.15s ease;
   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 {
 .video-card:hover {
   border-color: #dfe7e0;
   border-color: #dfe7e0;
   background: #fafbfa;
   background: #fafbfa;
@@ -1676,7 +1853,7 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
 
 
 .point-card {
 .point-card {
   display: grid;
   display: grid;
-  grid-template-columns: 20px minmax(0, 1fr) 23px;
+  grid-template-columns: 20px minmax(0, 1fr) auto;
   align-items: start;
   align-items: start;
   gap: 8px;
   gap: 8px;
   padding: 9px 7px;
   padding: 9px 7px;
@@ -1719,7 +1896,14 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
   font-weight: 800;
   font-weight: 800;
 }
 }
 
 
-.point-card button {
+.point-actions {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  gap: 5px;
+}
+
+.point-card .point-copy {
   width: 22px;
   width: 22px;
   height: 22px;
   height: 22px;
   display: grid;
   display: grid;
@@ -1732,6 +1916,19 @@ function detailUrlFor(video: VideoDiscoveryVideo): string {
   cursor: pointer;
   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,
 .page-state,
 .inline-state,
 .inline-state,
 .empty-state {
 .empty-state {