Task Survey 答案重複寫入 Bug 修復 — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 消除 task_survey 模組的答案重複寫入 bug,把多人協作 stale data 問題一併修掉,並補髒資料清理與 DB unique constraint 護欄。

Architecture:

  • Phase 1(止血):service 層改用 (task_survey_id, question_id) natural key 做 upsert;GET 加 SQL DISTINCT ON 去重;DB 加 UNIQUE constraint,先寫 dedup migration 留 updated_at 最新一筆。
  • Phase 2(協作一致性):新增 patch_task_survey_answer 單題 service + REST POST /project-survey/answers/<uid>/patch;socket on_update 改走單題 patch、移除 Redis fill_survey:<room>:updateson_join snapshot 改讀 DB。
  • Phase 3(效能):update_task_survey_answer / revert_question_answer_from_history 預取 + dict map,消除 N+1 SELECT。
  • Phase 4(收尾):跑全套整合測試、寫 changelog、issue 移到 resolved/。

Tech Stack: Flask + Flask-RESTful + SQLAlchemy 2.x(thread-local session via jedi_common)+ Flask-SocketIO + Redis + PostgreSQL(含 RLS)+ pytest(tests/ 目錄為整合測試)。

Reference docs:

  • 對應 issue:docs/issues/pending/2026-04-28-task-survey-answer-duplicate.md
  • Billows 後端 root cause + 修法:/Users/chouraymond/Projects/Billows/NICS/billows-be/docs/fixes/20260422_survey_checkpoint_performance/02_plan.md03_changelog.md
  • Billows 前端 R1/R2 修法:/Users/chouraymond/Projects/Billows/NICS/billows-fe/docs/fixes/20260417_survey_fill_data_loss/03_changelog.md

Conventions(from CLAUDE.md,每個 task 都套用):

  • 服務層 public method 一律 @transaction;不在 helper 內呼叫 get_session()
  • Repo 只 self.session.flush(),不要 commit();commit 由 @transaction 統一收。
  • Route 不查 DB、不 import ORM model;權限/前置條件全在 service 層。
  • 新 error code:放 common/code/error_code.py,命名 TASK_SURVEY_<HTTP><序號>
  • SQL migration:開頭 -- Date: YYYY-MM-DD;每段 -- N. 說明 (YYYY-MM-DD);新表/新權限段補 GRANT(本案改現有表,無新表)。
  • audit fields:created_user / updated_user 是 login_name,response 若有需同時帶 *_name;本次只動寫入路徑,response 不改格式。

§1

File Structure

