package store import ( "context" "encoding/json" "strings" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" ) type PortraitAsset struct { ID string `json:"id"` GatewayUserID string `json:"gatewayUserId,omitempty"` UserID string `json:"userId"` GatewayTenantID string `json:"gatewayTenantId,omitempty"` TenantID string `json:"tenantId,omitempty"` TenantKey string `json:"tenantKey,omitempty"` Name string `json:"name"` Description string `json:"description,omitempty"` SourceType string `json:"sourceType"` URL string `json:"url"` Preview string `json:"preview,omitempty"` MimeType string `json:"mimeType,omitempty"` ByteSize int64 `json:"size,omitempty"` SourceSHA256 string `json:"sourceSha256,omitempty"` PrivateAvatarEligible bool `json:"privateAvatarEligible"` Status string `json:"status"` LastError string `json:"lastError,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type PortraitAssetBinding struct { ID string `json:"id"` AssetID string `json:"assetId"` PlatformID string `json:"platformId"` ProjectName string `json:"projectName,omitempty"` AssetGroupID string `json:"assetGroupId,omitempty"` RemoteAssetID string `json:"remoteAssetId,omitempty"` RemoteAssetURI string `json:"remoteAssetUri,omitempty"` Status string `json:"status"` LastErrorCode string `json:"lastErrorCode,omitempty"` LastErrorMessage string `json:"lastErrorMessage,omitempty"` LastSyncedAt string `json:"lastSyncedAt,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type PortraitAssetInput struct { GatewayUserID string UserID string GatewayTenantID string TenantID string TenantKey string Name string Description string SourceType string URL string Preview string MimeType string ByteSize int64 SourceSHA256 string PrivateAvatarEligible bool Metadata map[string]any } type PortraitAssetListFilter struct { Keyword string SourceType string Page int PageSize int } type PortraitAssetListResult struct { Items []PortraitAsset Total int Page int PageSize int } type PortraitAssetPlatform struct { PlatformID string PlatformKey string Provider string Credentials map[string]any Config map[string]any } const portraitAssetColumns = ` a.id::text, COALESCE(a.gateway_user_id::text, ''), a.user_id, COALESCE(a.gateway_tenant_id::text, ''), COALESCE(a.tenant_id, ''), COALESCE(a.tenant_key, ''), a.name, a.description, a.source_type, a.url, a.preview, a.mime_type, a.byte_size, a.source_sha256, a.private_avatar_eligible, a.status, a.last_error, a.metadata, a.created_at, a.updated_at` func (s *Store) CreatePortraitAsset(ctx context.Context, input PortraitAssetInput) (PortraitAsset, error) { metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata)) return scanPortraitAsset(s.pool.QueryRow(ctx, ` INSERT INTO gateway_portrait_assets ( gateway_user_id, user_id, gateway_tenant_id, tenant_id, tenant_key, name, description, source_type, url, preview, mime_type, byte_size, source_sha256, private_avatar_eligible, status, metadata ) VALUES ( NULLIF($1, '')::uuid, $2, NULLIF($3, '')::uuid, NULLIF($4, ''), NULLIF($5, ''), $6, $7, $8, $9, $10, $11, $12, $13, $14, 'not_synced', $15::jsonb ) RETURNING `+portraitAssetColumns, input.GatewayUserID, input.UserID, input.GatewayTenantID, input.TenantID, input.TenantKey, strings.TrimSpace(input.Name), strings.TrimSpace(input.Description), strings.TrimSpace(input.SourceType), strings.TrimSpace(input.URL), strings.TrimSpace(input.Preview), strings.TrimSpace(input.MimeType), input.ByteSize, strings.TrimSpace(input.SourceSHA256), input.PrivateAvatarEligible, string(metadata), )) } func (s *Store) FindPortraitAssetBySourceHash(ctx context.Context, user *auth.User, sourceSHA256 string) (PortraitAsset, bool, error) { sourceSHA256 = strings.TrimSpace(sourceSHA256) if sourceSHA256 == "" { return PortraitAsset{}, false, nil } gatewayUserID, userID := portraitAssetUserKeys(user) asset, err := scanPortraitAsset(s.pool.QueryRow(ctx, ` SELECT `+portraitAssetColumns+` FROM gateway_portrait_assets a WHERE ((NULLIF($1, '')::uuid IS NOT NULL AND a.gateway_user_id = NULLIF($1, '')::uuid) OR (NULLIF($2, '') IS NOT NULL AND a.user_id = $2)) AND a.source_sha256 = $3 ORDER BY a.created_at DESC LIMIT 1`, gatewayUserID, userID, sourceSHA256)) if IsNotFound(err) { return PortraitAsset{}, false, nil } return asset, err == nil, err } func (s *Store) FindPortraitAssetForUser(ctx context.Context, user *auth.User, assetID string) (PortraitAsset, bool, error) { assetID = strings.TrimSpace(assetID) if assetID == "" { return PortraitAsset{}, false, nil } gatewayUserID, userID := portraitAssetUserKeys(user) asset, err := scanPortraitAsset(s.pool.QueryRow(ctx, ` SELECT `+portraitAssetColumns+` FROM gateway_portrait_assets a WHERE a.id = NULLIF($3, '')::uuid AND ((NULLIF($1, '')::uuid IS NOT NULL AND a.gateway_user_id = NULLIF($1, '')::uuid) OR (NULLIF($2, '') IS NOT NULL AND a.user_id = $2))`, gatewayUserID, userID, assetID)) if IsNotFound(err) { return PortraitAsset{}, false, nil } return asset, err == nil, err } func (s *Store) ListPortraitAssets(ctx context.Context, user *auth.User, filter PortraitAssetListFilter) (PortraitAssetListResult, error) { page := filter.Page if page < 1 { page = 1 } pageSize := filter.PageSize if pageSize < 1 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } gatewayUserID, userID := portraitAssetUserKeys(user) keyword := strings.TrimSpace(filter.Keyword) if keyword != "" { keyword = "%" + keyword + "%" } where := ` WHERE ((NULLIF($1, '')::uuid IS NOT NULL AND a.gateway_user_id = NULLIF($1, '')::uuid) OR (NULLIF($2, '') IS NOT NULL AND a.user_id = $2)) AND (NULLIF($3, '') IS NULL OR a.source_type = $3) AND (NULLIF($4, '') IS NULL OR a.name ILIKE $4 OR a.description ILIKE $4)` args := []any{gatewayUserID, userID, strings.TrimSpace(filter.SourceType), keyword} var total int if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM gateway_portrait_assets a `+where, args...).Scan(&total); err != nil { return PortraitAssetListResult{}, err } args = append(args, pageSize, (page-1)*pageSize) rows, err := s.pool.Query(ctx, `SELECT `+portraitAssetColumns+` FROM gateway_portrait_assets a `+where+` ORDER BY a.created_at DESC LIMIT $5 OFFSET $6`, args...) if err != nil { return PortraitAssetListResult{}, err } defer rows.Close() items := make([]PortraitAsset, 0) for rows.Next() { asset, err := scanPortraitAsset(rows) if err != nil { return PortraitAssetListResult{}, err } items = append(items, asset) } if err := rows.Err(); err != nil { return PortraitAssetListResult{}, err } return PortraitAssetListResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } func (s *Store) GetPortraitAssetBinding(ctx context.Context, assetID string, platformID string) (PortraitAssetBinding, bool, error) { binding, err := scanPortraitAssetBinding(s.pool.QueryRow(ctx, ` SELECT id::text, asset_id::text, platform_id::text, project_name, asset_group_id, remote_asset_id, remote_asset_uri, status, last_error_code, last_error_message, COALESCE(last_synced_at::text, ''), created_at, updated_at FROM gateway_portrait_asset_bindings WHERE asset_id = $1::uuid AND platform_id = $2::uuid`, assetID, platformID)) if IsNotFound(err) { return PortraitAssetBinding{}, false, nil } return binding, err == nil, err } func (s *Store) UpsertPortraitAssetBinding(ctx context.Context, binding PortraitAssetBinding) (PortraitAssetBinding, error) { return scanPortraitAssetBinding(s.pool.QueryRow(ctx, ` INSERT INTO gateway_portrait_asset_bindings ( asset_id, platform_id, project_name, asset_group_id, remote_asset_id, remote_asset_uri, status, last_error_code, last_error_message, last_synced_at ) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8, $9, now()) ON CONFLICT (asset_id, platform_id) DO UPDATE SET project_name = EXCLUDED.project_name, asset_group_id = EXCLUDED.asset_group_id, remote_asset_id = CASE WHEN EXCLUDED.remote_asset_id <> '' THEN EXCLUDED.remote_asset_id ELSE gateway_portrait_asset_bindings.remote_asset_id END, remote_asset_uri = CASE WHEN EXCLUDED.remote_asset_uri <> '' THEN EXCLUDED.remote_asset_uri ELSE gateway_portrait_asset_bindings.remote_asset_uri END, status = EXCLUDED.status, last_error_code = EXCLUDED.last_error_code, last_error_message = EXCLUDED.last_error_message, last_synced_at = now(), updated_at = now() RETURNING id::text, asset_id::text, platform_id::text, project_name, asset_group_id, remote_asset_id, remote_asset_uri, status, last_error_code, last_error_message, COALESCE(last_synced_at::text, ''), created_at, updated_at`, binding.AssetID, binding.PlatformID, strings.TrimSpace(binding.ProjectName), strings.TrimSpace(binding.AssetGroupID), strings.TrimSpace(binding.RemoteAssetID), strings.TrimSpace(binding.RemoteAssetURI), strings.TrimSpace(binding.Status), strings.TrimSpace(binding.LastErrorCode), strings.TrimSpace(binding.LastErrorMessage), )) } func (s *Store) UpdatePortraitAssetStatus(ctx context.Context, assetID string, status string, lastError string) error { _, err := s.pool.Exec(ctx, ` UPDATE gateway_portrait_assets SET status = $2, last_error = $3, updated_at = now() WHERE id = $1::uuid`, assetID, strings.TrimSpace(status), strings.TrimSpace(lastError)) return err } func (s *Store) PortraitAssetBindingSummary(ctx context.Context, assetID string) (active int, total int, latestError string, updatedAt string, err error) { err = s.pool.QueryRow(ctx, ` SELECT COUNT(*) FILTER (WHERE status = 'active'), COUNT(*), COALESCE((ARRAY_AGG(NULLIF(last_error_message, '') ORDER BY updated_at DESC) FILTER (WHERE NULLIF(last_error_message, '') IS NOT NULL))[1], ''), COALESCE(MAX(updated_at)::text, '') FROM gateway_portrait_asset_bindings WHERE asset_id = $1::uuid`, assetID).Scan(&active, &total, &latestError, &updatedAt) return } func (s *Store) ListPortraitAssetPlatforms(ctx context.Context) ([]PortraitAssetPlatform, error) { rows, err := s.pool.Query(ctx, ` SELECT p.id::text, p.platform_key, p.provider, p.credentials, p.config FROM integration_platforms p WHERE p.deleted_at IS NULL AND p.status = 'enabled' AND LOWER(p.provider) IN ('volces', 'volces-openai') ORDER BY COALESCE(p.dynamic_priority, p.priority), p.created_at`) if err != nil { return nil, err } defer rows.Close() items := make([]PortraitAssetPlatform, 0) for rows.Next() { var item PortraitAssetPlatform var credentials, config []byte if err := rows.Scan(&item.PlatformID, &item.PlatformKey, &item.Provider, &credentials, &config); err != nil { return nil, err } item.Credentials = decodeObject(credentials) item.Config = decodeObject(config) items = append(items, item) } return items, rows.Err() } func portraitAssetUserKeys(user *auth.User) (string, string) { if user == nil { return "", "" } gatewayUserID := strings.TrimSpace(user.GatewayUserID) if gatewayUserID == "" && user.Source == "gateway" { gatewayUserID = strings.TrimSpace(user.ID) } return gatewayUserID, strings.TrimSpace(user.ID) } type portraitAssetScanner interface{ Scan(dest ...any) error } func scanPortraitAsset(scanner portraitAssetScanner) (PortraitAsset, error) { var asset PortraitAsset var metadata []byte err := scanner.Scan( &asset.ID, &asset.GatewayUserID, &asset.UserID, &asset.GatewayTenantID, &asset.TenantID, &asset.TenantKey, &asset.Name, &asset.Description, &asset.SourceType, &asset.URL, &asset.Preview, &asset.MimeType, &asset.ByteSize, &asset.SourceSHA256, &asset.PrivateAvatarEligible, &asset.Status, &asset.LastError, &metadata, &asset.CreatedAt, &asset.UpdatedAt, ) if err != nil { return PortraitAsset{}, err } asset.Metadata = decodeObject(metadata) return asset, nil } func scanPortraitAssetBinding(scanner portraitAssetScanner) (PortraitAssetBinding, error) { var binding PortraitAssetBinding if err := scanner.Scan( &binding.ID, &binding.AssetID, &binding.PlatformID, &binding.ProjectName, &binding.AssetGroupID, &binding.RemoteAssetID, &binding.RemoteAssetURI, &binding.Status, &binding.LastErrorCode, &binding.LastErrorMessage, &binding.LastSyncedAt, &binding.CreatedAt, &binding.UpdatedAt, ); err != nil { return PortraitAssetBinding{}, err } return binding, nil }