feat: 补好友拒绝、客户端配置 API 与 OpenAPI/popadmin
为 Spark Messenger 主路径补齐 POST /v1/friends/requests/reject,并落地法律协议/反馈/订阅/密码登录及管理页,同步 OpenAPI 文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1498c0258f
commit
1edcb959b7
@ -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 = `<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() {
|
||||
|
||||
const email = ($('crashEmail') && $('crashEmail').value) || '';
|
||||
|
||||
32
docs/daily/2026-09-24-change-list-sleepheart.md
Normal file
32
docs/daily/2026-09-24-change-list-sleepheart.md
Normal 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
|
||||
@ -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 全页面接入检查
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
58
internal/module/client/entity/types.go
Normal file
58
internal/module/client/entity/types.go
Normal 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"`
|
||||
}
|
||||
306
internal/module/client/logic/service.go
Normal file
306
internal/module/client/logic/service.go
Normal 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),
|
||||
}
|
||||
}
|
||||
19
internal/module/client/logic/service_test.go
Normal file
19
internal/module/client/logic/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
216
internal/module/client/model/repo.go
Normal file
216
internal/module/client/model/repo.go
Normal 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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
44
internal/module/friend/logic/reject.go
Normal file
44
internal/module/friend/logic/reject.go
Normal 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)
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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]
|
||||
|
||||
122
internal/transport/http/handler/admin/client.go
Normal file
122
internal/transport/http/handler/admin/client.go
Normal 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})
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
61
internal/transport/http/handler/client/handler.go
Normal file
61
internal/transport/http/handler/client/handler.go
Normal 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{})
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
85
migrations/0023_client_legal_feedback_subscription.sql
Normal file
85
migrations/0023_client_legal_feedback_subscription.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user