chore: 合入 stash Ops hardening 与 migration 000041
Ask/catalog 权限与审计加固、量表读权限统一,以及未提交的 ops hardening 变更。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
func (h *AdminHandler) registerAskOps(authed *gin.RouterGroup) {
|
||||
authed.GET("/ask/threads", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListAskThreads)
|
||||
authed.GET("/ask/threads/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.GetAskThread)
|
||||
authed.GET("/ask/threads/:id/messages", middleware.RequireAdminPermission(h.Svc, admin.PermAskTranscriptRead), h.GetAskThreadMessages)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAskThreads(c *gin.Context) {
|
||||
@@ -44,7 +45,7 @@ func (h *AdminHandler) GetAskThread(c *gin.Context) {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
|
||||
return
|
||||
}
|
||||
detail, err := h.Svc.GetAskSessionDetail(c.Request.Context(), id)
|
||||
meta, err := h.Svc.GetAskSessionMeta(c.Request.Context(), id)
|
||||
if errors.Is(err, admin.ErrAskThreadNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
|
||||
return
|
||||
@@ -53,5 +54,28 @@ func (h *AdminHandler) GetAskThread(c *gin.Context) {
|
||||
response.Fail(c, http.StatusInternalServerError, 50020, "get ask thread failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, meta)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetAskThreadMessages(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id")
|
||||
return
|
||||
}
|
||||
detail, err := h.Svc.GetAskSessionTranscript(c.Request.Context(), adminID, id)
|
||||
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, 50021, "get ask transcript failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, detail)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func (h *AdminHandler) registerContent(authed *gin.RouterGroup) {
|
||||
authed.GET("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListHomeTools)
|
||||
authed.PUT("/home/tools", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ReplaceHomeTools)
|
||||
authed.GET("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.ListScales)
|
||||
authed.GET("/scales", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.ListScales)
|
||||
authed.PATCH("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermContentWrite), h.PatchScale)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
|
||||
func (h *AdminHandler) registerExploreScales(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/explore")
|
||||
g.GET("/scales", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListExploreScales)
|
||||
g.GET("/scales/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetExploreScale)
|
||||
g.GET("/scales", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.ListExploreScales)
|
||||
g.GET("/scales/:id", middleware.RequireAnyAdminPermission(h.Svc, admin.PermExploreRead, admin.PermContentWrite), h.GetExploreScale)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListExploreScales(c *gin.Context) {
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
|
||||
func (h *AdminHandler) registerFunnelDefinitions(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/analytics")
|
||||
g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.ListFunnelDefinitions)
|
||||
g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.GetFunnelDefinition)
|
||||
g.GET("/funnel-definitions", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListFunnelDefinitions)
|
||||
g.GET("/funnel-definitions/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetFunnelDefinition)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListFunnelDefinitions(c *gin.Context) {
|
||||
|
||||
@@ -68,6 +68,10 @@ func (h *AdminHandler) CreateAskFeedback(c *gin.Context) {
|
||||
response.Fail(c, http.StatusNotFound, 40402, "ask thread not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrMessageNotInThread) {
|
||||
response.Fail(c, http.StatusBadRequest, 40030, "message not in thread")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50026, "create feedback failed")
|
||||
return
|
||||
|
||||
@@ -60,6 +60,10 @@ func (h *AskHandler) SubmitFeedback(c *gin.Context) {
|
||||
response.Fail(c, http.StatusNotFound, 40410, msg)
|
||||
return
|
||||
}
|
||||
if strings.Contains(msg, "message not in thread") {
|
||||
response.Fail(c, http.StatusBadRequest, 40030, msg)
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, msg)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,7 +99,47 @@ func TestAskOperations(t *testing.T) {
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID, nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("detail http=%d msg=%s", code, env.Message)
|
||||
t.Fatalf("meta http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
var meta map[string]any
|
||||
_ = json.Unmarshal(env.Data, &meta)
|
||||
if _, has := meta["messages"]; has {
|
||||
t.Fatalf("meta must not include messages: %#v", meta)
|
||||
}
|
||||
if meta["id"] != threadID {
|
||||
t.Fatalf("meta id mismatch: %#v", meta)
|
||||
}
|
||||
|
||||
metaOnlyRole := uuid.New()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
metaOnlyRole, "ask_meta_"+metaOnlyRole.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')`, metaOnlyRole)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metaUser := fmt.Sprintf("askmeta_%d", time.Now().UnixNano())
|
||||
hash2, _ := bcrypt.GenerateFromPassword([]byte("meta-pass"), bcrypt.DefaultCost)
|
||||
_, err = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
metaUser, string(hash2), metaOnlyRole)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, metaUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, metaOnlyRole)
|
||||
})
|
||||
metaTok := adminLogin(t, r, metaUser, "meta-pass")
|
||||
_, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID+"/messages", nil, metaTok)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 without transcript.read, got %d", code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID+"/messages", nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("transcript http=%d msg=%s", code, env.Message)
|
||||
}
|
||||
var detail struct {
|
||||
Messages []struct {
|
||||
@@ -111,5 +151,26 @@ func TestAskOperations(t *testing.T) {
|
||||
if len(detail.Messages) < 2 {
|
||||
t.Fatalf("expected user+assistant, got %#v", detail.Messages)
|
||||
}
|
||||
|
||||
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)
|
||||
audited := false
|
||||
for _, it := range audit.Items {
|
||||
if it.Action == "ask.transcript.read" {
|
||||
audited = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !audited {
|
||||
t.Fatalf("missing ask.transcript.read audit: %#v", audit.Items)
|
||||
}
|
||||
_ = key
|
||||
}
|
||||
|
||||
@@ -132,5 +132,34 @@ func TestQualityFeedback(t *testing.T) {
|
||||
if !okAudit {
|
||||
t.Fatal("missing ask.feedback.create audit")
|
||||
}
|
||||
|
||||
// cross-thread message_id must be rejected
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
|
||||
"profile_id": profileID, "scene": "self",
|
||||
}, key)
|
||||
otherThread := decodeData[map[string]any](t, env.Data)["id"].(string)
|
||||
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+otherThread+"/messages", map[string]any{
|
||||
"content": "另一线程",
|
||||
}, key)
|
||||
// fetch a message id from other thread via admin transcript
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+otherThread+"/messages", nil, tok)
|
||||
if code != 200 {
|
||||
t.Fatalf("other transcript http=%d", code)
|
||||
}
|
||||
var otherDetail struct {
|
||||
Messages []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &otherDetail)
|
||||
if len(otherDetail.Messages) == 0 {
|
||||
t.Fatal("expected messages on other thread")
|
||||
}
|
||||
foreignMsg := otherDetail.Messages[0].ID
|
||||
_, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/ask/threads/"+threadID+"/feedback",
|
||||
map[string]any{"rating": 2, "tag": "other", "message_id": foreignMsg}, tok)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for foreign message_id, got %d", code)
|
||||
}
|
||||
_ = key
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ type AdminPermissionChecker interface {
|
||||
|
||||
// RequireAdminPermission aborts with 403 when the admin lacks code.
|
||||
func RequireAdminPermission(checker AdminPermissionChecker, code string) gin.HandlerFunc {
|
||||
return RequireAnyAdminPermission(checker, code)
|
||||
}
|
||||
|
||||
// RequireAnyAdminPermission aborts with 403 when the admin lacks all of codes.
|
||||
func RequireAnyAdminPermission(checker AdminPermissionChecker, codes ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
adminID, ok := AdminIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -25,18 +30,27 @@ func RequireAdminPermission(checker AdminPermissionChecker, code string) gin.Han
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
okPerm, err := checker.HasPermission(c.Request.Context(), adminID, code)
|
||||
if err != nil {
|
||||
if len(codes) == 0 {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, "permission check failed")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !okPerm {
|
||||
checker.DenyPermission(c.Request.Context(), adminID, code, c.FullPath())
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
c.Abort()
|
||||
return
|
||||
var lastCode string
|
||||
for _, code := range codes {
|
||||
lastCode = code
|
||||
okPerm, err := checker.HasPermission(c.Request.Context(), adminID, code)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50000, "permission check failed")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if okPerm {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
checker.DenyPermission(c.Request.Context(), adminID, lastCode, c.FullPath())
|
||||
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func (r *AdminRepo) ListSystemPrompts(ctx context.Context) ([]SystemPromptRow, e
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, scene, body, version, active, system, updated_at
|
||||
FROM system_prompts
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func (r *AdminRepo) ListKnowledgeSources(ctx context.Context) ([]KnowledgeSource
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, description, source_kind, version, active, system, updated_at
|
||||
FROM knowledge_sources
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListBlockPolicies(ctx context.Context) ([]BlockPolicyRow, er
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, action, active, system, updated_at
|
||||
FROM block_policies
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (r *AdminRepo) ListBanners(ctx context.Context) ([]BannerRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at
|
||||
FROM ops_banners
|
||||
ORDER BY active DESC, sort_order ASC, code ASC`)
|
||||
ORDER BY active DESC, sort_order ASC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func (r *AdminRepo) ListFeedSlots(ctx context.Context) ([]FeedSlotRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, slot_key, placement, active, system, updated_at
|
||||
FROM ops_feed_slots
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func (r *AdminRepo) ListFilterRules(ctx context.Context) ([]FilterRuleRow, error
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, category, pattern, action, active, system, updated_at
|
||||
FROM filter_rules
|
||||
ORDER BY active DESC, category ASC, code ASC`)
|
||||
ORDER BY active DESC, category ASC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListCrisisEvents(ctx context.Context) ([]CrisisEventRow, err
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, severity, active, system, updated_at
|
||||
FROM crisis_events
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (r *AdminRepo) ListCrisisPolicies(ctx context.Context) ([]CrisisPolicyRow,
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, severity, pattern, action, helpline_text, active, system, updated_at
|
||||
FROM crisis_policies
|
||||
ORDER BY active DESC, severity DESC, code ASC`)
|
||||
ORDER BY active DESC, severity DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListFunnelDefinitions(ctx context.Context) ([]FunnelDefiniti
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM funnel_definitions
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListHandoffCases(ctx context.Context) ([]HandoffCaseRow, err
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, status, active, system, updated_at
|
||||
FROM ask_handoff_cases
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListImageCardDecks(ctx context.Context) ([]ImageCardDeckRow,
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM image_card_decks
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListInterventionOutcomes(ctx context.Context) ([]Interventio
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, outcome, active, system, updated_at
|
||||
FROM intervention_outcomes
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListKnowledgeChunks(ctx context.Context) ([]KnowledgeChunkRo
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, source_code, title, body, active, system, updated_at
|
||||
FROM knowledge_chunks
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListModerationCases(ctx context.Context) ([]ModerationCaseRo
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, status, active, system, updated_at
|
||||
FROM moderation_cases
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListPrivacyRequests(ctx context.Context) ([]PrivacyRequestRo
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, kind, status, active, system, updated_at
|
||||
FROM privacy_requests
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -76,6 +76,15 @@ func (r *AdminRepo) CreateAdminQualityFeedback(
|
||||
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
|
||||
@@ -117,6 +126,18 @@ func (r *AskRepo) CreateUserQualityFeedback(
|
||||
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)
|
||||
@@ -138,6 +159,17 @@ func (r *AdminRepo) askThreadExists(ctx context.Context, threadID uuid.UUID) (bo
|
||||
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")
|
||||
|
||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListReportTemplates(ctx context.Context) ([]ReportTemplateRo
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, scene, active, system, updated_at
|
||||
FROM report_templates
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListRhythmConfigs(ctx context.Context) ([]RhythmConfigRow, e
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM rhythm_configs
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ type ScaleAdminItem struct {
|
||||
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`)
|
||||
WHERE deleted_at IS NULL ORDER BY created_at LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func (r *AdminRepo) ListScheduledPublications(ctx context.Context) ([]ScheduledP
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, target_kind, target_code, active, system, updated_at
|
||||
FROM ops_scheduled_publications
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (r *AdminRepo) ListStarConfigs(ctx context.Context) ([]StarConfigRow, error
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, active, system, updated_at
|
||||
FROM star_configs
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func (r *AdminRepo) ListToolDefinitions(ctx context.Context) ([]ToolDefinitionRo
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, description, active, system, updated_at
|
||||
FROM tool_definitions
|
||||
ORDER BY active DESC, code ASC`)
|
||||
ORDER BY active DESC, code ASC LIMIT 500`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// AskSessionDetail is AskSessionView plus messages.
|
||||
// AskSessionDetail is AskSessionView plus messages (transcript).
|
||||
type AskSessionDetail struct {
|
||||
repository.AskSessionView
|
||||
Messages []repository.AskMessageView `json:"messages"`
|
||||
@@ -30,8 +31,20 @@ func (s *Service) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetAskSessionDetail loads meta + messages.
|
||||
func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (*AskSessionDetail, error) {
|
||||
// GetAskSessionMeta loads thread meta without message bodies.
|
||||
func (s *Service) GetAskSessionMeta(ctx context.Context, threadID uuid.UUID) (*repository.AskSessionView, error) {
|
||||
view, err := s.Repo.GetAskSession(ctx, threadID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrAskThreadNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// GetAskSessionTranscript loads message bodies and audits access.
|
||||
func (s *Service) GetAskSessionTranscript(ctx context.Context, adminID, threadID uuid.UUID) (*AskSessionDetail, error) {
|
||||
view, err := s.Repo.GetAskSession(ctx, threadID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrAskThreadNotFound
|
||||
@@ -46,5 +59,10 @@ func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (
|
||||
if msgs == nil {
|
||||
msgs = []repository.AskMessageView{}
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{
|
||||
"message_count": len(msgs),
|
||||
"user_id": view.UserID.String(),
|
||||
})
|
||||
_ = s.Repo.InsertAudit(ctx, adminID, "ask.transcript.read", "ask_thread", threadID.String(), meta)
|
||||
return &AskSessionDetail{AskSessionView: *view, Messages: msgs}, nil
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBadFeedbackRating = errString("rating must be 1-5")
|
||||
ErrBadFeedbackTag = errString("invalid tag")
|
||||
ErrFeedbackNoteLong = errString("note too long")
|
||||
ErrBadFeedbackRating = errString("rating must be 1-5")
|
||||
ErrBadFeedbackTag = errString("invalid tag")
|
||||
ErrFeedbackNoteLong = errString("note too long")
|
||||
ErrMessageNotInThread = errString("message not in thread")
|
||||
)
|
||||
|
||||
// ListQualityFeedback lists recent QualityFeedback.
|
||||
@@ -54,6 +55,8 @@ func (s *Service) CreateQualityFeedback(
|
||||
return nil, ErrFeedbackNoteLong
|
||||
case strings.Contains(msg, "thread not found"):
|
||||
return nil, ErrAskThreadNotFound
|
||||
case strings.Contains(msg, "message not in thread"):
|
||||
return nil, ErrMessageNotInThread
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
PermMembershipCodesRead = "admin.membership.codes.read"
|
||||
PermMembershipCodesWrite = "admin.membership.codes.write"
|
||||
PermAskRead = "admin.ask.read"
|
||||
PermAskTranscriptRead = "admin.ask.transcript.read"
|
||||
PermAskFeedbackWrite = "admin.ask.feedback.write"
|
||||
PermContentSafetyRead = "admin.content_safety.read"
|
||||
PermAIConfigRead = "admin.ai_config.read"
|
||||
@@ -41,7 +42,7 @@ var knownPermissions = map[string]struct{}{
|
||||
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
||||
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
||||
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
|
||||
PermAskRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
|
||||
PermAskRead: {}, PermAskTranscriptRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
|
||||
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {},
|
||||
}
|
||||
|
||||
|
||||
@@ -38,5 +38,8 @@ func (s *Service) SubmitFeedback(ctx context.Context, userID, threadID uuid.UUID
|
||||
if strings.Contains(msg, "thread not found") {
|
||||
return nil, errors.New("ask thread not found")
|
||||
}
|
||||
if strings.Contains(msg, "message not in thread") {
|
||||
return nil, errors.New("message not in thread")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user