feat: 补好友拒绝、客户端配置 API 与 OpenAPI/popadmin

为 Spark Messenger 主路径补齐 POST /v1/friends/requests/reject,并落地法律协议/反馈/订阅/密码登录及管理页,同步 OpenAPI 文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-24 14:01:46 +08:00
parent 1498c0258f
commit 1edcb959b7
21 changed files with 1449 additions and 28 deletions

View File

@ -72,6 +72,12 @@ const NAV = [
{ id: 'music', label: '音乐列表' }, { id: 'music', label: '音乐列表' },
{ id: 'legalDocs', label: '隐私协议' },
{ id: 'subPlans', label: '订阅配置' },
{ id: 'sparkFeedbacks', label: 'App反馈' },
{ id: 'novelCms', label: '小说内容管理' }, { id: 'novelCms', label: '小说内容管理' },
{ id: 'developerApps', 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 = `<div class="note error">法律文档 API:${esc(e.message)}。需部署 spark-server 0023 迁移。</div>`;
return;
}
const privacy = data.privacy || {};
const terms = data.terms || {};
$('content').innerHTML = `
<div class="note">SleepHeart 隐私政策 / 用户协议 · 管理 <code>/api/admin/spark/legal</code> · 客户端 <code>GET /v1/legal/agreements</code></div>
<div class="section-block" style="margin-bottom:16px">
<h3>隐私政策</h3>
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
<select id="legLangP"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select>
<input id="legTitleP" placeholder="标题" value="${esc(privacy.title||'')}" style="flex:1;min-width:160px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="legVerP" placeholder="版本" value="${esc(privacy.version||'1.0')}" style="width:100px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
</div>
<textarea id="legBodyP" rows="8" style="width:100%;margin-top:8px;padding:10px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">${esc(privacy.content||'')}</textarea>
<button type="button" id="legSaveP" style="margin-top:8px">保存隐私政策</button>
</div>
<div class="section-block">
<h3>用户协议</h3>
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
<select id="legLangT"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select>
<input id="legTitleT" placeholder="标题" value="${esc(terms.title||'')}" style="flex:1;min-width:160px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<input id="legVerT" placeholder="版本" value="${esc(terms.version||'1.0')}" style="width:100px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
</div>
<textarea id="legBodyT" rows="8" style="width:100%;margin-top:8px;padding:10px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">${esc(terms.content||'')}</textarea>
<button type="button" id="legSaveT" style="margin-top:8px">保存用户协议</button>
</div>`;
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 = `<div class="note error">订阅配置 API:${esc(e.message)}</div>`;
return;
}
$('content').innerHTML = `
<div class="note">SleepHeart 订阅引导 · <code>/api/admin/spark/subscriptions/plans</code> · 客户端 <code>GET /v1/subscriptions/plans</code> · plan_id=week|month|quarter|year</div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
<select id="spPlan"><option value="week">week</option><option value="month">month</option><option value="quarter">quarter</option><option value="year">year</option></select>
<select id="spLang"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select>
<input id="spTitle" placeholder="标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:120px">
<input id="spSub" placeholder="副标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">
<input id="spPrice" placeholder="展示价" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
<input id="spCents" type="number" placeholder="分" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
<input id="spDays" type="number" placeholder="天数" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:80px">
<input id="spBadge" placeholder="角标" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:80px">
<input id="spProduct" placeholder="product_id" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">
<input id="spSort" type="number" value="1" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:70px">
<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="spEnabled" checked>启用</label>
<button type="button" id="spSave">保存套餐</button>
<button type="button" class="secondary" id="spReload">刷新</button>
</div>
<table>
<thead><tr><th>plan</th><th>lang</th><th>标题</th><th>价格</th><th>天数</th><th>角标</th><th>product</th><th>启用</th></tr></thead>
<tbody>
${items.length ? items.map((p)=>`<tr><td>${esc(p.plan_id)}</td><td>${esc(p.lang)}</td><td>${esc(p.title)}<div class="note" style="margin:0">${esc(p.subtitle||'')}</div></td><td>${esc(p.price_display)} (${esc(p.price_cents)})</td><td>${esc(p.period_days)}</td><td>${esc(p.badge||'')}</td><td>${esc(p.product_id)}</td><td>${esc(p.enabled)}</td></tr>`).join('') : '<tr><td colspan="8">暂无套餐</td></tr>'}
</tbody>
</table>`;
$('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 = `<div class="note error">App反馈 API:${esc(e.message)}</div>`;
return;
}
const list = data.list || [];
$('content').innerHTML = `
<div class="note">SleepHeart 意见反馈 · <code>/api/admin/spark/feedbacks</code> · 客户端 <code>POST /v1/feedback</code> · 共 ${esc(data.total||0)} 条</div>
<table>
<thead><tr><th>ID</th><th>内容</th><th>邮箱</th><th>语言</th><th>状态</th><th>时间</th><th></th></tr></thead>
<tbody>
${list.length ? list.map((f)=>`<tr>
<td>${esc(f.id)}</td><td style="max-width:280px">${esc(f.content)}</td><td>${esc(f.email||'')}</td>
<td>${esc(f.language||'')}</td><td>${esc(f.status||'')}</td><td>${esc(f.created_at||'')}</td>
<td>
<button type="button" class="secondary btn-sm" data-fdone="${esc(f.id)}">完成</button>
<button type="button" class="secondary btn-sm" data-fign="${esc(f.id)}">忽略</button>
</td></tr>`).join('') : '<tr><td colspan="7">暂无反馈</td></tr>'}
</tbody>
</table>`;
$('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() { async crashLogs() {
const email = ($('crashEmail') && $('crashEmail').value) || ''; const email = ($('crashEmail') && $('crashEmail').value) || '';

View File

@ -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

View File

@ -44,3 +44,35 @@
### 验证 ### 验证
- `make test`: skipped(文档) - `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 全页面接入检查

View File

@ -43,6 +43,8 @@ import (
momentmodel "spark-server/internal/module/moment/model" momentmodel "spark-server/internal/module/moment/model"
musiclogic "spark-server/internal/module/music/logic" musiclogic "spark-server/internal/module/music/logic"
musicmodel "spark-server/internal/module/music/model" 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" smslogic "spark-server/internal/module/sms/logic"
squarelogic "spark-server/internal/module/square/logic" squarelogic "spark-server/internal/module/square/logic"
squaremodel "spark-server/internal/module/square/model" 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 := authlogic.NewLoginService(jwtSvc, users)
login.Devices = devices login.Devices = devices
login.Sessions = sessions login.Sessions = sessions
client := wireClient(db, users)
if client != nil {
login.Passwords = client
}
var deliverQueue deliverlogic.Queue var deliverQueue deliverlogic.Queue
inbox, mailbox := wireInbox(idgen, db, rdb) inbox, mailbox := wireInbox(idgen, db, rdb)
app.deliver, deliverQueue = wireDeliver(cfg, log, rdb, sessions, inbox, mailbox) 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, Sync: syncSvc,
}) })
app.http = httpx.New(log, cfg.Server.HTTP.Addr, httpEngineDeps( 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 return app, nil
} }
@ -227,6 +233,7 @@ func httpEngineDeps(
moments *momentlogic.Service, moments *momentlogic.Service,
square *squarelogic.Service, square *squarelogic.Service,
music *musiclogic.Service, music *musiclogic.Service,
client *clientlogic.Service,
messages *msglogic.Service, messages *msglogic.Service,
syncSvc *synclogic.Service, syncSvc *synclogic.Service,
adminSvc *adminlogic.Service, adminSvc *adminlogic.Service,
@ -236,7 +243,7 @@ func httpEngineDeps(
) *httpx.EngineDeps { ) *httpx.EngineDeps {
return &httpx.EngineDeps{ return &httpx.EngineDeps{
Auth: jwtSvc, Login: login, SMS: sms, Users: users, Friends: friends, Groups: groups, 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, Messages: messages, Sync: syncSvc, Admin: adminSvc, AdminTokens: adminTokens,
Health: probe, AuthLimiter: authLimit, 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) 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 { 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 { if idgen == nil || friends == nil || users == nil {
return nil return nil

View File

@ -18,12 +18,17 @@ type Verifier interface {
AuthenticateEmail(ctx context.Context, email, verifyCode string, device *userentity.LoginDevice) (*userentity.User, error) 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. // DeviceRegistrar resolves physical device_id from idfv / android_id.
type DeviceRegistrar interface { type DeviceRegistrar interface {
Register(ctx context.Context, in devicelogic.RegisterInput) (uint64, error) 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 { type LoginInput struct {
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"` 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"` 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"` 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 { type RefreshInput struct {
RefreshToken string `json:"refresh_token" validate:"required" label:"field.refresh_token" errmsg:"required:validate.login.refresh_required"` 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. // LoginService is the application login use-case.
type LoginService struct { type LoginService struct {
Issuer Issuer Issuer Issuer
Users Verifier Users Verifier
Devices DeviceRegistrar Passwords PasswordVerifier
Sessions SessionKicker Devices DeviceRegistrar
Sessions SessionKicker
} }
func NewLoginService(issuer Issuer, users Verifier) *LoginService { 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() { if !deviceentity.AppID(in.AppID).Valid() {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginAppIDInvalid, nil)) 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() { if !dtype.Valid() {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil)) 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 { if err != nil {
return nil, err 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) u, err := s.Users.AuthenticateEmail(ctx, in.Email, in.VerifyCode, ld)
if err != nil { if err != nil {
return nil, err 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 { if u == nil || u.ID <= 0 {
return nil, errs.ErrUnauthorized 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 { if err != nil {
return nil, err return nil, err
} }
@ -123,31 +176,24 @@ func (s *LoginService) Logout(ctx context.Context) error {
return nil return nil
} }
func loginResolvedDeviceType(in *LoginInput) deviceentity.DeviceType { func loginResolvedDeviceType(deviceType, platform uint8) deviceentity.DeviceType {
if in == nil { if deviceType >= 1 {
return deviceentity.TypeUnknown return deviceentity.DeviceTypeFromWire(deviceType)
} }
if in.DeviceType >= 1 { if platform >= 1 {
return deviceentity.DeviceTypeFromWire(in.DeviceType) return deviceentity.DeviceTypeFromWire(platform)
}
if in.Platform >= 1 {
return deviceentity.DeviceTypeFromWire(in.Platform)
} }
return deviceentity.TypeUnknown return deviceentity.TypeUnknown
} }
func loginDevice(ctx context.Context, in *LoginInput, deviceID uint64) *userentity.LoginDevice { func loginDeviceFrom(ctx context.Context, appID uint8, deviceName, idfv, androidID string, dtype deviceentity.DeviceType, deviceID uint64) *userentity.LoginDevice {
if in == nil {
return &userentity.LoginDevice{DeviceID: deviceID}
}
dtype := loginResolvedDeviceType(in)
d := &userentity.LoginDevice{ d := &userentity.LoginDevice{
DeviceID: deviceID, DeviceID: deviceID,
AppID: in.AppID, AppID: appID,
DeviceType: dtype.PlatformName(), DeviceType: dtype.PlatformName(),
DeviceName: in.DeviceName, DeviceName: deviceName,
IDFV: in.IDFV, IDFV: idfv,
AndroidID: in.AndroidID, AndroidID: androidID,
RealIP: reqctx.RealIP(ctx), RealIP: reqctx.RealIP(ctx),
Language: reqctx.Language(ctx), Language: reqctx.Language(ctx),
} }

View File

@ -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"`
}

