owpengram-server/internal/store/postgres/langpack.go
A 1292540350 feat: sync langpack and auth updates
Sync telesrv commits 49f9bab and 04d9563 into the public mirror. Exclude private docs and runtime key material per sync rules.
2026-07-18 00:07:41 +08:00

467 lines
16 KiB
Go

package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// LangPackStore 用 PostgreSQL 实现 store.LangPackStore。
type LangPackStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewLangPackStore 基于 pgx 连接池(或事务)创建 LangPackStore。
func NewLangPackStore(db sqlcgen.DBTX) *LangPackStore {
return &LangPackStore{db: db, q: sqlcgen.New(db)}
}
func (s *LangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
meta, found, err := s.meta(ctx, langPack, langCode)
if err != nil || !found {
return meta, err
}
meta.FromVersion = fromVersion
if meta.Version <= fromVersion {
return meta, nil
}
rows, err := s.q.ListLangPackStrings(ctx, sqlcgen.ListLangPackStringsParams{
LangPack: langPack,
LangCode: langCode,
})
if err != nil {
return domain.LangPack{}, fmt.Errorf("list lang pack strings: %w", err)
}
meta.Strings = make([]domain.LangPackString, 0, len(rows))
for _, row := range rows {
meta.Strings = append(meta.Strings, langPackStringFromListRow(row))
}
return meta, nil
}
func (s *LangPackStore) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) {
meta, found, err := s.meta(ctx, langPack, langCode)
if err != nil || !found {
return meta, err
}
if len(keys) == 0 {
return s.GetPack(ctx, langPack, langCode, 0)
}
rows, err := s.q.GetLangPackStringsByKeys(ctx, sqlcgen.GetLangPackStringsByKeysParams{
LangPack: langPack,
LangCode: langCode,
Keys: keys,
})
if err != nil {
return domain.LangPack{}, fmt.Errorf("get lang pack strings: %w", err)
}
meta.Strings = make([]domain.LangPackString, 0, len(rows))
for _, row := range rows {
meta.Strings = append(meta.Strings, langPackStringFromKeysRow(row))
}
return meta, nil
}
func (s *LangPackStore) ListLanguages(ctx context.Context, langPack string) ([]domain.LangPackLanguage, error) {
rows, err := s.q.ListLangPackLanguages(ctx, langPack)
if err != nil {
return nil, fmt.Errorf("list lang pack languages: %w", err)
}
out := make([]domain.LangPackLanguage, 0, len(rows))
for _, row := range rows {
out = append(out, domain.LangPackLanguage{
LangPack: row.LangPack,
LangCode: row.LangCode,
Name: row.Name,
NativeName: row.NativeName,
StringsCount: int(row.StringsCount),
TranslatedCount: int(row.StringsCount),
})
}
return out, nil
}
func (s *LangPackStore) GetSeedCatalog(ctx context.Context, catalog string) (domain.LangPackSeedCatalog, error) {
if catalog == "" {
catalog = "default"
}
encoded, err := s.q.GetLangPackSeedHash(ctx, langPackSeedManifestStateKey(catalog))
if errors.Is(err, pgx.ErrNoRows) {
return domain.LangPackSeedCatalog{Catalog: catalog}, nil
}
if err != nil {
return domain.LangPackSeedCatalog{}, fmt.Errorf("get langpack seed catalog %q: %w", catalog, err)
}
var state domain.LangPackSeedCatalog
if err := json.Unmarshal([]byte(encoded), &state); err != nil {
return domain.LangPackSeedCatalog{}, fmt.Errorf("decode langpack seed catalog %q: %w", catalog, err)
}
if state.Catalog != catalog {
return domain.LangPackSeedCatalog{}, fmt.Errorf("langpack seed catalog key %q contains catalog %q", catalog, state.Catalog)
}
return state, nil
}
func (s *LangPackStore) ReconcileSeed(ctx context.Context, seed domain.LangPackSeed) (int, error) {
txer, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
if !ok {
return 0, errors.New("langpack seed reconciliation requires transaction support")
}
tx, err := txer.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin langpack seed reconciliation: %w", err)
}
written, err := reconcileSeedWith(ctx, tx, s.q.WithTx(tx), seed)
if err != nil {
_ = tx.Rollback(ctx)
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit langpack seed reconciliation: %w", err)
}
return written, nil
}
func (s *LangPackStore) UpsertPack(ctx context.Context, pack domain.LangPack) error {
if txer, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
}); ok {
tx, err := txer.Begin(ctx)
if err != nil {
return fmt.Errorf("begin lang pack upsert: %w", err)
}
q := s.q.WithTx(tx)
if err := replacePackWithCopy(ctx, tx, q, pack); err != nil {
_ = tx.Rollback(ctx)
return err
}
if err := q.DeleteLangPackSeedHash(ctx, langPackSeedStateKey(pack.LangPack, pack.LangCode)); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("delete lang pack seed hash: %w", err)
}
if err := invalidateLangPackSeedCatalogs(ctx, tx); err != nil {
_ = tx.Rollback(ctx)
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit lang pack upsert: %w", err)
}
return nil
}
if err := replacePackWithQueries(ctx, s.q, pack); err != nil {
return err
}
if err := s.q.DeleteLangPackSeedHash(ctx, langPackSeedStateKey(pack.LangPack, pack.LangCode)); err != nil {
return fmt.Errorf("delete lang pack seed hash: %w", err)
}
if err := invalidateLangPackSeedCatalogs(ctx, s.db); err != nil {
return err
}
return nil
}
func reconcileSeedWith(ctx context.Context, tx pgx.Tx, q *sqlcgen.Queries, seed domain.LangPackSeed) (int, error) {
catalog := seed.Catalog
if catalog == "" {
catalog = "default"
}
reconciledScopes := append([]string(nil), seed.Scopes...)
scopes := make(map[string]struct{}, len(seed.Scopes))
wanted := make(map[string]map[string]struct{}, len(seed.Scopes))
for _, scope := range seed.Scopes {
scopes[scope] = struct{}{}
wanted[scope] = make(map[string]struct{})
}
previousScopesJSON, err := q.GetLangPackSeedHash(ctx, langPackSeedCatalogStateKey(catalog))
if err == nil {
var previousScopes []string
if err := json.Unmarshal([]byte(previousScopesJSON), &previousScopes); err != nil {
return 0, fmt.Errorf("decode previous langpack seed scopes for %q: %w", catalog, err)
}
for _, scope := range previousScopes {
if _, exists := scopes[scope]; exists {
continue
}
scopes[scope] = struct{}{}
wanted[scope] = make(map[string]struct{})
reconciledScopes = append(reconciledScopes, scope)
}
} else if !errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("get previous langpack seed scopes for %q: %w", catalog, err)
}
for _, entry := range seed.Packs {
if err := validateSeedEntry(entry); err != nil {
return 0, err
}
if _, ok := scopes[entry.Pack.LangPack]; !ok {
return 0, fmt.Errorf("seeded langpack %s/%s is outside reconciliation scopes", entry.Pack.LangPack, entry.Pack.LangCode)
}
if _, exists := wanted[entry.Pack.LangPack][entry.Pack.LangCode]; exists {
return 0, fmt.Errorf("duplicate seeded langpack %s/%s", entry.Pack.LangPack, entry.Pack.LangCode)
}
wanted[entry.Pack.LangPack][entry.Pack.LangCode] = struct{}{}
}
for _, scope := range reconciledScopes {
codes, err := q.ListLangPackCodes(ctx, scope)
if err != nil {
return 0, fmt.Errorf("list existing langpack codes for %q: %w", scope, err)
}
for _, code := range codes {
if _, keep := wanted[scope][code]; keep {
continue
}
params := sqlcgen.DeleteLangPackStringsParams{LangPack: scope, LangCode: code}
if err := q.DeleteLangPackStrings(ctx, params); err != nil {
return 0, fmt.Errorf("delete removed langpack strings %s/%s: %w", scope, code, err)
}
if err := q.DeleteLangPackMeta(ctx, sqlcgen.DeleteLangPackMetaParams(params)); err != nil {
return 0, fmt.Errorf("delete removed langpack metadata %s/%s: %w", scope, code, err)
}
if err := q.DeleteLangPackSeedHash(ctx, langPackSeedStateKey(scope, code)); err != nil {
return 0, fmt.Errorf("delete removed langpack seed hash %s/%s: %w", scope, code, err)
}
}
}
written := 0
for _, entry := range seed.Packs {
pack := entry.Pack
meta, metaErr := q.GetLangPackMeta(ctx, sqlcgen.GetLangPackMetaParams{
LangPack: pack.LangPack,
LangCode: pack.LangCode,
})
metaFound := metaErr == nil
if metaErr != nil && !errors.Is(metaErr, pgx.ErrNoRows) {
return 0, fmt.Errorf("get existing langpack metadata %s/%s: %w", pack.LangPack, pack.LangCode, metaErr)
}
if metaFound && pack.Version < int(meta.Version) {
return 0, fmt.Errorf("langpack version rollback %s/%s: %d < %d", pack.LangPack, pack.LangCode, pack.Version, meta.Version)
}
stateKey := langPackSeedStateKey(pack.LangPack, pack.LangCode)
oldHash, hashErr := q.GetLangPackSeedHash(ctx, stateKey)
hashFound := hashErr == nil
if hashErr != nil && !errors.Is(hashErr, pgx.ErrNoRows) {
return 0, fmt.Errorf("get langpack seed hash %s/%s: %w", pack.LangPack, pack.LangCode, hashErr)
}
if metaFound && hashFound && pack.Version == int(meta.Version) && oldHash != entry.ContentHash {
return 0, fmt.Errorf("langpack %s/%s v%d content changed without version bump", pack.LangPack, pack.LangCode, pack.Version)
}
if metaFound && hashFound && pack.Version == int(meta.Version) && int(meta.StringsCount) == entry.StringsCount && oldHash == entry.ContentHash {
continue
}
if !entry.ContentLoaded {
return 0, fmt.Errorf("langpack %s/%s content is required but was not loaded", pack.LangPack, pack.LangCode)
}
if err := replacePackWithCopy(ctx, tx, q, pack); err != nil {
return 0, err
}
if err := q.PutLangPackSeedHash(ctx, sqlcgen.PutLangPackSeedHashParams{Key: stateKey, ContentHash: entry.ContentHash}); err != nil {
return 0, fmt.Errorf("put langpack seed hash %s/%s: %w", pack.LangPack, pack.LangCode, err)
}
written += len(pack.Strings)
}
encodedScopes, err := json.Marshal(seed.Scopes)
if err != nil {
return 0, fmt.Errorf("encode langpack seed scopes for %q: %w", catalog, err)
}
if err := q.PutLangPackSeedHash(ctx, sqlcgen.PutLangPackSeedHashParams{
Key: langPackSeedCatalogStateKey(catalog),
ContentHash: string(encodedScopes),
}); err != nil {
return 0, fmt.Errorf("put langpack seed scopes for %q: %w", catalog, err)
}
manifest := seedCatalogSnapshot(seed)
encodedManifest, err := json.Marshal(manifest)
if err != nil {
return 0, fmt.Errorf("encode langpack seed catalog %q: %w", catalog, err)
}
if err := q.PutLangPackSeedHash(ctx, sqlcgen.PutLangPackSeedHashParams{
Key: langPackSeedManifestStateKey(catalog),
ContentHash: string(encodedManifest),
}); err != nil {
return 0, fmt.Errorf("put langpack seed catalog %q: %w", catalog, err)
}
return written, nil
}
func replacePackWithCopy(ctx context.Context, tx pgx.Tx, q *sqlcgen.Queries, pack domain.LangPack) error {
params := sqlcgen.DeleteLangPackStringsParams{LangPack: pack.LangPack, LangCode: pack.LangCode}
if err := q.DeleteLangPackStrings(ctx, params); err != nil {
return fmt.Errorf("delete previous lang pack strings: %w", err)
}
if err := q.UpsertLangPackMeta(ctx, sqlcgen.UpsertLangPackMetaParams{
LangPack: pack.LangPack,
LangCode: pack.LangCode,
Version: int32(pack.Version),
StringsCount: int32(len(pack.Strings)),
}); err != nil {
return fmt.Errorf("upsert lang pack meta: %w", err)
}
if len(pack.Strings) == 0 {
return nil
}
count, err := tx.CopyFrom(
ctx,
pgx.Identifier{"lang_pack_strings"},
[]string{
"lang_pack", "lang_code", "key", "version", "pluralized", "value",
"zero_value", "one_value", "two_value", "few_value", "many_value", "other_value", "deleted",
},
pgx.CopyFromSlice(len(pack.Strings), func(i int) ([]any, error) {
item := pack.Strings[i]
return []any{
pack.LangPack, pack.LangCode, item.Key, int32(pack.Version), item.Pluralized, item.Value,
item.ZeroValue, item.OneValue, item.TwoValue, item.FewValue, item.ManyValue, item.OtherValue, item.Deleted,
}, nil
}),
)
if err != nil {
return fmt.Errorf("copy lang pack strings: %w", err)
}
if count != int64(len(pack.Strings)) {
return fmt.Errorf("copy lang pack strings wrote %d rows, want %d", count, len(pack.Strings))
}
return nil
}
func replacePackWithQueries(ctx context.Context, q *sqlcgen.Queries, pack domain.LangPack) error {
if err := q.DeleteLangPackStrings(ctx, sqlcgen.DeleteLangPackStringsParams{LangPack: pack.LangPack, LangCode: pack.LangCode}); err != nil {
return fmt.Errorf("delete previous lang pack strings: %w", err)
}
if err := q.UpsertLangPackMeta(ctx, sqlcgen.UpsertLangPackMetaParams{
LangPack: pack.LangPack,
LangCode: pack.LangCode,
Version: int32(pack.Version),
StringsCount: int32(len(pack.Strings)),
}); err != nil {
return fmt.Errorf("upsert lang pack meta: %w", err)
}
for _, item := range pack.Strings {
if err := q.UpsertLangPackString(ctx, sqlcgen.UpsertLangPackStringParams{
LangPack: pack.LangPack,
LangCode: pack.LangCode,
Key: item.Key,
Version: int32(pack.Version),
Pluralized: item.Pluralized,
Value: item.Value,
ZeroValue: item.ZeroValue,
OneValue: item.OneValue,
TwoValue: item.TwoValue,
FewValue: item.FewValue,
ManyValue: item.ManyValue,
OtherValue: item.OtherValue,
Deleted: item.Deleted,
}); err != nil {
return fmt.Errorf("upsert lang pack string %q: %w", item.Key, err)
}
}
return nil
}
func langPackSeedStateKey(langPack, langCode string) string {
return "langpack:v1:entry:" + langPack + ":" + langCode
}
func langPackSeedCatalogStateKey(catalog string) string {
return "langpack:v1:catalog:" + catalog
}
func langPackSeedManifestStateKey(catalog string) string {
return "langpack:v2:catalog:" + catalog
}
func validateSeedEntry(entry domain.LangPackSeedEntry) error {
if entry.SourceHash == "" || entry.ContentHash == "" || entry.StringsCount <= 0 {
return fmt.Errorf("langpack %s/%s has incomplete seed metadata", entry.Pack.LangPack, entry.Pack.LangCode)
}
if entry.ContentLoaded && len(entry.Pack.Strings) != entry.StringsCount {
return fmt.Errorf("langpack %s/%s loaded %d strings, want %d", entry.Pack.LangPack, entry.Pack.LangCode, len(entry.Pack.Strings), entry.StringsCount)
}
return nil
}
func seedCatalogSnapshot(seed domain.LangPackSeed) domain.LangPackSeedCatalog {
state := domain.LangPackSeedCatalog{
Catalog: seed.Catalog,
Scopes: append([]string(nil), seed.Scopes...),
Packs: make([]domain.LangPackSeedCatalogEntry, 0, len(seed.Packs)),
}
for _, entry := range seed.Packs {
state.Packs = append(state.Packs, domain.LangPackSeedCatalogEntry{
LangPack: entry.Pack.LangPack,
LangCode: entry.Pack.LangCode,
Version: entry.Pack.Version,
SourceHash: entry.SourceHash,
ContentHash: entry.ContentHash,
StringsCount: entry.StringsCount,
})
}
return state
}
func invalidateLangPackSeedCatalogs(ctx context.Context, db sqlcgen.DBTX) error {
if _, err := db.Exec(ctx, `DELETE FROM seed_states WHERE key LIKE 'langpack:v2:catalog:%'`); err != nil {
return fmt.Errorf("invalidate langpack seed catalogs: %w", err)
}
return nil
}
func (s *LangPackStore) meta(ctx context.Context, langPack, langCode string) (domain.LangPack, bool, error) {
row, err := s.q.GetLangPackMeta(ctx, sqlcgen.GetLangPackMetaParams{
LangPack: langPack,
LangCode: langCode,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LangPack{LangPack: langPack, LangCode: langCode}, false, nil
}
return domain.LangPack{}, false, fmt.Errorf("get lang pack meta: %w", err)
}
return domain.LangPack{
LangPack: row.LangPack,
LangCode: row.LangCode,
Version: int(row.Version),
}, true, nil
}
func langPackStringFromListRow(row sqlcgen.ListLangPackStringsRow) domain.LangPackString {
return domain.LangPackString{
Key: row.Key,
Value: row.Value,
Pluralized: row.Pluralized,
ZeroValue: row.ZeroValue,
OneValue: row.OneValue,
TwoValue: row.TwoValue,
FewValue: row.FewValue,
ManyValue: row.ManyValue,
OtherValue: row.OtherValue,
Deleted: row.Deleted,
}
}
func langPackStringFromKeysRow(row sqlcgen.GetLangPackStringsByKeysRow) domain.LangPackString {
return domain.LangPackString{
Key: row.Key,
Value: row.Value,
Pluralized: row.Pluralized,
ZeroValue: row.ZeroValue,
OneValue: row.OneValue,
TwoValue: row.TwoValue,
FewValue: row.FewValue,
ManyValue: row.ManyValue,
OtherValue: row.OtherValue,
Deleted: row.Deleted,
}
}