feat(ECR-020): QualityFeedback 问答质量反馈并 Closed
新增 ask_quality_feedback、运营/C端评分 API 与 admin-h5 问答反馈区;禁改消息/UGC/真支付。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,7 +37,7 @@
|
|||||||
| Ops_Content | ExploreConfig (partial) | Shipped Ops-C |
|
| Ops_Content | ExploreConfig (partial) | Shipped Ops-C |
|
||||||
| Account_Risk | AccountLifecycle | Spec via ECR-013B(Identity_Profile owns UserStatus) |
|
| Account_Risk | AccountLifecycle | Spec via ECR-013B(Identity_Profile owns UserStatus) |
|
||||||
| Content_Safety | ContentSafety · CrisisCare | ContentSafety **ECR-019**(FilterRule);CrisisCare Draft |
|
| Content_Safety | ContentSafety · CrisisCare | ContentSafety **ECR-019**(FilterRule);CrisisCare Draft |
|
||||||
| Ask_Ops | AskOperations · AICoreConfig | AskOperations **ECR-017**(只读);AICoreConfig Draft |
|
| Ask_Ops | AskOperations · AICoreConfig | AskOperations **ECR-017/020**;AICoreConfig Draft |
|
||||||
| Ops_CMS_NoUGC | OpsCMS | Draft |
|
| Ops_CMS_NoUGC | OpsCMS | Draft |
|
||||||
| Community | — | **Forbidden** |
|
| Community | — | **Forbidden** |
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Status: `Draft`
|
|||||||
| ModerationCase | Content_Safety | ContentSafety | 后置 |
|
| ModerationCase | Content_Safety | ContentSafety | 后置 |
|
||||||
| CrisisEvent / CrisisPolicy | Content_Safety | CrisisCare | 后置 |
|
| CrisisEvent / CrisisPolicy | Content_Safety | CrisisCare | 后置 |
|
||||||
| SystemPrompt / Knowledge* / ToolDefinition | Ask_Ops | AICoreConfig | 后置 |
|
| SystemPrompt / Knowledge* / ToolDefinition | Ask_Ops | AICoreConfig | 后置 |
|
||||||
| AskSessionView / QualityFeedback | Ask_Ops | AskOperations | **ECR-017** AskSessionView 只读;QualityFeedback 后置 |
|
| AskSessionView / QualityFeedback | Ask_Ops | AskOperations | **ECR-017** AskSessionView;**ECR-020** QualityFeedback |
|
||||||
| Banner / FeedSlot | Ops_CMS_NoUGC | OpsCMS | 后置 |
|
| Banner / FeedSlot | Ops_CMS_NoUGC | OpsCMS | 后置 |
|
||||||
| UGC* | Community | — | **Forbidden** |
|
| UGC* | Community | — | **Forbidden** |
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
|||||||
| Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5`(ECR-006 Closed) |
|
| Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5`(ECR-006 Closed) |
|
||||||
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Closed** · Spec `ops-analytics.md`) |
|
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Closed** · Spec `ops-analytics.md`) |
|
||||||
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Closed** · Spec `ops-content.md`) |
|
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Closed** · Spec `ops-content.md`) |
|
||||||
| Phase D+ | **Contract-First**:**ECR-013A…018 Closed** → **ECR-019** ContentSafety(Loop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 |
|
| Phase D+ | **Contract-First**:**ECR-013A…019 Closed** → **ECR-020** QualityFeedback(Loop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 |
|
||||||
| 排除 | **UGC / 社区广场**(M10.2)仍 `[No]`;真支付最后 |
|
| 排除 | **UGC / 社区广场**(M10.2)仍 `[No]`;真支付最后 |
|
||||||
|
|
||||||
不计入 P1 Complete;不进入五 Tab。
|
不计入 P1 Complete;不进入五 Tab。
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
| [ops-ask-operations.md](ops-ask-operations.md) | 问答运营 AskOperations | §7 | `admin-h5` `/ask` · `GET /admin/ask/threads*` | Ops-D · **ECR-017 Closed** |
|
| [ops-ask-operations.md](ops-ask-operations.md) | 问答运营 AskOperations | §7 | `admin-h5` `/ask` · `GET /admin/ask/threads*` | Ops-D · **ECR-017 Closed** |
|
||||||
| [ops-entitlement.md](ops-entitlement.md) | 用户权益 Entitlement | §7 | `admin-h5` 用户详情「权益」· `GET /admin/users/:id/entitlements` | Ops-D · **ECR-018 Closed** |
|
| [ops-entitlement.md](ops-entitlement.md) | 用户权益 Entitlement | §7 | `admin-h5` 用户详情「权益」· `GET /admin/users/:id/entitlements` | Ops-D · **ECR-018 Closed** |
|
||||||
| [ops-content-safety.md](ops-content-safety.md) | 内容安全 ContentSafety | §7 | `admin-h5` `/safety` · `GET /admin/content-safety/*` | Ops-D · **ECR-019 Closed** |
|
| [ops-content-safety.md](ops-content-safety.md) | 内容安全 ContentSafety | §7 | `admin-h5` `/safety` · `GET /admin/content-safety/*` | Ops-D · **ECR-019 Closed** |
|
||||||
|
| [ops-quality-feedback.md](ops-quality-feedback.md) | 问答质量反馈 QualityFeedback | §7 | `admin-h5` `/ask` · `GET/POST /admin/ask/feedback*` | Ops-D · **ECR-020 Closed** |
|
||||||
|
|
||||||
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
|
新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ Phase A 可先 `console`/本地;不挡验收。
|
|||||||
| **J(ECR-017 Closed)** | AskOperations — Spec `ops-ask-operations.md` |
|
| **J(ECR-017 Closed)** | AskOperations — Spec `ops-ask-operations.md` |
|
||||||
| **K(ECR-018 Closed)** | Entitlement — Spec `ops-entitlement.md` |
|
| **K(ECR-018 Closed)** | Entitlement — Spec `ops-entitlement.md` |
|
||||||
| **L(ECR-019 Closed)** | ContentSafety FilterRule — Spec `ops-content-safety.md` |
|
| **L(ECR-019 Closed)** | ContentSafety FilterRule — Spec `ops-content-safety.md` |
|
||||||
| 后置 | QualityFeedback / AICoreConfig / CrisisCare(Loop 续跑) |
|
| **M(ECR-020 Closed)** | QualityFeedback — Spec `ops-quality-feedback.md` |
|
||||||
|
| 后置 | AICoreConfig / CrisisCare(Loop 续跑) |
|
||||||
| D | 订单筛选 · 展示价 · 退款只读(另开 ECR) |
|
| D | 订单筛选 · 展示价 · 退款只读(另开 ECR) |
|
||||||
| 后置 | 封禁加深(Account_Risk)· 推送占位 |
|
| 后置 | 封禁加深(Account_Risk)· 推送占位 |
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Feature Spec: QualityFeedback(Ops · ECR-020)
|
||||||
|
|
||||||
|
> Status: `Active`(Loop continuous · **ECR-020 Closed**)
|
||||||
|
> Parent: WAVE0-FROZEN · Predecessor: ECR-019 Closed
|
||||||
|
> Capability: `AskOperations` · BC: `Ask_Ops`
|
||||||
|
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
改写消息正文 · SystemPrompt/Knowledge · HandoffCase 全量 · AICoreConfig · UGC 广场 · 真支付
|
||||||
|
|
||||||
|
## L2 Domain
|
||||||
|
|
||||||
|
| 概念 | 语义 |
|
||||||
|
|------|------|
|
||||||
|
| `QualityFeedback` | 绑定 thread(可选 message);rating 1–5;tag ∈ {helpful,off_topic,unsafe,other};source ∈ {admin,user} |
|
||||||
|
|
||||||
|
## L3 API
|
||||||
|
|
||||||
|
| Method | Path | 权限 | 语义 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| GET | `/admin/ask/feedback` | `admin.ask.read` | 列表 |
|
||||||
|
| POST | `/admin/ask/threads/:id/feedback` | `admin.ask.feedback.write` | 运营提交 |
|
||||||
|
| POST | `/ask/threads/:id/feedback` | DeviceAuth 用户 | C 端评分 |
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
`000021`:`ask_quality_feedback` + 授予 `admin.ask.feedback.write`
|
||||||
|
|
||||||
|
## L4 AC
|
||||||
|
|
||||||
|
| ID | Then |
|
||||||
|
|----|------|
|
||||||
|
| AC-F-01 | admin POST feedback → 200 · list 可见 |
|
||||||
|
| AC-F-02 | user POST feedback → 200 |
|
||||||
|
| AC-F-03 | 无效 rating → 400 |
|
||||||
|
| AC-S-01 | 无 Admin → admin list 401 |
|
||||||
|
| AC-S-02 | 无 feedback.write → admin POST 403 |
|
||||||
|
| AC-P-01 | list < 500ms |
|
||||||
|
| AC-O-01 | admin 提交写 AuditLog `ask.feedback.create` |
|
||||||
|
|
||||||
|
contract_diff: `docs/CONTRACT_DIFF/ECR-020.yaml`
|
||||||
@@ -275,6 +275,26 @@ export const adminApi = {
|
|||||||
'/content-safety/evaluate',
|
'/content-safety/evaluate',
|
||||||
{ text },
|
{ text },
|
||||||
),
|
),
|
||||||
|
askFeedback: () =>
|
||||||
|
request<{
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
thread_id: string
|
||||||
|
message_id?: string
|
||||||
|
source: string
|
||||||
|
rating: number
|
||||||
|
tag?: string
|
||||||
|
note?: string
|
||||||
|
created_at: string
|
||||||
|
}>
|
||||||
|
}>('GET', '/ask/feedback'),
|
||||||
|
createAskFeedback: (threadId: string, body: { rating: number; tag?: string; note?: string; message_id?: string }) =>
|
||||||
|
request<{
|
||||||
|
id: string
|
||||||
|
thread_id: string
|
||||||
|
rating: number
|
||||||
|
source: string
|
||||||
|
}>('POST', `/ask/threads/${threadId}/feedback`, body),
|
||||||
orders: () =>
|
orders: () =>
|
||||||
request<{
|
request<{
|
||||||
items: Array<{
|
items: Array<{
|
||||||
|
|||||||
@@ -2,23 +2,34 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { adminApi } from '@/api/client'
|
import { adminApi } from '@/api/client'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
type Thread = Awaited<ReturnType<typeof adminApi.askThreads>>['items'][number]
|
type Thread = Awaited<ReturnType<typeof adminApi.askThreads>>['items'][number]
|
||||||
type Detail = Awaited<ReturnType<typeof adminApi.askThread>>
|
type Detail = Awaited<ReturnType<typeof adminApi.askThread>>
|
||||||
|
type Feedback = Awaited<ReturnType<typeof adminApi.askFeedback>>['items'][number]
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const items = ref<Thread[]>([])
|
const items = ref<Thread[]>([])
|
||||||
const selected = ref<Detail | null>(null)
|
const selected = ref<Detail | null>(null)
|
||||||
const detailErr = ref('')
|
const detailErr = ref('')
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
|
const feedback = ref<Feedback[]>([])
|
||||||
|
const rating = ref(4)
|
||||||
|
const tag = ref('helpful')
|
||||||
|
const note = ref('')
|
||||||
|
const fbMsg = ref('')
|
||||||
|
|
||||||
|
const canWriteFeedback = () => auth.can('admin.ask.feedback.write')
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await adminApi.askThreads()
|
const [threads, fb] = await Promise.all([adminApi.askThreads(), adminApi.askFeedback()])
|
||||||
items.value = res.items || []
|
items.value = threads.items || []
|
||||||
|
feedback.value = fb.items || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '加载失败'
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -30,6 +41,7 @@ async function openThread(id: string) {
|
|||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
detailErr.value = ''
|
detailErr.value = ''
|
||||||
selected.value = null
|
selected.value = null
|
||||||
|
fbMsg.value = ''
|
||||||
try {
|
try {
|
||||||
selected.value = await adminApi.askThread(id)
|
selected.value = await adminApi.askThread(id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -39,6 +51,24 @@ async function openThread(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitFeedback() {
|
||||||
|
if (!selected.value) return
|
||||||
|
fbMsg.value = ''
|
||||||
|
try {
|
||||||
|
await adminApi.createAskFeedback(selected.value.id, {
|
||||||
|
rating: rating.value,
|
||||||
|
tag: tag.value,
|
||||||
|
note: note.value || undefined,
|
||||||
|
})
|
||||||
|
fbMsg.value = '已提交质量反馈'
|
||||||
|
note.value = ''
|
||||||
|
const fb = await adminApi.askFeedback()
|
||||||
|
feedback.value = fb.items || []
|
||||||
|
} catch (e) {
|
||||||
|
fbMsg.value = e instanceof Error ? e.message : '提交失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fmtTime(iso?: string) {
|
function fmtTime(iso?: string) {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
try {
|
try {
|
||||||
@@ -54,7 +84,7 @@ onMounted(load)
|
|||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<h1>问答会话</h1>
|
<h1>问答会话</h1>
|
||||||
<p class="muted">只读 AskSessionView · 不可改写消息</p>
|
<p class="muted">AskSessionView · QualityFeedback · 不可改写消息</p>
|
||||||
<p v-if="loading" class="muted">加载中…</p>
|
<p v-if="loading" class="muted">加载中…</p>
|
||||||
<p v-else-if="error" class="err">{{ error }}</p>
|
<p v-else-if="error" class="err">{{ error }}</p>
|
||||||
<div v-else class="layout">
|
<div v-else class="layout">
|
||||||
@@ -76,6 +106,13 @@ onMounted(load)
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<h2 class="sub">近期反馈</h2>
|
||||||
|
<p v-if="!feedback.length" class="muted">暂无</p>
|
||||||
|
<ul v-else class="fb-list">
|
||||||
|
<li v-for="f in feedback.slice(0, 8)" :key="f.id">
|
||||||
|
{{ f.source }} · {{ f.rating }}★ · {{ f.tag || '—' }} · {{ fmtTime(f.created_at) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="card detail">
|
<div class="card detail">
|
||||||
<h2>会话详情</h2>
|
<h2>会话详情</h2>
|
||||||
@@ -93,6 +130,25 @@ onMounted(load)
|
|||||||
<time>{{ fmtTime(m.created_at) }}</time>
|
<time>{{ fmtTime(m.created_at) }}</time>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div v-if="canWriteFeedback()" class="fb-form">
|
||||||
|
<h3>质量反馈</h3>
|
||||||
|
<select v-model.number="rating">
|
||||||
|
<option :value="1">1</option>
|
||||||
|
<option :value="2">2</option>
|
||||||
|
<option :value="3">3</option>
|
||||||
|
<option :value="4">4</option>
|
||||||
|
<option :value="5">5</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="tag">
|
||||||
|
<option value="helpful">helpful</option>
|
||||||
|
<option value="off_topic">off_topic</option>
|
||||||
|
<option value="unsafe">unsafe</option>
|
||||||
|
<option value="other">other</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="note" type="text" placeholder="备注(可选)" />
|
||||||
|
<button class="btn" type="button" @click="submitFeedback">提交</button>
|
||||||
|
<span class="muted">{{ fbMsg }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="muted">选择左侧会话查看消息</p>
|
<p v-else class="muted">选择左侧会话查看消息</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +159,8 @@ onMounted(load)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||||
|
h2.sub { margin-top: 1rem; font-size: 0.95rem; }
|
||||||
|
h3 { margin: 0.75rem 0 0.4rem; font-size: 0.95rem; }
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.1fr 1fr;
|
grid-template-columns: 1.1fr 1fr;
|
||||||
@@ -121,6 +179,15 @@ h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
|||||||
.msgs p { margin: 0.25rem 0; white-space: pre-wrap; word-break: break-word; }
|
.msgs p { margin: 0.25rem 0; white-space: pre-wrap; word-break: break-word; }
|
||||||
.msgs time { font-size: 0.75rem; color: var(--muted); }
|
.msgs time { font-size: 0.75rem; color: var(--muted); }
|
||||||
.msgs .assistant { background: rgba(255, 244, 242, 0.6); }
|
.msgs .assistant { background: rgba(255, 244, 242, 0.6); }
|
||||||
|
.fb-list { margin: 0; padding-left: 1.1rem; font-size: 0.85rem; color: var(--muted); }
|
||||||
|
.fb-form {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center;
|
||||||
|
margin-top: 0.85rem; padding-top: 0.75rem; border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.fb-form select, .fb-form input {
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; padding: 0.4rem 0.55rem;
|
||||||
|
}
|
||||||
|
.fb-form input { min-width: 10rem; flex: 1; }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.layout { grid-template-columns: 1fr; }
|
.layout { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
|||||||
h.registerRedemption(authed)
|
h.registerRedemption(authed)
|
||||||
h.registerInsight(authed)
|
h.registerInsight(authed)
|
||||||
h.registerAskOps(authed)
|
h.registerAskOps(authed)
|
||||||
|
h.registerQualityFeedback(authed)
|
||||||
h.registerEntitlement(authed)
|
h.registerEntitlement(authed)
|
||||||
h.registerContentSafety(authed)
|
h.registerContentSafety(authed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *AdminHandler) registerQualityFeedback(authed *gin.RouterGroup) {
|
||||||
|
authed.GET("/ask/feedback", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListAskFeedback)
|
||||||
|
authed.POST("/ask/threads/:id/feedback", middleware.RequireAdminPermission(h.Svc, admin.PermAskFeedbackWrite), h.CreateAskFeedback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListAskFeedback(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
items, err := h.Svc.ListQualityFeedback(c.Request.Context(), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50025, "list feedback failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateAskFeedback(c *gin.Context) {
|
||||||
|
adminID, ok := middleware.AdminIDFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
threadID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Rating int `json:"rating"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
MessageID *string `json:"message_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40000, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var msgID *uuid.UUID
|
||||||
|
if body.MessageID != nil && *body.MessageID != "" {
|
||||||
|
id, err := uuid.Parse(*body.MessageID)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40002, "invalid message_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgID = &id
|
||||||
|
}
|
||||||
|
row, err := h.Svc.CreateQualityFeedback(c.Request.Context(), adminID, threadID, msgID, body.Rating, body.Tag, body.Note)
|
||||||
|
if errors.Is(err, admin.ErrBadFeedbackRating) || errors.Is(err, admin.ErrBadFeedbackTag) || errors.Is(err, admin.ErrFeedbackNoteLong) {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40000, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrAskThreadNotFound) {
|
||||||
|
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50026, "create feedback failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, row)
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ func (h *AskHandler) Register(rg *gin.RouterGroup) {
|
|||||||
rg.DELETE("/ask/threads/:id", h.ClearThread)
|
rg.DELETE("/ask/threads/:id", h.ClearThread)
|
||||||
rg.GET("/ask/threads/:id/messages", h.ListMessages)
|
rg.GET("/ask/threads/:id/messages", h.ListMessages)
|
||||||
rg.POST("/ask/threads/:id/messages", h.SendMessage)
|
rg.POST("/ask/threads/:id/messages", h.SendMessage)
|
||||||
|
h.registerFeedback(rg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetQuota handles GET /ask/quota.
|
// GetQuota handles GET /ask/quota.
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||||
|
asksvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *AskHandler) registerFeedback(rg *gin.RouterGroup) {
|
||||||
|
rg.POST("/ask/threads/:id/feedback", h.SubmitFeedback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitFeedback handles POST /ask/threads/:id/feedback.
|
||||||
|
func (h *AskHandler) SubmitFeedback(c *gin.Context) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
threadID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 10000, "invalid thread id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Rating int `json:"rating"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
MessageID *string `json:"message_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var msgID *uuid.UUID
|
||||||
|
if req.MessageID != nil && *req.MessageID != "" {
|
||||||
|
id, err := uuid.Parse(*req.MessageID)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 10000, "invalid message_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgID = &id
|
||||||
|
}
|
||||||
|
row, err := h.Svc.SubmitFeedback(c.Request.Context(), userID, threadID, asksvc.SubmitFeedbackInput{
|
||||||
|
MessageID: msgID, Rating: req.Rating, Tag: req.Tag, Note: req.Note,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "rating") || strings.Contains(msg, "tag") || strings.Contains(msg, "note") {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 40000, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "thread not found") {
|
||||||
|
response.Fail(c, http.StatusNotFound, 40410, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50000, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, row)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQualityFeedback(t *testing.T) {
|
||||||
|
r, pool := setupAPIPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tok := adminLogin(t, r, "admin", "change-me")
|
||||||
|
|
||||||
|
_, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/feedback", nil, "")
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
limitedRoleID := uuid.New()
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||||
|
limitedRoleID, "qf_lim_"+limitedRoleID.String()[:8])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.ask.read')`, limitedRoleID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
limUser := fmt.Sprintf("qflim_%d", time.Now().UnixNano())
|
||||||
|
_, err = pool.Exec(ctx, `
|
||||||
|
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||||
|
limUser, string(hash), limitedRoleID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limUser)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||||
|
})
|
||||||
|
limTok := adminLogin(t, r, limUser, "limited-pass")
|
||||||
|
|
||||||
|
key := mustRegister(t, r)
|
||||||
|
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
|
||||||
|
"relation": "self", "birth_date": "1993-03-03", "display_name": "评",
|
||||||
|
}, key)
|
||||||
|
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||||
|
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
|
||||||
|
"profile_id": profileID, "scene": "self",
|
||||||
|
}, key)
|
||||||
|
threadID := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||||
|
_, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
|
||||||
|
"content": "打分测试",
|
||||||
|
}, key)
|
||||||
|
|
||||||
|
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/ask/threads/"+threadID+"/feedback",
|
||||||
|
map[string]any{"rating": 4, "tag": "helpful"}, limTok)
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 without feedback.write, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/ask/threads/"+threadID+"/feedback",
|
||||||
|
map[string]any{"rating": 9}, tok)
|
||||||
|
if code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 bad rating, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/ask/threads/"+threadID+"/feedback",
|
||||||
|
map[string]any{"rating": 5, "tag": "helpful", "note": "ops ok"}, tok)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("admin feedback http=%d msg=%s", code, env.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/feedback", nil, tok)
|
||||||
|
if code != 200 || time.Since(start) > 500*time.Millisecond {
|
||||||
|
t.Fatalf("list http=%d dur=%v", code, time.Since(start))
|
||||||
|
}
|
||||||
|
var list struct {
|
||||||
|
Items []struct {
|
||||||
|
ThreadID string `json:"thread_id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Rating int `json:"rating"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(env.Data, &list)
|
||||||
|
found := false
|
||||||
|
for _, it := range list.Items {
|
||||||
|
if it.ThreadID == threadID && it.Source == "admin" && it.Rating == 5 {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("admin feedback missing: %#v", list.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, _, httpCode := doJSONExpect(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/feedback",
|
||||||
|
map[string]any{"rating": 3, "tag": "other"}, key, 0)
|
||||||
|
if httpCode != 200 {
|
||||||
|
t.Fatalf("user feedback http=%d body=%s", httpCode, env.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, tok)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("audit %d", code)
|
||||||
|
}
|
||||||
|
var audit struct {
|
||||||
|
Items []struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(env.Data, &audit)
|
||||||
|
okAudit := false
|
||||||
|
for _, it := range audit.Items {
|
||||||
|
if it.Action == "ask.feedback.create" {
|
||||||
|
okAudit = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !okAudit {
|
||||||
|
t.Fatal("missing ask.feedback.create audit")
|
||||||
|
}
|
||||||
|
_ = key
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QualityFeedbackRow is AskOperations QualityFeedback.
|
||||||
|
type QualityFeedbackRow struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ThreadID uuid.UUID `json:"thread_id"`
|
||||||
|
MessageID *uuid.UUID `json:"message_id,omitempty"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Rating int `json:"rating"`
|
||||||
|
Tag *string `json:"tag,omitempty"`
|
||||||
|
Note *string `json:"note,omitempty"`
|
||||||
|
CreatedByAdmin *uuid.UUID `json:"created_by_admin,omitempty"`
|
||||||
|
CreatedByUser *uuid.UUID `json:"created_by_user,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListQualityFeedback returns recent feedback rows.
|
||||||
|
func (r *AdminRepo) ListQualityFeedback(ctx context.Context, limit, offset int) ([]QualityFeedbackRow, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT id, thread_id, message_id, source, rating, tag, note,
|
||||||
|
created_by_admin, created_by_user, created_at
|
||||||
|
FROM ask_quality_feedback
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2`, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanQualityFeedback(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanQualityFeedback(rows pgx.Rows) ([]QualityFeedbackRow, error) {
|
||||||
|
var out []QualityFeedbackRow
|
||||||
|
for rows.Next() {
|
||||||
|
var f QualityFeedbackRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note,
|
||||||
|
&f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAdminQualityFeedback inserts ops feedback + audit.
|
||||||
|
func (r *AdminRepo) CreateAdminQualityFeedback(
|
||||||
|
ctx context.Context, adminID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note *string,
|
||||||
|
) (*QualityFeedbackRow, error) {
|
||||||
|
if err := validateFeedback(rating, tag, note); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tag, note = cleanTag(tag), cleanNote(note)
|
||||||
|
ok, err := r.askThreadExists(ctx, threadID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("ask thread not found")
|
||||||
|
}
|
||||||
|
tx, err := r.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
var f QualityFeedbackRow
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_admin)
|
||||||
|
VALUES ($1,$2,'admin',$3,$4,$5,$6)
|
||||||
|
RETURNING id, thread_id, message_id, source, rating, tag, note, created_by_admin, created_by_user, created_at`,
|
||||||
|
threadID, messageID, rating, tag, note, adminID,
|
||||||
|
).Scan(&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note,
|
||||||
|
&f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{"rating": rating, "thread_id": threadID.String()})
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||||
|
VALUES ($1,'ask.feedback.create','ask_thread',$2,$3)`,
|
||||||
|
adminID, threadID.String(), meta); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserQualityFeedback inserts C-end feedback for owned thread.
|
||||||
|
func (r *AskRepo) CreateUserQualityFeedback(
|
||||||
|
ctx context.Context, userID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note *string,
|
||||||
|
) (*QualityFeedbackRow, error) {
|
||||||
|
if err := validateFeedback(rating, tag, note); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tag, note = cleanTag(tag), cleanNote(note)
|
||||||
|
_, err := r.GetThreadForUser(ctx, userID, threadID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("ask thread not found")
|
||||||
|
}
|
||||||
|
var f QualityFeedbackRow
|
||||||
|
err = r.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO ask_quality_feedback(thread_id, message_id, source, rating, tag, note, created_by_user)
|
||||||
|
VALUES ($1,$2,'user',$3,$4,$5,$6)
|
||||||
|
RETURNING id, thread_id, message_id, source, rating, tag, note, created_by_admin, created_by_user, created_at`,
|
||||||
|
threadID, messageID, rating, tag, note, userID,
|
||||||
|
).Scan(&f.ID, &f.ThreadID, &f.MessageID, &f.Source, &f.Rating, &f.Tag, &f.Note,
|
||||||
|
&f.CreatedByAdmin, &f.CreatedByUser, &f.CreatedAt)
|
||||||
|
return &f, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminRepo) askThreadExists(ctx context.Context, threadID uuid.UUID) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := r.Pool.QueryRow(ctx, `
|
||||||
|
SELECT 1 FROM ask_threads WHERE id=$1 AND deleted_at IS NULL`, threadID).Scan(&n)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFeedback(rating int, tag, note *string) error {
|
||||||
|
if rating < 1 || rating > 5 {
|
||||||
|
return errors.New("rating must be 1-5")
|
||||||
|
}
|
||||||
|
if tag != nil && *tag != "" {
|
||||||
|
switch *tag {
|
||||||
|
case "helpful", "off_topic", "unsafe", "other":
|
||||||
|
default:
|
||||||
|
return errors.New("invalid tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if note != nil && utf8.RuneCountInString(*note) > 500 {
|
||||||
|
return errors.New("note too long")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanTag(tag *string) *string {
|
||||||
|
if tag == nil || *tag == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanNote(note *string) *string {
|
||||||
|
if note == nil || *note == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return note
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrBadFeedbackRating = errString("rating must be 1-5")
|
||||||
|
ErrBadFeedbackTag = errString("invalid tag")
|
||||||
|
ErrFeedbackNoteLong = errString("note too long")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListQualityFeedback lists recent QualityFeedback.
|
||||||
|
func (s *Service) ListQualityFeedback(ctx context.Context, limit, offset int) ([]repository.QualityFeedbackRow, error) {
|
||||||
|
items, err := s.Repo.ListQualityFeedback(ctx, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []repository.QualityFeedbackRow{}
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateQualityFeedback is ops-submitted feedback.
|
||||||
|
func (s *Service) CreateQualityFeedback(
|
||||||
|
ctx context.Context, adminID, threadID uuid.UUID, messageID *uuid.UUID, rating int, tag, note string,
|
||||||
|
) (*repository.QualityFeedbackRow, error) {
|
||||||
|
var tagPtr, notePtr *string
|
||||||
|
if strings.TrimSpace(tag) != "" {
|
||||||
|
t := strings.TrimSpace(tag)
|
||||||
|
tagPtr = &t
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(note) != "" {
|
||||||
|
n := strings.TrimSpace(note)
|
||||||
|
notePtr = &n
|
||||||
|
}
|
||||||
|
row, err := s.Repo.CreateAdminQualityFeedback(ctx, adminID, threadID, messageID, rating, tagPtr, notePtr)
|
||||||
|
if err == nil {
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "rating"):
|
||||||
|
return nil, ErrBadFeedbackRating
|
||||||
|
case strings.Contains(msg, "tag"):
|
||||||
|
return nil, ErrBadFeedbackTag
|
||||||
|
case strings.Contains(msg, "note"):
|
||||||
|
return nil, ErrFeedbackNoteLong
|
||||||
|
case strings.Contains(msg, "thread not found"):
|
||||||
|
return nil, ErrAskThreadNotFound
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
PermMembershipCodesRead = "admin.membership.codes.read"
|
PermMembershipCodesRead = "admin.membership.codes.read"
|
||||||
PermMembershipCodesWrite = "admin.membership.codes.write"
|
PermMembershipCodesWrite = "admin.membership.codes.write"
|
||||||
PermAskRead = "admin.ask.read"
|
PermAskRead = "admin.ask.read"
|
||||||
|
PermAskFeedbackWrite = "admin.ask.feedback.write"
|
||||||
PermContentSafetyRead = "admin.content_safety.read"
|
PermContentSafetyRead = "admin.content_safety.read"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ var knownPermissions = map[string]struct{}{
|
|||||||
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
||||||
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
||||||
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
|
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
|
||||||
PermAskRead: {}, PermContentSafetyRead: {},
|
PermAskRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package ask
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubmitFeedbackInput for C-end QualityFeedback.
|
||||||
|
type SubmitFeedbackInput struct {
|
||||||
|
MessageID *uuid.UUID
|
||||||
|
Rating int
|
||||||
|
Tag string
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitFeedback records user QualityFeedback on owned thread.
|
||||||
|
func (s *Service) SubmitFeedback(ctx context.Context, userID, threadID uuid.UUID, in SubmitFeedbackInput) (*repository.QualityFeedbackRow, error) {
|
||||||
|
var tagPtr, notePtr *string
|
||||||
|
if t := strings.TrimSpace(in.Tag); t != "" {
|
||||||
|
tagPtr = &t
|
||||||
|
}
|
||||||
|
if n := strings.TrimSpace(in.Note); n != "" {
|
||||||
|
notePtr = &n
|
||||||
|
}
|
||||||
|
row, err := s.Ask.CreateUserQualityFeedback(ctx, userID, threadID, in.MessageID, in.Rating, tagPtr, notePtr)
|
||||||
|
if err == nil {
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "rating") || strings.Contains(msg, "tag") || strings.Contains(msg, "note") {
|
||||||
|
return nil, errors.New(msg)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "thread not found") {
|
||||||
|
return nil, errors.New("ask thread not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- ECR-020 down
|
||||||
|
|
||||||
|
DELETE FROM admin_role_permissions WHERE code = 'admin.ask.feedback.write';
|
||||||
|
DROP TABLE IF EXISTS ask_quality_feedback;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- ECR-020 QualityFeedback
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ask_quality_feedback (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
thread_id uuid NOT NULL REFERENCES ask_threads(id),
|
||||||
|
message_id uuid NULL REFERENCES ask_messages(id),
|
||||||
|
source varchar(16) NOT NULL CHECK (source IN ('admin','user')),
|
||||||
|
rating smallint NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||||
|
tag varchar(32) NULL CHECK (tag IS NULL OR tag IN ('helpful','off_topic','unsafe','other')),
|
||||||
|
note text NULL,
|
||||||
|
created_by_admin uuid NULL REFERENCES admin_accounts(id),
|
||||||
|
created_by_user uuid NULL REFERENCES users(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ask_qf_thread ON ask_quality_feedback(thread_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ask_qf_created ON ask_quality_feedback(created_at DESC);
|
||||||
|
|
||||||
|
INSERT INTO admin_role_permissions(role_id, code)
|
||||||
|
SELECT r.id, 'admin.ask.feedback.write'
|
||||||
|
FROM admin_roles r
|
||||||
|
WHERE r.name = 'super_admin'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Backend Design: ECR-020 QualityFeedback
|
||||||
|
|
||||||
|
| ID | BD-2026-020 |
|
||||||
|
| Status | Approved |
|
||||||
|
| Coding | Loop authorized |
|
||||||
|
| Level | L2 |
|
||||||
|
| Migration | YES 000021 |
|
||||||
|
|
||||||
|
## Backend Change Boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
Domain: QualityFeedback
|
||||||
|
App: AdminHandler + AskHandler → services → repos
|
||||||
|
API: GET /admin/ask/feedback
|
||||||
|
POST /admin/ask/threads/{id}/feedback
|
||||||
|
POST /ask/threads/{id}/feedback
|
||||||
|
Permission: admin.ask.read · admin.ask.feedback.write
|
||||||
|
Migration: 000021
|
||||||
|
UI: admin-h5 /ask feedback panel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of boundary
|
||||||
|
|
||||||
|
Message mutation · SystemPrompt · UGC plaza · Payment
|
||||||
|
|
||||||
|
Rollback: down migration + remove routes/UI
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 2026-08-07
|
## 2026-08-07
|
||||||
|
|
||||||
|
- **ECR-020 Closed**:QualityFeedback(`ask_quality_feedback` · admin/user 评分 · migration 000021)
|
||||||
- **ECR-019 Closed**:ContentSafety FilterRule(`filter_rules` · evaluate · admin-h5 `/safety` · migration 000020)
|
- **ECR-019 Closed**:ContentSafety FilterRule(`filter_rules` · evaluate · admin-h5 `/safety` · migration 000020)
|
||||||
- **ECR-018 Closed**:Entitlement(`GET /admin/users/:id/entitlements` · admin-h5 权益 Tab · Migration NO)
|
- **ECR-018 Closed**:Entitlement(`GET /admin/users/:id/entitlements` · admin-h5 权益 Tab · Migration NO)
|
||||||
- **ECR-017 Closed**:AskOperations(AskSessionView 只读 · `admin.ask.read` · admin-h5 `/ask` · migration 000019)
|
- **ECR-017 Closed**:AskOperations(AskSessionView 只读 · `admin.ask.read` · admin-h5 `/ask` · migration 000019)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# CODE_REVIEW — ECR-020
|
||||||
|
|
||||||
|
**Verdict:** Approve → Closed
|
||||||
|
|
||||||
|
Date: 2026-08-07 · Loop continuous
|
||||||
|
|
||||||
|
- QualityFeedback 表 + admin/user API;无消息改写
|
||||||
|
- Audit on admin create · Integration AC mapped
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
ecr: ECR-020
|
||||||
|
capability: AskOperations
|
||||||
|
bounded_context: Ask_Ops
|
||||||
|
parent: WAVE0-FROZEN
|
||||||
|
predecessor: ECR-019
|
||||||
|
change:
|
||||||
|
type: additive
|
||||||
|
breaking_change: false
|
||||||
|
migration_required: true
|
||||||
|
compatibility_notes: >
|
||||||
|
Adds ask_quality_feedback and admin/user feedback APIs.
|
||||||
|
No message body mutation.
|
||||||
|
|
||||||
|
apis:
|
||||||
|
- method: GET
|
||||||
|
path: /api/v1/admin/ask/feedback
|
||||||
|
change: added
|
||||||
|
- method: POST
|
||||||
|
path: /api/v1/admin/ask/threads/{id}/feedback
|
||||||
|
change: added
|
||||||
|
- method: POST
|
||||||
|
path: /api/v1/ask/threads/{id}/feedback
|
||||||
|
change: added
|
||||||
|
perms:
|
||||||
|
- code: admin.ask.feedback.write
|
||||||
|
change: added
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# ECR-020
|
||||||
|
|
||||||
|
**Title:** QualityFeedback(问答质量反馈薄切片)
|
||||||
|
**Status:** **Closed**
|
||||||
|
**Closed:** 2026-08-07(Loop continuous)
|
||||||
|
**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-019 Closed
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
`ask_quality_feedback`;运营 list/create · C 端评分;admin-h5 问答页反馈区。
|
||||||
|
|
||||||
|
## Forbidden
|
||||||
|
|
||||||
|
改消息 · Prompt 配置 · UGC · 真支付
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
Spec `ops-quality-feedback.md` · BD-2026-020 · CONTRACT_DIFF/ECR-020.yaml · TEST_REPORT/ECR-020.md
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-020
|
||||||
|
|
||||||
|
1. migration 000021 ask_quality_feedback + perm
|
||||||
|
2. Admin + Ask feedback APIs
|
||||||
|
3. admin-h5 Ask 页反馈区
|
||||||
|
4. Integration · Closed
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# HANDOFF — ECR-020 Architect → Engineer
|
||||||
|
|
||||||
|
Loop continuous · Approved + Coding. Migration 000021. Forbidden: 改消息/UGC/真支付.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# HANDOFF — ECR-020 Engineer → Reviewer
|
||||||
|
|
||||||
|
TestQualityFeedback PASS · Ask 页反馈 · Ready for Closed.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-020
|
||||||
|
|
||||||
|
对齐 ops-quality-feedback.md · Approved · Loop · L2 · QualityFeedback
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# STATE — ECR-020
|
||||||
|
|
||||||
|
| Status | **Closed** |
|
||||||
|
| Phase | closed |
|
||||||
|
| Spec | ops-quality-feedback.md |
|
||||||
|
| Updated | 2026-08-07 |
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
id: TASK-020-ECR020
|
||||||
|
ecr: ECR-020
|
||||||
|
title: QualityFeedback implement
|
||||||
|
role: engineer
|
||||||
|
status: closed
|
||||||
|
change_level: L2
|
||||||
|
parent: WAVE0-FROZEN
|
||||||
|
predecessor: ECR-019
|
||||||
|
acceptance:
|
||||||
|
- Spec AC mapped
|
||||||
|
- No message rewrite / UGC / payment
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# TEST_REPORT — ECR-020 QualityFeedback
|
||||||
|
|
||||||
|
Date: 2026-08-07 · Loop continuous · commit: (pending)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/api && go test ./internal/integration/ -run TestQualityFeedback -count=1
|
||||||
|
npm run build:admin
|
||||||
|
python3 scripts/ess-validate.py --phase review --ecr ECR-020
|
||||||
|
python3 scripts/ess-gate-check.py --ecr ECR-020
|
||||||
|
```
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| TestQualityFeedback | PASS |
|
||||||
|
| build:admin | PASS |
|
||||||
|
| ess-validate review | PASS |
|
||||||
|
|
||||||
|
## AC
|
||||||
|
|
||||||
|
| ID | Evidence |
|
||||||
|
|----|----------|
|
||||||
|
| AC-F-01 | admin POST → list 可见 |
|
||||||
|
| AC-F-02 | user POST → 200 |
|
||||||
|
| AC-F-03 | rating=9 → 400 |
|
||||||
|
| AC-S-01 | 无 token list → 401 |
|
||||||
|
| AC-S-02 | 仅 ask.read POST → 403 |
|
||||||
|
| AC-P-01 | list < 500ms |
|
||||||
|
| AC-O-01 | AuditLog ask.feedback.create |
|
||||||
@@ -24,3 +24,4 @@
|
|||||||
| ECR-017 | AskOperations | **Closed** | Spec ops-ask-operations · BD-2026-017 · migration 000019 · TEST_REPORT · CODE_REVIEW · Loop continuous |
|
| ECR-017 | AskOperations | **Closed** | Spec ops-ask-operations · BD-2026-017 · migration 000019 · TEST_REPORT · CODE_REVIEW · Loop continuous |
|
||||||
| ECR-018 | Entitlement | **Closed** | Spec ops-entitlement · BD-2026-018 · TEST_REPORT · CODE_REVIEW · Loop continuous |
|
| ECR-018 | Entitlement | **Closed** | Spec ops-entitlement · BD-2026-018 · TEST_REPORT · CODE_REVIEW · Loop continuous |
|
||||||
| ECR-019 | ContentSafety | **Closed** | Spec ops-content-safety · BD-2026-019 · migration 000020 · TEST_REPORT · CODE_REVIEW · Loop continuous |
|
| ECR-019 | ContentSafety | **Closed** | Spec ops-content-safety · BD-2026-019 · migration 000020 · TEST_REPORT · CODE_REVIEW · Loop continuous |
|
||||||
|
| ECR-020 | QualityFeedback | **Closed** | Spec ops-quality-feedback · BD-2026-020 · migration 000021 · TEST_REPORT · CODE_REVIEW · Loop continuous |
|
||||||
|
|||||||
@@ -30,4 +30,4 @@ Human 明文:**直接用 Loop,不用人工确认。**
|
|||||||
|
|
||||||
| Done | Next |
|
| Done | Next |
|
||||||
|------|------|
|
|------|------|
|
||||||
| ECR-013A…019 Closed | **ECR-020** QualityFeedback 薄切片(或 AICoreConfig);禁真支付/UGC |
|
| ECR-013A…020 Closed | **ECR-021** AICoreConfig 薄切片(SystemPrompt 只读优先);禁真支付/UGC |
|
||||||
|
|||||||
@@ -461,6 +461,72 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
|
|
||||||
|
/api/v1/admin/ask/feedback:
|
||||||
|
get:
|
||||||
|
tags: [admin]
|
||||||
|
summary: List QualityFeedback
|
||||||
|
description: Requires admin.ask.read
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
|
||||||
|
/api/v1/admin/ask/threads/{id}/feedback:
|
||||||
|
post:
|
||||||
|
tags: [admin]
|
||||||
|
summary: Create QualityFeedback (ops)
|
||||||
|
description: Requires admin.ask.feedback.write; writes AuditLog
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [rating]
|
||||||
|
properties:
|
||||||
|
rating: { type: integer, minimum: 1, maximum: 5 }
|
||||||
|
tag: { type: string, enum: [helpful, off_topic, unsafe, other] }
|
||||||
|
note: { type: string }
|
||||||
|
message_id: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
'400':
|
||||||
|
description: Invalid
|
||||||
|
'403':
|
||||||
|
description: Forbidden
|
||||||
|
|
||||||
|
/api/v1/ask/threads/{id}/feedback:
|
||||||
|
post:
|
||||||
|
tags: [ask]
|
||||||
|
summary: User QualityFeedback on owned thread
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [rating]
|
||||||
|
properties:
|
||||||
|
rating: { type: integer, minimum: 1, maximum: 5 }
|
||||||
|
tag: { type: string }
|
||||||
|
note: { type: string }
|
||||||
|
message_id: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
/api/v1/admin/orders:
|
/api/v1/admin/orders:
|
||||||
get:
|
get:
|
||||||
tags: [admin]
|
tags: [admin]
|
||||||
|
|||||||
Reference in New Issue
Block a user