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:
jackyu66git
2026-08-07 23:02:13 +08:00
co-authored by Cursor
parent e270a38393
commit 93227d3316
34 changed files with 932 additions and 9 deletions
+1
View File
@@ -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)
}
+1
View File
@@ -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.
+67
View File
@@ -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
}
}
+2 -1
View File
@@ -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 (
+42
View File
@@ -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;