feat(client): 接入同开发者应用墙 API 与管理端 CRUD
合并此前 stash 的 developer-apps:免登录列表、popadmin 管理与 0027 迁移。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2e7735f103
commit
c58b60419e
@ -2895,7 +2895,7 @@ const pages = {
|
|||||||
|
|
||||||
$('content').innerHTML = `
|
$('content').innerHTML = `
|
||||||
|
|
||||||
<div class="note">同开发者应用(火花)· 客户端 <code>GET /api/client/pop/developer-apps</code></div>
|
<div class="note">同开发者应用(火花)· 客户端 <code>GET /v1/developer-apps</code>(免登录)· 别名 <code>GET /api/client/pop/developer-apps</code></div>
|
||||||
|
|
||||||
<div class="toolbar" style="flex-wrap:wrap">
|
<div class="toolbar" style="flex-wrap:wrap">
|
||||||
|
|
||||||
|
|||||||
@ -205,3 +205,36 @@ make install-git-hooks # 本机只需一次
|
|||||||
### 关联
|
### 关联
|
||||||
|
|
||||||
- 用户需求:广场机器人 RSS + 15 天过期 + 后台机器人媒体压缩检测
|
- 用户需求:广场机器人 RSS + 15 天过期 + 后台机器人媒体压缩检测
|
||||||
|
|
||||||
|
## 14:30 — 同开发者应用墙(Discover)
|
||||||
|
|
||||||
|
### 概要
|
||||||
|
|
||||||
|
- 新增免登录客户端接口 `GET /v1/developer-apps`(别名 `/api/client/pop|spark/developer-apps`)
|
||||||
|
- popadmin「同开发者应用」对接 `/api/admin/developer-apps` CRUD + 同步官网图标目录
|
||||||
|
- 回落默认官网应用目录;migration 编号为 0027(避开已占用的 0024 feedback)
|
||||||
|
|
||||||
|
### 变更范围
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 功能 | 同开发者应用列表 API |
|
||||||
|
| 管理端 | popadmin developer-apps 路径可落库 |
|
||||||
|
| 迁移 | `t_developer_apps` 种子数据(0027) |
|
||||||
|
|
||||||
|
### 主要文件
|
||||||
|
|
||||||
|
- `migrations/0027_developer_apps.sql` — 表与种子
|
||||||
|
- `internal/module/client/logic/developer_apps.go` — 业务
|
||||||
|
- `internal/module/client/model/repo.go` — 仓储
|
||||||
|
- `internal/transport/http/router*.go` / `auth.go` / handlers — 路由与免登录
|
||||||
|
- `deploy/popadmin/popadmin.js` — 客户端路径说明
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- `make test`: skipped(本机 PATH 无 go)
|
||||||
|
- 需部署 spark-server 并跑 migrate 后线上接口才可用;无 DB 时 logic 回落官网默认目录
|
||||||
|
|
||||||
|
### 关联
|
||||||
|
|
||||||
|
- 任务:发现页顶部接入同开发者应用列表(stash 合并进 main)
|
||||||
|
|||||||
@ -65,3 +65,35 @@ type SubscriptionConfig struct {
|
|||||||
Subheadline string `json:"subheadline"`
|
Subheadline string `json:"subheadline"`
|
||||||
Plans []SubscriptionPlan `json:"plans"`
|
Plans []SubscriptionPlan `json:"plans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeveloperApp is one row in the same-developer app wall.
|
||||||
|
type DeveloperApp struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SourceAppCode string `json:"sourceAppCode"`
|
||||||
|
TargetAppName string `json:"targetAppName"`
|
||||||
|
TargetPackageName string `json:"targetPackageName"`
|
||||||
|
Subtitle string `json:"subtitle"`
|
||||||
|
IconURL string `json:"iconUrl"`
|
||||||
|
StoreURL string `json:"storeUrl"`
|
||||||
|
FallbackURL string `json:"fallbackUrl"`
|
||||||
|
SchemaURL string `json:"schemaUrl"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeveloperAppItem is the client-facing card (Discover / welfare).
|
||||||
|
type DeveloperAppItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Subtitle string `json:"subtitle"`
|
||||||
|
IconURL string `json:"iconUrl"`
|
||||||
|
StoreURL string `json:"storeUrl"`
|
||||||
|
SchemaURL string `json:"schemaUrl"`
|
||||||
|
PackageName string `json:"packageName"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeveloperAppsPayload is GET /v1/developer-apps data.
|
||||||
|
type DeveloperAppsPayload struct {
|
||||||
|
Items []DeveloperAppItem `json:"items"`
|
||||||
|
}
|
||||||
|
|||||||
159
internal/module/client/logic/developer_apps.go
Normal file
159
internal/module/client/logic/developer_apps.go
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"spark-server/internal/module/client/entity"
|
||||||
|
"spark-server/internal/pkg/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeveloperApps returns enabled apps for the given source app (client wall).
|
||||||
|
func (s *Service) DeveloperApps(ctx context.Context, sourceAppCode, excludePackage string) (*entity.DeveloperAppsPayload, error) {
|
||||||
|
sourceAppCode = normalizeAppCode(sourceAppCode)
|
||||||
|
excludePackage = strings.TrimSpace(excludePackage)
|
||||||
|
rows := s.loadDeveloperApps(ctx, sourceAppCode, true)
|
||||||
|
// SleepHeart 可回退 spark 共用墙
|
||||||
|
if len(rows) == 0 && sourceAppCode == "sleepheart" {
|
||||||
|
rows = s.loadDeveloperApps(ctx, "spark", true)
|
||||||
|
}
|
||||||
|
items := make([]entity.DeveloperAppItem, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if excludePackage != "" && strings.EqualFold(row.TargetPackageName, excludePackage) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, toDeveloperAppItem(row))
|
||||||
|
}
|
||||||
|
return &entity.DeveloperAppsPayload{Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDeveloperAppsAdmin returns all rows for popadmin (including disabled).
|
||||||
|
func (s *Service) ListDeveloperAppsAdmin(ctx context.Context, sourceAppCode string) ([]entity.DeveloperApp, error) {
|
||||||
|
return s.loadDeveloperApps(ctx, normalizeAppCode(sourceAppCode), false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDeveloperAppAdmin inserts or updates one recommendation.
|
||||||
|
func (s *Service) CreateDeveloperAppAdmin(ctx context.Context, in entity.DeveloperApp) (*entity.DeveloperApp, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, errs.ErrAvailable
|
||||||
|
}
|
||||||
|
in.SourceAppCode = normalizeAppCode(in.SourceAppCode)
|
||||||
|
in.TargetAppName = strings.TrimSpace(in.TargetAppName)
|
||||||
|
in.TargetPackageName = strings.TrimSpace(in.TargetPackageName)
|
||||||
|
in.Subtitle = strings.TrimSpace(in.Subtitle)
|
||||||
|
in.IconURL = strings.TrimSpace(in.IconURL)
|
||||||
|
in.StoreURL = strings.TrimSpace(in.StoreURL)
|
||||||
|
in.FallbackURL = strings.TrimSpace(in.FallbackURL)
|
||||||
|
in.SchemaURL = strings.TrimSpace(in.SchemaURL)
|
||||||
|
if in.TargetAppName == "" {
|
||||||
|
return nil, errs.ErrInvalidArgument.WithMsg("targetAppName required")
|
||||||
|
}
|
||||||
|
if in.TargetPackageName == "" {
|
||||||
|
in.TargetPackageName = "pkg_" + strings.ToLower(strings.ReplaceAll(in.TargetAppName, " ", "_"))
|
||||||
|
}
|
||||||
|
if in.StoreURL == "" && in.FallbackURL != "" {
|
||||||
|
in.StoreURL = in.FallbackURL
|
||||||
|
}
|
||||||
|
in.Enabled = true
|
||||||
|
if err := s.repo.UpsertDeveloperApp(ctx, &in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDeveloperAppAdmin removes one row by id.
|
||||||
|
func (s *Service) DeleteDeveloperAppAdmin(ctx context.Context, id int64) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return errs.ErrAvailable
|
||||||
|
}
|
||||||
|
if id <= 0 {
|
||||||
|
return errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
return s.repo.DeleteDeveloperApp(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncWebsiteIconsAdmin upserts catalog apps + default icons from wyrs365.com.
|
||||||
|
func (s *Service) SyncWebsiteIconsAdmin(ctx context.Context) (int, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return 0, errs.ErrAvailable
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, src := range []string{"spark", "sleepheart"} {
|
||||||
|
for _, row := range websiteDeveloperCatalog(src) {
|
||||||
|
cur := row
|
||||||
|
if err := s.repo.UpsertDeveloperApp(ctx, &cur); err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadDeveloperApps(ctx context.Context, sourceAppCode string, enabledOnly bool) []entity.DeveloperApp {
|
||||||
|
if s.repo != nil {
|
||||||
|
rows, err := s.repo.ListDeveloperApps(ctx, sourceAppCode, enabledOnly)
|
||||||
|
if err == nil && len(rows) > 0 {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultDeveloperApps(sourceAppCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDeveloperAppItem(a entity.DeveloperApp) entity.DeveloperAppItem {
|
||||||
|
store := a.StoreURL
|
||||||
|
if store == "" {
|
||||||
|
store = a.FallbackURL
|
||||||
|
}
|
||||||
|
return entity.DeveloperAppItem{
|
||||||
|
ID: a.ID, Name: a.TargetAppName, Subtitle: a.Subtitle, IconURL: a.IconURL,
|
||||||
|
StoreURL: store, SchemaURL: a.SchemaURL, PackageName: a.TargetPackageName, SortOrder: a.SortOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAppCode(code string) string {
|
||||||
|
code = strings.ToLower(strings.TrimSpace(code))
|
||||||
|
switch code {
|
||||||
|
case "", "pop":
|
||||||
|
return "spark"
|
||||||
|
case "health", "healthios", "sleep", "sleep-heart":
|
||||||
|
return "sleepheart"
|
||||||
|
default:
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultDeveloperApps(sourceAppCode string) []entity.DeveloperApp {
|
||||||
|
return websiteDeveloperCatalog(sourceAppCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func websiteDeveloperCatalog(sourceAppCode string) []entity.DeveloperApp {
|
||||||
|
mk := func(name, pkg, sub, icon, page string, order int) entity.DeveloperApp {
|
||||||
|
if icon == "" {
|
||||||
|
icon = "https://www.wyrs365.com/assets/logo-piggy-account.png"
|
||||||
|
}
|
||||||
|
return entity.DeveloperApp{
|
||||||
|
SourceAppCode: sourceAppCode, TargetAppName: name, TargetPackageName: pkg,
|
||||||
|
Subtitle: sub, IconURL: icon, StoreURL: page, FallbackURL: page,
|
||||||
|
SortOrder: order, Enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []entity.DeveloperApp{
|
||||||
|
mk("Flipclock桌面翻页时钟", "com.wyrs.flipclock", "桌面翻页时钟小组件",
|
||||||
|
"https://www.wyrs365.com/assets/logo-flipclock.png", "https://www.wyrs365.com/flipclock", 10),
|
||||||
|
mk("手指测心率", "com.wyrs.heartrate", "指尖 PPG 测心率",
|
||||||
|
"https://www.wyrs365.com/assets/logo-heartrate.png", "https://www.wyrs365.com/heartrate", 20),
|
||||||
|
mk("猪咪记账记事本", "com.wyrs.piggy", "记账 · 倒数日 · 小组件",
|
||||||
|
"https://www.wyrs365.com/assets/logo-piggy-account.png", "https://www.wyrs365.com/piggy-account", 30),
|
||||||
|
mk("鼾声分贝仪", "com.wyrs.snore", "鼾声检测与分贝测量",
|
||||||
|
"https://www.wyrs365.com/assets/logo-piggy-account.png", "https://www.wyrs365.com/decibel", 40),
|
||||||
|
mk("微雨LedBanner", "com.wyrs.ledbanner", "LED 滚动灯牌",
|
||||||
|
"https://www.wyrs365.com/assets/logo-piggy-account.png", "https://www.wyrs365.com/led-banner", 50),
|
||||||
|
mk("微雨PDF扫描转换器", "com.wyrs.wypdf", "PDF 扫描转换",
|
||||||
|
"https://www.wyrs365.com/assets/logo-piggy-account.png", "https://www.wyrs365.com/pdf-scanner", 60),
|
||||||
|
mk("RunSteps", "com.wyrs.runsteps", "步数与运动",
|
||||||
|
"https://www.wyrs365.com/assets/logo-piggy-account.png", "https://www.wyrs365.com/runsteps", 70),
|
||||||
|
mk("火花", "com.wyrs.spark", "即时通讯 · 火花",
|
||||||
|
"https://www.wyrs365.com/assets/logo-piggy-account.png", "https://www.wyrs365.com/", 80),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,6 +27,9 @@ type Repository interface {
|
|||||||
UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error
|
UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error
|
||||||
GetPasswordHash(ctx context.Context, userID int64) (string, error)
|
GetPasswordHash(ctx context.Context, userID int64) (string, error)
|
||||||
SetPasswordHash(ctx context.Context, userID int64, hash string) error
|
SetPasswordHash(ctx context.Context, userID int64, hash string) error
|
||||||
|
ListDeveloperApps(ctx context.Context, sourceAppCode string, enabledOnly bool) ([]entity.DeveloperApp, error)
|
||||||
|
UpsertDeveloperApp(ctx context.Context, a *entity.DeveloperApp) error
|
||||||
|
DeleteDeveloperApp(ctx context.Context, id int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserFinder interface {
|
type UserFinder interface {
|
||||||
|
|||||||
@ -115,6 +115,21 @@ func (s *stubClientRepo) SetPasswordHash(_ context.Context, userID int64, hash s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubClientRepo) ListDeveloperApps(_ context.Context, _ string, _ bool) ([]entity.DeveloperApp, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubClientRepo) UpsertDeveloperApp(_ context.Context, a *entity.DeveloperApp) error {
|
||||||
|
if a != nil && a.ID == 0 {
|
||||||
|
a.ID = 1
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubClientRepo) DeleteDeveloperApp(_ context.Context, _ int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type stubUserFinder struct {
|
type stubUserFinder struct {
|
||||||
byEmail map[string]*userentity.User
|
byEmail map[string]*userentity.User
|
||||||
}
|
}
|
||||||
@ -167,6 +182,13 @@ func TestLegalPlansFeedbackFallback(t *testing.T) {
|
|||||||
if loginFB.AppVersion != "1.2.3" {
|
if loginFB.AppVersion != "1.2.3" {
|
||||||
t.Fatalf("app_version from header want 1.2.3 got %q", loginFB.AppVersion)
|
t.Fatalf("app_version from header want 1.2.3 got %q", loginFB.AppVersion)
|
||||||
}
|
}
|
||||||
|
apps, err := svc.DeveloperApps(t.Context(), "sleepheart", "")
|
||||||
|
if err != nil || apps == nil || len(apps.Items) == 0 {
|
||||||
|
t.Fatalf("developer apps %+v err=%v", apps, err)
|
||||||
|
}
|
||||||
|
if apps.Items[0].Name == "" || apps.Items[0].StoreURL == "" {
|
||||||
|
t.Fatalf("developer app item incomplete %+v", apps.Items[0])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientServiceAdminAndRepo(t *testing.T) {
|
func TestClientServiceAdminAndRepo(t *testing.T) {
|
||||||
|
|||||||
@ -240,3 +240,95 @@ func (r *Repo) SetPasswordHash(ctx context.Context, userID int64, hash string) e
|
|||||||
row := passwordRow{UserID: userID, PasswordHash: hash, UpdatedAt: time.Now()}
|
row := passwordRow{UserID: userID, PasswordHash: hash, UpdatedAt: time.Now()}
|
||||||
return r.db.WithContext(ctx).Save(&row).Error
|
return r.db.WithContext(ctx).Save(&row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type developerAppRow struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
SourceAppCode string `gorm:"column:source_app_code"`
|
||||||
|
TargetAppName string `gorm:"column:target_app_name"`
|
||||||
|
TargetPackageName string `gorm:"column:target_package_name"`
|
||||||
|
Subtitle string `gorm:"column:subtitle"`
|
||||||
|
IconURL string `gorm:"column:icon_url"`
|
||||||
|
StoreURL string `gorm:"column:store_url"`
|
||||||
|
FallbackURL string `gorm:"column:fallback_url"`
|
||||||
|
SchemaURL string `gorm:"column:schema_url"`
|
||||||
|
SortOrder int `gorm:"column:sort_order"`
|
||||||
|
Enabled bool `gorm:"column:enabled"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (developerAppRow) TableName() string { return "t_developer_apps" }
|
||||||
|
|
||||||
|
func (r *Repo) ListDeveloperApps(ctx context.Context, sourceAppCode string, enabledOnly bool) ([]entity.DeveloperApp, error) {
|
||||||
|
q := r.db.WithContext(ctx).Where("source_app_code = ?", sourceAppCode)
|
||||||
|
if enabledOnly {
|
||||||
|
q = q.Where("enabled = 1")
|
||||||
|
}
|
||||||
|
var rows []developerAppRow
|
||||||
|
if err := q.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]entity.DeveloperApp, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, mapDeveloperApp(row))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) UpsertDeveloperApp(ctx context.Context, a *entity.DeveloperApp) error {
|
||||||
|
now := time.Now()
|
||||||
|
var existing developerAppRow
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Where("source_app_code = ? AND target_package_name = ?", a.SourceAppCode, a.TargetPackageName).
|
||||||
|
Take(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
existing.TargetAppName = a.TargetAppName
|
||||||
|
existing.Subtitle = a.Subtitle
|
||||||
|
if a.IconURL != "" {
|
||||||
|
existing.IconURL = a.IconURL
|
||||||
|
}
|
||||||
|
if a.StoreURL != "" {
|
||||||
|
existing.StoreURL = a.StoreURL
|
||||||
|
}
|
||||||
|
if a.FallbackURL != "" {
|
||||||
|
existing.FallbackURL = a.FallbackURL
|
||||||
|
}
|
||||||
|
if a.SchemaURL != "" {
|
||||||
|
existing.SchemaURL = a.SchemaURL
|
||||||
|
}
|
||||||
|
existing.SortOrder = a.SortOrder
|
||||||
|
existing.Enabled = a.Enabled
|
||||||
|
existing.UpdatedAt = now
|
||||||
|
if err := r.db.WithContext(ctx).Save(&existing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.ID = existing.ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row := developerAppRow{
|
||||||
|
SourceAppCode: a.SourceAppCode, TargetAppName: a.TargetAppName,
|
||||||
|
TargetPackageName: a.TargetPackageName, Subtitle: a.Subtitle, IconURL: a.IconURL,
|
||||||
|
StoreURL: a.StoreURL, FallbackURL: a.FallbackURL, SchemaURL: a.SchemaURL,
|
||||||
|
SortOrder: a.SortOrder, Enabled: a.Enabled, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.ID = row.ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) DeleteDeveloperApp(ctx context.Context, id int64) error {
|
||||||
|
return r.db.WithContext(ctx).Delete(&developerAppRow{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapDeveloperApp(row developerAppRow) entity.DeveloperApp {
|
||||||
|
return entity.DeveloperApp{
|
||||||
|
ID: row.ID, SourceAppCode: row.SourceAppCode, TargetAppName: row.TargetAppName,
|
||||||
|
TargetPackageName: row.TargetPackageName, Subtitle: row.Subtitle, IconURL: row.IconURL,
|
||||||
|
StoreURL: row.StoreURL, FallbackURL: row.FallbackURL, SchemaURL: row.SchemaURL,
|
||||||
|
SortOrder: row.SortOrder, Enabled: row.Enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -95,7 +95,9 @@ func publicPath(path string) bool {
|
|||||||
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/auth/password-login", "/v1/auth/password",
|
||||||
"/v1/legal/agreements", "/v1/feedback", "/v1/subscriptions/plans",
|
"/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",
|
||||||
|
"/v1/developer-apps",
|
||||||
|
"/api/client/pop/developer-apps", "/api/client/spark/developer-apps":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import (
|
|||||||
clientlogic "spark-server/internal/module/client/logic"
|
clientlogic "spark-server/internal/module/client/logic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClientHandler serves popadmin legal / subscription / feedback ops.
|
// ClientHandler serves popadmin legal / subscription / feedback / developer-apps ops.
|
||||||
type ClientHandler struct {
|
type ClientHandler struct {
|
||||||
svc *clientlogic.Service
|
svc *clientlogic.Service
|
||||||
}
|
}
|
||||||
@ -122,3 +122,67 @@ func (h *ClientHandler) PatchFeedback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
OKMsg(c, "已更新", gin.H{"id": id, "status": body.Status})
|
OKMsg(c, "已更新", gin.H{"id": id, "status": body.Status})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ClientHandler) ListDeveloperApps(c *gin.Context) {
|
||||||
|
if !h.ready(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appCode := c.Query("appCode")
|
||||||
|
if appCode == "" {
|
||||||
|
appCode = c.Query("sourceAppCode")
|
||||||
|
}
|
||||||
|
if appCode == "" {
|
||||||
|
appCode = "spark"
|
||||||
|
}
|
||||||
|
list, err := h.svc.ListDeveloperAppsAdmin(c.Request.Context(), appCode)
|
||||||
|
if err != nil {
|
||||||
|
Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OK(c, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientHandler) CreateDeveloperApp(c *gin.Context) {
|
||||||
|
if !h.ready(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body entity.DeveloperApp
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
Fail(c, http.StatusBadRequest, "无效 JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.svc.CreateDeveloperAppAdmin(c.Request.Context(), body)
|
||||||
|
if err != nil {
|
||||||
|
Fail(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OKMsg(c, "已保存", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientHandler) DeleteDeveloperApp(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
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteDeveloperAppAdmin(c.Request.Context(), id); err != nil {
|
||||||
|
Fail(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OKMsg(c, "已删除", gin.H{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientHandler) SyncDeveloperAppIcons(c *gin.Context) {
|
||||||
|
if !h.ready(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.svc.SyncWebsiteIconsAdmin(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
Fail(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OKMsg(c, "已同步", gin.H{"count": n})
|
||||||
|
}
|
||||||
|
|||||||
@ -59,3 +59,19 @@ func (h *Handler) SetPassword(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
respond.JSON(c, gin.H{})
|
respond.JSON(c, gin.H{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeveloperApps(c *gin.Context) {
|
||||||
|
source := c.Query("appCode")
|
||||||
|
if source == "" {
|
||||||
|
source = c.Query("sourceAppCode")
|
||||||
|
}
|
||||||
|
if source == "" {
|
||||||
|
source = "sleepheart"
|
||||||
|
}
|
||||||
|
out, err := h.svc.DeveloperApps(c.Request.Context(), source, c.Query("packageName"))
|
||||||
|
if err != nil {
|
||||||
|
respond.JSONError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(c, out)
|
||||||
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) {
|
|||||||
registerDocs(r)
|
registerDocs(r)
|
||||||
registerHealth(r, deps.Health)
|
registerHealth(r, deps.Health)
|
||||||
registerAdminRoutes(r, deps)
|
registerAdminRoutes(r, deps)
|
||||||
|
registerClientPopAliases(r, deps)
|
||||||
v1 := r.Group("/v1")
|
v1 := r.Group("/v1")
|
||||||
registerMetaRoutes(v1)
|
registerMetaRoutes(v1)
|
||||||
registerAuthRoutes(v1, deps)
|
registerAuthRoutes(v1, deps)
|
||||||
@ -99,6 +100,18 @@ func registerClientRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
|||||||
v1.GET("/legal/agreements", h.LegalAgreements)
|
v1.GET("/legal/agreements", h.LegalAgreements)
|
||||||
v1.POST("/feedback", h.SubmitFeedback)
|
v1.POST("/feedback", h.SubmitFeedback)
|
||||||
v1.GET("/subscriptions/plans", h.SubscriptionPlans)
|
v1.GET("/subscriptions/plans", h.SubscriptionPlans)
|
||||||
|
v1.GET("/developer-apps", h.DeveloperApps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerClientPopAliases(r *gin.Engine, deps *EngineDeps) {
|
||||||
|
if deps == nil || deps.Client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h := clienthandler.New(deps.Client)
|
||||||
|
for _, prefix := range []string{"/api/client/pop", "/api/client/spark"} {
|
||||||
|
g := r.Group(prefix)
|
||||||
|
g.GET("/developer-apps", h.DeveloperApps)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerSMSRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
func registerSMSRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
||||||
|
|||||||
@ -39,6 +39,8 @@ func registerAdminRoutes(r *gin.Engine, deps *EngineDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerDeveloperAppsAdminRoot(r, auth, ch)
|
||||||
|
|
||||||
for _, prefix := range []string{"/api/admin/spark", "/api/admin/pop"} {
|
for _, prefix := range []string{"/api/admin/spark", "/api/admin/pop"} {
|
||||||
g := r.Group(prefix)
|
g := r.Group(prefix)
|
||||||
g.Use(auth)
|
g.Use(auth)
|
||||||
@ -105,4 +107,21 @@ func registerClientAdmin(g *gin.RouterGroup, h *adminhandler.ClientHandler) {
|
|||||||
g.PUT("/subscriptions/plans", h.PutPlan)
|
g.PUT("/subscriptions/plans", h.PutPlan)
|
||||||
g.GET("/feedbacks", h.ListFeedback)
|
g.GET("/feedbacks", h.ListFeedback)
|
||||||
g.PATCH("/feedbacks/:id", h.PatchFeedback)
|
g.PATCH("/feedbacks/:id", h.PatchFeedback)
|
||||||
|
g.GET("/developer-apps", h.ListDeveloperApps)
|
||||||
|
g.POST("/developer-apps", h.CreateDeveloperApp)
|
||||||
|
g.DELETE("/developer-apps/:id", h.DeleteDeveloperApp)
|
||||||
|
g.POST("/developer-apps/sync-website-icons", h.SyncDeveloperAppIcons)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerDeveloperAppsAdminRoot(r *gin.Engine, auth gin.HandlerFunc, h *adminhandler.ClientHandler) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// popadmin.js uses /api/admin/developer-apps (no spark|pop prefix).
|
||||||
|
g := r.Group("/api/admin")
|
||||||
|
g.Use(auth)
|
||||||
|
g.GET("/developer-apps", h.ListDeveloperApps)
|
||||||
|
g.POST("/developer-apps", h.CreateDeveloperApp)
|
||||||
|
g.DELETE("/developer-apps/:id", h.DeleteDeveloperApp)
|
||||||
|
g.POST("/developer-apps/sync-website-icons", h.SyncDeveloperAppIcons)
|
||||||
}
|
}
|
||||||
|
|||||||
51
migrations/0027_developer_apps.sql
Normal file
51
migrations/0027_developer_apps.sql
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
-- 同开发者应用(应用墙):popadmin 可配,客户端 Discover / 福利页展示。
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS t_developer_apps (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '自增 id',
|
||||||
|
source_app_code VARCHAR(64) NOT NULL DEFAULT 'spark' COMMENT '来源 App 编码(spark|sleepheart|…)',
|
||||||
|
target_app_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '展示名称',
|
||||||
|
target_package_name VARCHAR(256) NOT NULL DEFAULT '' COMMENT '包名 / Bundle ID',
|
||||||
|
subtitle VARCHAR(256) NOT NULL DEFAULT '' COMMENT '副标题',
|
||||||
|
icon_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '图标 URL',
|
||||||
|
store_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'App Store / 下载链接',
|
||||||
|
fallback_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '备用跳转链接',
|
||||||
|
schema_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'URL Scheme / 深链',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '越小越靠前',
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=上架',
|
||||||
|
updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
|
||||||
|
UNIQUE KEY uk_dev_apps_source_pkg (source_app_code, target_package_name),
|
||||||
|
KEY idx_dev_apps_source (source_app_code, enabled, sort_order)
|
||||||
|
) COMMENT='同开发者推荐应用列表';
|
||||||
|
|
||||||
|
INSERT INTO t_developer_apps
|
||||||
|
(source_app_code, target_app_name, target_package_name, subtitle, icon_url, store_url, fallback_url, schema_url, sort_order, enabled, updated_at)
|
||||||
|
VALUES
|
||||||
|
('spark', 'Flipclock桌面翻页时钟', 'com.wyrs.flipclock', '桌面翻页时钟小组件', 'https://www.wyrs365.com/assets/logo-flipclock.png',
|
||||||
|
'https://www.wyrs365.com/flipclock', 'https://www.wyrs365.com/flipclock', '', 10, 1, CURRENT_TIMESTAMP(3)),
|
||||||
|
('spark', '手指测心率', 'com.wyrs.heartrate', '指尖 PPG 测心率', 'https://www.wyrs365.com/assets/logo-heartrate.png',
|
||||||
|
'https://www.wyrs365.com/heartrate', 'https://www.wyrs365.com/heartrate', '', 20, 1, CURRENT_TIMESTAMP(3)),
|
||||||
|
('spark', '猪咪记账记事本', 'com.wyrs.piggy', '记账 · 倒数日 · 小组件', 'https://www.wyrs365.com/assets/logo-piggy-account.png',
|
||||||
|
'https://www.wyrs365.com/piggy-account', 'https://www.wyrs365.com/piggy-account', '', 30, 1, CURRENT_TIMESTAMP(3)),
|
||||||
|
('spark', '鼾声分贝仪', 'com.wyrs.snore', '鼾声检测与分贝测量', 'https://www.wyrs365.com/assets/logo-piggy-account.png',
|
||||||
|
'https://www.wyrs365.com/decibel', 'https://www.wyrs365.com/decibel', '', 40, 1, CURRENT_TIMESTAMP(3)),
|
||||||
|
('spark', '微雨LedBanner', 'com.wyrs.ledbanner', 'LED 滚动灯牌', 'https://www.wyrs365.com/assets/logo-piggy-account.png',
|
||||||
|
'https://www.wyrs365.com/led-banner', 'https://www.wyrs365.com/led-banner', '', 50, 1, CURRENT_TIMESTAMP(3)),
|
||||||
|
('sleepheart', 'Flipclock桌面翻页时钟', 'com.wyrs.flipclock', '桌面翻页时钟小组件', 'https://www.wyrs365.com/assets/logo-flipclock.png',
|
||||||
|
'https://www.wyrs365.com/flipclock', 'https://www.wyrs365.com/flipclock', '', 10, 1, CURRENT_TIMESTAMP(3)),
|
||||||
|
('sleepheart', '手指测心率', 'com.wyrs.heartrate', '指尖 PPG 测心率', 'https://www.wyrs365.com/assets/logo-heartrate.png',
|
||||||
|
'https://www.wyrs365.com/heartrate', 'https://www.wyrs365.com/heartrate', '', 20, 1, CURRENT_TIMESTAMP(3)),
|
||||||
|
('sleepheart', '猪咪记账记事本', 'com.wyrs.piggy', '记账 · 倒数日 · 小组件', 'https://www.wyrs365.com/assets/logo-piggy-account.png',
|
||||||
|
'https://www.wyrs365.com/piggy-account', 'https://www.wyrs365.com/piggy-account', '', 30, 1, CURRENT_TIMESTAMP(3)),
|
||||||
|
('sleepheart', '鼾声分贝仪', 'com.wyrs.snore', '鼾声检测与分贝测量', 'https://www.wyrs365.com/assets/logo-piggy-account.png',
|
||||||
|
'https://www.wyrs365.com/decibel', 'https://www.wyrs365.com/decibel', '', 40, 1, CURRENT_TIMESTAMP(3)),
|
||||||
|
('sleepheart', '微雨LedBanner', 'com.wyrs.ledbanner', 'LED 滚动灯牌', 'https://www.wyrs365.com/assets/logo-piggy-account.png',
|
||||||
|
'https://www.wyrs365.com/led-banner', 'https://www.wyrs365.com/led-banner', '', 50, 1, CURRENT_TIMESTAMP(3))
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
target_app_name = VALUES(target_app_name),
|
||||||
|
subtitle = VALUES(subtitle),
|
||||||
|
icon_url = IF(icon_url = '' OR icon_url IS NULL, VALUES(icon_url), icon_url),
|
||||||
|
store_url = IF(store_url = '' OR store_url IS NULL, VALUES(store_url), store_url),
|
||||||
|
fallback_url = IF(fallback_url = '' OR fallback_url IS NULL, VALUES(fallback_url), fallback_url),
|
||||||
|
sort_order = VALUES(sort_order),
|
||||||
|
enabled = VALUES(enabled),
|
||||||
|
updated_at = CURRENT_TIMESTAMP(3);
|
||||||
Loading…
Reference in New Issue
Block a user