View File

@ -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),
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -284,3 +284,29 @@ func TestSystemGreeterWritesMessage(t *testing.T) {
t.Fatal("missing system message") 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")
}
}

View File

@ -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)
}

View File

@ -77,7 +77,8 @@ func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc {
func authLimitPath(path string) bool { func authLimitPath(path string) bool {
switch path { 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 return true
default: default:
return false return false
@ -90,6 +91,8 @@ func publicPath(path string) bool {
"/favicon.ico", "/apple-touch-icon.png": "/favicon.ico", "/apple-touch-icon.png":
return true return true
case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", 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": "/v1/music/playlist", "/v1/music/categories", "/v1/music/settings":
return true return true
default: default:

View File

@ -361,6 +361,8 @@ tags:
- name: system - name: system
- name: meta - name: meta
description: 客户端枚举 JSON。`GET /v1/meta/enums`,无需登录。 description: 客户端枚举 JSON。`GET /v1/meta/enums`,无需登录。
- name: client
description: 客户端配置(协议/反馈/订阅)
- name: auth - name: auth
- name: users - name: users
- name: friends - name: friends
@ -641,6 +643,77 @@ paths:
code: "40001" code: "40001"
msg: 请填写验证码 msg: 请填写验证码
data: {} 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: /v1/auth/token/refresh:
post: post:
tags: [auth] tags: [auth]
@ -682,6 +755,72 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $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: /v1/users/me:
get: get:
tags: [users] tags: [users]
@ -825,6 +964,33 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $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: /v1/friends:
get: get:
tags: [friends] tags: [friends]

View File

@ -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})
}

View File

@ -30,6 +30,20 @@ func (h *Handler) Login(c *gin.Context) {
respond.JSON(c, tok) 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) { func (h *Handler) Refresh(c *gin.Context) {
var in authlogic.RefreshInput var in authlogic.RefreshInput
if err := respond.BindJSON(c, &in); err != nil { if err := respond.BindJSON(c, &in); err != nil {

View File

@ -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{})
}

View File

@ -65,6 +65,24 @@ func (h *Handler) ListRequests(c *gin.Context) {
respond.JSON(c, out) 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) { func (h *Handler) Accept(c *gin.Context) {
uid, ok := currentUID(c) uid, ok := currentUID(c)
if !ok { if !ok {

View File

@ -10,6 +10,7 @@ import (
"spark-server/internal/pkg/reply" "spark-server/internal/pkg/reply"
"spark-server/internal/pkg/reqctx" "spark-server/internal/pkg/reqctx"
authhandler "spark-server/internal/transport/http/handler/auth" authhandler "spark-server/internal/transport/http/handler/auth"
clienthandler "spark-server/internal/transport/http/handler/client"
convhandler "spark-server/internal/transport/http/handler/conversation" convhandler "spark-server/internal/transport/http/handler/conversation"
favhandler "spark-server/internal/transport/http/handler/favorite" favhandler "spark-server/internal/transport/http/handler/favorite"
filehandler "spark-server/internal/transport/http/handler/file" filehandler "spark-server/internal/transport/http/handler/file"
@ -33,6 +34,7 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) {
registerMetaRoutes(v1) registerMetaRoutes(v1)
registerAuthRoutes(v1, deps) registerAuthRoutes(v1, deps)
registerSMSRoutes(v1, deps) registerSMSRoutes(v1, deps)
registerClientRoutes(v1, deps)
registerUserRoutes(v1, deps) registerUserRoutes(v1, deps)
registerFriendRoutes(v1, deps) registerFriendRoutes(v1, deps)
registerGroupRoutes(v1, deps) registerGroupRoutes(v1, deps)
@ -80,8 +82,23 @@ func registerAuthRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
} }
h := authhandler.New(deps.Login) h := authhandler.New(deps.Login)
g.POST("/login", h.Login) g.POST("/login", h.Login)
g.POST("/password-login", h.PasswordLogin)
g.POST("/token/refresh", h.Refresh) g.POST("/token/refresh", h.Refresh)
g.POST("/logout", h.Logout) 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) { 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("/lookup", h.Lookup)
g.GET("/requests", h.ListRequests) g.GET("/requests", h.ListRequests)
g.POST("/requests", h.Apply) g.POST("/requests", h.Apply)
g.POST("/requests/reject", h.Reject)
g.GET("", h.List) g.GET("", h.List)
g.POST("", h.Accept) g.POST("", h.Accept)
g.GET("/:uid", h.Get) g.GET("/:uid", h.Get)

View File

@ -16,6 +16,7 @@ func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) {
h = adminhandler.New(deps.Admin) h = adminhandler.New(deps.Admin)
} }
mh := adminhandler.NewMusic(deps.Music) mh := adminhandler.NewMusic(deps.Music)
ch := adminhandler.NewClient(deps.Client)
// Shared user ops (popadmin Host 可将 /api/admin/users* 切到本服务) // Shared user ops (popadmin Host 可将 /api/admin/users* 切到本服务)
if h != nil { if h != nil {
@ -45,6 +46,7 @@ func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) {
registerSparkAdmin(g, h) registerSparkAdmin(g, h)
} }
registerMusicAdmin(g, mh) 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/background", h.UploadBackground)
g.POST("/music/:id/audio", h.UploadAudio) 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)
}

View File

@ -14,6 +14,7 @@ import (
adminlogic "spark-server/internal/module/admin/logic" adminlogic "spark-server/internal/module/admin/logic"
authlogic "spark-server/internal/module/auth/logic" authlogic "spark-server/internal/module/auth/logic"
clientlogic "spark-server/internal/module/client/logic"
favlogic "spark-server/internal/module/favorite/logic" favlogic "spark-server/internal/module/favorite/logic"
friendlogic "spark-server/internal/module/friend/logic" friendlogic "spark-server/internal/module/friend/logic"
grouplogic "spark-server/internal/module/group/logic" grouplogic "spark-server/internal/module/group/logic"
@ -58,6 +59,7 @@ type EngineDeps struct {
Admin *adminlogic.Service Admin *adminlogic.Service
AdminTokens *adminlogic.TokenService AdminTokens *adminlogic.TokenService
Music *musiclogic.Service Music *musiclogic.Service
Client *clientlogic.Service
Health Health Health Health
AuthLimiter *ratelimit.Limiter AuthLimiter *ratelimit.Limiter
} }

View File

@ -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);