Files
digital-psychology/apps/api/internal/repository/quality_feedback_repo.go
T
jackyu66gitandCursor 93227d3316 feat(ECR-020): QualityFeedback 问答质量反馈并 Closed
新增 ask_quality_feedback、运营/C端评分 API 与 admin-h5 问答反馈区;禁改消息/UGC/真支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 23:02:13 +08:00

171 lines
5.1 KiB
Go

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
}