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:
@@ -275,6 +275,26 @@ export const adminApi = {
|
||||
'/content-safety/evaluate',
|
||||
{ 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: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
|
||||
@@ -2,23 +2,34 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Thread = Awaited<ReturnType<typeof adminApi.askThreads>>['items'][number]
|
||||
type Detail = Awaited<ReturnType<typeof adminApi.askThread>>
|
||||
type Feedback = Awaited<ReturnType<typeof adminApi.askFeedback>>['items'][number]
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Thread[]>([])
|
||||
const selected = ref<Detail | null>(null)
|
||||
const detailErr = ref('')
|
||||
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() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.askThreads()
|
||||
items.value = res.items || []
|
||||
const [threads, fb] = await Promise.all([adminApi.askThreads(), adminApi.askFeedback()])
|
||||
items.value = threads.items || []
|
||||
feedback.value = fb.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
@@ -30,6 +41,7 @@ async function openThread(id: string) {
|
||||
detailLoading.value = true
|
||||
detailErr.value = ''
|
||||
selected.value = null
|
||||
fbMsg.value = ''
|
||||
try {
|
||||
selected.value = await adminApi.askThread(id)
|
||||
} 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) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
@@ -54,7 +84,7 @@ onMounted(load)
|
||||
<template>
|
||||
<section>
|
||||
<h1>问答会话</h1>
|
||||
<p class="muted">只读 AskSessionView · 不可改写消息</p>
|
||||
<p class="muted">AskSessionView · QualityFeedback · 不可改写消息</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
@@ -76,6 +106,13 @@ onMounted(load)
|
||||
</tr>
|
||||
</tbody>
|
||||
</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 class="card detail">
|
||||
<h2>会话详情</h2>
|
||||
@@ -93,6 +130,25 @@ onMounted(load)
|
||||
<time>{{ fmtTime(m.created_at) }}</time>
|
||||
</li>
|
||||
</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>
|
||||
<p v-else class="muted">选择左侧会话查看消息</p>
|
||||
</div>
|
||||
@@ -103,6 +159,8 @@ onMounted(load)
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
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 {
|
||||
display: grid;
|
||||
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 time { font-size: 0.75rem; color: var(--muted); }
|
||||
.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) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
||||
h.registerRedemption(authed)
|
||||
h.registerInsight(authed)
|
||||
h.registerAskOps(authed)
|
||||
h.registerQualityFeedback(authed)
|
||||
h.registerEntitlement(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.GET("/ask/threads/:id/messages", h.ListMessages)
|
||||
rg.POST("/ask/threads/:id/messages", h.SendMessage)
|
||||
h.registerFeedback(rg)
|
||||
}
|
||||
|
||||
// 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"
|
||||
PermMembershipCodesWrite = "admin.membership.codes.write"
|
||||
PermAskRead = "admin.ask.read"
|
||||
PermAskFeedbackWrite = "admin.ask.feedback.write"
|
||||
PermContentSafetyRead = "admin.content_safety.read"
|
||||
)
|
||||
|
||||
@@ -34,7 +35,7 @@ var knownPermissions = map[string]struct{}{
|
||||
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
||||
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
||||
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
|
||||
PermAskRead: {}, PermContentSafetyRead: {},
|
||||
PermAskRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user