package app import ( "context" "encoding/json" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/geo-platform/tenant-api/internal/shared/auditlog" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/middleware" "github.com/geo-platform/tenant-api/internal/shared/response" ) type PromptRuleService struct { pool *pgxpool.Pool auditLogs *auditlog.AsyncWriter } func NewPromptRuleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *PromptRuleService { return &PromptRuleService{pool: pool, auditLogs: auditLogs} } // --- Group types --- type PromptRuleGroupRequest struct { Name string `json:"name" binding:"required"` SortOrder *int `json:"sort_order"` } type PromptRuleGroupResponse struct { ID int64 `json:"id"` Name string `json:"name"` SortOrder int `json:"sort_order"` CreatedAt string `json:"created_at"` } // --- Rule types --- type PromptRuleRequest struct { GroupID *int64 `json:"group_id"` Name string `json:"name" binding:"required"` PromptContent string `json:"prompt_content" binding:"required"` Scene *string `json:"scene"` DefaultTone *string `json:"default_tone"` DefaultWordCount *int `json:"default_word_count"` KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"` } type PromptRuleKnowledgeGroup struct { ID int64 `json:"id"` Name string `json:"name"` } type PromptRuleResponse struct { ID int64 `json:"id"` GroupID *int64 `json:"group_id"` Name string `json:"name"` PromptContent string `json:"prompt_content"` Scene *string `json:"scene"` DefaultTone *string `json:"default_tone"` DefaultWordCount *int `json:"default_word_count"` KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"` KnowledgeGroups []PromptRuleKnowledgeGroup `json:"knowledge_groups"` Status string `json:"status"` ArticleCount int `json:"article_count"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type PromptRuleListParams struct { GroupID *int64 `json:"group_id"` Status *string `json:"status"` Keyword *string `json:"keyword"` Page int `json:"page"` PageSize int `json:"page_size"` Ungrouped bool `json:"ungrouped"` } type PromptRuleListResponse struct { Items []PromptRuleResponse `json:"items"` Total int64 `json:"total"` } type PromptRuleStatusRequest struct { Status string `json:"status" binding:"required,oneof=enabled disabled"` } type PromptRuleSimple struct { ID int64 `json:"id"` Name string `json:"name"` } // --- Group CRUD --- func (s *PromptRuleService) ListGroups(ctx context.Context) ([]PromptRuleGroupResponse, error) { actor := auth.MustActor(ctx) rows, err := s.pool.Query(ctx, ` SELECT id, name, sort_order, created_at FROM prompt_rule_groups WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY sort_order, created_at `, actor.TenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list groups") } defer rows.Close() var groups []PromptRuleGroupResponse for rows.Next() { var g PromptRuleGroupResponse var ca interface{} if err := rows.Scan(&g.ID, &g.Name, &g.SortOrder, &ca); err != nil { return nil, response.ErrInternal(50010, "scan_failed", err.Error()) } g.CreatedAt = fmt.Sprintf("%v", ca) groups = append(groups, g) } if groups == nil { groups = []PromptRuleGroupResponse{} } return groups, nil } func (s *PromptRuleService) CreateGroup(ctx context.Context, req PromptRuleGroupRequest) (*PromptRuleGroupResponse, error) { actor := auth.MustActor(ctx) sortOrder := 0 if req.SortOrder != nil { sortOrder = *req.SortOrder } var id int64 var ca interface{} err := s.pool.QueryRow(ctx, ` INSERT INTO prompt_rule_groups (tenant_id, name, sort_order) VALUES ($1, $2, $3) RETURNING id, created_at `, actor.TenantID, req.Name, sortOrder).Scan(&id, &ca) if err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to create group") } afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name}) result := "success" resourceType := "prompt_rule_group" requestID := middleware.RequestIDFromContext(ctx) s.auditLogs.Log(auditlog.Entry{ OperatorID: actor.UserID, TenantID: &actor.TenantID, Module: "prompt_rule", Action: "create_group", ResourceType: &resourceType, ResourceID: &id, RequestID: nilIfEmptyString(requestID), AfterJSON: afterJSON, Result: &result, }) return &PromptRuleGroupResponse{ID: id, Name: req.Name, SortOrder: sortOrder, CreatedAt: fmt.Sprintf("%v", ca)}, nil } func (s *PromptRuleService) UpdateGroup(ctx context.Context, id int64, req PromptRuleGroupRequest) error { actor := auth.MustActor(ctx) sortOrder := 0 if req.SortOrder != nil { sortOrder = *req.SortOrder } tag, err := s.pool.Exec(ctx, ` UPDATE prompt_rule_groups SET name = $1, sort_order = $2, updated_at = NOW() WHERE id = $3 AND tenant_id = $4 AND deleted_at IS NULL `, req.Name, sortOrder, id, actor.TenantID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40430, "group_not_found", "prompt rule group not found") } return nil } func (s *PromptRuleService) DeleteGroup(ctx context.Context, id int64) error { actor := auth.MustActor(ctx) // Move rules in this group to ungrouped _, _ = s.pool.Exec(ctx, ` UPDATE prompt_rules SET group_id = NULL, updated_at = NOW() WHERE group_id = $1 AND tenant_id = $2 AND deleted_at IS NULL `, id, actor.TenantID) tag, err := s.pool.Exec(ctx, ` UPDATE prompt_rule_groups SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL `, id, actor.TenantID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40430, "group_not_found", "prompt rule group not found") } afterJSON, _ := json.Marshal(map[string]interface{}{"id": id}) result := "success" resourceType := "prompt_rule_group" requestID := middleware.RequestIDFromContext(ctx) s.auditLogs.Log(auditlog.Entry{ OperatorID: actor.UserID, TenantID: &actor.TenantID, Module: "prompt_rule", Action: "delete_group", ResourceType: &resourceType, ResourceID: &id, RequestID: nilIfEmptyString(requestID), AfterJSON: afterJSON, Result: &result, }) return nil } // --- Rule CRUD --- func (s *PromptRuleService) List(ctx context.Context, params PromptRuleListParams) (*PromptRuleListResponse, error) { actor := auth.MustActor(ctx) if params.Page < 1 { params.Page = 1 } if params.PageSize < 1 || params.PageSize > 100 { params.PageSize = 20 } offset := (params.Page - 1) * params.PageSize var total int64 var rows interface{ Close() } var queryErr error if params.Ungrouped { err := s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM prompt_rules WHERE tenant_id = $1 AND deleted_at IS NULL AND group_id IS NULL `, actor.TenantID).Scan(&total) if err != nil { return nil, response.ErrInternal(50010, "count_failed", "failed to count rules") } r, err := s.pool.Query(ctx, ` SELECT pr.id, pr.group_id, pr.name, pr.prompt_content, pr.scene, pr.default_tone, pr.default_word_count, pr.status, pr.created_at, pr.updated_at, COUNT(a.id)::INT AS article_count FROM prompt_rules pr LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.deleted_at IS NULL WHERE pr.tenant_id = $1 AND pr.deleted_at IS NULL AND pr.group_id IS NULL GROUP BY pr.id ORDER BY pr.created_at DESC LIMIT $2 OFFSET $3 `, actor.TenantID, params.PageSize, offset) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list rules") } rows = r queryErr = err } else { err := s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM prompt_rules WHERE tenant_id = $1 AND deleted_at IS NULL AND ($2::bigint IS NULL OR group_id = $2) AND ($3::text IS NULL OR status = $3) AND ($4::text IS NULL OR name ILIKE '%' || $4 || '%') `, actor.TenantID, params.GroupID, params.Status, params.Keyword).Scan(&total) if err != nil { return nil, response.ErrInternal(50010, "count_failed", "failed to count rules") } r, err := s.pool.Query(ctx, ` SELECT pr.id, pr.group_id, pr.name, pr.prompt_content, pr.scene, pr.default_tone, pr.default_word_count, pr.status, pr.created_at, pr.updated_at, COUNT(a.id)::INT AS article_count FROM prompt_rules pr LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.deleted_at IS NULL WHERE pr.tenant_id = $1 AND pr.deleted_at IS NULL AND ($2::bigint IS NULL OR pr.group_id = $2) AND ($3::text IS NULL OR pr.status = $3) AND ($4::text IS NULL OR pr.name ILIKE '%' || $4 || '%') GROUP BY pr.id ORDER BY pr.created_at DESC LIMIT $5 OFFSET $6 `, actor.TenantID, params.GroupID, params.Status, params.Keyword, params.PageSize, offset) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list rules") } rows = r queryErr = err } _ = queryErr pgxRows := rows.(interface { Close() Next() bool Scan(dest ...interface{}) error Err() error }) defer pgxRows.Close() var items []PromptRuleResponse for pgxRows.Next() { var r PromptRuleResponse var ca, ua interface{} if err := pgxRows.Scan(&r.ID, &r.GroupID, &r.Name, &r.PromptContent, &r.Scene, &r.DefaultTone, &r.DefaultWordCount, &r.Status, &ca, &ua, &r.ArticleCount); err != nil { return nil, response.ErrInternal(50010, "scan_failed", err.Error()) } r.CreatedAt = fmt.Sprintf("%v", ca) r.UpdatedAt = fmt.Sprintf("%v", ua) r.KnowledgeGroupIDs = []int64{} r.KnowledgeGroups = []PromptRuleKnowledgeGroup{} items = append(items, r) } if items == nil { items = []PromptRuleResponse{} } groupMap, err := s.loadPromptRuleKnowledgeGroupMap(ctx, actor.TenantID, collectPromptRuleIDs(items)) if err != nil { return nil, err } for index := range items { items[index].KnowledgeGroups = groupMap[items[index].ID] items[index].KnowledgeGroupIDs = promptRuleKnowledgeGroupIDs(items[index].KnowledgeGroups) } return &PromptRuleListResponse{Items: items, Total: total}, nil } func (s *PromptRuleService) Detail(ctx context.Context, id int64) (*PromptRuleResponse, error) { actor := auth.MustActor(ctx) var r PromptRuleResponse var ca, ua interface{} var articleCount int err := s.pool.QueryRow(ctx, ` SELECT pr.id, pr.group_id, pr.name, pr.prompt_content, pr.scene, pr.default_tone, pr.default_word_count, pr.status, pr.created_at, pr.updated_at, (SELECT COUNT(*) FROM articles WHERE prompt_rule_id = pr.id AND deleted_at IS NULL)::INT FROM prompt_rules pr WHERE pr.id = $1 AND pr.tenant_id = $2 AND pr.deleted_at IS NULL `, id, actor.TenantID).Scan(&r.ID, &r.GroupID, &r.Name, &r.PromptContent, &r.Scene, &r.DefaultTone, &r.DefaultWordCount, &r.Status, &ca, &ua, &articleCount) if err != nil { return nil, response.ErrNotFound(40431, "rule_not_found", "prompt rule not found") } r.CreatedAt = fmt.Sprintf("%v", ca) r.UpdatedAt = fmt.Sprintf("%v", ua) r.ArticleCount = articleCount groupMap, groupErr := s.loadPromptRuleKnowledgeGroupMap(ctx, actor.TenantID, []int64{id}) if groupErr != nil { return nil, groupErr } r.KnowledgeGroups = groupMap[id] r.KnowledgeGroupIDs = promptRuleKnowledgeGroupIDs(r.KnowledgeGroups) return &r, nil } func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (*PromptRuleResponse, error) { actor := auth.MustActor(ctx) knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, req.KnowledgeGroupIDs) if err != nil { return nil, err } tx, err := s.pool.Begin(ctx) if err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to create prompt rule") } defer func() { _ = tx.Rollback(ctx) }() var id int64 var ca interface{} err = tx.QueryRow(ctx, ` INSERT INTO prompt_rules (tenant_id, group_id, name, prompt_content, scene, default_tone, default_word_count) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at `, actor.TenantID, req.GroupID, req.Name, req.PromptContent, req.Scene, req.DefaultTone, req.DefaultWordCount).Scan(&id, &ca) if err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to create prompt rule") } if err := s.replacePromptRuleKnowledgeGroups(ctx, tx, id, promptRuleKnowledgeGroupIDs(knowledgeGroups)); err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to commit prompt rule") } afterJSON, _ := json.Marshal(map[string]interface{}{"id": id, "name": req.Name}) result := "success" resourceType := "prompt_rule" requestID := middleware.RequestIDFromContext(ctx) s.auditLogs.Log(auditlog.Entry{ OperatorID: actor.UserID, TenantID: &actor.TenantID, Module: "prompt_rule", Action: "create", ResourceType: &resourceType, ResourceID: &id, RequestID: nilIfEmptyString(requestID), AfterJSON: afterJSON, Result: &result, }) return &PromptRuleResponse{ ID: id, GroupID: req.GroupID, Name: req.Name, PromptContent: req.PromptContent, Scene: req.Scene, DefaultTone: req.DefaultTone, DefaultWordCount: req.DefaultWordCount, KnowledgeGroupIDs: promptRuleKnowledgeGroupIDs(knowledgeGroups), KnowledgeGroups: knowledgeGroups, Status: "enabled", ArticleCount: 0, CreatedAt: fmt.Sprintf("%v", ca), UpdatedAt: fmt.Sprintf("%v", ca), }, nil } func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRuleRequest) error { actor := auth.MustActor(ctx) knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, req.KnowledgeGroupIDs) if err != nil { return err } tx, err := s.pool.Begin(ctx) if err != nil { return response.ErrInternal(50010, "update_failed", "failed to update prompt rule") } defer func() { _ = tx.Rollback(ctx) }() tag, err := tx.Exec(ctx, ` UPDATE prompt_rules SET name = $1, group_id = $2, prompt_content = $3, scene = $4, default_tone = $5, default_word_count = $6, updated_at = NOW() WHERE id = $7 AND tenant_id = $8 AND deleted_at IS NULL `, req.Name, req.GroupID, req.PromptContent, req.Scene, req.DefaultTone, req.DefaultWordCount, id, actor.TenantID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found") } if err := s.replacePromptRuleKnowledgeGroups(ctx, tx, id, promptRuleKnowledgeGroupIDs(knowledgeGroups)); err != nil { return err } if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50010, "update_failed", "failed to commit prompt rule") } return nil } func (s *PromptRuleService) Delete(ctx context.Context, id int64) error { actor := auth.MustActor(ctx) tag, err := s.pool.Exec(ctx, ` UPDATE prompt_rules SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL `, id, actor.TenantID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found") } afterJSON, _ := json.Marshal(map[string]interface{}{"id": id}) result := "success" resourceType := "prompt_rule" requestID := middleware.RequestIDFromContext(ctx) s.auditLogs.Log(auditlog.Entry{ OperatorID: actor.UserID, TenantID: &actor.TenantID, Module: "prompt_rule", Action: "delete", ResourceType: &resourceType, ResourceID: &id, RequestID: nilIfEmptyString(requestID), AfterJSON: afterJSON, Result: &result, }) return nil } func (s *PromptRuleService) ToggleStatus(ctx context.Context, id int64, req PromptRuleStatusRequest) error { actor := auth.MustActor(ctx) tag, err := s.pool.Exec(ctx, ` UPDATE prompt_rules SET status = $1, updated_at = NOW() WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL `, req.Status, id, actor.TenantID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found") } return nil } func (s *PromptRuleService) ListSimple(ctx context.Context) ([]PromptRuleSimple, error) { actor := auth.MustActor(ctx) rows, err := s.pool.Query(ctx, ` SELECT id, name FROM prompt_rules WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'enabled' ORDER BY name `, actor.TenantID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list rules") } defer rows.Close() var items []PromptRuleSimple for rows.Next() { var r PromptRuleSimple if err := rows.Scan(&r.ID, &r.Name); err != nil { return nil, response.ErrInternal(50010, "scan_failed", err.Error()) } items = append(items, r) } if items == nil { items = []PromptRuleSimple{} } return items, nil } func (s *PromptRuleService) validatePromptRuleKnowledgeGroups( ctx context.Context, tenantID int64, groupIDs []int64, ) ([]PromptRuleKnowledgeGroup, error) { groupIDs = normalizeInt64IDs(groupIDs) if len(groupIDs) == 0 { return []PromptRuleKnowledgeGroup{}, nil } rows, err := s.pool.Query(ctx, ` SELECT id, name FROM knowledge_groups WHERE tenant_id = $1 AND id = ANY($2::bigint[]) AND deleted_at IS NULL ORDER BY sort_order, created_at, id `, tenantID, groupIDs) if err != nil { return nil, response.ErrInternal(50010, "knowledge_group_query_failed", "failed to query knowledge groups") } defer rows.Close() groups := make([]PromptRuleKnowledgeGroup, 0, len(groupIDs)) found := make(map[int64]struct{}, len(groupIDs)) for rows.Next() { var item PromptRuleKnowledgeGroup if err := rows.Scan(&item.ID, &item.Name); err != nil { return nil, response.ErrInternal(50010, "knowledge_group_query_failed", err.Error()) } groups = append(groups, item) found[item.ID] = struct{}{} } if len(found) != len(groupIDs) { return nil, response.ErrNotFound(40451, "knowledge_group_not_found", "knowledge group not found") } return groups, nil } func (s *PromptRuleService) loadPromptRuleKnowledgeGroupMap( ctx context.Context, tenantID int64, ruleIDs []int64, ) (map[int64][]PromptRuleKnowledgeGroup, error) { result := make(map[int64][]PromptRuleKnowledgeGroup, len(ruleIDs)) ruleIDs = normalizeInt64IDs(ruleIDs) if len(ruleIDs) == 0 { return result, nil } rows, err := s.pool.Query(ctx, ` SELECT prkg.prompt_rule_id, kg.id, kg.name FROM prompt_rule_knowledge_groups prkg JOIN knowledge_groups kg ON kg.id = prkg.knowledge_group_id WHERE prkg.prompt_rule_id = ANY($1::bigint[]) AND kg.tenant_id = $2 AND kg.deleted_at IS NULL ORDER BY kg.sort_order, kg.created_at, kg.id `, ruleIDs, tenantID) if err != nil { return nil, response.ErrInternal(50010, "knowledge_group_query_failed", "failed to query prompt rule knowledge groups") } defer rows.Close() for rows.Next() { var ( ruleID int64 group PromptRuleKnowledgeGroup ) if err := rows.Scan(&ruleID, &group.ID, &group.Name); err != nil { return nil, response.ErrInternal(50010, "knowledge_group_query_failed", err.Error()) } result[ruleID] = append(result[ruleID], group) } return result, nil } func (s *PromptRuleService) replacePromptRuleKnowledgeGroups( ctx context.Context, tx pgx.Tx, promptRuleID int64, groupIDs []int64, ) error { if _, err := tx.Exec(ctx, ` DELETE FROM prompt_rule_knowledge_groups WHERE prompt_rule_id = $1 `, promptRuleID); err != nil { return response.ErrInternal(50010, "update_failed", "failed to update prompt rule knowledge groups") } for _, groupID := range normalizeInt64IDs(groupIDs) { if _, err := tx.Exec(ctx, ` INSERT INTO prompt_rule_knowledge_groups (prompt_rule_id, knowledge_group_id) VALUES ($1, $2) `, promptRuleID, groupID); err != nil { return response.ErrInternal(50010, "update_failed", "failed to update prompt rule knowledge groups") } } return nil } func collectPromptRuleIDs(items []PromptRuleResponse) []int64 { ids := make([]int64, 0, len(items)) for _, item := range items { ids = append(ids, item.ID) } return normalizeInt64IDs(ids) } func promptRuleKnowledgeGroupIDs(groups []PromptRuleKnowledgeGroup) []int64 { ids := make([]int64, 0, len(groups)) for _, group := range groups { ids = append(ids, group.ID) } return normalizeInt64IDs(ids) }