diff --git a/deploy/popadmin/popadmin.js b/deploy/popadmin/popadmin.js
index 2ad6123..31f9acc 100644
--- a/deploy/popadmin/popadmin.js
+++ b/deploy/popadmin/popadmin.js
@@ -2895,7 +2895,7 @@ const pages = {
$('content').innerHTML = `
-
同开发者应用(火花)· 客户端 GET /api/client/pop/developer-apps
+ 同开发者应用(火花)· 客户端 GET /v1/developer-apps(免登录)· 别名 GET /api/client/pop/developer-apps
diff --git a/docs/daily/2026-09-24-change-list.md b/docs/daily/2026-09-24-change-list.md
index 22d8dbc..28d4d18 100644
--- a/docs/daily/2026-09-24-change-list.md
+++ b/docs/daily/2026-09-24-change-list.md
@@ -205,3 +205,36 @@ make install-git-hooks # 本机只需一次
### 关联
- 用户需求:广场机器人 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)
diff --git a/internal/module/client/entity/types.go b/internal/module/client/entity/types.go
index 85bf2a6..b6e7768 100644
--- a/internal/module/client/entity/types.go
+++ b/internal/module/client/entity/types.go
@@ -65,3 +65,35 @@ type SubscriptionConfig struct {
Subheadline string `json:"subheadline"`
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"`
+}
diff --git a/internal/module/client/logic/developer_apps.go b/internal/module/client/logic/developer_apps.go
new file mode 100644
index 0000000..7d4f8e4
--- /dev/null
+++ b/internal/module/client/logic/developer_apps.go
@@ -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),
+ }
+}
diff --git a/internal/module/client/logic/service.go b/internal/module/client/logic/service.go
index 7872416..63b2236 100644
--- a/internal/module/client/logic/service.go
+++ b/internal/module/client/logic/service.go
@@ -27,6 +27,9 @@ type Repository interface {
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
+ 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 {
diff --git a/internal/module/client/logic/service_test.go b/internal/module/client/logic/service_test.go
index 7b59c63..a094606 100644
--- a/internal/module/client/logic/service_test.go
+++ b/internal/module/client/logic/service_test.go
@@ -115,6 +115,21 @@ func (s *stubClientRepo) SetPasswordHash(_ context.Context, userID int64, hash s
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 {
byEmail map[string]*userentity.User
}
@@ -167,6 +182,13 @@ func TestLegalPlansFeedbackFallback(t *testing.T) {
if loginFB.AppVersion != "1.2.3" {
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) {
diff --git a/internal/module/client/model/repo.go b/internal/module/client/model/repo.go
index 225cc4f..171dd58 100644
--- a/internal/module/client/model/repo.go
+++ b/internal/module/client/model/repo.go
@@ -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()}
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,
+ }
+}
diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go
index 0b96ee7..65849ad 100644
--- a/internal/transport/http/auth.go
+++ b/internal/transport/http/auth.go
@@ -95,7 +95,9 @@ func publicPath(path string) bool {
case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes",
"/v1/auth/password-login", "/v1/auth/password",
"/v1/legal/agreements", "/v1/feedback", "/v1/subscriptions/plans",
- "/v1/music/playlist", "/v1/music/categories", "/v1/music/settings":
+ "/v1/music/playlist", "/v1/music/categories", "/v1/music/settings",
+ "/v1/developer-apps",
+ "/api/client/pop/developer-apps", "/api/client/spark/developer-apps":
return true
default:
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
diff --git a/internal/transport/http/handler/admin/client.go b/internal/transport/http/handler/admin/client.go
index 3621368..55b6005 100644
--- a/internal/transport/http/handler/admin/client.go
+++ b/internal/transport/http/handler/admin/client.go
@@ -11,7 +11,7 @@ import (
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 {
svc *clientlogic.Service
}
@@ -122,3 +122,67 @@ func (h *ClientHandler) PatchFeedback(c *gin.Context) {
}
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})
+}
diff --git a/internal/transport/http/handler/client/handler.go b/internal/transport/http/handler/client/handler.go
index 1281c9b..db0dfb9 100644
--- a/internal/transport/http/handler/client/handler.go
+++ b/internal/transport/http/handler/client/handler.go
@@ -59,3 +59,19 @@ func (h *Handler) SetPassword(c *gin.Context) {
}
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)
+}
diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go
index 65ccd15..66cb06c 100644
--- a/internal/transport/http/router.go
+++ b/internal/transport/http/router.go
@@ -30,6 +30,7 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) {
registerDocs(r)
registerHealth(r, deps.Health)
registerAdminRoutes(r, deps)
+ registerClientPopAliases(r, deps)
v1 := r.Group("/v1")
registerMetaRoutes(v1)
registerAuthRoutes(v1, deps)
@@ -99,6 +100,18 @@ func registerClientRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
v1.GET("/legal/agreements", h.LegalAgreements)
v1.POST("/feedback", h.SubmitFeedback)
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) {
diff --git a/internal/transport/http/router_admin.go b/internal/transport/http/router_admin.go
index 4a137e7..1bddf36 100644
--- a/internal/transport/http/router_admin.go
+++ b/internal/transport/http/router_admin.go
@@ -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"} {
g := r.Group(prefix)
g.Use(auth)
@@ -105,4 +107,21 @@ func registerClientAdmin(g *gin.RouterGroup, h *adminhandler.ClientHandler) {
g.PUT("/subscriptions/plans", h.PutPlan)
g.GET("/feedbacks", h.ListFeedback)
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)
}
diff --git a/migrations/0027_developer_apps.sql b/migrations/0027_developer_apps.sql
new file mode 100644
index 0000000..daea2e9
--- /dev/null
+++ b/migrations/0027_developer_apps.sql
@@ -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);