Ask/catalog 权限与审计加固、量表读权限统一,以及未提交的 ops hardening 变更。 Co-authored-by: Cursor <cursoragent@cursor.com>
203 lines
5.9 KiB
Go
203 lines
5.9 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")
|
|
}
|
|
if messageID != nil {
|
|
inThread, err := r.askMessageInThread(ctx, threadID, *messageID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !inThread {
|
|
return nil, errors.New("message not in thread")
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
if messageID != nil {
|
|
var n int
|
|
err = r.Pool.QueryRow(ctx, `
|
|
SELECT 1 FROM ask_messages
|
|
WHERE id=$1 AND thread_id=$2 AND deleted_at IS NULL`, *messageID, threadID).Scan(&n)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, errors.New("message not in thread")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
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 (r *AdminRepo) askMessageInThread(ctx context.Context, threadID, messageID uuid.UUID) (bool, error) {
|
|
var n int
|
|
err := r.Pool.QueryRow(ctx, `
|
|
SELECT 1 FROM ask_messages
|
|
WHERE id=$1 AND thread_id=$2 AND deleted_at IS NULL`, messageID, 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
|
|
}
|