316 lines
11 KiB
Go
316 lines
11 KiB
Go
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 != string(i18n.ZhCN) {
|
|
privacy, terms, err = s.repo.GetLegal(ctx, string(i18n.ZhCN))
|
|
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
|
|
}
|
|
if d == nil {
|
|
return errs.ErrInvalidArgument
|
|
}
|
|
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 != string(i18n.ZhCN) {
|
|
plans, err = s.repo.ListPlans(ctx, string(i18n.ZhCN))
|
|
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
|
|
}
|
|
if p == nil {
|
|
return errs.ErrInvalidArgument
|
|
}
|
|
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 string(i18n.ZhCN)
|
|
}
|
|
}
|
|
|
|
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: string(i18n.ZhCN), Title: "隐私政策",
|
|
Content: "SleepHeart 收集邮箱与设备数据用于登录与推荐,不会出售个人信息。您可随时注销账号。", Version: "1.0"},
|
|
Terms: entity.LegalDocument{Key: entity.DocTerms, Lang: string(i18n.ZhCN), 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),
|
|
}
|
|
}
|