feat(client): 接入同开发者应用墙 API 与管理端 CRUD

合并此前 stash 的 developer-apps:免登录列表、popadmin 管理与 0027 迁移。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-25 15:42:39 +08:00
parent 2e7735f103
commit c58b60419e
13 changed files with 509 additions and 3 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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