{answer.content}
{answer.is_mine && answer.remaining_edits === 0 &&本题修改机会已用完
}{answer.is_mine && editorOpen &&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 ? '编辑我的解答' : '我的解答'}
{error}
} {editorOpen && !myAnswer &&社区解答,未经专业验证。问题赞表达共性需求,答案赞决定默认获奖者。
+社区解答,未经专业验证。问题赞表达共性需求,答案赞决定默认获奖者。
{challenge.my_reward_status &&{answer.content}
{answer.is_mine && answer.remaining_edits === 0 &&本题修改机会已用完
}{answer.is_mine && editorOpen &&