diff --git a/backend/app/modules/admin/services/knowledge_challenge.py b/backend/app/modules/admin/services/knowledge_challenge.py index 7423efbf..bc4cc376 100644 --- a/backend/app/modules/admin/services/knowledge_challenge.py +++ b/backend/app/modules/admin/services/knowledge_challenge.py @@ -271,12 +271,16 @@ async def detail(db, actor, challenge_id): KnowledgeChallengeAnswer.challenge_id == challenge.id, KnowledgeChallengeAnswer.submitted_at.is_not(None), KnowledgeChallengeAnswer.hidden.is_(False) ).order_by(KnowledgeChallengeAnswer.id))).scalars().all() + author_ids = {answer.author_id for answer in answers} + authors = {user.id: user for user in (await db.execute( + select(UserDB).where(UserDB.id.in_(author_ids)))).scalars().all()} frozen = {entry["id"]: entry for entry in (challenge.snapshot or {}).get("answers", [])} items = [] - for position, answer in enumerate(answers): + for answer in answers: + author = authors.get(answer.author_id) entry = frozen.get(answer.id) liked = await db.get(KnowledgeChallengeLike, ("answer", answer.id, user_id)) - items.append({"id": answer.id, "author": f"匿名解答者{position + 1}", + items.append({"id": answer.id, "author": (author.name or author.username) if author else "未知用户", "content": entry["content"] if entry else answer.content, "like_count": entry["likes"] if entry else await answer_votes(db, answer.id), "submitted_at": knowledge.iso(answer.submitted_at), "is_mine": answer.author_id == user_id, diff --git a/backend/tests/test_knowledge_challenge.py b/backend/tests/test_knowledge_challenge.py index 8c0bfe34..56daa3cd 100644 --- a/backend/tests/test_knowledge_challenge.py +++ b/backend/tests/test_knowledge_challenge.py @@ -13,6 +13,7 @@ from app.core.auth_routes import get_current_active_user_from_token from app.core.database import get_async_db +from app.models.identity import UserDB from app.models.knowledge_challenge import ( KnowledgeChallenge, KnowledgeChallengeAnswer, KnowledgeChallengeAudit, KnowledgeChallengeLike, ) @@ -269,16 +270,64 @@ async def test_pause_still_settles_and_ordinary_users_cannot_manage(db, monkeypa @pytest.mark.asyncio -async def test_public_payloads_redact_identity_and_do_not_leak_drafts(db): +async def test_public_payloads_show_author_but_redact_sensitive_content_and_do_not_leak_drafts(db): challenge = await published(db) await submitted(db, challenge["id"], content="提单甲 alice-account 联系13800138000 https://private.example/a") + await service.write_answer(db, actor("bob"), challenge["id"], "未发布的私人草稿", 0) result = await service.detail(db, actor("voter"), challenge["id"]) + assert len(result["answers"]) == 1 + assert result["answers"][0]["author"] == "提单甲" + for secret in ("提单甲", "alice-account", "13800138000", "private.example"): + assert secret not in result["answers"][0]["content"] serialized = json.dumps(result, ensure_ascii=False) - for secret in ("提单甲", "alice-account", "13800138000", "private.example", "author_id", "publisher_id", "questioner_id"): + for secret in ("未发布的私人草稿", "author_id", "publisher_id", "questioner_id"): assert secret not in serialized assert result["can_manage_state"] is False +@pytest.mark.asyncio +@pytest.mark.parametrize("task_id", [None, 1]) +@pytest.mark.parametrize("completed", [False, True]) +async def test_answer_author_is_visible_for_all_readers_and_community_results(db, monkeypatch, task_id, completed): + if task_id is not None: + seed(db, task_id) + challenge = await published(db, task_id=task_id) + await submitted(db, challenge["id"], user_id="bob") + if completed: + after_deadline(monkeypatch) + await service.settle(db, challenge["id"]) + for reader in (actor("bob"), actor("voter"), admin()): + result = await service.detail(db, reader, challenge["id"]) + assert result["answers"][0]["author"] == "接单乙" + assert result["questioner"] == ("匿名提问人" if task_id else "平台发布") + if completed: + assert result["best_answer"]["author"] == "接单乙" + community = await service.community_result(db, reader, challenge["id"]) + assert community["best_answer"]["author"] == "接单乙" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", [None, ""]) +async def test_answer_author_falls_back_to_username_when_name_is_empty(db, name): + challenge = await published(db) + await submitted(db, challenge["id"]) + user = await db.get(UserDB, "alice") + user.name = name + await db.commit() + result = await service.detail(db, actor("voter"), challenge["id"]) + assert result["answers"][0]["author"] == "alice-account" + + +@pytest.mark.asyncio +async def test_answer_stays_readable_when_author_account_is_missing(db): + challenge = await published(db) + await submitted(db, challenge["id"]) + db.session.delete(await db.get(UserDB, "alice")) + await db.commit() + result = await service.detail(db, actor("voter"), challenge["id"]) + assert result["answers"][0]["author"] == "未知用户" + + def test_migration_is_idempotent_and_supports_multiple_independent_questions(): path = Path(__file__).parents[1] / "alembic/versions/e1f4a7b0c326_knowledge_challenges.py" spec = importlib.util.spec_from_file_location("challenge_migration", path) diff --git "a/docs/PRODUCT/\351\253\230\344\273\267\345\200\274\351\227\256\351\242\230\350\201\224\345\220\210\346\224\273\345\235\232\344\270\223\345\214\272\345\256\214\346\225\264\346\226\271\346\241\210.md" "b/docs/PRODUCT/\351\253\230\344\273\267\345\200\274\351\227\256\351\242\230\350\201\224\345\220\210\346\224\273\345\235\232\344\270\223\345\214\272\345\256\214\346\225\264\346\226\271\346\241\210.md" index 8221b311..012688a9 100644 --- "a/docs/PRODUCT/\351\253\230\344\273\267\345\200\274\351\227\256\351\242\230\350\201\224\345\220\210\346\224\273\345\235\232\344\270\223\345\214\272\345\256\214\346\225\264\346\226\271\346\241\210.md" +++ "b/docs/PRODUCT/\351\253\230\344\273\267\345\200\274\351\227\256\351\242\230\350\201\224\345\220\210\346\224\273\345\235\232\344\270\223\345\214\272\345\256\214\346\225\264\346\226\271\346\241\210.md" @@ -1,5 +1,7 @@ # 高价值问题联合攻坚专区完整方案 +> 2026-10-26 需求调整:取消回答者匿名展示。答案列表、最佳解答、管理员选择最佳回答及社区优选结果统一展示回答者的用户显示名(优先 `name`,为空时使用 `username`;账号不存在时显示“未知用户”),历史已发布答案同样适用,不新增迁移。回答前明确提示会展示用户名。问题提问人、发布管理员及正文敏感信息继续按原规则保护,草稿仅本人可见,点赞、评奖、奖励和实名人员榜规则不变。下文关于“解答人持续匿名”“答案使用匿名编号”的原始设计以本次调整为准。 + ## 1. 文档信息与结论 | 项目 | 内容 | diff --git a/docs/business_rules.md b/docs/business_rules.md index ce035a68..21f28540 100644 --- a/docs/business_rules.md +++ b/docs/business_rules.md @@ -199,7 +199,8 @@ ### 7.1 高价值问题独立专区(2026-10-20) -- 平台管理员独立发布问题、悬赏整数分金额及截止时间;工单关联可选,不建占位工单。独立问题公开为“平台发布”,关联问题为匿名提问人,答案题内匿名,发布人不默认获得提问奖。 +- 平台管理员独立发布问题、悬赏整数分金额及截止时间;工单关联可选,不建占位工单。独立问题公开为“平台发布”,关联问题为匿名提问人,发布人不默认获得提问奖。 +- 高价值问题取消回答者匿名机制:答案列表、最佳解答、管理员最佳回答选项和社区优选结果展示回答者用户显示名,优先 `name`、为空时回退 `username`、账号不存在时显示“未知用户”;历史答案同步适用。发布前提示展示用户名,草稿仍仅本人可见,问题相关身份及正文敏感信息继续保护,不改变点赞、奖励及实名人员榜规则。(2026-10-26调整) - 状态及结果管理仅允许原admin身份或全局“超级管理员/管理员”角色;服务端派生 `can_manage_knowledge_challenges`,前端不能通过普通内容审核/财务角色或按钮标记获得管理权。首页高价值问题卡片向全部登录用户展示,公开内容及参与接口仍要求有效登录;开放可见性不等于授予发布、状态变更或最终结果管理权限。 - 到期无内容审核、无专家派审、无全量管理员确认;按有效答案赞降序、首次正式提交时间升序、答案ID升序选出唯一最佳,零赞同样排序。无已提交合规答案则结束且无解答奖,草稿不参评。 - 题赞只决定问题榜热度;普通问题、关联同题、独立问题共用北京时间每日5题额度,取消不返额、跨入口不重复占额。答案另计每日5条,不得自赞;用户行锁序列化额度检查。 diff --git a/frontend/src/pages/admin/KnowledgeChallengeDetail.tsx b/frontend/src/pages/admin/KnowledgeChallengeDetail.tsx index d93ff658..40bc11e1 100644 --- a/frontend/src/pages/admin/KnowledgeChallengeDetail.tsx +++ b/frontend/src/pages/admin/KnowledgeChallengeDetail.tsx @@ -119,7 +119,7 @@ export function ChallengeAnswerEditor({ id, canAnswer, onSubmitted, onCancel, em finally { if (mounted.current) setBusy(false); } } return

{isEditing ? '编辑我的解答' : '我的解答'}

- {ready &&

匿名展示,请勿填写姓名、电话、客户信息或原工单内部链接。每人每天最多点赞5条不同答案,不能给自己的答案点赞。

+ {ready &&

回答将展示你的用户名,请勿填写电话、客户信息或原工单内部链接。每人每天最多点赞5条不同答案,不能给自己的答案点赞。

{editExhausted ? '本题解答已修改过一次,不能再次编辑。' : isEditing ? '每个问题的已发布解答仅可修改一次。提交修改后不能再次编辑;保存草稿或取消编辑不消耗修改次数。' : '首次发布后,每个问题的解答仅可再修改一次,请确认内容后发布。'}