package app import ( "context" "encoding/json" "fmt" "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"` } 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"` 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) items = append(items, r) } if items == nil { items = []PromptRuleResponse{} } 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 return &r, nil } func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (*PromptRuleResponse, error) { actor := auth.MustActor(ctx) var id int64 var ca interface{} err := s.pool.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") } 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, Status: "enabled", ArticleCount: 0, CreatedAt: fmt.Sprintf("%v", ca), }, nil } func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRuleRequest) error { actor := auth.MustActor(ctx) tag, err := s.pool.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") } 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 }