Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions backend/app/modules/admin/services/knowledge_challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 51 additions & 2 deletions backend/tests/test_knowledge_challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/PRODUCT/高价值问题联合攻坚专区完整方案.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# 高价值问题联合攻坚专区完整方案

> 2026-10-26 需求调整:取消回答者匿名展示。答案列表、最佳解答、管理员选择最佳回答及社区优选结果统一展示回答者的用户显示名(优先 `name`,为空时使用 `username`;账号不存在时显示“未知用户”),历史已发布答案同样适用,不新增迁移。回答前明确提示会展示用户名。问题提问人、发布管理员及正文敏感信息继续按原规则保护,草稿仅本人可见,点赞、评奖、奖励和实名人员榜规则不变。下文关于“解答人持续匿名”“答案使用匿名编号”的原始设计以本次调整为准。

## 1. 文档信息与结论

| 项目 | 内容 |
Expand Down
3 changes: 2 additions & 1 deletion docs/business_rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@

### 7.1 高价值问题独立专区(2026-10-20)

- 平台管理员独立发布问题、悬赏整数分金额及截止时间;工单关联可选,不建占位工单。独立问题公开为“平台发布”,关联问题为匿名提问人,答案题内匿名,发布人不默认获得提问奖。
- 平台管理员独立发布问题、悬赏整数分金额及截止时间;工单关联可选,不建占位工单。独立问题公开为“平台发布”,关联问题为匿名提问人,发布人不默认获得提问奖。
- 高价值问题取消回答者匿名机制:答案列表、最佳解答、管理员最佳回答选项和社区优选结果展示回答者用户显示名,优先 `name`、为空时回退 `username`、账号不存在时显示“未知用户”;历史答案同步适用。发布前提示展示用户名,草稿仍仅本人可见,问题相关身份及正文敏感信息继续保护,不改变点赞、奖励及实名人员榜规则。(2026-10-26调整)
- 状态及结果管理仅允许原admin身份或全局“超级管理员/管理员”角色;服务端派生 `can_manage_knowledge_challenges`,前端不能通过普通内容审核/财务角色或按钮标记获得管理权。首页高价值问题卡片向全部登录用户展示,公开内容及参与接口仍要求有效登录;开放可见性不等于授予发布、状态变更或最终结果管理权限。
- 到期无内容审核、无专家派审、无全量管理员确认;按有效答案赞降序、首次正式提交时间升序、答案ID升序选出唯一最佳,零赞同样排序。无已提交合规答案则结束且无解答奖,草稿不参评。
- 题赞只决定问题榜热度;普通问题、关联同题、独立问题共用北京时间每日5题额度,取消不返额、跨入口不重复占额。答案另计每日5条,不得自赞;用户行锁序列化额度检查。
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/admin/KnowledgeChallengeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function ChallengeAnswerEditor({ id, canAnswer, onSubmitted, onCancel, em
finally { if (mounted.current) setBusy(false); }
}
return <section className={embedded ? 'challenge-answer-editor' : 'mac-card mac-card--pad'}><h3>{isEditing ? '编辑我的解答' : '我的解答'}</h3><KnowledgeState loading={resource.loading && !ready} error={resource.error} retry={resource.reload} />
{ready && <form className="challenge-form" onSubmit={publish}><p className="challenge-notice">匿名展示,请勿填写姓名、电话、客户信息或原工单内部链接。每人每天最多点赞5条不同答案,不能给自己的答案点赞。</p>
{ready && <form className="challenge-form" onSubmit={publish}><p className="challenge-notice">回答将展示你的用户名,请勿填写电话、客户信息或原工单内部链接。每人每天最多点赞5条不同答案,不能给自己的答案点赞。</p>
<p role="note" className="knowledge-note">{editExhausted ? '本题解答已修改过一次,不能再次编辑。' : isEditing ? '每个问题的已发布解答仅可修改一次。提交修改后不能再次编辑;保存草稿或取消编辑不消耗修改次数。' : '首次发布后,每个问题的解答仅可再修改一次,请确认内容后发布。'}</p>
<label>答案内容<textarea className="knowledge-textarea" value={content} onChange={(event) => setContent(event.target.value)} required maxLength={20000} disabled={busy || !canSubmit} /></label>
<span className="knowledge-muted" role="status">{editExhausted ? '本题修改机会已用完' : canAnswer ? status : '问题已截止或暂停,不能继续提交'}</span>
Expand Down Expand Up @@ -166,7 +166,7 @@ export default function KnowledgeChallengeDetail() {
</section>
{error && <p role="alert" className="knowledge-discussion-error">{error}</p>}
{editorOpen && !myAnswer && <div ref={editorContainer}><ChallengeAnswerEditor key={challengeId} id={challengeId} canAnswer={canAnswer} onSubmitted={submitted} onCancel={() => setEditorOpen(false)} /></div>}
<section className="mac-card mac-card--pad"><h3>{challenge.best_answer ? '社区优选解答' : '匿名解答'} · {challenge.answers.length}</h3><p className="challenge-notice">社区解答,未经专业验证。问题赞表达共性需求,答案赞决定默认获奖者。</p>
<section className="mac-card mac-card--pad"><h3>{challenge.best_answer ? '社区优选解答' : '解答'} · {challenge.answers.length}</h3><p className="challenge-notice">社区解答,未经专业验证。问题赞表达共性需求,答案赞决定默认获奖者。</p>
{challenge.my_reward_status && <div className="knowledge-note">你的奖励已接入原奖励管理,可在<Link className="knowledge-link" to="/rewards">我的奖励</Link>查看实际处理状态。</div>}
{!challenge.answers.length && <KnowledgeEmpty>暂无解答,期待你的经验与思路</KnowledgeEmpty>}
{[...challenge.answers].sort((first, second) => Number(second.is_mine) - Number(first.is_mine) || Number(second.is_best) - Number(first.is_best)).map((answer) => <article key={answer.id} className={`challenge-answer${answer.is_best ? ' challenge-answer--best' : ''}`}><div className="knowledge-toolbar"><strong>{answer.author}{answer.is_best ? ' · 最佳解答' : ''}{answer.is_mine ? ' · 我的答案' : ''}</strong><div className="challenge-actions">{answer.is_mine && <button type="button" className="knowledge-link" disabled={!canAnswer || !answer.can_edit} title={answer.remaining_edits === 0 ? '本题修改机会已用完' : '每个问题的已发布解答仅可修改一次'} onClick={() => setEditorOpen(!editorOpen)}>{editorOpen ? '收起编辑' : '编辑'}</button>}<button type="button" className="knowledge-like" aria-pressed={answer.liked_by_me} disabled={busy || !answer.can_like || !canAnswer} onClick={() => void vote(() => likeChallengeAnswer(challenge.id, answer))}>有帮助 · {answer.like_count}</button></div></div><p className="challenge-content">{answer.content}</p><time className="knowledge-muted">{knowledgeTime(answer.submitted_at)}</time>{answer.is_mine && answer.remaining_edits === 0 && <p className="challenge-notice">本题修改机会已用完</p>}{answer.is_mine && editorOpen && <div ref={editorContainer}><ChallengeAnswerEditor key={challengeId} id={challengeId} canAnswer={canAnswer} onSubmitted={submitted} onCancel={() => setEditorOpen(false)} embedded /></div>}</article>)}
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/pages/admin/__tests__/KnowledgeChallenges.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function challenge(id = 1): ChallengeDetail {
function wrapped(element: React.ReactNode) { return render(<MemoryRouter>{element}</MemoryRouter>); }

function publishedAnswer(overrides: Partial<ChallengeAnswer> = {}): ChallengeAnswer {
return { id: 9, author: '匿名解答者1', content: '已发布的解答', like_count: 2,
return { id: 9, author: '张三', content: '已发布的解答', like_count: 2,
submitted_at: new Date().toISOString(), is_mine: true, is_best: false, liked_by_me: false,
can_like: false, can_edit: true, remaining_edits: 1, ...overrides };
}
Expand All @@ -42,6 +42,22 @@ function detailPage() {
describe('高价值问题交互', () => {
beforeEach(() => { vi.resetAllMocks(); });
afterEach(cleanup);
it.each([false, true])('解答列表及最佳解答显示回答用户名,最佳状态:%s', async (isBest) => {
const answer = publishedAnswer({ is_mine: false, is_best: isBest, can_edit: false });
api.fetchChallenge.mockResolvedValue({ ...challenge(), answers: [answer], best_answer: isBest ? answer : null });
detailPage();
const card = await screen.findByRole('article');
expect(card).toHaveTextContent(isBest ? '张三 · 最佳解答' : '张三');
expect(screen.getByRole('heading', { name: `${isBest ? '社区优选解答' : '解答'} · 1` })).toBeVisible();
expect(screen.queryByText(/匿名解答/)).toBeNull();
});
it('发布答案前提示展示用户名,不再提示匿名展示', async () => {
api.fetchMyChallengeAnswer.mockResolvedValue({ content: '', version: 0, submitted_at: null, remaining_edits: 1, can_submit: true });
wrapped(<ChallengeAnswerEditor id="1" canAnswer onSubmitted={vi.fn()} />);
await screen.findByLabelText('答案内容');
expect(screen.getByText(/回答将展示你的用户名/)).toBeVisible();
expect(screen.queryByText(/匿名展示/)).toBeNull();
});
it.each(['高价值问题1', '按问题点赞排序 · 到期按答案有效赞选出最佳解答'])('首页最多展示三题及奖金,点击%s进入完整榜单', async (content) => {
api.fetchChallengePreview.mockResolvedValue({ items: [1, 2, 3, 4].map(challenge), total: 4, can_manage: false });
const rendered = render(<MemoryRouter><Routes>
Expand Down
Loading