371 lines
12 KiB
Go
371 lines
12 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, authType string) ([]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 {
|
||
Contact string `json:"contact" validate:"omitempty,max=255"`
|
||
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
|
||
}
|
||
contact := strings.TrimSpace(in.Contact)
|
||
if contact == "" {
|
||
contact = strings.TrimSpace(in.Email)
|
||
}
|
||
email := strings.TrimSpace(strings.ToLower(in.Email))
|
||
f := &entity.Feedback{
|
||
Email: email, Contact: contact, Content: strings.TrimSpace(in.Content),
|
||
AppVersion: strings.TrimSpace(in.AppVersion),
|
||
Language: normalizeLang(reqctx.Language(ctx)), Status: "open", CreatedAt: time.Now(),
|
||
}
|
||
if f.AppVersion == "" {
|
||
if rc := reqctx.From(ctx); rc != nil {
|
||
f.AppVersion = strings.TrimSpace(rc.AppVersion)
|
||
}
|
||
}
|
||
if rc := reqctx.From(ctx); rc != nil && rc.UserID > 0 {
|
||
uid := rc.UserID
|
||
f.UserID = &uid
|
||
f.AuthType = entity.FeedbackAuthLogin
|
||
} else {
|
||
f.AuthType = entity.FeedbackAuthGuest
|
||
if contact == "" {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("游客反馈须填写 contact(邮箱或手机)")
|
||
}
|
||
if !looksLikeContact(contact) {
|
||
return nil, errs.ErrInvalidArgument.WithMsg("contact 须为邮箱或手机号")
|
||
}
|
||
if email == "" && strings.Contains(contact, "@") {
|
||
f.Email = strings.ToLower(contact)
|
||
}
|
||
}
|
||
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 looksLikeContact(s string) bool {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return false
|
||
}
|
||
if strings.Contains(s, "@") {
|
||
at := strings.IndexByte(s, '@')
|
||
return at > 0 && at < len(s)-1
|
||
}
|
||
digits := 0
|
||
for _, r := range s {
|
||
if r >= '0' && r <= '9' {
|
||
digits++
|
||
} else if r != '+' && r != '-' && r != ' ' {
|
||
return false
|
||
}
|
||
}
|
||
return digits >= 7 && digits <= 15
|
||
}
|
||
|
||
func (s *Service) ListFeedbackAdmin(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error) {
|
||
if s.repo == nil {
|
||
return nil, 0, nil
|
||
}
|
||
authType = strings.TrimSpace(authType)
|
||
return s.repo.ListFeedback(ctx, page, size, authType)
|
||
}
|
||
|
||
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 {
|
||
const (
|
||
pidWeek = "health.wyrs365.com.vip.week"
|
||
pidMonth = "health.wyrs365.com.vip.month"
|
||
pidQuarter = "health.wyrs365.com.vip.quarter"
|
||
pidYear = "health.wyrs365.com.vip.year"
|
||
)
|
||
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: "USD", PeriodDays: days, Badge: badge,
|
||
SortOrder: order, Enabled: true, ProductID: product,
|
||
}
|
||
}
|
||
if lang == "en" {
|
||
return []entity.SubscriptionPlan{
|
||
mk("week", "Weekly", "Flexible, cancel anytime", "$1/week", "", pidWeek, 100, 7, 1),
|
||
mk("month", "Monthly", "Most popular", "$3/month", "Popular", pidMonth, 300, 30, 2),
|
||
mk("quarter", "Quarterly", "Better monthly value", "$8/quarter", "", pidQuarter, 800, 90, 3),
|
||
mk("year", "Yearly", "Best value all year", "$12/year", "Best", pidYear, 1200, 365, 4),
|
||
}
|
||
}
|
||
if lang == "zh-TW" {
|
||
return []entity.SubscriptionPlan{
|
||
mk("week", "週卡", "靈活體驗,隨時可停", "$1/週", "", pidWeek, 100, 7, 1),
|
||
mk("month", "月卡", "最受歡迎", "$3/月", "熱門", pidMonth, 300, 30, 2),
|
||
mk("quarter", "季卡", "平均每月更省", "$8/季", "", pidQuarter, 800, 90, 3),
|
||
mk("year", "年卡", "全年暢聽會員曲庫", "$12/年", "超值", pidYear, 1200, 365, 4),
|
||
}
|
||
}
|
||
return []entity.SubscriptionPlan{
|
||
mk("week", "周卡", "灵活体验,随时可停", "$1/周", "", pidWeek, 100, 7, 1),
|
||
mk("month", "月卡", "最受欢迎", "$3/月", "热门", pidMonth, 300, 30, 2),
|
||
mk("quarter", "季卡", "平均每月更省", "$8/季", "", pidQuarter, 800, 90, 3),
|
||
mk("year", "年卡", "全年畅听会员曲库", "$12/年", "超值", pidYear, 1200, 365, 4),
|
||
}
|
||
}
|