spark-server/internal/module/client/logic/service.go
DESKTOP-L8OUL9F\gfl0034 d293de34f5 实现 TCP READ(命令 8),已读水位前进时清未读并通知其他设备。
补上会员价格按平台筛选,并修正广场机器人测试的 Feed 调用,使 go test 能通过。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 18:22:08 +08:00

381 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, packageName string) ([]entity.SubscriptionPlan, error)
ListPlansAdmin(ctx context.Context, lang, packageName 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
ListDeveloperApps(ctx context.Context, sourceAppCode string, enabledOnly bool) ([]entity.DeveloperApp, error)
UpsertDeveloperApp(ctx context.Context, a *entity.DeveloperApp) error
DeleteDeveloperApp(ctx context.Context, id int64) 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, packageName, platform string) (*entity.SubscriptionConfig, error) {
lang := normalizeLang(reqctx.Language(ctx))
packageName = canonicalHealthPackage(packageName)
platform = normalizePlatform(platform)
headline, sub := subscriptionCopy(lang)
if s.repo != nil {
plans, err := s.clientPlans(ctx, lang, packageName, platform)
if err != nil {
return nil, err
}
if len(plans) > 0 {
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
}
}
return &entity.SubscriptionConfig{
Headline: headline, Subheadline: sub, Plans: filterPlansByPlatform(defaultPlans(lang), platform),
}, nil
}
func (s *Service) ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
if s.repo == nil {
return defaultPlans(normalizeLang(lang)), nil
}
return s.repo.ListPlansAdmin(ctx, strings.TrimSpace(lang), canonicalHealthPackage(packageName))
}
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)
p.PackageName = canonicalHealthPackage(p.PackageName)
rawPlatform := strings.TrimSpace(p.Platform)
p.Platform = normalizePlatform(rawPlatform)
if rawPlatform != "" && p.Platform == "" {
return errs.ErrInvalidArgument.WithMsg("platform must be ios|android|web")
}
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 string(i18n.ZhTW), "zh-HK":
return string(i18n.ZhTW)
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),
}
}