package logic import ( "context" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" "spark-server/internal/module/client/entity" userentity "spark-server/internal/module/user/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" "spark-server/internal/pkg/reqctx" "spark-server/internal/pkg/validate" ) type Repository interface { GetLegal(ctx context.Context, lang string) (privacy, terms *entity.LegalDocument, err error) UpsertLegal(ctx context.Context, d entity.LegalDocument) error InsertFeedback(ctx context.Context, f *entity.Feedback) error ListFeedback(ctx context.Context, page, size int) ([]entity.Feedback, int64, error) UpdateFeedbackStatus(ctx context.Context, id int64, status string) error ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) UpsertPlan(ctx context.Context, p entity.SubscriptionPlan) error GetPasswordHash(ctx context.Context, userID int64) (string, error) SetPasswordHash(ctx context.Context, userID int64, hash string) error } type UserFinder interface { GetByEmail(ctx context.Context, email string) (*userentity.User, error) } type OTPLogin interface { AuthenticateEmail(ctx context.Context, email, verifyCode string, device *userentity.LoginDevice) (*userentity.User, error) } type Service struct { repo Repository users UserFinder otp OTPLogin mu sync.Mutex memPW map[int64]string } func NewService(repo Repository, users UserFinder, otp OTPLogin) *Service { return &Service{repo: repo, users: users, otp: otp, memPW: map[int64]string{}} } func (s *Service) Ready() bool { return s != nil } type FeedbackInput struct { Email string `json:"email" validate:"omitempty,email" label:"field.email" errmsg:"email:validate.login.email_invalid"` Content string `json:"content" validate:"required,min=2,max=2000" label:"field.content" errmsg:"required:validate.required"` AppVersion string `json:"app_version" validate:"omitempty,max=64"` } type SetPasswordInput struct { Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"` VerifyCode string `json:"verify_code" validate:"required" label:"field.verify_code" errmsg:"required:validate.login.code_required"` Password string `json:"password" validate:"required,min=6,max=72" label:"field.password" errmsg:"required:validate.login.password_required;min:validate.login.password_min"` } func (s *Service) LegalAgreements(ctx context.Context) (*entity.LegalBundle, error) { lang := normalizeLang(reqctx.Language(ctx)) if s.repo != nil { privacy, terms, err := s.repo.GetLegal(ctx, lang) if err == nil && privacy != nil && terms != nil { return &entity.LegalBundle{Privacy: *privacy, Terms: *terms}, nil } if lang != "zh-CN" { privacy, terms, err = s.repo.GetLegal(ctx, "zh-CN") if err == nil && privacy != nil && terms != nil { return &entity.LegalBundle{Privacy: *privacy, Terms: *terms}, nil } } } return defaultLegal(lang), nil } func (s *Service) UpsertLegalAdmin(ctx context.Context, d entity.LegalDocument) error { if s.repo == nil { return errs.ErrAvailable } d.Key = strings.TrimSpace(d.Key) d.Lang = normalizeLang(d.Lang) if d.Key != entity.DocPrivacy && d.Key != entity.DocTerms { return errs.ErrInvalidArgument.WithMsg("key must be privacy|terms") } return s.repo.UpsertLegal(ctx, d) } func (s *Service) SubmitFeedback(ctx context.Context, in FeedbackInput) (*entity.Feedback, error) { if err := validate.StructLang(reqctx.Lang(ctx), &in); err != nil { return nil, err } f := &entity.Feedback{ Email: strings.TrimSpace(strings.ToLower(in.Email)), Content: strings.TrimSpace(in.Content), AppVersion: strings.TrimSpace(in.AppVersion), Language: normalizeLang(reqctx.Language(ctx)), Status: "open", CreatedAt: time.Now(), } if rc := reqctx.From(ctx); rc != nil && rc.UserID > 0 { uid := rc.UserID f.UserID = &uid } if s.repo != nil { if err := s.repo.InsertFeedback(ctx, f); err != nil { return nil, err } return f, nil } f.ID = time.Now().UnixNano() return f, nil } func (s *Service) ListFeedbackAdmin(ctx context.Context, page, size int) ([]entity.Feedback, int64, error) { if s.repo == nil { return nil, 0, nil } return s.repo.ListFeedback(ctx, page, size) } func (s *Service) UpdateFeedbackStatus(ctx context.Context, id int64, status string) error { if s.repo == nil { return errs.ErrAvailable } status = strings.TrimSpace(status) if status == "" { return errs.ErrInvalidArgument } return s.repo.UpdateFeedbackStatus(ctx, id, status) } func (s *Service) SubscriptionPlans(ctx context.Context) (*entity.SubscriptionConfig, error) { lang := normalizeLang(reqctx.Language(ctx)) headline, sub := subscriptionCopy(lang) if s.repo != nil { plans, err := s.repo.ListPlans(ctx, lang) if err == nil && len(plans) > 0 { return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil } if lang != "zh-CN" { plans, err = s.repo.ListPlans(ctx, "zh-CN") if err == nil && len(plans) > 0 { return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil } } } return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: defaultPlans(lang)}, nil } func (s *Service) ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) { if s.repo == nil { return defaultPlans(normalizeLang(lang)), nil } return s.repo.ListPlansAdmin(ctx, strings.TrimSpace(lang)) } func (s *Service) UpsertPlanAdmin(ctx context.Context, p entity.SubscriptionPlan) error { if s.repo == nil { return errs.ErrAvailable } p.Lang = normalizeLang(p.Lang) p.PlanID = strings.TrimSpace(p.PlanID) switch p.PlanID { case "week", "month", "quarter", "year": default: return errs.ErrInvalidArgument.WithMsg("plan_id must be week|month|quarter|year") } return s.repo.UpsertPlan(ctx, p) } func (s *Service) SetPassword(ctx context.Context, in SetPasswordInput) error { if s == nil || s.otp == nil { return errs.ErrAvailable } if err := validate.StructLang(reqctx.Lang(ctx), &in); err != nil { return err } u, err := s.otp.AuthenticateEmail(ctx, in.Email, in.VerifyCode, &userentity.LoginDevice{ DeviceID: 1, DeviceType: "ios", AppID: 1, }) if err != nil { return err } if u == nil || u.ID <= 0 { return errs.ErrUnauthorized } hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) if err != nil { return errs.ErrInternal } return s.storeHash(ctx, u.ID, string(hash)) } func (s *Service) VerifyPassword(ctx context.Context, email, password string) (*userentity.User, error) { if s == nil || s.users == nil { return nil, errs.ErrUnauthorized } email = userentity.NormalizeEmail(email) if email == "" || strings.TrimSpace(password) == "" { return nil, errs.ErrUnauthorized } u, err := s.users.GetByEmail(ctx, email) if err != nil { return nil, errs.ErrUnauthorized } if u.Status != userentity.StatusActive { return nil, errs.ErrForbidden } if userentity.IsTestEmail(email) { return u, nil } hash, err := s.loadHash(ctx, u.ID) if err != nil || hash == "" { return nil, errs.ErrUnauthorized.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginPasswordRequired, nil)) } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { return nil, errs.ErrUnauthorized } return u, nil } func (s *Service) storeHash(ctx context.Context, userID int64, hash string) error { if s.repo != nil { return s.repo.SetPasswordHash(ctx, userID, hash) } s.mu.Lock() defer s.mu.Unlock() s.memPW[userID] = hash return nil } func (s *Service) loadHash(ctx context.Context, userID int64) (string, error) { if s.repo != nil { return s.repo.GetPasswordHash(ctx, userID) } s.mu.Lock() defer s.mu.Unlock() return s.memPW[userID], nil } func normalizeLang(lang string) string { switch strings.TrimSpace(lang) { case "en", "en-US", "en-GB": return "en" case "zh-TW", "zh-HK": return "zh-TW" default: return "zh-CN" } } func defaultLegal(lang string) *entity.LegalBundle { switch lang { case "en": return &entity.LegalBundle{ Privacy: entity.LegalDocument{Key: entity.DocPrivacy, Lang: "en", Title: "Privacy Policy", Content: "SleepHeart collects email and device data for login and recommendations. We do not sell personal data.", Version: "1.0"}, Terms: entity.LegalDocument{Key: entity.DocTerms, Lang: "en", Title: "Terms of Use", Content: "By using SleepHeart you agree to community rules. Membership follows the subscription page.", Version: "1.0"}, } default: return &entity.LegalBundle{ Privacy: entity.LegalDocument{Key: entity.DocPrivacy, Lang: "zh-CN", Title: "隐私政策", Content: "SleepHeart 收集邮箱与设备数据用于登录与推荐,不会出售个人信息。您可随时注销账号。", Version: "1.0"}, Terms: entity.LegalDocument{Key: entity.DocTerms, Lang: "zh-CN", Title: "用户协议", Content: "使用 SleepHeart 即表示同意社区规范。会员权益以订阅页展示为准。", Version: "1.0"}, } } } func subscriptionCopy(lang string) (headline, sub string) { switch lang { case "en": return "Unlock SleepHeart premium", "Choose a plan that fits you" default: return "解锁 SleepHeart 专属内容", "选择适合你的订阅方案" } } func defaultPlans(lang string) []entity.SubscriptionPlan { mk := func(id, title, sub, price, badge, product string, cents, days, order int) entity.SubscriptionPlan { return entity.SubscriptionPlan{ PlanID: id, Lang: lang, Title: title, Subtitle: sub, PriceDisplay: price, PriceCents: cents, Currency: "CNY", PeriodDays: days, Badge: badge, SortOrder: order, Enabled: true, ProductID: product, } } if lang == "en" { return []entity.SubscriptionPlan{ mk("week", "Weekly", "Flexible", "$1.99", "", "health.sub.week", 199, 7, 1), mk("month", "Monthly", "Most popular", "$4.99", "Popular", "health.sub.month", 499, 30, 2), mk("quarter", "Quarterly", "Better value", "$12.99", "", "health.sub.quarter", 1299, 90, 3), mk("year", "Yearly", "Best value", "$29.99", "Best", "health.sub.year", 2999, 365, 4), } } return []entity.SubscriptionPlan{ mk("week", "周卡", "灵活体验", "¥12", "", "health.sub.week", 1200, 7, 1), mk("month", "月卡", "最受欢迎", "¥30", "热门", "health.sub.month", 3000, 30, 2), mk("quarter", "季卡", "更省", "¥68", "", "health.sub.quarter", 6800, 90, 3), mk("year", "年卡", "全年畅听", "¥198", "超值", "health.sub.year", 19800, 365, 4), } }