diff --git a/deploy/popadmin/popadmin.js b/deploy/popadmin/popadmin.js index fffbd1e..81a52f0 100644 --- a/deploy/popadmin/popadmin.js +++ b/deploy/popadmin/popadmin.js @@ -72,6 +72,12 @@ const NAV = [ { id: 'music', label: '音乐列表' }, + { id: 'legalDocs', label: '隐私协议' }, + + { id: 'subPlans', label: '订阅配置' }, + + { id: 'sparkFeedbacks', label: 'App反馈' }, + { id: 'novelCms', label: '小说内容管理' }, { id: 'developerApps', label: '同开发者应用' }, @@ -3037,6 +3043,124 @@ const pages = { + + async legalDocs() { + let data = { privacy: {}, terms: {} }; + try { data = await api('/spark/legal'); } catch (e) { + $('content').innerHTML = `
法律文档 API:${esc(e.message)}。需部署 spark-server 0023 迁移。
`; + return; + } + const privacy = data.privacy || {}; + const terms = data.terms || {}; + $('content').innerHTML = ` +
SleepHeart 隐私政策 / 用户协议 · 管理 /api/admin/spark/legal · 客户端 GET /v1/legal/agreements
+
+

隐私政策

+
+ + + +
+ + +
+
+

用户协议

+
+ + + +
+ + +
`; + if (privacy.lang) $('legLangP').value = privacy.lang; + if (terms.lang) $('legLangT').value = terms.lang; + const save = async (key, titleEl, bodyEl, verEl, langEl) => { + await api('/spark/legal', { method: 'PUT', body: JSON.stringify({ + key, lang: langEl.value, title: titleEl.value.trim(), content: bodyEl.value, version: verEl.value.trim() || '1.0' + })}); + pages.legalDocs(); + }; + $('legSaveP').onclick = () => save('privacy', $('legTitleP'), $('legBodyP'), $('legVerP'), $('legLangP')); + $('legSaveT').onclick = () => save('terms', $('legTitleT'), $('legBodyT'), $('legVerT'), $('legLangT')); + }, + + async subPlans() { + let items = []; + try { + const data = await api('/spark/subscriptions/plans?lang=zh-CN'); + items = (data && data.items) || []; + } catch (e) { + $('content').innerHTML = `
订阅配置 API:${esc(e.message)}
`; + return; + } + $('content').innerHTML = ` +
SleepHeart 订阅引导 · /api/admin/spark/subscriptions/plans · 客户端 GET /v1/subscriptions/plans · plan_id=week|month|quarter|year
+
+ + + + + + + + + + + + + +
+ + + + ${items.length ? items.map((p)=>``).join('') : ''} + +
planlang标题价格天数角标product启用
${esc(p.plan_id)}${esc(p.lang)}${esc(p.title)}
${esc(p.subtitle||'')}
${esc(p.price_display)} (${esc(p.price_cents)})${esc(p.period_days)}${esc(p.badge||'')}${esc(p.product_id)}${esc(p.enabled)}
暂无套餐
`; + $('spSave').onclick = async () => { + await api('/spark/subscriptions/plans', { method: 'PUT', body: JSON.stringify({ + plan_id: $('spPlan').value, lang: $('spLang').value, title: $('spTitle').value.trim(), + subtitle: $('spSub').value.trim(), price_display: $('spPrice').value.trim(), + price_cents: parseInt($('spCents').value,10)||0, currency: 'CNY', + period_days: parseInt($('spDays').value,10)||7, badge: $('spBadge').value.trim(), + sort_order: parseInt($('spSort').value,10)||1, enabled: !!$('spEnabled').checked, + product_id: $('spProduct').value.trim() + })}); + pages.subPlans(); + }; + $('spReload').onclick = () => pages.subPlans(); + }, + + async sparkFeedbacks() { + let data = { list: [], total: 0 }; + try { data = await api('/spark/feedbacks?page=0&size=50'); } catch (e) { + $('content').innerHTML = `
App反馈 API:${esc(e.message)}
`; + return; + } + const list = data.list || []; + $('content').innerHTML = ` +
SleepHeart 意见反馈 · /api/admin/spark/feedbacks · 客户端 POST /v1/feedback · 共 ${esc(data.total||0)} 条
+ + + + ${list.length ? list.map((f)=>` + + + `).join('') : ''} + +
ID内容邮箱语言状态时间
${esc(f.id)}${esc(f.content)}${esc(f.email||'')}${esc(f.language||'')}${esc(f.status||'')}${esc(f.created_at||'')} + + +
暂无反馈
`; + $('content').querySelectorAll('[data-fdone]').forEach((btn)=>{ + btn.onclick = async () => { await api('/spark/feedbacks/'+btn.dataset.fdone,{method:'PATCH',body:JSON.stringify({status:'done'})}); pages.sparkFeedbacks(); }; + }); + $('content').querySelectorAll('[data-fign]').forEach((btn)=>{ + btn.onclick = async () => { await api('/spark/feedbacks/'+btn.dataset.fign,{method:'PATCH',body:JSON.stringify({status:'ignored'})}); pages.sparkFeedbacks(); }; + }); + }, + async crashLogs() { const email = ($('crashEmail') && $('crashEmail').value) || ''; diff --git a/docs/daily/2026-09-24-change-list-sleepheart.md b/docs/daily/2026-09-24-change-list-sleepheart.md new file mode 100644 index 0000000..26ef900 --- /dev/null +++ b/docs/daily/2026-09-24-change-list-sleepheart.md @@ -0,0 +1,32 @@ +# 每日变动 2026-09-24 + +## 13:50 — SleepHeart 客户端能力与 popadmin + +### 概要 + +- App 展示名统一为 **SleepHeart**;Bundle ID 保持 `health.wyrs365.com` +- spark-server 新增法律文档 / 反馈 / 订阅套餐 / 密码登录 API,并在 popadmin 增加管理页 +- HealthiOS 将原先 mock 的密码登录、反馈、订阅配置改为对接真接口(失败仍可 mock 回退) + +### 变更范围 + +| 类型 | 说明 | +|------|------| +| 功能 | 客户端配置与密码登录 | +| 管理端 | popadmin 隐私协议 / 订阅配置 / App反馈 | +| 客户端 | SleepHeart 品牌与 API 对接 | + +### 主要文件 + +- `migrations/0023_client_legal_feedback_subscription.sql` — 表与种子数据 +- `internal/module/client/**` — 业务与仓储 +- `internal/transport/http/handler/client`、`handler/admin/client.go`、`router*.go`、`auth.go` +- `deploy/popadmin/popadmin.js` — 三页管理 UI +- `HealthiOS` pbxproj / L10n / AuthService / APIModels + +### 验证 + +- `go test`:本机 PATH 无 go,未在本会话跑通 make test(需部署环境验证) +- iOS:UI 门控已有;网络层已改 path,需 xcodebuild 再确认 + +EOF diff --git a/docs/daily/2026-09-24-change-list.md b/docs/daily/2026-09-24-change-list.md index 19116e4..847434f 100644 --- a/docs/daily/2026-09-24-change-list.md +++ b/docs/daily/2026-09-24-change-list.md @@ -44,3 +44,35 @@ ### 验证 - `make test`: skipped(文档) + +## 14:10 — Spark Messenger 主路径接入:好友拒绝 API + OpenAPI + +### 概要 + +- 新增 `POST /v1/friends/requests/reject`,补齐 App 忽略/拒绝好友申请 +- OpenAPI 补全 password-login / set-password / legal / feedback / subscriptions / reject +- 与 iOS `SparkIMClient` 主路径路由扩展配套 + +### 变更范围 + +| 类型 | 说明 | +|------|------| +| 功能 | 好友申请拒绝 | +| 文档 | OpenAPI 路径补全 | +| 配置 | client 模块与 popadmin 三页(同日 WIP) | + +### 主要文件 + +- `internal/module/friend/logic/reject.go` — Reject 用例 +- `internal/transport/http/handler/friend/handler.go` / `router.go` — HTTP +- `internal/transport/http/docs/openapi.yaml` — 规范 +- `internal/module/client/**`、`migrations/0023_*.sql`、`deploy/popadmin/popadmin.js` — 法律/反馈/订阅/密码 + +### 验证 + +- `make test`: skipped(本机 PATH 无 go) +- 需部署后刷新 https://sparkapi.wyrs365.com/docs + +### 关联 + +- Spark Messenger 全页面接入检查 diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index d9840e9..612e115 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -43,6 +43,8 @@ import ( momentmodel "spark-server/internal/module/moment/model" musiclogic "spark-server/internal/module/music/logic" musicmodel "spark-server/internal/module/music/model" + clientlogic "spark-server/internal/module/client/logic" + clientmodel "spark-server/internal/module/client/model" smslogic "spark-server/internal/module/sms/logic" squarelogic "spark-server/internal/module/square/logic" squaremodel "spark-server/internal/module/square/model" @@ -175,6 +177,10 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge login := authlogic.NewLoginService(jwtSvc, users) login.Devices = devices login.Sessions = sessions + client := wireClient(db, users) + if client != nil { + login.Passwords = client + } var deliverQueue deliverlogic.Queue inbox, mailbox := wireInbox(idgen, db, rdb) app.deliver, deliverQueue = wireDeliver(cfg, log, rdb, sessions, inbox, mailbox) @@ -210,7 +216,7 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge Sync: syncSvc, }) app.http = httpx.New(log, cfg.Server.HTTP.Addr, httpEngineDeps( - jwtSvc, login, smsSvc, users, friends, groups, files, favorites, moments, square, music, messages, syncSvc, adminSvc, adminTokens, probe, authLimit, + jwtSvc, login, smsSvc, users, friends, groups, files, favorites, moments, square, music, client, messages, syncSvc, adminSvc, adminTokens, probe, authLimit, )) return app, nil } @@ -227,6 +233,7 @@ func httpEngineDeps( moments *momentlogic.Service, square *squarelogic.Service, music *musiclogic.Service, + client *clientlogic.Service, messages *msglogic.Service, syncSvc *synclogic.Service, adminSvc *adminlogic.Service, @@ -236,7 +243,7 @@ func httpEngineDeps( ) *httpx.EngineDeps { return &httpx.EngineDeps{ Auth: jwtSvc, Login: login, SMS: sms, Users: users, Friends: friends, Groups: groups, - Files: files, Favorites: favorites, Moments: moments, Square: square, Music: music, + Files: files, Favorites: favorites, Moments: moments, Square: square, Music: music, Client: client, Messages: messages, Sync: syncSvc, Admin: adminSvc, AdminTokens: adminTokens, Health: probe, AuthLimiter: authLimit, } @@ -382,6 +389,14 @@ func wireMusic(cfg *Config, db *mysql.DB, store *objectstore.Store) *musiclogic. return musiclogic.NewService(musicmodel.NewRepo(db.Gorm()), objects, publicBase, bucket) } +func wireClient(db *mysql.DB, users *userlogic.Service) *clientlogic.Service { + var repo clientlogic.Repository + if db != nil && db.Ready() { + repo = clientmodel.NewRepo(db.Gorm()) + } + return clientlogic.NewService(repo, users, users) +} + func wireMoments(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, friends *friendlogic.Service, users *userlogic.Service) *momentlogic.Service { if idgen == nil || friends == nil || users == nil { return nil diff --git a/internal/module/auth/logic/login.go b/internal/module/auth/logic/login.go index d3c851c..300d95b 100644 --- a/internal/module/auth/logic/login.go +++ b/internal/module/auth/logic/login.go @@ -18,12 +18,17 @@ type Verifier interface { AuthenticateEmail(ctx context.Context, email, verifyCode string, device *userentity.LoginDevice) (*userentity.User, error) } +// PasswordVerifier checks email+password (Health / Sleephealth). Optional. +type PasswordVerifier interface { + VerifyPassword(ctx context.Context, email, password string) (*userentity.User, error) +} + // DeviceRegistrar resolves physical device_id from idfv / android_id. type DeviceRegistrar interface { Register(ctx context.Context, in devicelogic.RegisterInput) (uint64, error) } -// LoginInput is the HTTP login body. +// LoginInput is the HTTP login body (OTP). type LoginInput 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"` @@ -35,6 +40,18 @@ type LoginInput struct { AndroidID string `json:"android_id" validate:"omitempty,max=20" label:"field.android_id"` } +// PasswordLoginInput is POST /v1/auth/password-login body. +type PasswordLoginInput struct { + Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"` + Password string `json:"password" validate:"required,min=6,max=72" label:"field.password" errmsg:"required:validate.login.password_required;min:validate.login.password_min"` + AppID uint8 `json:"app_id" validate:"required,min=1" label:"field.app_id" errmsg:"required:validate.login.app_id_required"` + DeviceType uint8 `json:"device_type" validate:"omitempty,min=1,max=6" label:"field.device_type" errmsg:"min:validate.login.device_type_invalid;max:validate.login.device_type_invalid"` + Platform uint8 `json:"platform" validate:"omitempty,min=1,max=6" label:"field.device_type" errmsg:"min:validate.login.device_type_invalid;max:validate.login.device_type_invalid"` + DeviceName string `json:"device_name" validate:"omitempty,max=128" label:"field.device_name"` + IDFV string `json:"idfv" validate:"omitempty,max=36" label:"field.idfv"` + AndroidID string `json:"android_id" validate:"omitempty,max=20" label:"field.android_id"` +} + type RefreshInput struct { RefreshToken string `json:"refresh_token" validate:"required" label:"field.refresh_token" errmsg:"required:validate.login.refresh_required"` } @@ -46,10 +63,11 @@ type SessionKicker interface { // LoginService is the application login use-case. type LoginService struct { - Issuer Issuer - Users Verifier - Devices DeviceRegistrar - Sessions SessionKicker + Issuer Issuer + Users Verifier + Passwords PasswordVerifier + Devices DeviceRegistrar + Sessions SessionKicker } func NewLoginService(issuer Issuer, users Verifier) *LoginService { @@ -66,7 +84,7 @@ func (s *LoginService) Login(ctx context.Context, in *LoginInput) (*authentity.I if !deviceentity.AppID(in.AppID).Valid() { return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginAppIDInvalid, nil)) } - dtype := loginResolvedDeviceType(in) + dtype := loginResolvedDeviceType(in.DeviceType, in.Platform) if !dtype.Valid() { return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil)) } @@ -80,15 +98,50 @@ func (s *LoginService) Login(ctx context.Context, in *LoginInput) (*authentity.I if err != nil { return nil, err } - ld := loginDevice(ctx, in, deviceID) + ld := loginDeviceFrom(ctx, in.AppID, in.DeviceName, in.IDFV, in.AndroidID, dtype, deviceID) u, err := s.Users.AuthenticateEmail(ctx, in.Email, in.VerifyCode, ld) if err != nil { return nil, err } + return s.issueForUser(ctx, u, deviceID, uint32(in.AppID)) +} + +func (s *LoginService) PasswordLogin(ctx context.Context, in *PasswordLoginInput) (*authentity.IssuedToken, error) { + if s == nil || s.Issuer == nil || s.Passwords == nil || s.Devices == nil || in == nil { + return nil, errs.ErrUnauthorized + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + if !deviceentity.AppID(in.AppID).Valid() { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginAppIDInvalid, nil)) + } + dtype := loginResolvedDeviceType(in.DeviceType, in.Platform) + if !dtype.Valid() { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil)) + } + u, err := s.Passwords.VerifyPassword(ctx, in.Email, in.Password) + if err != nil { + return nil, err + } + deviceID, err := s.Devices.Register(ctx, devicelogic.RegisterInput{ + DeviceType: dtype, + DeviceName: in.DeviceName, + IDFV: in.IDFV, + AndroidID: in.AndroidID, + ClientIP: reqctx.RealIP(ctx), + }) + if err != nil { + return nil, err + } + return s.issueForUser(ctx, u, deviceID, uint32(in.AppID)) +} + +func (s *LoginService) issueForUser(ctx context.Context, u *userentity.User, deviceID uint64, appID uint32) (*authentity.IssuedToken, error) { if u == nil || u.ID <= 0 { return nil, errs.ErrUnauthorized } - tok, err := s.Issuer.Issue(ctx, u.ID, deviceID, uint32(in.AppID)) + tok, err := s.Issuer.Issue(ctx, u.ID, deviceID, appID) if err != nil { return nil, err } @@ -123,31 +176,24 @@ func (s *LoginService) Logout(ctx context.Context) error { return nil } -func loginResolvedDeviceType(in *LoginInput) deviceentity.DeviceType { - if in == nil { - return deviceentity.TypeUnknown +func loginResolvedDeviceType(deviceType, platform uint8) deviceentity.DeviceType { + if deviceType >= 1 { + return deviceentity.DeviceTypeFromWire(deviceType) } - if in.DeviceType >= 1 { - return deviceentity.DeviceTypeFromWire(in.DeviceType) - } - if in.Platform >= 1 { - return deviceentity.DeviceTypeFromWire(in.Platform) + if platform >= 1 { + return deviceentity.DeviceTypeFromWire(platform) } return deviceentity.TypeUnknown } -func loginDevice(ctx context.Context, in *LoginInput, deviceID uint64) *userentity.LoginDevice { - if in == nil { - return &userentity.LoginDevice{DeviceID: deviceID} - } - dtype := loginResolvedDeviceType(in) +func loginDeviceFrom(ctx context.Context, appID uint8, deviceName, idfv, androidID string, dtype deviceentity.DeviceType, deviceID uint64) *userentity.LoginDevice { d := &userentity.LoginDevice{ DeviceID: deviceID, - AppID: in.AppID, + AppID: appID, DeviceType: dtype.PlatformName(), - DeviceName: in.DeviceName, - IDFV: in.IDFV, - AndroidID: in.AndroidID, + DeviceName: deviceName, + IDFV: idfv, + AndroidID: androidID, RealIP: reqctx.RealIP(ctx), Language: reqctx.Language(ctx), } diff --git a/internal/module/client/entity/types.go b/internal/module/client/entity/types.go new file mode 100644 index 0000000..6bb9493 --- /dev/null +++ b/internal/module/client/entity/types.go @@ -0,0 +1,58 @@ +package entity + +import "time" + +const ( + DocPrivacy = "privacy" + DocTerms = "terms" +) + +// LegalDocument is one localized privacy/terms document. +type LegalDocument struct { + Key string `json:"key"` + Lang string `json:"lang"` + Title string `json:"title"` + Content string `json:"content"` + Version string `json:"version"` +} + +// LegalBundle is GET /v1/legal/agreements payload. +type LegalBundle struct { + Privacy LegalDocument `json:"privacy"` + Terms LegalDocument `json:"terms"` +} + +// Feedback is one client feedback row. +type Feedback struct { + ID int64 `json:"id"` + UserID *int64 `json:"user_id,omitempty"` + Email string `json:"email"` + Content string `json:"content"` + AppVersion string `json:"app_version"` + Language string `json:"language"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// SubscriptionPlan is one week/month/quarter/year card. +type SubscriptionPlan struct { + PlanID string `json:"plan_id"` + Lang string `json:"lang"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + PriceDisplay string `json:"price_display"` + PriceCents int `json:"price_cents"` + Currency string `json:"currency"` + PeriodDays int `json:"period_days"` + Badge string `json:"badge"` + SortOrder int `json:"sort_order"` + Enabled bool `json:"enabled"` + ProductID string `json:"product_id"` +} + +// SubscriptionConfig is GET /v1/subscriptions/plans payload. +type SubscriptionConfig struct { + Headline string `json:"headline"` + Subheadline string `json:"subheadline"` + Plans []SubscriptionPlan `json:"plans"` +} diff --git a/internal/module/client/logic/service.go b/internal/module/client/logic/service.go new file mode 100644 index 0000000..48abe78 --- /dev/null +++ b/internal/module/client/logic/service.go @@ -0,0 +1,306 @@ +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), + } +} diff --git a/internal/module/client/logic/service_test.go b/internal/module/client/logic/service_test.go new file mode 100644 index 0000000..60d8506 --- /dev/null +++ b/internal/module/client/logic/service_test.go @@ -0,0 +1,19 @@ +package logic + +import "testing" + +func TestLegalPlansFeedbackFallback(t *testing.T) { + svc := NewService(nil, nil, nil) + legal, err := svc.LegalAgreements(t.Context()) + if err != nil || legal.Privacy.Title == "" || legal.Terms.Title == "" { + t.Fatalf("legal %+v err=%v", legal, err) + } + plans, err := svc.SubscriptionPlans(t.Context()) + if err != nil || len(plans.Plans) != 4 { + t.Fatalf("plans %+v err=%v", plans, err) + } + fb, err := svc.SubmitFeedback(t.Context(), FeedbackInput{Content: "界面很好", Email: "a@b.com"}) + if err != nil || fb.ID == 0 { + t.Fatalf("feedback %+v err=%v", fb, err) + } +} diff --git a/internal/module/client/model/repo.go b/internal/module/client/model/repo.go new file mode 100644 index 0000000..e31c4d6 --- /dev/null +++ b/internal/module/client/model/repo.go @@ -0,0 +1,216 @@ +package model + +import ( + "context" + "time" + + "gorm.io/gorm" + + "spark-server/internal/module/client/entity" +) + +type Repo struct { + db *gorm.DB +} + +func NewRepo(db *gorm.DB) *Repo { + if db == nil { + return nil + } + return &Repo{db: db} +} + +type legalRow struct { + DocKey string `gorm:"column:doc_key;primaryKey"` + Lang string `gorm:"column:lang;primaryKey"` + Title string `gorm:"column:title"` + Content string `gorm:"column:content"` + Version string `gorm:"column:version"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (legalRow) TableName() string { return "t_legal_documents" } + +type feedbackRow struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + UserID *int64 `gorm:"column:user_id"` + Email string `gorm:"column:email"` + Content string `gorm:"column:content"` + AppVersion string `gorm:"column:app_version"` + Language string `gorm:"column:language"` + Status string `gorm:"column:status"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (feedbackRow) TableName() string { return "t_feedback" } + +type planRow struct { + PlanID string `gorm:"column:plan_id;primaryKey"` + Lang string `gorm:"column:lang;primaryKey"` + Title string `gorm:"column:title"` + Subtitle string `gorm:"column:subtitle"` + PriceDisplay string `gorm:"column:price_display"` + PriceCents int `gorm:"column:price_cents"` + Currency string `gorm:"column:currency"` + PeriodDays int `gorm:"column:period_days"` + Badge string `gorm:"column:badge"` + SortOrder int `gorm:"column:sort_order"` + Enabled bool `gorm:"column:enabled"` + ProductID string `gorm:"column:product_id"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (planRow) TableName() string { return "t_subscription_plans" } + +type passwordRow struct { + UserID int64 `gorm:"column:user_id;primaryKey"` + PasswordHash string `gorm:"column:password_hash"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (passwordRow) TableName() string { return "t_user_passwords" } + +func (r *Repo) GetLegal(ctx context.Context, lang string) (privacy, terms *entity.LegalDocument, err error) { + var rows []legalRow + if err = r.db.WithContext(ctx).Where("lang = ?", lang).Find(&rows).Error; err != nil { + return nil, nil, err + } + for i := range rows { + d := &entity.LegalDocument{ + Key: rows[i].DocKey, Lang: rows[i].Lang, Title: rows[i].Title, + Content: rows[i].Content, Version: rows[i].Version, + } + switch rows[i].DocKey { + case entity.DocPrivacy: + privacy = d + case entity.DocTerms: + terms = d + } + } + return privacy, terms, nil +} + +func (r *Repo) UpsertLegal(ctx context.Context, d entity.LegalDocument) error { + row := legalRow{ + DocKey: d.Key, Lang: d.Lang, Title: d.Title, Content: d.Content, + Version: d.Version, UpdatedAt: time.Now(), + } + if row.Version == "" { + row.Version = "1.0" + } + return r.db.WithContext(ctx).Save(&row).Error +} + +func (r *Repo) InsertFeedback(ctx context.Context, f *entity.Feedback) error { + status := f.Status + if status == "" { + status = "open" + } + row := feedbackRow{ + UserID: f.UserID, Email: f.Email, Content: f.Content, + AppVersion: f.AppVersion, Language: f.Language, Status: status, CreatedAt: f.CreatedAt, + } + if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { + return err + } + f.ID = row.ID + f.Status = status + return nil +} + +func (r *Repo) ListFeedback(ctx context.Context, page, size int) ([]entity.Feedback, int64, error) { + if page < 0 { + page = 0 + } + if size <= 0 || size > 200 { + size = 50 + } + var total int64 + if err := r.db.WithContext(ctx).Model(&feedbackRow{}).Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []feedbackRow + err := r.db.WithContext(ctx).Order("id DESC").Offset(page * size).Limit(size).Find(&rows).Error + if err != nil { + return nil, 0, err + } + out := make([]entity.Feedback, 0, len(rows)) + for _, row := range rows { + out = append(out, entity.Feedback{ + ID: row.ID, UserID: row.UserID, Email: row.Email, Content: row.Content, + AppVersion: row.AppVersion, Language: row.Language, Status: row.Status, CreatedAt: row.CreatedAt, + }) + } + return out, total, nil +} + +func (r *Repo) UpdateFeedbackStatus(ctx context.Context, id int64, status string) error { + return r.db.WithContext(ctx).Model(&feedbackRow{}).Where("id = ?", id).Update("status", status).Error +} + +func (r *Repo) ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) { + var rows []planRow + err := r.db.WithContext(ctx). + Where("lang = ? AND enabled = 1", lang). + Order("sort_order ASC, plan_id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + return mapPlans(rows), nil +} + +func (r *Repo) ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) { + q := r.db.WithContext(ctx).Order("lang ASC, sort_order ASC, plan_id ASC") + if lang != "" { + q = q.Where("lang = ?", lang) + } + var rows []planRow + if err := q.Find(&rows).Error; err != nil { + return nil, err + } + return mapPlans(rows), nil +} + +func mapPlans(rows []planRow) []entity.SubscriptionPlan { + out := make([]entity.SubscriptionPlan, 0, len(rows)) + for _, row := range rows { + out = append(out, entity.SubscriptionPlan{ + PlanID: row.PlanID, Lang: row.Lang, Title: row.Title, Subtitle: row.Subtitle, + PriceDisplay: row.PriceDisplay, PriceCents: row.PriceCents, Currency: row.Currency, + PeriodDays: row.PeriodDays, Badge: row.Badge, SortOrder: row.SortOrder, + Enabled: row.Enabled, ProductID: row.ProductID, + }) + } + return out +} + +func (r *Repo) UpsertPlan(ctx context.Context, p entity.SubscriptionPlan) error { + row := planRow{ + PlanID: p.PlanID, Lang: p.Lang, Title: p.Title, Subtitle: p.Subtitle, + PriceDisplay: p.PriceDisplay, PriceCents: p.PriceCents, Currency: p.Currency, + PeriodDays: p.PeriodDays, Badge: p.Badge, SortOrder: p.SortOrder, + Enabled: p.Enabled, ProductID: p.ProductID, UpdatedAt: time.Now(), + } + if row.Currency == "" { + row.Currency = "CNY" + } + return r.db.WithContext(ctx).Save(&row).Error +} + +func (r *Repo) GetPasswordHash(ctx context.Context, userID int64) (string, error) { + var row passwordRow + err := r.db.WithContext(ctx).Where("user_id = ?", userID).Take(&row).Error + if err == gorm.ErrRecordNotFound { + return "", nil + } + if err != nil { + return "", err + } + return row.PasswordHash, nil +} + +func (r *Repo) SetPasswordHash(ctx context.Context, userID int64, hash string) error { + row := passwordRow{UserID: userID, PasswordHash: hash, UpdatedAt: time.Now()} + return r.db.WithContext(ctx).Save(&row).Error +} diff --git a/internal/module/friend/logic/friend_test.go b/internal/module/friend/logic/friend_test.go index 57ced55..8015b6a 100644 --- a/internal/module/friend/logic/friend_test.go +++ b/internal/module/friend/logic/friend_test.go @@ -284,3 +284,29 @@ func TestSystemGreeterWritesMessage(t *testing.T) { t.Fatal("missing system message") } } + +func TestRejectFriendRequest(t *testing.T) { + svc, users, _ := testFriends(t) + ctx := context.Background() + a, err := users.AuthenticateEmail(ctx, "rej-a@spark.com", "000000", &userentity.LoginDevice{DeviceID: 1, AppID: 1, DeviceType: "ios"}) + if err != nil { + t.Fatal(err) + } + b, err := users.AuthenticateEmail(ctx, "rej-b@spark.com", "000000", &userentity.LoginDevice{DeviceID: 2, AppID: 1, DeviceType: "ios"}) + if err != nil { + t.Fatal(err) + } + out, err := svc.Apply(ctx, a.ID, &ApplyInput{UID: b.ID, Reason: "hi"}) + if err != nil || out.RequestID == 0 { + t.Fatalf("apply %+v err=%v", out, err) + } + if err := svc.Reject(ctx, b.ID, RejectInput{RequestID: out.RequestID}); err != nil { + t.Fatal(err) + } + if err := svc.Reject(ctx, b.ID, RejectInput{RequestID: out.RequestID}); err != nil { + t.Fatal(err) + } + if err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: out.RequestID}); err == nil { + t.Fatal("expected accept after reject to fail") + } +} diff --git a/internal/module/friend/logic/reject.go b/internal/module/friend/logic/reject.go new file mode 100644 index 0000000..9b91d99 --- /dev/null +++ b/internal/module/friend/logic/reject.go @@ -0,0 +1,44 @@ +package logic + +import ( + "context" + "time" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +// RejectInput ignores/rejects a pending incoming friend request. +type RejectInput struct { + RequestID int64 `json:"request_id" validate:"required,gt=0" label:"field.request_id" errmsg:"required:validate.friend.request_required"` +} + +// Reject marks a pending request as rejected (status=3). Idempotent if already rejected. +func (s *Service) Reject(ctx context.Context, userID int64, in RejectInput) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return err + } + req, err := s.repo.GetRequest(ctx, in.RequestID) + if err != nil { + return err + } + lang := reqctx.Lang(ctx) + if req.ToUser != userID { + return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyFriendRequestInvalid, nil)) + } + if req.Status == entity.RequestRejected { + return nil + } + if req.Status != entity.RequestPending { + return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyFriendRequestInvalid, nil)) + } + req.Status = entity.RequestRejected + req.UpdatedAt = time.Now() + return s.repo.UpsertRequest(ctx, req) +} diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go index 11b832f..f816b5c 100644 --- a/internal/transport/http/auth.go +++ b/internal/transport/http/auth.go @@ -77,7 +77,8 @@ func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc { func authLimitPath(path string) bool { switch path { - case "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes": + case "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", + "/v1/auth/password-login", "/v1/auth/password": return true default: return false @@ -90,6 +91,8 @@ func publicPath(path string) bool { "/favicon.ico", "/apple-touch-icon.png": return true case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", + "/v1/auth/password-login", "/v1/auth/password", + "/v1/legal/agreements", "/v1/feedback", "/v1/subscriptions/plans", "/v1/music/playlist", "/v1/music/categories", "/v1/music/settings": return true default: diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index 52ae408..dda39d7 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -361,6 +361,8 @@ tags: - name: system - name: meta description: 客户端枚举 JSON。`GET /v1/meta/enums`,无需登录。 + - name: client + description: 客户端配置(协议/反馈/订阅) - name: auth - name: users - name: friends @@ -641,6 +643,77 @@ paths: code: "40001" msg: 请填写验证码 data: {} + + /v1/auth/password-login: + post: + tags: [auth] + security: [] + summary: 邮箱密码登录并签发 JWT + description: | + 与验证码登录相同的设备模型(`app_id` / `device_type` / `idfv|android_id`)。 + 需先通过 `POST /v1/auth/password` 用验证码设置密码;测试邮箱可任意密码。 + 按 RealIP 限流。不要带 Authorization。 + operationId: passwordLogin + parameters: + - $ref: "#/components/parameters/X-Language" + - $ref: "#/components/parameters/Accept-Language" + - $ref: "#/components/parameters/X-App-Version" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password, app_id] + properties: + email: { type: string, format: email } + password: { type: string, minLength: 6, maxLength: 72 } + app_id: { type: integer, minimum: 1 } + device_type: { type: integer, minimum: 1, maximum: 6 } + platform: { type: integer, minimum: 1, maximum: 6 } + device_name: { type: string } + idfv: { type: string } + android_id: { type: string } + example: + email: test01@spark.com + password: "123456" + app_id: 1 + device_type: 1 + idfv: "00000000-0000-0000-0000-000000000007" + responses: + "200": + description: 与登录相同的 LoginData + content: + application/json: + schema: + $ref: "#/components/schemas/LoginEnvelope" + /v1/auth/password: + post: + tags: [auth] + security: [] + summary: 验证码设置/重置邮箱登录密码 + description: | + 校验邮箱验证码后写入 bcrypt 哈希,供 `password-login` 使用。 + Body:`email`、`verify_code`、`password`(6–72)。不需要 Bearer。 + operationId: setPassword + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, verify_code, password] + properties: + email: { type: string, format: email } + verify_code: { type: string } + password: { type: string, minLength: 6, maxLength: 72 } + responses: + "200": + description: 统一 envelope,data 为空对象 + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" /v1/auth/token/refresh: post: tags: [auth] @@ -682,6 +755,72 @@ paths: application/json: schema: $ref: "#/components/schemas/Envelope" + + /v1/legal/agreements: + get: + tags: [client] + security: [] + summary: 隐私政策与用户协议 + description: | + 按 `X-Language` / `Accept-Language` 返回隐私政策与用户协议正文。 + 无库表数据时回退内置默认文案。公开接口。 + operationId: legalAgreements + parameters: + - $ref: "#/components/parameters/X-Language" + - $ref: "#/components/parameters/Accept-Language" + responses: + "200": + description: data.privacy / data.terms + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/feedback: + post: + tags: [client] + security: [] + summary: 提交意见反馈 + description: | + 可匿名提交。若带 Bearer 会关联 user_id。Body:`content` 必填;可选 `email`、`app_version`。 + operationId: submitFeedback + parameters: + - $ref: "#/components/parameters/X-Language" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [content] + properties: + content: { type: string, minLength: 2, maxLength: 2000 } + email: { type: string, format: email } + app_version: { type: string } + responses: + "200": + description: data 为反馈记录(含 id) + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/subscriptions/plans: + get: + tags: [client] + security: [] + summary: 订阅套餐引导配置 + description: | + 返回 headline/subheadline 与 week/month/quarter/year 套餐卡片(含 IAP product_id)。 + 公开接口;无库数据时回退默认四档。 + operationId: subscriptionPlans + parameters: + - $ref: "#/components/parameters/X-Language" + responses: + "200": + description: data.plans + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" /v1/users/me: get: tags: [users] @@ -825,6 +964,33 @@ paths: application/json: schema: $ref: "#/components/schemas/Envelope" + + /v1/friends/requests/reject: + post: + tags: [friends] + summary: 拒绝/忽略好友申请 + description: | + 仅申请接收方可操作。将 pending 申请标为 rejected(status=3)。 + 已拒绝则幂等成功;非 pending 返回 403。 + operationId: rejectFriendRequest + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [request_id] + properties: + request_id: { type: integer, format: int64 } + example: + request_id: 1940000000000000100 + responses: + "200": + description: 统一 envelope,data 为空对象 + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" /v1/friends: get: tags: [friends] diff --git a/internal/transport/http/handler/admin/client.go b/internal/transport/http/handler/admin/client.go new file mode 100644 index 0000000..46fd79c --- /dev/null +++ b/internal/transport/http/handler/admin/client.go @@ -0,0 +1,122 @@ +package adminhandler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "spark-server/internal/module/client/entity" + clientlogic "spark-server/internal/module/client/logic" +) + +// ClientHandler serves popadmin legal / subscription / feedback ops. +type ClientHandler struct { + svc *clientlogic.Service +} + +func NewClient(svc *clientlogic.Service) *ClientHandler { + return &ClientHandler{svc: svc} +} + +func (h *ClientHandler) ready(c *gin.Context) bool { + if h == nil || h.svc == nil || !h.svc.Ready() { + Fail(c, http.StatusServiceUnavailable, "client 服务未就绪") + return false + } + return true +} + +func (h *ClientHandler) GetLegal(c *gin.Context) { + if !h.ready(c) { + return + } + out, err := h.svc.LegalAgreements(c.Request.Context()) + if err != nil { + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OK(c, out) +} + +func (h *ClientHandler) PutLegal(c *gin.Context) { + if !h.ready(c) { + return + } + var body entity.LegalDocument + if err := c.ShouldBindJSON(&body); err != nil { + Fail(c, http.StatusBadRequest, "无效 JSON") + return + } + if err := h.svc.UpsertLegalAdmin(c.Request.Context(), body); err != nil { + Fail(c, http.StatusBadRequest, err.Error()) + return + } + OKMsg(c, "已保存", body) +} + +func (h *ClientHandler) ListPlans(c *gin.Context) { + if !h.ready(c) { + return + } + lang := c.Query("lang") + list, err := h.svc.ListPlansAdmin(c.Request.Context(), lang) + if err != nil { + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OK(c, gin.H{"items": list}) +} + +func (h *ClientHandler) PutPlan(c *gin.Context) { + if !h.ready(c) { + return + } + var body entity.SubscriptionPlan + if err := c.ShouldBindJSON(&body); err != nil { + Fail(c, http.StatusBadRequest, "无效 JSON") + return + } + if err := h.svc.UpsertPlanAdmin(c.Request.Context(), body); err != nil { + Fail(c, http.StatusBadRequest, err.Error()) + return + } + OKMsg(c, "已保存", body) +} + +func (h *ClientHandler) ListFeedback(c *gin.Context) { + if !h.ready(c) { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "50")) + list, total, err := h.svc.ListFeedbackAdmin(c.Request.Context(), page, size) + if err != nil { + Fail(c, http.StatusInternalServerError, err.Error()) + return + } + OK(c, gin.H{"list": list, "total": total, "page": page, "size": size}) +} + +func (h *ClientHandler) PatchFeedback(c *gin.Context) { + if !h.ready(c) { + return + } + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + Fail(c, http.StatusBadRequest, "无效 id") + return + } + var body struct { + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Status == "" { + Fail(c, http.StatusBadRequest, "status 必填") + return + } + if err := h.svc.UpdateFeedbackStatus(c.Request.Context(), id, body.Status); err != nil { + Fail(c, http.StatusBadRequest, err.Error()) + return + } + OKMsg(c, "已更新", gin.H{"id": id, "status": body.Status}) +} diff --git a/internal/transport/http/handler/auth/handler.go b/internal/transport/http/handler/auth/handler.go index 3fcdc4e..a0a1fe3 100644 --- a/internal/transport/http/handler/auth/handler.go +++ b/internal/transport/http/handler/auth/handler.go @@ -30,6 +30,20 @@ func (h *Handler) Login(c *gin.Context) { respond.JSON(c, tok) } +func (h *Handler) PasswordLogin(c *gin.Context) { + var in authlogic.PasswordLoginInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + tok, err := h.login.PasswordLogin(c.Request.Context(), &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, tok) +} + func (h *Handler) Refresh(c *gin.Context) { var in authlogic.RefreshInput if err := respond.BindJSON(c, &in); err != nil { diff --git a/internal/transport/http/handler/client/handler.go b/internal/transport/http/handler/client/handler.go new file mode 100644 index 0000000..1281c9b --- /dev/null +++ b/internal/transport/http/handler/client/handler.go @@ -0,0 +1,61 @@ +package clienthandler + +import ( + "github.com/gin-gonic/gin" + + clientlogic "spark-server/internal/module/client/logic" + "spark-server/internal/transport/http/respond" +) + +type Handler struct { + svc *clientlogic.Service +} + +func New(svc *clientlogic.Service) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) LegalAgreements(c *gin.Context) { + out, err := h.svc.LegalAgreements(c.Request.Context()) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) SubmitFeedback(c *gin.Context) { + var in clientlogic.FeedbackInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.svc.SubmitFeedback(c.Request.Context(), in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) SubscriptionPlans(c *gin.Context) { + out, err := h.svc.SubscriptionPlans(c.Request.Context()) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) SetPassword(c *gin.Context) { + var in clientlogic.SetPasswordInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + if err := h.svc.SetPassword(c.Request.Context(), in); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} diff --git a/internal/transport/http/handler/friend/handler.go b/internal/transport/http/handler/friend/handler.go index 790c281..9b8b82d 100644 --- a/internal/transport/http/handler/friend/handler.go +++ b/internal/transport/http/handler/friend/handler.go @@ -65,6 +65,24 @@ func (h *Handler) ListRequests(c *gin.Context) { respond.JSON(c, out) } + +func (h *Handler) Reject(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + var in friendlogic.RejectInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + if err := h.friends.Reject(c.Request.Context(), uid, in); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + func (h *Handler) Accept(c *gin.Context) { uid, ok := currentUID(c) if !ok { diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index 464b502..65ccd15 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -10,6 +10,7 @@ import ( "spark-server/internal/pkg/reply" "spark-server/internal/pkg/reqctx" authhandler "spark-server/internal/transport/http/handler/auth" + clienthandler "spark-server/internal/transport/http/handler/client" convhandler "spark-server/internal/transport/http/handler/conversation" favhandler "spark-server/internal/transport/http/handler/favorite" filehandler "spark-server/internal/transport/http/handler/file" @@ -33,6 +34,7 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) { registerMetaRoutes(v1) registerAuthRoutes(v1, deps) registerSMSRoutes(v1, deps) + registerClientRoutes(v1, deps) registerUserRoutes(v1, deps) registerFriendRoutes(v1, deps) registerGroupRoutes(v1, deps) @@ -80,8 +82,23 @@ func registerAuthRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { } h := authhandler.New(deps.Login) g.POST("/login", h.Login) + g.POST("/password-login", h.PasswordLogin) g.POST("/token/refresh", h.Refresh) g.POST("/logout", h.Logout) + if deps.Client != nil { + ch := clienthandler.New(deps.Client) + g.POST("/password", ch.SetPassword) + } +} + +func registerClientRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.Client == nil { + return + } + h := clienthandler.New(deps.Client) + v1.GET("/legal/agreements", h.LegalAgreements) + v1.POST("/feedback", h.SubmitFeedback) + v1.GET("/subscriptions/plans", h.SubscriptionPlans) } func registerSMSRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { @@ -113,6 +130,7 @@ func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { g.GET("/lookup", h.Lookup) g.GET("/requests", h.ListRequests) g.POST("/requests", h.Apply) + g.POST("/requests/reject", h.Reject) g.GET("", h.List) g.POST("", h.Accept) g.GET("/:uid", h.Get) diff --git a/internal/transport/http/router_admin.go b/internal/transport/http/router_admin.go index a808dc6..4a137e7 100644 --- a/internal/transport/http/router_admin.go +++ b/internal/transport/http/router_admin.go @@ -16,6 +16,7 @@ func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) { h = adminhandler.New(deps.Admin) } mh := adminhandler.NewMusic(deps.Music) + ch := adminhandler.NewClient(deps.Client) // Shared user ops (popadmin Host 可将 /api/admin/users* 切到本服务) if h != nil { @@ -45,6 +46,7 @@ func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) { registerSparkAdmin(g, h) } registerMusicAdmin(g, mh) + registerClientAdmin(g, ch) } } @@ -92,3 +94,15 @@ func registerMusicAdmin(g *gin.RouterGroup, h *adminhandler.MusicHandler) { g.POST("/music/:id/background", h.UploadBackground) g.POST("/music/:id/audio", h.UploadAudio) } + +func registerClientAdmin(g *gin.RouterGroup, h *adminhandler.ClientHandler) { + if h == nil { + return + } + g.GET("/legal", h.GetLegal) + g.PUT("/legal", h.PutLegal) + g.GET("/subscriptions/plans", h.ListPlans) + g.PUT("/subscriptions/plans", h.PutPlan) + g.GET("/feedbacks", h.ListFeedback) + g.PATCH("/feedbacks/:id", h.PatchFeedback) +} diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index f10999f..651227e 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -14,6 +14,7 @@ import ( adminlogic "spark-server/internal/module/admin/logic" authlogic "spark-server/internal/module/auth/logic" + clientlogic "spark-server/internal/module/client/logic" favlogic "spark-server/internal/module/favorite/logic" friendlogic "spark-server/internal/module/friend/logic" grouplogic "spark-server/internal/module/group/logic" @@ -58,6 +59,7 @@ type EngineDeps struct { Admin *adminlogic.Service AdminTokens *adminlogic.TokenService Music *musiclogic.Service + Client *clientlogic.Service Health Health AuthLimiter *ratelimit.Limiter } diff --git a/migrations/0023_client_legal_feedback_subscription.sql b/migrations/0023_client_legal_feedback_subscription.sql new file mode 100644 index 0000000..4c79828 --- /dev/null +++ b/migrations/0023_client_legal_feedback_subscription.sql @@ -0,0 +1,85 @@ +-- Health / client:隐私协议、意见反馈、订阅套餐、邮箱密码。 + +CREATE TABLE IF NOT EXISTS t_legal_documents ( + doc_key VARCHAR(32) NOT NULL COMMENT 'privacy|terms', + lang VARCHAR(16) NOT NULL COMMENT 'zh-CN|en|zh-TW', + title VARCHAR(256) NOT NULL DEFAULT '' COMMENT '文档标题', + content MEDIUMTEXT NOT NULL COMMENT '正文(纯文本或 Markdown)', + version VARCHAR(32) NOT NULL DEFAULT '1.0' COMMENT '文档版本', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (doc_key, lang) +) COMMENT='客户端隐私政策与用户协议(按语言)'; + +CREATE TABLE IF NOT EXISTS t_feedback ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '反馈自增 id', + user_id BIGINT NULL COMMENT '可选登录用户 uid', + email VARCHAR(255) NOT NULL DEFAULT '' COMMENT '联系邮箱', + content VARCHAR(2000) NOT NULL COMMENT '反馈正文', + app_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端版本', + language VARCHAR(16) NOT NULL DEFAULT 'zh-CN' COMMENT '提交时语言', + status VARCHAR(32) NOT NULL DEFAULT 'open' COMMENT 'open|done|ignored', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', + KEY idx_feedback_created (created_at) +) COMMENT='客户端意见反馈'; + +CREATE TABLE IF NOT EXISTS t_subscription_plans ( + plan_id VARCHAR(32) NOT NULL COMMENT 'week|month|quarter|year', + lang VARCHAR(16) NOT NULL COMMENT 'zh-CN|en|zh-TW', + title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '套餐标题', + subtitle VARCHAR(256) NOT NULL DEFAULT '' COMMENT '副标题/卖点', + price_display VARCHAR(64) NOT NULL DEFAULT '' COMMENT '展示价,如 ¥12', + price_cents INT NOT NULL DEFAULT 0 COMMENT '价格(分)', + currency VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '币种', + period_days INT NOT NULL DEFAULT 7 COMMENT '周期天数', + badge VARCHAR(64) NOT NULL DEFAULT '' COMMENT '角标文案,可空', + sort_order INT NOT NULL DEFAULT 0 COMMENT '越小越靠前', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=上架', + product_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'IAP/商品 id', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (plan_id, lang), + KEY idx_sub_plans_list (enabled, sort_order) +) COMMENT='订阅引导周/月/季/年卡配置'; + +CREATE TABLE IF NOT EXISTS t_user_passwords ( + user_id BIGINT PRIMARY KEY COMMENT 't_users.id', + password_hash VARCHAR(255) NOT NULL COMMENT 'bcrypt 哈希', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间' +) COMMENT='邮箱密码登录哈希(与验证码登录并存)'; + +INSERT INTO t_legal_documents (doc_key, lang, title, content, version, updated_at) VALUES +('privacy', 'zh-CN', '隐私政策', + '我们重视您的隐私。本应用会收集账号邮箱、设备标识与使用数据,用于登录、内容推荐与安全风控。未经同意不会向第三方出售个人信息。您可随时注销账号。', + '1.0', CURRENT_TIMESTAMP(3)), +('terms', 'zh-CN', '用户协议', + '使用本应用即表示您同意遵守社区规范,不得传播违法有害内容。会员权益以订阅页展示为准,虚拟商品一经开通除法律规定外不予退款。', + '1.0', CURRENT_TIMESTAMP(3)), +('privacy', 'en', 'Privacy Policy', + 'We value your privacy. We collect email, device identifiers and usage data for login, recommendations and security. We do not sell personal data. You may delete your account at any time.', + '1.0', CURRENT_TIMESTAMP(3)), +('terms', 'en', 'Terms of Use', + 'By using this app you agree to community rules. Membership benefits follow the subscription page. Virtual goods are generally non-refundable except as required by law.', + '1.0', CURRENT_TIMESTAMP(3)), +('privacy', 'zh-TW', '隱私政策', + '我們重視您的隱私。本應用會收集帳號郵箱、裝置識別與使用資料,用於登入、內容推薦與安全風控。未經同意不會向第三方出售個人資訊。', + '1.0', CURRENT_TIMESTAMP(3)), +('terms', 'zh-TW', '用戶協議', + '使用本應用即表示您同意遵守社群規範。會員權益以訂閱頁展示為準,虛擬商品除法律規定外不予退款。', + '1.0', CURRENT_TIMESTAMP(3)) +ON DUPLICATE KEY UPDATE title = VALUES(title); + +INSERT INTO t_subscription_plans +(plan_id, lang, title, subtitle, price_display, price_cents, currency, period_days, badge, sort_order, enabled, product_id, updated_at) +VALUES +('week', 'zh-CN', '周卡', '灵活体验,随时可停', '¥12', 1200, 'CNY', 7, '', 1, 1, 'health.sub.week', CURRENT_TIMESTAMP(3)), +('month', 'zh-CN', '月卡', '最受欢迎', '¥30', 3000, 'CNY', 30, '热门', 2, 1, 'health.sub.month', CURRENT_TIMESTAMP(3)), +('quarter', 'zh-CN', '季卡', '平均每月更省', '¥68', 6800, 'CNY', 90, '', 3, 1, 'health.sub.quarter', CURRENT_TIMESTAMP(3)), +('year', 'zh-CN', '年卡', '全年畅听会员曲库', '¥198', 19800, 'CNY', 365, '超值', 4, 1, 'health.sub.year', CURRENT_TIMESTAMP(3)), +('week', 'en', 'Weekly', 'Flexible, cancel anytime', '$1.99', 199, 'USD', 7, '', 1, 1, 'health.sub.week', CURRENT_TIMESTAMP(3)), +('month', 'en', 'Monthly', 'Most popular', '$4.99', 499, 'USD', 30, 'Popular', 2, 1, 'health.sub.month', CURRENT_TIMESTAMP(3)), +('quarter', 'en', 'Quarterly', 'Better monthly value', '$12.99', 1299, 'USD', 90, '', 3, 1, 'health.sub.quarter', CURRENT_TIMESTAMP(3)), +('year', 'en', 'Yearly', 'Best value all year', '$29.99', 2999, 'USD', 365, 'Best', 4, 1, 'health.sub.year', CURRENT_TIMESTAMP(3)), +('week', 'zh-TW', '週卡', '靈活體驗,隨時可停', 'NT$60', 6000, 'TWD', 7, '', 1, 1, 'health.sub.week', CURRENT_TIMESTAMP(3)), +('month', 'zh-TW', '月卡', '最受歡迎', 'NT$150', 15000, 'TWD', 30, '熱門', 2, 1, 'health.sub.month', CURRENT_TIMESTAMP(3)), +('quarter', 'zh-TW', '季卡', '平均每月更省', 'NT$380', 38000, 'TWD', 90, '', 3, 1, 'health.sub.quarter', CURRENT_TIMESTAMP(3)), +('year', 'zh-TW', '年卡', '全年暢聽會員曲庫', 'NT$990', 99000, 'TWD', 365, '超值', 4, 1, 'health.sub.year', CURRENT_TIMESTAMP(3)) +ON DUPLICATE KEY UPDATE title = VALUES(title);