Ask/catalog 权限与审计加固、量表读权限统一,以及未提交的 ops hardening 变更。 Co-authored-by: Cursor <cursoragent@cursor.com>
232 lines
6.6 KiB
Go
232 lines
6.6 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ScaleListItem is a published 探索测试.
|
|
type ScaleListItem struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// ScaleQuestion is one item in a scale.
|
|
type ScaleQuestion struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Sort int `json:"sort"`
|
|
Body json.RawMessage `json:"body"`
|
|
}
|
|
|
|
// ScaleDetail is scale + questions.
|
|
type ScaleDetail struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Access string `json:"access,omitempty"` // free | membership
|
|
Locked bool `json:"locked,omitempty"`
|
|
Questions []ScaleQuestion `json:"questions"`
|
|
}
|
|
|
|
// ScaleResultRow stored result.
|
|
type ScaleResultRow struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ScaleSlug string `json:"scale_slug"`
|
|
Result json.RawMessage `json:"result"`
|
|
}
|
|
|
|
// ScaleRepo loads scales and results.
|
|
type ScaleRepo struct {
|
|
Pool *pgxpool.Pool
|
|
}
|
|
|
|
// ListPublished returns published scales.
|
|
func (r *ScaleRepo) ListPublished(ctx context.Context) ([]ScaleListItem, error) {
|
|
rows, err := r.Pool.Query(ctx, `
|
|
SELECT slug, title, description FROM scales
|
|
WHERE status='published' AND deleted_at IS NULL ORDER BY created_at`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []ScaleListItem
|
|
for rows.Next() {
|
|
var it ScaleListItem
|
|
if err := rows.Scan(&it.Slug, &it.Title, &it.Description); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetBySlug loads a published scale with questions.
|
|
func (r *ScaleRepo) GetBySlug(ctx context.Context, slug string) (*ScaleDetail, error) {
|
|
d := &ScaleDetail{Slug: slug}
|
|
var scaleID uuid.UUID
|
|
err := r.Pool.QueryRow(ctx, `
|
|
SELECT id, title, description FROM scales
|
|
WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug,
|
|
).Scan(&scaleID, &d.Title, &d.Description)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := r.Pool.Query(ctx, `
|
|
SELECT id, sort, body FROM scale_questions
|
|
WHERE scale_id=$1 AND deleted_at IS NULL ORDER BY sort`, scaleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var q ScaleQuestion
|
|
if err := rows.Scan(&q.ID, &q.Sort, &q.Body); err != nil {
|
|
return nil, err
|
|
}
|
|
d.Questions = append(d.Questions, q)
|
|
}
|
|
return d, rows.Err()
|
|
}
|
|
|
|
// ScaleAdminItem is a scale row for ops.
|
|
type ScaleAdminItem struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// ListAllAdmin returns all non-deleted scales.
|
|
func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error) {
|
|
rows, err := r.Pool.Query(ctx, `
|
|
SELECT id, slug, title, description, status FROM scales
|
|
WHERE deleted_at IS NULL ORDER BY created_at LIMIT 500`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []ScaleAdminItem
|
|
for rows.Next() {
|
|
var it ScaleAdminItem
|
|
if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Description, &it.Status); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetAdmin loads one scale by id for ops read.
|
|
func (r *ScaleRepo) GetAdmin(ctx context.Context, id uuid.UUID) (*ScaleAdminItem, error) {
|
|
var it ScaleAdminItem
|
|
err := r.Pool.QueryRow(ctx, `
|
|
SELECT id, slug, title, description, status FROM scales
|
|
WHERE id=$1 AND deleted_at IS NULL`, id,
|
|
).Scan(&it.ID, &it.Slug, &it.Title, &it.Description, &it.Status)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &it, nil
|
|
}
|
|
|
|
// UpdateStatus sets published|draft.
|
|
func (r *ScaleRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status string) error {
|
|
return r.UpdateStatusWithAudit(ctx, id, status, uuid.Nil, nil)
|
|
}
|
|
|
|
// UpdateStatusWithAudit updates status and optionally writes audit in one tx.
|
|
func (r *ScaleRepo) UpdateStatusWithAudit(
|
|
ctx context.Context,
|
|
id uuid.UUID,
|
|
status string,
|
|
adminID uuid.UUID,
|
|
meta json.RawMessage,
|
|
) error {
|
|
tx, err := r.Pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE scales SET status=$2, updated_at=now()
|
|
WHERE id=$1 AND deleted_at IS NULL`, id, status)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return pgx.ErrNoRows
|
|
}
|
|
if adminID != uuid.Nil {
|
|
if meta == nil {
|
|
meta = json.RawMessage(`{}`)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
|
VALUES ($1,'scale.status','scale',$2,$3)`, adminID, id.String(), meta); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// SaveResult stores scoring output.
|
|
func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID uuid.UUID, answers, result json.RawMessage) (uuid.UUID, error) {
|
|
var id uuid.UUID
|
|
err := r.Pool.QueryRow(ctx, `
|
|
INSERT INTO scale_results(user_id, scale_id, profile_id, answers, result)
|
|
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
|
|
userID, scaleID, profileID, answers, result,
|
|
).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
// LatestResult returns the newest result for user + published slug.
|
|
func (r *ScaleRepo) LatestResult(ctx context.Context, userID uuid.UUID, slug string) (*ScaleResultRow, error) {
|
|
var row ScaleResultRow
|
|
err := r.Pool.QueryRow(ctx, `
|
|
SELECT sr.id, s.slug, sr.result
|
|
FROM scale_results sr
|
|
JOIN scales s ON s.id = sr.scale_id
|
|
WHERE sr.user_id = $1 AND s.slug = $2 AND s.deleted_at IS NULL
|
|
AND sr.deleted_at IS NULL
|
|
ORDER BY sr.created_at DESC
|
|
LIMIT 1`, userID, slug,
|
|
).Scan(&row.ID, &row.ScaleSlug, &row.Result)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &row, nil
|
|
}
|
|
|
|
// ScaleIDBySlug resolves id for a published scale.
|
|
func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) {
|
|
var id uuid.UUID
|
|
err := r.Pool.QueryRow(ctx, `
|
|
SELECT id FROM scales
|
|
WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
// EnsurePublished upserts scale metadata (curated bank stubs; questions served from embed).
|
|
func (r *ScaleRepo) EnsurePublished(ctx context.Context, slug, title, description string) (uuid.UUID, error) {
|
|
var id uuid.UUID
|
|
err := r.Pool.QueryRow(ctx, `
|
|
INSERT INTO scales (slug, title, description, status)
|
|
VALUES ($1,$2,$3,'published')
|
|
ON CONFLICT (slug) DO UPDATE SET
|
|
title=EXCLUDED.title,
|
|
description=EXCLUDED.description,
|
|
status='published',
|
|
updated_at=now(),
|
|
deleted_at=NULL
|
|
RETURNING id`, slug, title, description,
|
|
).Scan(&id)
|
|
return id, err
|
|
}
|