檔案 動作 責任
scripts/sql/2026-04-28-survey-answer-dedup.sql 新增 髒資料清理 + 加 UNIQUE (task_survey_id, question_id)
app/task_survey/service/question_answer_service.py upsert 改 natural key、GET 去重、新增 patch_task_survey_answer、預取 + map
app/task_survey/service/question_answer_history_service.py revert 改用預取 + map(消 N+1)
infra/task_survey/repository/question_answer_repo_impl.py 新增 get_latest_by_task_survey_id(DISTINCT ON)、upsert_by_natural_key
domain/task_survey/repository/question_answer.py 抽象方法簽名跟著加
domain/task_survey/service/question_answer_domain_service.py wrap 新 repo 方法
app/task_survey/handler/fill_survey_socketio_handler.py on_update 改單題 patch、移除 Redis updates;on_join 從 DB 讀
api/task_survey/routes/question_answer_route.py 新增 TaskSurveyAnswerPatchRoute(POST /project-survey/answers/<uid>/patch
api/task_survey/serializers/question_answer.py 新增 AnswerPatchItemSchema / AnswerPatchRequestSchema
api/task_survey/__init__.py 註冊新 patch route
common/code/error_code.py 新 error code(patch question_uid not found 等)
tests/test_task_survey_answer.py 新增 整合測試覆蓋 PUT / patch / socket 三條路徑
docs/changelog/2026-04-28-fix-task-survey-answer-duplicate-phase1.md 新增 Phase 1 changelog
docs/changelog/2026-04-28-fix-task-survey-answer-collab-phase2.md 新增 Phase 2 changelog
docs/changelog/2026-04-28-tweak-task-survey-answer-perf-phase3.md 新增 Phase 3 changelog
docs/issues/resolved/2026-04-28-task-survey-answer-duplicate.md rename Phase 4 收尾

Phase 1 — 止血:natural key upsert + DB unique constraint

Outcome:

  • PUT / socket 任何 payload(帶不帶 uid)都不會再產生重複 row。
  • DB 一筆 task_survey_id + question_id 一定唯一(unique constraint 保證)。
  • GET 看到的永遠是最新版本(DISTINCT ON)。
  • 髒資料清理乾淨。
§2

Task 1.1:寫整合測試骨架(先 RED)

Files:

  • Create: tests/test_task_survey_answer.py

前置確認: tests/conftest.py 有現成 app/client/auth headers fixture。複製 tests/test_grc_*.py 結構即可。

ls tests/conftest.py tests/helpers/ tests/test_grc_*.py | head

確認 auth_headersclientsession fixture 名稱。

# tests/test_task_survey_answer.py
import pytest

API = "/api/1.0/project-survey/answers"

@pytest.fixture
def seeded_task_survey(client, auth_headers):
    """建立一張 task_survey,回傳 uid 與題目清單。
    依 tests/helpers 內既有 seeding util;若無,直接呼 GRC 啟動專案的 fixture。
    """
    raise NotImplementedError("接 helper")  # 第一輪先讓 collection 就好


def test_put_twice_does_not_duplicate_rows(client, auth_headers, seeded_task_survey, db_session):
    ts_uid = seeded_task_survey["uid"]
    q_uid_1 = seeded_task_survey["question_uids"][0]

    payload_1 = {
        "status": 1,
        "answers": {q_uid_1: {"answer": "v1", "score": 0}},
    }
    r1 = client.put(f"{API}/{ts_uid}", json=payload_1, headers=auth_headers)
    assert r1.status_code == 200

    payload_2 = {
        "status": 1,
        "answers": {q_uid_1: {"answer": "v2", "score": 0}},  # 故意不帶 uid
    }
    r2 = client.put(f"{API}/{ts_uid}", json=payload_2, headers=auth_headers)
    assert r2.status_code == 200

    cnt = db_session.execute(
        "SELECT count(*) FROM survey.question_answers "
        "WHERE task_survey_id = :tid",
        {"tid": seeded_task_survey["id"]},
    ).scalar()
    assert cnt == 1, f"期望 1 row,實際 {cnt}(duplicate bug)"
pytest tests/test_task_survey_answer.py::test_put_twice_does_not_duplicate_rows -v

預期:AssertionError: 期望 1 row,實際 2 或 fixture 未實作。先 commit 失敗的測試骨架。

tests/test_grc_job.py / tests/helpers/ 找到「建立含 task_survey 的 project」最短路徑;複用,不要重新造輪。回填 seeded_task_survey 內容(包含 uididquestion_uids list)。

pytest tests/test_task_survey_answer.py::test_put_twice_does_not_duplicate_rows -v

預期:AssertionError: 期望 1 row,實際 2(看到 bug 確實重現)

git add tests/test_task_survey_answer.py
git commit -m "test(task-survey): add failing test for answer duplicate bug"

§3

Task 1.2:service 層改 natural key upsert(GREEN test 1)

Files:

  • Modify: app/task_survey/service/question_answer_service.py:49-136

關鍵差異:

  • 不再用 exist_answers_uid_list
  • 預先 existing_by_qid: dict[int, QuestionAnswerEntity] = {a.question_id: a for a in exist_answers},重複時保留 updated_at 最新(migration 後不該有重複,但 service 為防禦也保留 max 邏輯)。
  • answer_list 每筆,先 lookup question_id(透過 survey_question_domain_service.get_questions(survey_id=...) 一次取,建 q_uid_to_id),再去 existing_by_qid 找:找到就 update、找不到就 add。
# 替換 question_answer_service.py:54-94 整段
questions = self.survey_question_domain_service.get_questions(survey_id=task_survey.survey_id)
q_uid_to_id = {q.uid: q.id for q in questions}

exist_answers = self.question_answer_domain_service.get_question_answers(
    QuestionAnswerQueryEntity(task_survey_id=task_survey.id)
)
existing_by_qid: dict[int, QuestionAnswerEntity] = {}
for a in exist_answers:
    cur = existing_by_qid.get(a.question_id)
    if cur is None or (a.updated_at and (cur.updated_at is None or a.updated_at > cur.updated_at)):
        existing_by_qid[a.question_id] = a

res_answer_list = []
for question_uid, answer in answers.items():
    question_id = q_uid_to_id.get(question_uid)
    if question_id is None:
        # 整張 survey 沒這題 — 不應該發生,跳過避免抽風
        continue

    payload_answer = answer.get("answer")
    payload_ext_answer = answer.get("ext_answer")
    payload_feedback = answer.get("feedback", "")
    payload_ext_feedback = answer.get("ext_feedback", "")
    payload_score = answer.get("score", 0)

    existing = existing_by_qid.get(question_id)
    if existing is not None:
        existing.score = payload_score
        existing.answer = payload_answer
        existing.ext_answer = payload_ext_answer
        existing.feedback = payload_feedback
        existing.ext_feedback = payload_ext_feedback
        existing.is_delete = 0
        existing.updated_user = user
        self.question_answer_domain_service.update_question_answer(existing)
        res_answer_list.append(existing)
    else:
        new_qa = QuestionAnswerEntity(
            score=payload_score,
            answer=payload_answer,
            ext_answer=payload_ext_answer,
            feedback=payload_feedback,
            ext_feedback=payload_ext_feedback,
            is_delete=0,
            question_id=question_id,
            survey_id=task_survey.survey_id,
            task_survey_id=task_survey.id,
            created_user=user,
            updated_user=user,
        )
        new_qa = self.question_answer_domain_service.add_question_answer(new_qa)
        existing_by_qid[question_id] = new_qa  # 防止同次 payload 同題重複造重複
        res_answer_list.append(new_qa)

注意:

  • 移除 answer["question_uid"] = question_uid 那段、移除 exist_answers_uid_list 變數、移除 new_answers filter。
  • 保留 is_deleteupdate_user(注意舊 code 是 update_user 而非 updated_user,看 entity 欄位名統一)。
pytest tests/test_task_survey_answer.py::test_put_twice_does_not_duplicate_rows -v

預期:PASS。

def test_put_twice_updates_existing_value(client, auth_headers, seeded_task_survey, db_session):
    ts_uid = seeded_task_survey["uid"]
    q_uid = seeded_task_survey["question_uids"][0]

    client.put(f"{API}/{ts_uid}", json={"status": 1, "answers": {q_uid: {"answer": "v1"}}}, headers=auth_headers)
    client.put(f"{API}/{ts_uid}", json={"status": 1, "answers": {q_uid: {"answer": "v2"}}}, headers=auth_headers)

    rows = db_session.execute(
        "SELECT answer FROM survey.question_answers "
        "WHERE task_survey_id = :tid AND question_id IN "
        "  (SELECT id FROM survey.survey_questions WHERE uid = :quid)",
        {"tid": seeded_task_survey["id"], "quid": q_uid},
    ).fetchall()
    assert len(rows) == 1
    assert rows[0][0] == "v2"
pytest tests/test_task_survey_answer.py -v
git add app/task_survey/service/question_answer_service.py tests/test_task_survey_answer.py
git commit -m "fix(task-survey): use natural key upsert in update_task_survey_answer

對應 docs/issues/pending/2026-04-28-task-survey-answer-duplicate.md 根因 §3.1。
不再用 row uid 做 new vs existing 判斷,改用 (task_survey_id, question_id)。"

§4

Task 1.3:GET 加 SQL 去重(DISTINCT ON)

Files:

  • Modify: infra/task_survey/repository/question_answer_repo_impl.py
  • Modify: domain/task_survey/repository/question_answer.py
  • Modify: domain/task_survey/service/question_answer_domain_service.py
  • Modify: app/task_survey/service/question_answer_service.py:35-46
def test_get_dedup_when_dirty_data_exists(client, auth_headers, seeded_task_survey, db_session):
    ts_id = seeded_task_survey["id"]
    q_id = seeded_task_survey["question_id_for"](seeded_task_survey["question_uids"][0])

    # 手插兩筆同 (task_survey_id, question_id),模擬髒資料(migration 前狀態)
    # 注意:Phase 1.5 加 unique constraint 之後就插不進去;此測試在 1.5 完成後改用 raw SQL with ON CONFLICT DO NOTHING bypass,或拆成「constraint 還沒 enforce 前」的版本
    db_session.execute(
        "INSERT INTO survey.question_answers (task_survey_id, question_id, answer, created_user, updated_user, updated_at) "
        "VALUES (:tid, :qid, '\"old\"', 'seed', 'seed', NOW() - INTERVAL '1 hour')",
        {"tid": ts_id, "qid": q_id},
    )
    db_session.execute(
        "INSERT INTO survey.question_answers (task_survey_id, question_id, answer, created_user, updated_user, updated_at) "
        "VALUES (:tid, :qid, '\"new\"', 'seed', 'seed', NOW())",
        {"tid": ts_id, "qid": q_id},
    )
    db_session.commit()

    r = client.get(f"{API}/{seeded_task_survey['uid']}", headers=auth_headers)
    assert r.status_code == 200
    body = r.get_json()["data"]
    q_uid = seeded_task_survey["question_uids"][0]
    assert body[q_uid]["answer"] == "new"

註:此測試與 Task 1.5 add unique constraint 衝突 — 在 Task 1.5 之後改用 staging table 或 marker 來 bypass。先寫失敗版,1.5 時再調整。

pytest tests/test_task_survey_answer.py::test_get_dedup_when_dirty_data_exists -v

預期:FAIL — 目前 service GET 不去重。

# infra/task_survey/repository/question_answer_repo_impl.py
from sqlalchemy import desc

class QuestionAnswerRepoImpl(...):
    ...
    def get_latest_by_task_survey_id(self, task_survey_id: int) -> list[QuestionAnswerEntity]:
        """以 (question_id) 為 partition、updated_at desc 取每組最新一筆。
        即使 DB 還有重複髒資料也能回對的版本。"""
        from sqlalchemy import select, func, text
        # PostgreSQL DISTINCT ON
        stmt = (
            select(self.model)
            .where(self.model.task_survey_id == task_survey_id)
            .order_by(self.model.question_id, desc(self.model.updated_at), desc(self.model.id))
            .distinct(self.model.question_id)
        )
        rows = self.session.execute(stmt).scalars().all()
        return self.mapper.to_list_entity(rows)
# domain/task_survey/repository/question_answer.py
@abstractmethod
def get_latest_by_task_survey_id(self, task_survey_id: int) -> list[QuestionAnswerEntity]: ...
# domain/task_survey/service/question_answer_domain_service.py
def get_latest_question_answers(self, task_survey_id: int) -> list[QuestionAnswerEntity]:
    return self.question_answer_repo.get_latest_by_task_survey_id(task_survey_id)
# app/task_survey/service/question_answer_service.py:40
res = self.question_answer_domain_service.get_latest_question_answers(task_survey.id)
res = QuestionAnswerResponseDTO.from_entity_list(res)
ans = {q.question_uid: q for q in res}
return ans
pytest tests/test_task_survey_answer.py -v
git add infra/ domain/ app/task_survey/service/question_answer_service.py tests/
git commit -m "fix(task-survey): use DISTINCT ON for GET to ignore duplicate rows"

§5

Task 1.4:寫 dedup migration

Files:

  • Create: scripts/sql/2026-04-28-survey-answer-dedup.sql
-- Date: 2026-04-28
-- Task Survey 答案表去重 + 加 UNIQUE constraint
-- 對應 issue: docs/issues/pending/2026-04-28-task-survey-answer-duplicate.md
-- 對應 plan : docs/features/FR-021-2604-survey-answer-duplicate-fix/implementation-plan.md (Phase 1.4)

-- 1. 備份重複資料以利稽核(保留 7 天再 drop) (2026-04-28)
CREATE TABLE IF NOT EXISTS survey.question_answers_dedup_backup_20260428 AS
SELECT * FROM survey.question_answers WHERE 1=0;

-- 2. 把要刪除的(同 task_survey_id+question_id 內非最新)寫進備份 (2026-04-28)
WITH ranked AS (
    SELECT id,
           ROW_NUMBER() OVER (
               PARTITION BY task_survey_id, question_id
               ORDER BY updated_at DESC NULLS LAST, id DESC
           ) AS rn
    FROM survey.question_answers
)
INSERT INTO survey.question_answers_dedup_backup_20260428
SELECT qa.* FROM survey.question_answers qa
JOIN ranked r ON r.id = qa.id
WHERE r.rn > 1;

-- 3. 刪除非最新的重複 row (2026-04-28)
WITH ranked AS (
    SELECT id,
           ROW_NUMBER() OVER (
               PARTITION BY task_survey_id, question_id
               ORDER BY updated_at DESC NULLS LAST, id DESC
           ) AS rn
    FROM survey.question_answers
)
DELETE FROM survey.question_answers qa
USING ranked r
WHERE qa.id = r.id AND r.rn > 1;

-- 4. 加 UNIQUE constraint (2026-04-28)
ALTER TABLE survey.question_answers
    ADD CONSTRAINT uq_question_answers_task_survey_question
    UNIQUE (task_survey_id, question_id);

-- 5. 驗證:每組 (task_survey_id, question_id) 必須剛好 1 筆 (2026-04-28)
DO $$
DECLARE
    dup_count integer;
BEGIN
    SELECT COUNT(*) INTO dup_count FROM (
        SELECT 1 FROM survey.question_answers
        GROUP BY task_survey_id, question_id
        HAVING COUNT(*) > 1
    ) g;
    IF dup_count > 0 THEN
        RAISE EXCEPTION 'Dedup failed: % duplicate groups remain', dup_count;
    END IF;
END $$;

註:本 migration 改現有表,不新增 table,無需 GRANT(原權限延續)。

PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg \
    -f scripts/sql/2026-04-28-survey-answer-dedup.sql

預期:所有指令成功,最後 DO block 不拋例外。

PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg -c "
SELECT
  (SELECT COUNT(*) FROM survey.question_answers) AS total_rows,
  (SELECT COUNT(*) FROM (SELECT 1 FROM survey.question_answers GROUP BY task_survey_id, question_id HAVING COUNT(*) > 1) g) AS duplicate_pairs,
  (SELECT COUNT(*) FROM survey.question_answers_dedup_backup_20260428) AS backup_rows;
"

預期:duplicate_pairs = 0;total_rows = 602(distinct 數);backup_rows = 1736(原 2338 - 602)。

加 unique constraint 後,原本手插重複的方式會被擋。改成:

# 改用 ON CONFLICT DO NOTHING + 暫時 disable trigger 是過度。
# 最簡單:把這個 case 改用 raw INSERT bypass constraint 是不可能的。
# 改測試方向:「即使透過 service 多次寫入,GET 也只回最新版本」,這已經被 test_put_twice_updates_existing_value 涵蓋。
# 結論:刪除 test_get_dedup_when_dirty_data_exists,改保留 test_put_twice_updates_existing_value 即可。

刪除 test_get_dedup_when_dirty_data_exists,理由寫進測試檔註解。

pytest tests/test_task_survey_answer.py -v
git add scripts/sql/2026-04-28-survey-answer-dedup.sql tests/test_task_survey_answer.py
git commit -m "fix(task-survey): dedup migration + UNIQUE(task_survey_id, question_id)

dev DB 跑後 2338→602 rows,1736 重複 row 進備份表 question_answers_dedup_backup_20260428。"

§6

Task 1.5:補 socket 路徑當前的 PUT 行為測試

Files:

  • Modify: tests/test_task_survey_answer.py

socket on_update 目前還是走 update_task_survey_answer(Phase 2 才會改成 patch);先補測試保證 Phase 1 邏輯改完後 socket 也不會生重複。

def test_socket_full_data_path_does_not_duplicate(client, auth_headers, seeded_task_survey, db_session, container):
    """socket on_update 內部呼叫 update_task_survey_answer(room, None, full_data, user),
    多次呼叫不應產生重複 row(Phase 1 service 修完後)。"""
    qa_service = container.question_answer_container.question_answer_service()
    ts_uid = seeded_task_survey["uid"]
    q_uid = seeded_task_survey["question_uids"][0]

    full_data_1 = {q_uid: {"answer": "via socket v1"}}
    qa_service.update_task_survey_answer(ts_uid, None, full_data_1, "test-user")

    full_data_2 = {q_uid: {"answer": "via socket v2"}}
    qa_service.update_task_survey_answer(ts_uid, None, full_data_2, "test-user")

    cnt = db_session.execute(
        "SELECT count(*) FROM survey.question_answers "
        "WHERE task_survey_id = :tid",
        {"tid": seeded_task_survey["id"]},
    ).scalar()
    assert cnt == 1
pytest tests/test_task_survey_answer.py -v
git add tests/test_task_survey_answer.py
git commit -m "test(task-survey): cover socket on_update path against duplicate"

§7

Task 1.6:寫 Phase 1 changelog

Files:

  • Create: docs/changelog/2026-04-28-fix-task-survey-answer-duplicate-phase1.md
---
type: fix
modules: [task_survey]
issue: docs/issues/pending/2026-04-28-task-survey-answer-duplicate.md
commit: <填 phase1 最後一筆 commit hash>
---

# fix(task-survey): 答案重複寫入 bug Phase 1(natural key upsert + DB 去重)

## 為什麼

`POST /project-survey/answers/<uid>` PUT 與 socket on_update 共用的 `update_task_survey_answer` 用 row uid 判斷新舊,payload 沒帶 uid(或對不上)就 INSERT,造成同 (task_survey_id, question_id) 多筆 row。dev DB 累積 1736 筆重複(71%)。對應 Billows NICS 已修過的 pattern A,跨專案根因相同。

## 變更

- `app/task_survey/service/question_answer_service.py`
  - `update_task_survey_answer` 改用 `(task_survey_id, question_id)` natural key upsert
  - `get_answer_list_by_task_survey_uid` 改用新 repo method `get_latest_question_answers` 走 SQL DISTINCT ON 去重
- `infra/task_survey/repository/question_answer_repo_impl.py` 新增 `get_latest_by_task_survey_id`
- `domain/task_survey/repository/question_answer.py` 新增抽象方法
- `domain/task_survey/service/question_answer_domain_service.py` wrap
- `scripts/sql/2026-04-28-survey-answer-dedup.sql`
  - 把同組非最新 row 寫進備份表 `survey.question_answers_dedup_backup_20260428`
  - 刪除這些 row
  - `UNIQUE (task_survey_id, question_id)` constraint
  - DO block 驗證跑完後沒有重複組

## API 變更

無外部合約變動。GET 回傳的 dict 結構不變、PUT 接收的 payload 結構不變。

## 測試結果

$ pytest tests/test_task_survey_answer.py -v ... 3 passed ...


dev DB 驗證:
- 跑 migration 前:2338 rows / 602 distinct pairs / 430 重複組
- 跑 migration 後:602 rows / 0 重複組 / backup table 1736 rows

## 參考

- 對應 issue:`docs/issues/pending/2026-04-28-task-survey-answer-duplicate.md`
- 對應 plan:`docs/features/FR-021-2604-survey-answer-duplicate-fix/implementation-plan.md`(Phase 1)
- Billows NICS 對照:`/Users/chouraymond/Projects/Billows/NICS/billows-be/docs/fixes/20260422_survey_checkpoint_performance/`
git add docs/changelog/2026-04-28-fix-task-survey-answer-duplicate-phase1.md
git commit -m "docs(changelog): task-survey answer duplicate fix phase 1"

Phase 2 — 多人協作一致性:socket 單題 patch + on_join 從 DB 讀

Outcome:

  • socket on_update 不再寫整份 full_data,改成只寫被改的那題。
  • on_join snapshot 從 DB 讀,移除 Redis fill_survey:<room>:updates
  • 新增 REST POST /project-survey/answers/<uid>/patch,前端 fetch keepalive 可用來在關分頁前 flush dirty 欄位。
  • 寫 history 的時機與舊版一致(暫存/提交時,不在每次 socket update 寫)。
§8

Task 2.1:service 新增 patch_task_survey_answer

Files:

  • Modify: app/task_survey/service/question_answer_service.py
def test_patch_single_question_upserts_one_row(client, auth_headers, seeded_task_survey, db_session, container):
    qa_service = container.question_answer_container.question_answer_service()
    ts_uid = seeded_task_survey["uid"]
    q_uid = seeded_task_survey["question_uids"][0]

    qa_service.patch_task_survey_answer(ts_uid, q_uid, {"answer": "patched v1"}, "test-user")
    qa_service.patch_task_survey_answer(ts_uid, q_uid, {"answer": "patched v2"}, "test-user")

    rows = db_session.execute(
        "SELECT answer FROM survey.question_answers "
        "WHERE task_survey_id = :tid",
        {"tid": seeded_task_survey["id"]},
    ).fetchall()
    assert len(rows) == 1
    assert rows[0][0] == "patched v2"
pytest tests/test_task_survey_answer.py::test_patch_single_question_upserts_one_row -v

預期:AttributeError: 'QuestionAnswerService' object has no attribute 'patch_task_survey_answer'

# app/task_survey/service/question_answer_service.py
@transaction
def patch_task_survey_answer(
    self,
    task_survey_uid: str,
    question_uid: str,
    answer_patch: dict,
    user: str,
) -> QuestionAnswerEntity:
    """單題 upsert。供 socket on_update 與 REST patch endpoint 使用。

    與 update_task_survey_answer 不同:
    - 不寫 history(history 只在 PUT 暫存/提交時寫)
    - 不變更 task_survey.status(status 由暫存/提交按鈕掌控)
    - 只動一題,N+1 也不存在
    """
    task_survey = self.task_survey_domain_service.get_task_survey_by_uid(task_survey_uid)
    if not task_survey:
        raise NotFound(ErrorCode.TASK_SURVEY_NOT_FOUND)

    question = next(
        (q for q in self.survey_question_domain_service.get_questions(survey_id=task_survey.survey_id)
         if q.uid == question_uid),
        None,
    )
    if question is None:
        raise NotFound(ErrorCode.TASK_SURVEY_QUESTION_NOT_FOUND)

    existing = self.question_answer_domain_service.get_question_answer(
        QuestionAnswerQueryEntity(task_survey_id=task_survey.id, question_id=question.id)
    )

    if existing is not None:
        existing.answer = answer_patch.get("answer", existing.answer)
        existing.ext_answer = answer_patch.get("ext_answer", existing.ext_answer)
        existing.feedback = answer_patch.get("feedback", existing.feedback)
        existing.ext_feedback = answer_patch.get("ext_feedback", existing.ext_feedback)
        existing.score = answer_patch.get("score", existing.score)
        existing.is_delete = 0
        existing.updated_user = user
        return self.question_answer_domain_service.update_question_answer(existing)

    new_qa = QuestionAnswerEntity(
        score=answer_patch.get("score", 0),
        answer=answer_patch.get("answer"),
        ext_answer=answer_patch.get("ext_answer"),
        feedback=answer_patch.get("feedback", ""),
        ext_feedback=answer_patch.get("ext_feedback", ""),
        is_delete=0,
        question_id=question.id,
        survey_id=task_survey.survey_id,
        task_survey_id=task_survey.id,
        created_user=user,
        updated_user=user,
    )
    return self.question_answer_domain_service.add_question_answer(new_qa)
# common/code/error_code.py
TASK_SURVEY_QUESTION_NOT_FOUND = ("問卷題目不存在", "TASK_SURVEY_404006")
pytest tests/test_task_survey_answer.py::test_patch_single_question_upserts_one_row -v
git add app/task_survey/service/question_answer_service.py common/code/error_code.py tests/test_task_survey_answer.py
git commit -m "feat(task-survey): add patch_task_survey_answer for single-question upsert"

§9

Task 2.2:新增 REST POST /project-survey/answers/<uid>/patch

Files:

  • Create: api/task_survey/serializers/question_answer_patch.py
  • Modify: api/task_survey/routes/question_answer_route.py
  • Modify: api/task_survey/__init__.py
def test_post_answers_patch_endpoint(client, auth_headers, seeded_task_survey, db_session):
    ts_uid = seeded_task_survey["uid"]
    q_uid = seeded_task_survey["question_uids"][0]

    payload = {"patches": [{"question_uid": q_uid, "answer": {"answer": "rest-patch"}}]}
    r = client.post(f"{API}/{ts_uid}/patch", json=payload, headers=auth_headers)
    assert r.status_code == 200

    cnt = db_session.execute(
        "SELECT count(*) FROM survey.question_answers "
        "WHERE task_survey_id = :tid",
        {"tid": seeded_task_survey["id"]},
    ).scalar()
    assert cnt == 1

預期:404 或 405。

# api/task_survey/serializers/question_answer_patch.py
from marshmallow import Schema, fields

class _PatchAnswerInner(Schema):
    answer = fields.Raw(required=False, allow_none=True)
    ext_answer = fields.Raw(required=False, allow_none=True)
    feedback = fields.Raw(required=False, allow_none=True)
    ext_feedback = fields.Raw(required=False, allow_none=True)
    score = fields.Integer(required=False)


class PatchItemSchema(Schema):
    question_uid = fields.String(required=True)
    answer = fields.Nested(_PatchAnswerInner, required=True)


class PatchRequestSchema(Schema):
    patches = fields.List(fields.Nested(PatchItemSchema), required=True)
# api/task_survey/routes/question_answer_route.py 新增
class TaskSurveyAnswerPatchRoute(MethodResource):
    @doc(description='批次單題 patch(供 socket / keepalive flush 用)',
         tags=['Main Project Survey Answer'], params=AUTH_PARAMS)
    @jwt_required()
    def post(self, uid,
             question_answer_service: QuestionAnswerService = Provide[
                 Containers.question_answer_container.question_answer_service]):
        user = get_user_context().login_name
        payload = request.get_json(silent=True) or {}
        patches = payload.get("patches", [])
        for patch in patches:
            question_answer_service.patch_task_survey_answer(
                uid, patch["question_uid"], patch.get("answer", {}), user
            )
        return return_response(True, {"applied": len(patches)})
# api/task_survey/__init__.py 在 TaskSurveysAnswersRoute 之後
from api.task_survey.routes.question_answer_route import TaskSurveyAnswerPatchRoute
api.add_resource(TaskSurveyAnswerPatchRoute, '/project-survey/answers/<string:uid>/patch')
pytest tests/test_task_survey_answer.py::test_post_answers_patch_endpoint -v
git add api/task_survey/
git commit -m "feat(task-survey): add POST /project-survey/answers/<uid>/patch"

§10

Task 2.3:socket on_update 改走單題 patch、移除 Redis updates

Files:

  • Modify: app/task_survey/handler/fill_survey_socketio_handler.py
def on_update(self, data):
    """問卷填答案更新(單題 patch)。

    舊版接收 full_data 整份覆蓋 + Redis snapshot,會造成 stale data 與重複寫入。
    新版只動被改的那題,避免多人協作覆蓋彼此編輯。
    """
    user = data.get('user')
    room = data.get('room')
    receive_data = data.get('data') or {}
    question_uid = receive_data.get('question_uid')
    answer_patch = receive_data.get('answer') or {}

    if not question_uid:
        emit('error', {'msg': 'question_uid is required'})
        return

    # 寫 DB(單題 upsert)
    self.question_answer_service.patch_task_survey_answer(room, question_uid, answer_patch, user)

    # broadcast(payload 維持原 shape,前端不需動)
    result = {
        'msg': f' {user} 更新資料',
        'user': user,
        'room': room,
        'uid': receive_data.get('uid'),
        'data': receive_data,
    }
    emit('updated', result, room=room)
def on_join(self, data):
    user = data.get('user')
    room = data.get('room')
    data["event"] = "join"

    redis_cli = RedisClient()
    redis_cli.ping()
    key = f'fill-survey:{room}:users'
    users = redis_cli.get_all_from_list(key)
    if user not in users:
        redis_cli.lpush(key, user)
    users = redis_cli.get_all_from_list(key)

    result = {
        'msg': f'{user} 加入編輯',
        'users': users,
        'user': user,
        'data': None,
    }

    # 多人時,從 DB 拉最新 answer dict(取代舊 Redis snapshot)
    if len(users) > 1:
        result['data'] = self.question_answer_service.get_answer_list_by_task_survey_uid(room)

    join_room(room)
    emit('joined', result, room=room)
def on_leave(self, data):
    user = data.get('user')
    room = data.get('room')
    data["event"] = "leave"

    redis_cli = RedisClient()
    key = f'fill-survey:{room}:users'
    users = redis_cli.get_all_from_list(key)
    if user in users:
        redis_cli.lrem(key, user)
    users = redis_cli.get_all_from_list(key)

    # 不再需要清 fill_survey:<room>:updates(已不寫入)

    leave_room(room)
    emit('left', {'msg': f'{user} 離開編輯', 'users': users, 'user': user}, room=room)
pytest tests/test_task_survey_answer.py -v

跑前端 dev server,兩個分頁開同一張問卷:

  1. A 改第 1 題 → B 看到更新;DB 該題只有 1 row。
  2. B 改第 2 題 → A 看到更新;A 第 1 題的編輯不被 B 覆蓋。
  3. C 後加入 → 看到 A、B 都改完的最新版本。

對照 Billows 04_conversation.md § 7 的驗證清單。

git add app/task_survey/handler/fill_survey_socketio_handler.py
git commit -m "fix(task-survey): socket on_update uses single-question patch, on_join reads from DB

移除 fill_survey:<room>:updates Redis stale snapshot 模式。
對應 Billows R1 / R2 修法。"

§11

Task 2.4:清掉 Redis fill_survey:<room>:updates 殘留 key(可選 cleanup)

Files:

  • Modify: app/task_survey/handler/fill_survey_socketio_handler.py(移除任何剩下的 rpush/get_last_one_from_list 呼叫)
grep -rn "fill_survey:.*:updates" app/ infra/ domain/

預期:0 行。

redis-cli -h 192.168.50.189 KEYS "fill_survey:*:updates" | xargs -I{} redis-cli -h 192.168.50.189 DEL {}

註:dev only;staging/prod 等發版時跑同樣指令。


§12

Task 2.5:寫 Phase 2 changelog

Files:

  • Create: docs/changelog/2026-04-28-fix-task-survey-answer-collab-phase2.md

內容:socket on_update 改單題 patch、on_join 從 DB、新增 patch endpoint、移除 Redis updates;對應 Billows R1/R2。


Phase 3 — 效能:消除 N+1 SELECT

Outcome:

  • update_task_survey_answer 100 題 PUT 從目前的 ~200+ SELECT 降到 ~5 SELECT。
  • revert_question_answer_from_history 100 題 revert 同樣處理。
§13

Task 3.1:Phase 1 後 update 路徑檢視

Phase 1 的 service 改寫已經建了 existing_by_qid map,主要 N+1 已消。但 question_answer_domain_service.update_question_answerverify_question_answer_exist_by_uid 每筆還跑一次 SELECT,不必要。

question_answer_service.update_task_survey_answer 內 update branch 直接呼 self.question_answer_domain_service.question_answer_repo.update(existing)?— 違反 DDD(不能跨 domain service 直接戳 repo)。

正確做法:在 domain service 加 update_question_answer_without_verify(entity),或修改 update_question_answer 改用「entity 已驗證過」的版本。

選比較簡單:

# domain/task_survey/service/question_answer_domain_service.py
def update_question_answer_in_memory(self, entity: QuestionAnswerEntity) -> QuestionAnswerEntity:
    """已 in-memory 確認 entity 來自 DB,不再跑 verify_*。"""
    return self.question_answer_repo.update(entity)

service 內 update branch 改用此 method。

def test_put_100_questions_under_3s(client, auth_headers, seeded_100q_task_survey):
    import time
    ts_uid = seeded_100q_task_survey["uid"]
    answers = {q_uid: {"answer": f"v_{i}"} for i, q_uid in enumerate(seeded_100q_task_survey["question_uids"])}

    t0 = time.time()
    r = client.put(f"{API}/{ts_uid}", json={"status": 1, "answers": answers}, headers=auth_headers)
    elapsed = time.time() - t0

    assert r.status_code == 200
    assert elapsed < 3.0, f"100 題 PUT 跑 {elapsed:.2f}s,超過 3s 上限"

§14

Task 3.2:revert 路徑同樣處理

Files:

  • Modify: app/task_survey/service/question_answer_history_service.py
@transaction
def revert_question_answer_from_history(self, task_survey_uid, history_uid, user) -> bool:
    task_survey = self.task_survey_domain_service.get_task_survey_by_uid(task_survey_uid)
    answer_history = self.question_answer_history_domain_service.get_question_answer_history(history_uid)
    history_answers = self.question_answer_history_detail_domain_service.get_history_details(
        QuestionAnswerHistoryDetailQueryEntity(history_id=answer_history.id)
    )

    # 預取一次當前 answers,建 map
    current_answers = self.question_answer_domain_service.get_question_answers(
        QuestionAnswerQueryEntity(task_survey_id=task_survey.id)
    )
    current_by_qid = {a.question_id: a for a in current_answers}

    history_detail_entity_list = []
    for answer in history_answers:
        question_answer = current_by_qid.get(answer.question_id)
        if question_answer is None:
            continue  # 該題已被刪 — skip

        question_answer.score = answer.score
        question_answer.answer = answer.answer
        question_answer.ext_answer = answer.ext_answer
        question_answer.feedback = answer.feedback
        question_answer.ext_feedback = answer.ext_feedback
        question_answer.updated_user = user
        self.question_answer_domain_service.update_question_answer_in_memory(question_answer)

        history_detail_entity_list.append(
            QuestionAnswerHistoryDetailEntity(
                task_survey_id=task_survey.id,
                question_id=answer.question_id,
                score=answer.score,
                answer=answer.answer,
                ext_answer=answer.ext_answer,
                feedback=answer.feedback,
                ext_feedback=answer.ext_feedback,
                updated_user=user,
                created_user=user,
            )
        )

    history_entity = QuestionAnswerHistoryEntity(
        task_survey_id=task_survey.id,
        type="R",
        updated_user=user,
        created_user=user,
    )
    self.question_answer_history_domain_service.create_history_with_details(
        history_entity, history_detail_entity_list
    )
    return True
def test_revert_100_questions_under_3s(...):  # 同上 pattern

§15

Task 3.3:Phase 3 changelog

Files:

  • Create: docs/changelog/2026-04-28-tweak-task-survey-answer-perf-phase3.md

Phase 4 — 收尾

§16

Task 4.1:跑全套 task_survey 測試

pytest tests/test_task_survey_answer.py -v
pytest tests/  # 確認沒打到別處

預期全綠。

PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg -c "
SELECT COUNT(*) FROM (
    SELECT 1 FROM survey.question_answers
    GROUP BY task_survey_id, question_id HAVING COUNT(*) > 1
) g;
"

預期:0。

§17

Task 4.2:把 issue 從 pending 移到 resolved

## ⓪ Resolution

| 項目 | 內容 |
|------|------|
| 修復日期 | 2026-04-28 |
| Commits | <Phase1>, <Phase2>, <Phase3> short hashes |
| Changelogs | docs/changelog/2026-04-28-fix-task-survey-answer-duplicate-phase1.md / phase2 / phase3 |
| Follow-up | 前端是否帶 row uid 不是 root cause(後端已防禦),但建議讓 fe 補上 dirty tracking + keepalive flush 對應 Billows fe 修法 |
git mv docs/issues/pending/2026-04-28-task-survey-answer-duplicate.md \
       docs/issues/resolved/2026-04-28-task-survey-answer-duplicate.md
git commit -m "docs(issue): mark task-survey answer duplicate fix resolved"
§18

Task 4.3:staging / prod 上線清單給使用者

寫在最終回覆裡(不進 repo):

  1. Staging / prod 都需要跑 scripts/sql/2026-04-28-survey-answer-dedup.sql,跑前先在 staging 確認 backup table 行為。
  2. 上線後跑 Redis cleanup:redis-cli KEYS "fill_survey:*:updates" | xargs -I{} redis-cli DEL {}
  3. 通知前端團隊新增的 POST /project-survey/answers/<uid>/patch 可在 unmount/beforeunload/pagehide 用 fetch({keepalive: true}) flush dirty 欄位(對應 Billows fe 03_changelog § 5)。

§19

執行順序建議

Phase 風險 建議獨立 PR / 連 PR
1 低 — 純內部邏輯 + DB constraint 獨立 PR、上 staging 驗
2 中 — 改 socket 行為,多人協作行為變動 獨立 PR、上 staging 兩瀏覽器驗
3 低 — 純效能,沒行為變動 可併 Phase 2 PR
4 低 — 文件 併最後一個 PR

§20

風險評估

面向 風險 緩解
Phase 1 unique constraint 加 constraint 失敗(dedup 沒清乾淨) migration 內 DO block 自動驗證、跑前 backup 表保留 7 天
Phase 2 socket 行為變動 前端依賴 full_data 接收格式 broadcast 仍 emit updated,payload shape 不變;多人測試覆蓋
Phase 2 移除 Redis updates 既有 prod 環境 Redis 還有舊 key 上線後跑 cleanup 指令;不影響功能(沒有 reader)
Phase 3 update_in_memory 跳過 verify 可能寫入已被 hard-delete 的 row 接 entity 來自當下 transaction 內預取,無此 race
前端未配合 前端仍送 PUT full payload 後端 PUT 路徑(natural key upsert)已防禦;前端 patch endpoint 是優化非必要