spark-server/internal/module/client/logic/service.go
DESKTOP-L8OUL9F\gfl0034 c58b60419e feat(client): 接入同开发者应用墙 API 与管理端 CRUD
合并此前 stash 的 developer-apps:免登录列表、popadmin 管理与 0027 迁移。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-25 15:42:39 +08:00

374 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 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
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) (*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),
}
}