327 lines
13 KiB
Go
327 lines
13 KiB
Go
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
|
|
}
|