Files
root db95b8e4ee feat(kol): store KOL prompt content in database with object storage fallback
Add prompt_content columns to kol_prompts and kol_prompt_revisions so
prompt bodies live alongside their metadata, eliminating an extra round
trip to object storage for the common read path while keeping the old
asset key as a fallback for legacy rows.

Reads go through a singleflight-deduped, cache-backed loader that
prefers the database column, falls back to the asset key, and tolerates
missing objects via a new objectstorage.ErrObjectNotFound returned by
both the Aliyun OSS and MinIO clients on 404/NoSuchKey responses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:47:51 +08:00

444 lines
12 KiB
Go

package repository
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
)
type KolPrompt struct {
ID int64
TenantID int64
PackageID int64
Name string
PlatformHint *string
Status string
SortOrder int
PublishedRevisionNo *int
DraftRevisionNo *int
PromptAssetKey *string
PromptContent *string
SchemaJSON []byte
CardConfigJSON []byte
CreatedBy int64
UpdatedBy int64
CreatedAt time.Time
UpdatedAt time.Time
}
type ActiveKolPrompt struct {
ID int64
TenantID int64
PackageID int64
PublishedRevisionNo *int
}
type CreateKolPromptInput struct {
TenantID int64
PackageID int64
Name string
PlatformHint *string
SortOrder int
CreatedBy int64
}
type UpdateKolPromptMetadataInput struct {
ID int64
TenantID int64
Name string
PlatformHint *string
SortOrder int
UpdatedBy int64
}
type SaveKolPromptInput struct {
ID int64
TenantID int64
Name string
PlatformHint *string
SortOrder int
PromptAssetKey *string
PromptContent string
SchemaJSON []byte
CardConfigJSON []byte
Status string
PublishedRevisionNo *int
UpdatedBy int64
}
type PublishKolPromptInput struct {
ID int64
TenantID int64
Name string
PlatformHint *string
SortOrder int
PromptAssetKey *string
PromptContent string
SchemaJSON []byte
CardConfigJSON []byte
PublishedRevisionNo int
UpdatedBy int64
}
type KolPromptRepository interface {
Create(ctx context.Context, input CreateKolPromptInput) (*KolPrompt, error)
GetByID(ctx context.Context, tenantID, id int64) (*KolPrompt, error)
GetContentByID(ctx context.Context, tenantID, id int64) (*string, error)
ListByPackage(ctx context.Context, tenantID, packageID int64) ([]KolPrompt, error)
CountByPackages(ctx context.Context, tenantID int64, packageIDs []int64) (map[int64]int, error)
ListActiveByPackage(ctx context.Context, tenantID, packageID int64) ([]ActiveKolPrompt, error)
UpdateMetadata(ctx context.Context, input UpdateKolPromptMetadataInput) error
Save(ctx context.Context, input SaveKolPromptInput) error
Publish(ctx context.Context, input PublishKolPromptInput) error
SetPublishedRevision(ctx context.Context, tenantID, promptID int64, revNo int, updatedBy int64) error
UpdateStatus(ctx context.Context, tenantID, id int64, status string, updatedBy int64) error
SoftDelete(ctx context.Context, tenantID, id int64) error
}
type kolPromptRepository struct {
db generated.DBTX
q generated.Querier
}
func NewKolPromptRepository(db generated.DBTX) KolPromptRepository {
return &kolPromptRepository{db: db, q: newQuerier(db)}
}
func mapKolPromptRow(
id int64,
tenantID int64,
packageID int64,
name string,
platformHint pgtype.Text,
status string,
sortOrder int32,
publishedRevisionNo pgtype.Int4,
draftRevisionNo pgtype.Int4,
promptAssetKey pgtype.Text,
promptContent pgtype.Text,
schemaJSON []byte,
cardConfigJSON []byte,
createdBy int64,
updatedBy int64,
createdAt pgtype.Timestamptz,
updatedAt pgtype.Timestamptz,
) *KolPrompt {
return &KolPrompt{
ID: id,
TenantID: tenantID,
PackageID: packageID,
Name: name,
PlatformHint: nullableText(platformHint),
Status: status,
SortOrder: int(sortOrder),
PublishedRevisionNo: nullableIntFromInt4(publishedRevisionNo),
DraftRevisionNo: nullableIntFromInt4(draftRevisionNo),
PromptAssetKey: nullableText(promptAssetKey),
PromptContent: nullableText(promptContent),
SchemaJSON: schemaJSON,
CardConfigJSON: cardConfigJSON,
CreatedBy: createdBy,
UpdatedBy: updatedBy,
CreatedAt: timeFromTimestamp(createdAt),
UpdatedAt: timeFromTimestamp(updatedAt),
}
}
func scanKolPrompt(scanner interface{ Scan(...any) error }) (*KolPrompt, error) {
var (
id int64
tenantID int64
packageID int64
name string
platformHint pgtype.Text
status string
sortOrder int32
publishedRevisionNo pgtype.Int4
draftRevisionNo pgtype.Int4
promptAssetKey pgtype.Text
promptContent pgtype.Text
schemaJSON []byte
cardConfigJSON []byte
createdBy int64
updatedBy int64
createdAt pgtype.Timestamptz
updatedAt pgtype.Timestamptz
)
if err := scanner.Scan(
&id,
&tenantID,
&packageID,
&name,
&platformHint,
&status,
&sortOrder,
&publishedRevisionNo,
&draftRevisionNo,
&promptAssetKey,
&promptContent,
&schemaJSON,
&cardConfigJSON,
&createdBy,
&updatedBy,
&createdAt,
&updatedAt,
); err != nil {
return nil, err
}
return mapKolPromptRow(
id,
tenantID,
packageID,
name,
platformHint,
status,
sortOrder,
publishedRevisionNo,
draftRevisionNo,
promptAssetKey,
promptContent,
schemaJSON,
cardConfigJSON,
createdBy,
updatedBy,
createdAt,
updatedAt,
), nil
}
func (r *kolPromptRepository) Create(ctx context.Context, input CreateKolPromptInput) (*KolPrompt, error) {
row, err := r.q.CreateKolPrompt(ctx, generated.CreateKolPromptParams{
TenantID: input.TenantID,
PackageID: input.PackageID,
Name: input.Name,
PlatformHint: pgText(input.PlatformHint),
SortOrder: int32(input.SortOrder),
CreatedBy: input.CreatedBy,
})
if err != nil {
return nil, err
}
return r.GetByID(ctx, input.TenantID, row.ID)
}
func (r *kolPromptRepository) GetByID(ctx context.Context, tenantID, id int64) (*KolPrompt, error) {
row := r.db.QueryRow(ctx, `
SELECT id, tenant_id, package_id, name, platform_hint, status, sort_order,
published_revision_no, draft_revision_no, prompt_asset_key, NULL::text AS prompt_content, schema_json, card_config_json,
created_by, updated_by, created_at, updated_at
FROM kol_prompts
WHERE id = $1
AND tenant_id = $2
AND deleted_at IS NULL
`, id, tenantID)
prompt, err := scanKolPrompt(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return prompt, nil
}
func (r *kolPromptRepository) GetContentByID(ctx context.Context, tenantID, id int64) (*string, error) {
var content pgtype.Text
err := r.db.QueryRow(ctx, `
SELECT prompt_content
FROM kol_prompts
WHERE id = $1
AND tenant_id = $2
AND deleted_at IS NULL
`, id, tenantID).Scan(&content)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return nullableText(content), nil
}
func (r *kolPromptRepository) ListByPackage(ctx context.Context, tenantID, packageID int64) ([]KolPrompt, error) {
rows, err := r.db.Query(ctx, `
SELECT id, tenant_id, package_id, name, platform_hint, status, sort_order,
published_revision_no, draft_revision_no, prompt_asset_key, NULL::text AS prompt_content, schema_json, card_config_json,
created_by, updated_by, created_at, updated_at
FROM kol_prompts
WHERE package_id = $1
AND tenant_id = $2
AND deleted_at IS NULL
ORDER BY sort_order ASC, created_at ASC
`, packageID, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]KolPrompt, 0)
for rows.Next() {
prompt, err := scanKolPrompt(rows)
if err != nil {
return nil, err
}
result = append(result, *prompt)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func (r *kolPromptRepository) CountByPackages(ctx context.Context, tenantID int64, packageIDs []int64) (map[int64]int, error) {
counts := make(map[int64]int, len(packageIDs))
if len(packageIDs) == 0 {
return counts, nil
}
rows, err := r.db.Query(ctx, `
SELECT package_id, COUNT(*)::int
FROM kol_prompts
WHERE tenant_id = $1
AND package_id = ANY($2::bigint[])
AND deleted_at IS NULL
GROUP BY package_id
`, tenantID, packageIDs)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var packageID int64
var count int
if err := rows.Scan(&packageID, &count); err != nil {
return nil, err
}
counts[packageID] = count
}
if err := rows.Err(); err != nil {
return nil, err
}
return counts, nil
}
func (r *kolPromptRepository) ListActiveByPackage(ctx context.Context, tenantID, packageID int64) ([]ActiveKolPrompt, error) {
rows, err := r.q.ListActiveKolPromptsByPackage(ctx, generated.ListActiveKolPromptsByPackageParams{
PackageID: packageID,
TenantID: tenantID,
})
if err != nil {
return nil, err
}
result := make([]ActiveKolPrompt, len(rows))
for i, row := range rows {
result[i] = ActiveKolPrompt{
ID: row.ID,
TenantID: row.TenantID,
PackageID: row.PackageID,
PublishedRevisionNo: nullableIntFromInt4(row.PublishedRevisionNo),
}
}
return result, nil
}
func (r *kolPromptRepository) UpdateMetadata(ctx context.Context, input UpdateKolPromptMetadataInput) error {
return r.q.UpdateKolPromptMetadata(ctx, generated.UpdateKolPromptMetadataParams{
Name: input.Name,
PlatformHint: pgText(input.PlatformHint),
SortOrder: int32(input.SortOrder),
UpdatedBy: input.UpdatedBy,
ID: input.ID,
TenantID: input.TenantID,
})
}
func (r *kolPromptRepository) Save(ctx context.Context, input SaveKolPromptInput) error {
publishedRevisionNo := pgtype.Int4{}
if input.PublishedRevisionNo != nil {
publishedRevisionNo = pgtype.Int4{Int32: int32(*input.PublishedRevisionNo), Valid: true}
}
_, err := r.db.Exec(ctx, `
UPDATE kol_prompts
SET name = $1,
platform_hint = $2,
sort_order = $3,
prompt_asset_key = $4,
prompt_content = $5,
schema_json = $6::jsonb,
card_config_json = $7::jsonb,
status = $8,
published_revision_no = $9,
updated_by = $10,
updated_at = NOW()
WHERE id = $11
AND tenant_id = $12
AND deleted_at IS NULL
`, input.Name, pgText(input.PlatformHint), int32(input.SortOrder), pgText(input.PromptAssetKey), input.PromptContent, input.SchemaJSON, input.CardConfigJSON, input.Status, publishedRevisionNo, input.UpdatedBy, input.ID, input.TenantID)
return err
}
func (r *kolPromptRepository) Publish(ctx context.Context, input PublishKolPromptInput) error {
_, err := r.db.Exec(ctx, `
UPDATE kol_prompts
SET name = $1,
platform_hint = $2,
sort_order = $3,
prompt_asset_key = $4,
prompt_content = $5,
schema_json = $6::jsonb,
card_config_json = $7::jsonb,
published_revision_no = $8,
draft_revision_no = NULL,
status = 'active',
updated_by = $9,
updated_at = NOW()
WHERE id = $10
AND tenant_id = $11
AND deleted_at IS NULL
`, input.Name, pgText(input.PlatformHint), int32(input.SortOrder), pgText(input.PromptAssetKey), input.PromptContent, input.SchemaJSON, input.CardConfigJSON, input.PublishedRevisionNo, input.UpdatedBy, input.ID, input.TenantID)
return err
}
func (r *kolPromptRepository) SetPublishedRevision(ctx context.Context, tenantID, promptID int64, revNo int, updatedBy int64) error {
return r.q.UpdateKolPromptPublishedRevision(ctx, generated.UpdateKolPromptPublishedRevisionParams{
RevisionNo: pgtype.Int4{Int32: int32(revNo), Valid: true},
UpdatedBy: updatedBy,
ID: promptID,
TenantID: tenantID,
})
}
func (r *kolPromptRepository) UpdateStatus(ctx context.Context, tenantID, id int64, status string, updatedBy int64) error {
return r.q.UpdateKolPromptStatus(ctx, generated.UpdateKolPromptStatusParams{
Status: status,
UpdatedBy: updatedBy,
ID: id,
TenantID: tenantID,
})
}
func (r *kolPromptRepository) SoftDelete(ctx context.Context, tenantID, id int64) error {
return r.q.SoftDeleteKolPrompt(ctx, generated.SoftDeleteKolPromptParams{
ID: id,
TenantID: tenantID,
})
}
func nullableIntFromInt4(value pgtype.Int4) *int {
if !value.Valid {
return nil
}
v := int(value.Int32)
return &v
}