data layer
This commit is contained in:
0
data-layer/api/internal/service/.gitkeep
Normal file
0
data-layer/api/internal/service/.gitkeep
Normal file
66
data-layer/api/internal/service/profile_service.go
Normal file
66
data-layer/api/internal/service/profile_service.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/cache"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/model"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/repo"
|
||||
)
|
||||
|
||||
type ProfileService struct {
|
||||
profiles *repo.ProfileRepo
|
||||
events *repo.EventRepo
|
||||
cache *cache.Cache
|
||||
profileTTL time.Duration
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewProfileService(p *repo.ProfileRepo, e *repo.EventRepo, c *cache.Cache, profileTTL time.Duration, log *zap.Logger) *ProfileService {
|
||||
return &ProfileService{profiles: p, events: e, cache: c, profileTTL: profileTTL, log: log}
|
||||
}
|
||||
|
||||
func (s *ProfileService) Get(ctx context.Context, workspaceID, profileID string) (*model.Profile, error) {
|
||||
key, err := cache.Key("profile", workspaceID, profileID)
|
||||
if err != nil {
|
||||
return nil, apperr.Internal(err)
|
||||
}
|
||||
if b, ok := s.cache.Get(ctx, key); ok {
|
||||
var p model.Profile
|
||||
if jerr := json.Unmarshal(b, &p); jerr == nil {
|
||||
return &p, nil
|
||||
}
|
||||
}
|
||||
p, err := s.profiles.GetByID(ctx, workspaceID, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b, jerr := json.Marshal(p); jerr == nil {
|
||||
if cerr := s.cache.Set(ctx, key, b, s.profileTTL); cerr != nil {
|
||||
s.log.Warn("cache set", zap.String("key", key), zap.Error(cerr))
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *ProfileService) Timeline(ctx context.Context, workspaceID, profileID string, limit, offset int) (*model.QueryResult, error) {
|
||||
uid, err := s.profiles.GetUserIDForProfile(ctx, workspaceID, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if uid == "" {
|
||||
return nil, apperr.NotFound("profile has no user_id and cannot be timelined")
|
||||
}
|
||||
start := time.Now()
|
||||
res, err := s.events.QueryProfileTimeline(ctx, workspaceID, uid, limit, offset)
|
||||
if err != nil {
|
||||
return nil, apperr.Internal(err)
|
||||
}
|
||||
res.DurationMS = time.Since(start).Milliseconds()
|
||||
return res, nil
|
||||
}
|
||||
87
data-layer/api/internal/service/query_service.go
Normal file
87
data-layer/api/internal/service/query_service.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Package service holds business logic. It owns cache orchestration around
|
||||
// the read repos and never touches HTTP/chi or the SQL drivers directly.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/cache"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/model"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/repo"
|
||||
)
|
||||
|
||||
type QueryService struct {
|
||||
events *repo.EventRepo
|
||||
analytics *repo.AnalyticsRepo
|
||||
cache *cache.Cache
|
||||
queryTTL time.Duration
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewQueryService(events *repo.EventRepo, analytics *repo.AnalyticsRepo, c *cache.Cache, queryTTL time.Duration, log *zap.Logger) *QueryService {
|
||||
return &QueryService{events: events, analytics: analytics, cache: c, queryTTL: queryTTL, log: log}
|
||||
}
|
||||
|
||||
// cached wraps `fetch` with the per-workspace Redis cache. Result is JSON-
|
||||
// encoded on miss; CacheHit is set true on hit.
|
||||
func (s *QueryService) cached(
|
||||
ctx context.Context,
|
||||
kind, workspaceID string,
|
||||
params any,
|
||||
fetch func(context.Context) (*model.QueryResult, error),
|
||||
) (*model.QueryResult, error) {
|
||||
key, err := cache.Key(kind, workspaceID, params)
|
||||
if err != nil {
|
||||
return nil, apperr.Internal(err)
|
||||
}
|
||||
if cached, ok := s.cache.Get(ctx, key); ok {
|
||||
var out model.QueryResult
|
||||
if jerr := json.Unmarshal(cached, &out); jerr == nil {
|
||||
out.CacheHit = true
|
||||
return &out, nil
|
||||
}
|
||||
}
|
||||
start := time.Now()
|
||||
res, err := fetch(ctx)
|
||||
if err != nil {
|
||||
return nil, apperr.Internal(err)
|
||||
}
|
||||
res.DurationMS = time.Since(start).Milliseconds()
|
||||
res.CacheHit = false
|
||||
|
||||
if b, jerr := json.Marshal(res); jerr == nil {
|
||||
if cerr := s.cache.Set(ctx, key, b, s.queryTTL); cerr != nil {
|
||||
s.log.Warn("cache set", zap.String("key", key), zap.Error(cerr))
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *QueryService) Events(ctx context.Context, q model.EventQuery) (*model.QueryResult, error) {
|
||||
return s.cached(ctx, "query:events", q.WorkspaceID, q, func(c context.Context) (*model.QueryResult, error) {
|
||||
return s.events.QueryEvents(c, q)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *QueryService) Funnel(ctx context.Context, q repo.FunnelQuery) (*model.QueryResult, error) {
|
||||
return s.cached(ctx, "query:funnel", q.WorkspaceID, q, func(c context.Context) (*model.QueryResult, error) {
|
||||
return s.analytics.Funnel(c, q)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *QueryService) Retention(ctx context.Context, q repo.RetentionQuery) (*model.QueryResult, error) {
|
||||
return s.cached(ctx, "query:retention", q.WorkspaceID, q, func(c context.Context) (*model.QueryResult, error) {
|
||||
return s.analytics.Retention(c, q)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *QueryService) Sessions(ctx context.Context, q repo.SessionQuery) (*model.QueryResult, error) {
|
||||
return s.cached(ctx, "query:session", q.WorkspaceID, q, func(c context.Context) (*model.QueryResult, error) {
|
||||
return s.analytics.Sessions(c, q)
|
||||
})
|
||||
}
|
||||
98
data-layer/api/internal/service/sql_service.go
Normal file
98
data-layer/api/internal/service/sql_service.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/apperr"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/model"
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/repo"
|
||||
)
|
||||
|
||||
// SQLService backs the Custom SQL sandbox. It applies two layers of guard:
|
||||
// 1. App-level: parse the statement, reject anything that is not a single
|
||||
// SELECT and anything containing DDL/DML keywords.
|
||||
// 2. DB-level: queries run against a SELECT-only ClickHouse account so the
|
||||
// server rejects writes even if app-level checks are bypassed.
|
||||
type SQLService struct {
|
||||
ch driver.Conn // read-only conn
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewSQLService(roConn driver.Conn, log *zap.Logger) *SQLService {
|
||||
return &SQLService{ch: roConn, log: log}
|
||||
}
|
||||
|
||||
var forbiddenKeywords = []string{
|
||||
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE",
|
||||
"GRANT", "REVOKE", "ATTACH", "DETACH", "OPTIMIZE", "RENAME", "EXCHANGE",
|
||||
}
|
||||
|
||||
// validateReadOnly rejects multi-statement input and obvious DDL/DML.
|
||||
func validateReadOnly(sql string) error {
|
||||
trimmed := strings.TrimSpace(sql)
|
||||
if trimmed == "" {
|
||||
return apperr.BadRequest("sql is empty", "sql", nil)
|
||||
}
|
||||
// Reject multiple statements -- the ClickHouse driver also rejects this,
|
||||
// but we want a friendly error before hitting the wire.
|
||||
if strings.Contains(strings.TrimRight(trimmed, ";"), ";") {
|
||||
return apperr.BadRequest("only a single statement is allowed", "sql", nil)
|
||||
}
|
||||
upper := strings.ToUpper(trimmed)
|
||||
if !strings.HasPrefix(upper, "SELECT") && !strings.HasPrefix(upper, "WITH") {
|
||||
return apperr.BadRequest("only SELECT statements are allowed", "sql", nil)
|
||||
}
|
||||
// Token-level keyword scan: \bKW\b to avoid false positives like "created_at".
|
||||
for _, kw := range forbiddenKeywords {
|
||||
if hasWord(upper, kw) {
|
||||
return apperr.BadRequest("statement contains forbidden keyword: "+kw, "sql", nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasWord(s, word string) bool {
|
||||
for {
|
||||
idx := strings.Index(s, word)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
left := idx == 0 || !isIdent(s[idx-1])
|
||||
right := idx+len(word) == len(s) || !isIdent(s[idx+len(word)])
|
||||
if left && right {
|
||||
return true
|
||||
}
|
||||
s = s[idx+len(word):]
|
||||
}
|
||||
}
|
||||
|
||||
func isIdent(c byte) bool {
|
||||
return c == '_' || (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|
||||
}
|
||||
|
||||
// Run executes the (validated) SQL against the read-only ClickHouse user.
|
||||
// Results are never cached -- queries are arbitrary.
|
||||
func (s *SQLService) Run(ctx context.Context, sql string) (*model.QueryResult, error) {
|
||||
if err := validateReadOnly(sql); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start := time.Now()
|
||||
rows, err := s.ch.Query(ctx, sql)
|
||||
if err != nil {
|
||||
// ClickHouse syntax / permission errors are user-visible, not 500.
|
||||
return nil, apperr.BadRequest("clickhouse rejected query: "+err.Error(), "sql", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
res, err := repo.ScanRows(rows)
|
||||
if err != nil {
|
||||
return nil, apperr.Internal(err)
|
||||
}
|
||||
res.DurationMS = time.Since(start).Milliseconds()
|
||||
return res, nil
|
||||
}
|
||||
Reference in New Issue
Block a user