package model import ( "context" "errors" "strings" "time" "gorm.io/gorm" "spark-server/internal/module/client/entity" ) type Repo struct { db *gorm.DB } func NewRepo(db *gorm.DB) *Repo { if db == nil { return nil } return &Repo{db: db} } type legalRow struct { DocKey string `gorm:"column:doc_key;primaryKey"` Lang string `gorm:"column:lang;primaryKey"` Title string `gorm:"column:title"` Content string `gorm:"column:content"` Version string `gorm:"column:version"` UpdatedAt time.Time `gorm:"column:updated_at"` } func (legalRow) TableName() string { return "t_legal_documents" } type feedbackRow struct { ID int64 `gorm:"column:id;primaryKey;autoIncrement"` UserID *int64 `gorm:"column:user_id"` AuthType string `gorm:"column:auth_type"` Email string `gorm:"column:email"` Contact string `gorm:"column:contact"` Content string `gorm:"column:content"` AppVersion string `gorm:"column:app_version"` Language string `gorm:"column:language"` Status string `gorm:"column:status"` CreatedAt time.Time `gorm:"column:created_at"` } func (feedbackRow) TableName() string { return "t_feedback" } type planRow struct { PlanID string `gorm:"column:plan_id;primaryKey"` Lang string `gorm:"column:lang;primaryKey"` PackageName string `gorm:"column:package_name;primaryKey"` Platform string `gorm:"column:platform;primaryKey"` Title string `gorm:"column:title"` Subtitle string `gorm:"column:subtitle"` PriceDisplay string `gorm:"column:price_display"` PriceCents int `gorm:"column:price_cents"` Currency string `gorm:"column:currency"` PeriodDays int `gorm:"column:period_days"` Badge string `gorm:"column:badge"` SortOrder int `gorm:"column:sort_order"` Enabled bool `gorm:"column:enabled"` ProductID string `gorm:"column:product_id"` UpdatedAt time.Time `gorm:"column:updated_at"` } func (planRow) TableName() string { return "t_subscription_plans" } type passwordRow struct { UserID int64 `gorm:"column:user_id;primaryKey"` PasswordHash string `gorm:"column:password_hash"` UpdatedAt time.Time `gorm:"column:updated_at"` } func (passwordRow) TableName() string { return "t_user_passwords" } func (r *Repo) GetLegal(ctx context.Context, lang string) (privacy, terms *entity.LegalDocument, err error) { var rows []legalRow if err = r.db.WithContext(ctx).Where("lang = ?", lang).Find(&rows).Error; err != nil { return nil, nil, err } for i := range rows { d := &entity.LegalDocument{ Key: rows[i].DocKey, Lang: rows[i].Lang, Title: rows[i].Title, Content: rows[i].Content, Version: rows[i].Version, } switch rows[i].DocKey { case entity.DocPrivacy: privacy = d case entity.DocTerms: terms = d } } return privacy, terms, nil } func (r *Repo) UpsertLegal(ctx context.Context, d *entity.LegalDocument) error { if d == nil { return errors.New("client: nil legal document") } row := legalRow{ DocKey: d.Key, Lang: d.Lang, Title: d.Title, Content: d.Content, Version: d.Version, UpdatedAt: time.Now(), } if row.Version == "" { row.Version = "1.0" } return r.db.WithContext(ctx).Save(&row).Error } func (r *Repo) InsertFeedback(ctx context.Context, f *entity.Feedback) error { status := f.Status if status == "" { status = "open" } authType := f.AuthType if authType == "" { authType = entity.FeedbackAuthGuest } row := feedbackRow{ UserID: f.UserID, AuthType: authType, Email: f.Email, Contact: f.Contact, Content: f.Content, AppVersion: f.AppVersion, Language: f.Language, Status: status, CreatedAt: f.CreatedAt, } if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { return err } f.ID = row.ID f.Status = status f.AuthType = authType return nil } func (r *Repo) ListFeedback(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error) { if page < 0 { page = 0 } if size <= 0 || size > 200 { size = 50 } q := r.db.WithContext(ctx).Model(&feedbackRow{}) if authType == entity.FeedbackAuthLogin || authType == entity.FeedbackAuthGuest { q = q.Where("auth_type = ?", authType) } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err } var rows []feedbackRow listQ := r.db.WithContext(ctx).Order("id DESC").Offset(page * size).Limit(size) if authType == entity.FeedbackAuthLogin || authType == entity.FeedbackAuthGuest { listQ = listQ.Where("auth_type = ?", authType) } err := listQ.Find(&rows).Error if err != nil { return nil, 0, err } out := make([]entity.Feedback, 0, len(rows)) for i := range rows { row := &rows[i] out = append(out, entity.Feedback{ ID: row.ID, UserID: row.UserID, AuthType: row.AuthType, Email: row.Email, Contact: row.Contact, Content: row.Content, AppVersion: row.AppVersion, Language: row.Language, Status: row.Status, CreatedAt: row.CreatedAt, }) } return out, total, nil } func (r *Repo) UpdateFeedbackStatus(ctx context.Context, id int64, status string) error { return r.db.WithContext(ctx).Model(&feedbackRow{}).Where("id = ?", id).Update("status", status).Error } func (r *Repo) ListPlans(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) { var rows []planRow err := r.db.WithContext(ctx). Where("lang = ? AND enabled = 1", lang). Order("sort_order ASC, plan_id ASC"). Find(&rows).Error if err != nil { return nil, err } return filterPlansByPackage(mapPlans(rows), packageName), nil } func (r *Repo) ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) { q := r.db.WithContext(ctx).Order("package_name ASC, lang ASC, sort_order ASC, plan_id ASC") if lang != "" { q = q.Where("lang = ?", lang) } if packageName != "" { q = q.Where("package_name = ?", packageName) } var rows []planRow if err := q.Find(&rows).Error; err != nil { return nil, err } return mapPlans(rows), nil } func mapPlans(rows []planRow) []entity.SubscriptionPlan { out := make([]entity.SubscriptionPlan, 0, len(rows)) for i := range rows { row := &rows[i] out = append(out, entity.SubscriptionPlan{ PlanID: row.PlanID, Lang: row.Lang, PackageName: row.PackageName, Platform: row.Platform, Title: row.Title, Subtitle: row.Subtitle, PriceDisplay: row.PriceDisplay, PriceCents: row.PriceCents, Currency: row.Currency, PeriodDays: row.PeriodDays, Badge: row.Badge, SortOrder: row.SortOrder, Enabled: row.Enabled, ProductID: row.ProductID, }) } return out } // filterPlansByPackage:有精确包名则用之,否则回退 package_name 为空的默认档。 func filterPlansByPackage(items []entity.SubscriptionPlan, packageName string) []entity.SubscriptionPlan { packageName = strings.TrimSpace(packageName) if packageName == "" { // 无包名:仅返回默认兜底,避免多包混在一起 out := make([]entity.SubscriptionPlan, 0, len(items)) for _, p := range items { if strings.TrimSpace(p.PackageName) == "" { out = append(out, p) } } if len(out) > 0 { return out } return items } want := strings.ToLower(entity.CanonicalHealthPackage(packageName)) exact := make([]entity.SubscriptionPlan, 0) fallback := make([]entity.SubscriptionPlan, 0) for _, p := range items { pkg := entity.CanonicalHealthPackage(p.PackageName) if pkg != "" && strings.ToLower(pkg) == want { exact = append(exact, p) } else if pkg == "" { fallback = append(fallback, p) } } if len(exact) > 0 { return exact } return fallback } func (r *Repo) UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error { if p == nil { return errors.New("client: nil subscription plan") } row := planRow{ PlanID: p.PlanID, Lang: p.Lang, PackageName: strings.TrimSpace(p.PackageName), Platform: p.Platform, Title: p.Title, Subtitle: p.Subtitle, PriceDisplay: p.PriceDisplay, PriceCents: p.PriceCents, Currency: p.Currency, PeriodDays: p.PeriodDays, Badge: p.Badge, SortOrder: p.SortOrder, Enabled: p.Enabled, ProductID: p.ProductID, UpdatedAt: time.Now(), } if row.Currency == "" { row.Currency = "USD" } return r.db.WithContext(ctx).Save(&row).Error } func (r *Repo) GetPasswordHash(ctx context.Context, userID int64) (string, error) { var row passwordRow err := r.db.WithContext(ctx).Where("user_id = ?", userID).Take(&row).Error if err == gorm.ErrRecordNotFound { return "", nil } if err != nil { return "", err } return row.PasswordHash, nil } func (r *Repo) SetPasswordHash(ctx context.Context, userID int64, hash string) error { row := passwordRow{UserID: userID, PasswordHash: hash, UpdatedAt: time.Now()} return r.db.WithContext(ctx).Save(&row).Error } type developerAppRow struct { ID int64 `gorm:"column:id;primaryKey;autoIncrement"` SourceAppCode string `gorm:"column:source_app_code"` TargetAppName string `gorm:"column:target_app_name"` TargetPackageName string `gorm:"column:target_package_name"` Subtitle string `gorm:"column:subtitle"` IconURL string `gorm:"column:icon_url"` StoreURL string `gorm:"column:store_url"` FallbackURL string `gorm:"column:fallback_url"` SchemaURL string `gorm:"column:schema_url"` SortOrder int `gorm:"column:sort_order"` Enabled bool `gorm:"column:enabled"` UpdatedAt time.Time `gorm:"column:updated_at"` } func (developerAppRow) TableName() string { return "t_developer_apps" } func (r *Repo) ListDeveloperApps(ctx context.Context, sourceAppCode string, enabledOnly bool) ([]entity.DeveloperApp, error) { q := r.db.WithContext(ctx).Where("source_app_code = ?", sourceAppCode) if enabledOnly { q = q.Where("enabled = 1") } var rows []developerAppRow if err := q.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil { return nil, err } out := make([]entity.DeveloperApp, 0, len(rows)) for i := range rows { out = append(out, mapDeveloperApp(&rows[i])) } return out, nil } func (r *Repo) UpsertDeveloperApp(ctx context.Context, a *entity.DeveloperApp) error { now := time.Now() var existing developerAppRow err := r.db.WithContext(ctx). Where("source_app_code = ? AND target_package_name = ?", a.SourceAppCode, a.TargetPackageName). Take(&existing).Error if err == nil { existing.TargetAppName = a.TargetAppName existing.Subtitle = a.Subtitle if a.IconURL != "" { existing.IconURL = a.IconURL } if a.StoreURL != "" { existing.StoreURL = a.StoreURL } if a.FallbackURL != "" { existing.FallbackURL = a.FallbackURL } if a.SchemaURL != "" { existing.SchemaURL = a.SchemaURL } existing.SortOrder = a.SortOrder existing.Enabled = a.Enabled existing.UpdatedAt = now if err := r.db.WithContext(ctx).Save(&existing).Error; err != nil { return err } a.ID = existing.ID return nil } if err != nil && err != gorm.ErrRecordNotFound { return err } row := developerAppRow{ SourceAppCode: a.SourceAppCode, TargetAppName: a.TargetAppName, TargetPackageName: a.TargetPackageName, Subtitle: a.Subtitle, IconURL: a.IconURL, StoreURL: a.StoreURL, FallbackURL: a.FallbackURL, SchemaURL: a.SchemaURL, SortOrder: a.SortOrder, Enabled: a.Enabled, UpdatedAt: now, } if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { return err } a.ID = row.ID return nil } func (r *Repo) DeleteDeveloperApp(ctx context.Context, id int64) error { return r.db.WithContext(ctx).Delete(&developerAppRow{}, id).Error } func mapDeveloperApp(row *developerAppRow) entity.DeveloperApp { return entity.DeveloperApp{ ID: row.ID, SourceAppCode: row.SourceAppCode, TargetAppName: row.TargetAppName, TargetPackageName: row.TargetPackageName, Subtitle: row.Subtitle, IconURL: row.IconURL, StoreURL: row.StoreURL, FallbackURL: row.FallbackURL, SchemaURL: row.SchemaURL, SortOrder: row.SortOrder, Enabled: row.Enabled, } }