spark-server/internal/module/client/logic/service.go
DESKTOP-L8OUL9F\gfl0034 1edcb959b7 feat: 补好友拒绝、客户端配置 API 与 OpenAPI/popadmin
为 Spark Messenger 主路径补齐 POST /v1/friends/requests/reject,并落地法律协议/反馈/订阅/密码登录及管理页,同步 OpenAPI 文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 14:01:46 +08:00

307 lines
10 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 != "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),
}
}