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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | plan | lang | 标题 | 价格 | 天数 | 角标 | product | 启用 |
+
+ ${items.length ? items.map((p)=>`| ${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)} |
`).join('') : '| 暂无套餐 |
'}
+
+
`;
+ $('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)} 条
+
+ | ID | 内容 | 邮箱 | 语言 | 状态 | 时间 | |
+
+ ${list.length ? list.map((f)=>`
+ | ${esc(f.id)} | ${esc(f.content)} | ${esc(f.email||'')} |
+ ${esc(f.language||'')} | ${esc(f.status||'')} | ${esc(f.created_at||'')} |
+
+
+
+ |
`).join('') : '| 暂无反馈 |
'}
+
+
`;
+ $('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);