Add app logo metadata

This commit is contained in:
zhx 2026-07-14 11:29:05 +08:00
parent 14604861ea
commit d6c2948ab0
15 changed files with 228 additions and 18 deletions

View File

@ -0,0 +1,43 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- 只新增无索引的展示列,并强制 INSTANT 算法;不支持元数据即时变更时直接失败,避免意外触发全表重建。
SET @apps_logo_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'apps'
AND COLUMN_NAME = 'logo_url'
);
SET @apps_logo_ddl = IF(
@apps_logo_column_exists = 0,
'ALTER TABLE apps ADD COLUMN logo_url VARCHAR(1024) NOT NULL DEFAULT '''' COMMENT ''应用 Logo 的公网访问地址'', ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE apps_logo_stmt FROM @apps_logo_ddl;
EXECUTE apps_logo_stmt;
DEALLOCATE PREPARE apps_logo_stmt;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
-- app_code 有唯一索引,五个常量键只会做定点查找;不在 apps 表的 legacy App 由 admin-server 财务源配置提供 Logo。
UPDATE apps
SET logo_url = CASE app_code
WHEN 'lalu' THEN 'https://media.haiyihy.com/admin/apps/logos/lalu.png'
WHEN 'huwaa' THEN 'https://media.haiyihy.com/admin/apps/logos/huwaa.png'
WHEN 'fami' THEN 'https://media.haiyihy.com/admin/apps/logos/fami.png'
WHEN 'aslan' THEN 'https://media.haiyihy.com/admin/apps/logos/aslan.png'
WHEN 'yumi' THEN 'https://media.haiyihy.com/admin/apps/logos/yumi.png'
ELSE logo_url
END,
updated_at_ms = @now_ms
WHERE app_code IN ('lalu', 'huwaa', 'fami', 'aslan', 'yumi')
AND logo_url <> CASE app_code
WHEN 'lalu' THEN 'https://media.haiyihy.com/admin/apps/logos/lalu.png'
WHEN 'huwaa' THEN 'https://media.haiyihy.com/admin/apps/logos/huwaa.png'
WHEN 'fami' THEN 'https://media.haiyihy.com/admin/apps/logos/fami.png'
WHEN 'aslan' THEN 'https://media.haiyihy.com/admin/apps/logos/aslan.png'
WHEN 'yumi' THEN 'https://media.haiyihy.com/admin/apps/logos/yumi.png'
ELSE logo_url
END;

View File

@ -18,6 +18,7 @@ money_region_sources:
- enabled: true - enabled: true
app_code: "yumi" app_code: "yumi"
app_name: "Yumi" app_name: "Yumi"
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
sys_origin: "LIKEI" sys_origin: "LIKEI"
mongo_uri: "mongodb://root:123456@10.2.21.9:27017/tarab_all?authSource=admin&readPreference=secondaryPreferred&retryWrites=false&maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=6000" mongo_uri: "mongodb://root:123456@10.2.21.9:27017/tarab_all?authSource=admin&readPreference=secondaryPreferred&retryWrites=false&maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=6000"
mongo_database: "tarab_all" mongo_database: "tarab_all"
@ -26,6 +27,7 @@ money_region_sources:
- enabled: true - enabled: true
app_code: "aslan" app_code: "aslan"
app_name: "Aslan" app_name: "Aslan"
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
sys_origin: "ATYOU" sys_origin: "ATYOU"
mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred" mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred"
mongo_database: "atyou" mongo_database: "atyou"
@ -36,6 +38,7 @@ finance_bill_sources:
- enabled: true - enabled: true
app_code: "yumi" app_code: "yumi"
app_name: "Yumi" app_name: "Yumi"
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
sys_origin: "LIKEI" sys_origin: "LIKEI"
mongo_uri: "mongodb://root:123456@10.2.21.9:27017/tarab_all?authSource=admin&readPreference=secondaryPreferred&retryWrites=false&maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=6000" mongo_uri: "mongodb://root:123456@10.2.21.9:27017/tarab_all?authSource=admin&readPreference=secondaryPreferred&retryWrites=false&maxPoolSize=50&minPoolSize=5&maxIdleTimeMS=6000"
mongo_database: "tarab_all" mongo_database: "tarab_all"
@ -47,6 +50,7 @@ finance_bill_sources:
- enabled: true - enabled: true
app_code: "aslan" app_code: "aslan"
app_name: "Aslan" app_name: "Aslan"
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
sys_origin: "ATYOU" sys_origin: "ATYOU"
mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred" mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred"
mongo_database: "atyou" mongo_database: "atyou"

View File

@ -18,6 +18,7 @@ money_region_sources:
- enabled: false - enabled: false
app_code: "yumi" app_code: "yumi"
app_name: "Yumi" app_name: "Yumi"
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
sys_origin: "LIKEI" sys_origin: "LIKEI"
mongo_uri: "" mongo_uri: ""
mongo_database: "tarab_all" mongo_database: "tarab_all"
@ -26,6 +27,7 @@ money_region_sources:
- enabled: false - enabled: false
app_code: "aslan" app_code: "aslan"
app_name: "Aslan" app_name: "Aslan"
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
sys_origin: "ATYOU" sys_origin: "ATYOU"
mongo_uri: "" mongo_uri: ""
mongo_database: "atyou" mongo_database: "atyou"
@ -36,6 +38,7 @@ finance_bill_sources:
- enabled: false - enabled: false
app_code: "yumi" app_code: "yumi"
app_name: "Yumi" app_name: "Yumi"
logo_url: "https://media.haiyihy.com/admin/apps/logos/yumi.png"
sys_origin: "LIKEI" sys_origin: "LIKEI"
mongo_uri: "" mongo_uri: ""
mongo_database: "tarab_all" mongo_database: "tarab_all"
@ -47,6 +50,7 @@ finance_bill_sources:
- enabled: false - enabled: false
app_code: "aslan" app_code: "aslan"
app_name: "Aslan" app_name: "Aslan"
logo_url: "https://media.haiyihy.com/admin/apps/logos/aslan.png"
sys_origin: "ATYOU" sys_origin: "ATYOU"
mongo_uri: "" mongo_uri: ""
mongo_database: "atyou" mongo_database: "atyou"

View File

@ -149,6 +149,7 @@ type MoneyRegionSourceConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"` AppCode string `yaml:"app_code"`
AppName string `yaml:"app_name"` AppName string `yaml:"app_name"`
LogoURL string `yaml:"logo_url"`
SysOrigin string `yaml:"sys_origin"` SysOrigin string `yaml:"sys_origin"`
MongoURI string `yaml:"mongo_uri"` MongoURI string `yaml:"mongo_uri"`
MongoDatabase string `yaml:"mongo_database"` MongoDatabase string `yaml:"mongo_database"`
@ -162,6 +163,7 @@ type FinanceBillSourceConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"` AppCode string `yaml:"app_code"`
AppName string `yaml:"app_name"` AppName string `yaml:"app_name"`
LogoURL string `yaml:"logo_url"`
SysOrigin string `yaml:"sys_origin"` SysOrigin string `yaml:"sys_origin"`
MongoURI string `yaml:"mongo_uri"` MongoURI string `yaml:"mongo_uri"`
MongoDatabase string `yaml:"mongo_database"` MongoDatabase string `yaml:"mongo_database"`
@ -237,6 +239,7 @@ func Default() Config {
Enabled: false, Enabled: false,
AppCode: "yumi", AppCode: "yumi",
AppName: "Yumi", AppName: "Yumi",
LogoURL: "https://media.haiyihy.com/admin/apps/logos/yumi.png",
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
MongoDatabase: "tarab_all", MongoDatabase: "tarab_all",
MongoCollection: "sys_region_config", MongoCollection: "sys_region_config",
@ -246,6 +249,7 @@ func Default() Config {
Enabled: false, Enabled: false,
AppCode: "aslan", AppCode: "aslan",
AppName: "Aslan", AppName: "Aslan",
LogoURL: "https://media.haiyihy.com/admin/apps/logos/aslan.png",
SysOrigin: "ATYOU", SysOrigin: "ATYOU",
MongoDatabase: "tarab_all", MongoDatabase: "tarab_all",
MongoCollection: "sys_region_config", MongoCollection: "sys_region_config",
@ -257,6 +261,7 @@ func Default() Config {
Enabled: false, Enabled: false,
AppCode: "yumi", AppCode: "yumi",
AppName: "Yumi", AppName: "Yumi",
LogoURL: "https://media.haiyihy.com/admin/apps/logos/yumi.png",
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
MongoDatabase: "tarab_all", MongoDatabase: "tarab_all",
MongoCollection: "in_app_purchase_details", MongoCollection: "in_app_purchase_details",
@ -266,6 +271,7 @@ func Default() Config {
Enabled: false, Enabled: false,
AppCode: "aslan", AppCode: "aslan",
AppName: "Aslan", AppName: "Aslan",
LogoURL: "https://media.haiyihy.com/admin/apps/logos/aslan.png",
// Aslanlikei-services 项目)与 Yumi 同一套平台代码,但独立 Mongo 集群;业务库是 atyou。 // Aslanlikei-services 项目)与 Yumi 同一套平台代码,但独立 Mongo 集群;业务库是 atyou。
SysOrigin: "ATYOU", SysOrigin: "ATYOU",
MongoDatabase: "atyou", MongoDatabase: "atyou",
@ -585,6 +591,7 @@ func (cfg *Config) Normalize() {
// legacy 区域来源只服务财务范围目录这里先规整配置handler 层只处理业务过滤,不重复处理大小写和默认集合名。 // legacy 区域来源只服务财务范围目录这里先规整配置handler 层只处理业务过滤,不重复处理大小写和默认集合名。
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode)) source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
source.AppName = strings.TrimSpace(source.AppName) source.AppName = strings.TrimSpace(source.AppName)
source.LogoURL = strings.TrimSpace(source.LogoURL)
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin)) source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
source.MongoURI = strings.TrimSpace(source.MongoURI) source.MongoURI = strings.TrimSpace(source.MongoURI)
source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/") source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/")
@ -605,6 +612,7 @@ func (cfg *Config) Normalize() {
// legacy 账单源只服务财务充值明细配置层统一大小写和默认集合handler 只按 app_code 命中。 // legacy 账单源只服务财务充值明细配置层统一大小写和默认集合handler 只按 app_code 命中。
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode)) source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
source.AppName = strings.TrimSpace(source.AppName) source.AppName = strings.TrimSpace(source.AppName)
source.LogoURL = strings.TrimSpace(source.LogoURL)
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin)) source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
source.MongoURI = strings.TrimSpace(source.MongoURI) source.MongoURI = strings.TrimSpace(source.MongoURI)
source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/") source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/")

View File

@ -14,6 +14,7 @@ type App struct {
AppID int64 `json:"appId"` AppID int64 `json:"appId"`
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
AppName string `json:"appName"` AppName string `json:"appName"`
LogoURL string `json:"logoUrl"`
PackageName string `json:"packageName"` PackageName string `json:"packageName"`
Platform string `json:"platform"` Platform string `json:"platform"`
Status string `json:"status"` Status string `json:"status"`
@ -30,7 +31,7 @@ func (s *Service) ListApps(ctx context.Context) ([]App, error) {
return nil, fmt.Errorf("user mysql is not configured") return nil, fmt.Errorf("user mysql is not configured")
} }
rows, err := s.userDB.QueryContext(ctx, ` rows, err := s.userDB.QueryContext(ctx, `
SELECT app_id, app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms SELECT app_id, app_code, app_name, logo_url, package_name, platform, status, created_at_ms, updated_at_ms
FROM apps FROM apps
WHERE status = 'active' WHERE status = 'active'
ORDER BY app_name ASC, app_code ASC ORDER BY app_name ASC, app_code ASC
@ -43,7 +44,7 @@ func (s *Service) ListApps(ctx context.Context) ([]App, error) {
items := []App{} items := []App{}
for rows.Next() { for rows.Next() {
var item App var item App
if err := rows.Scan(&item.AppID, &item.AppCode, &item.AppName, &item.PackageName, &item.Platform, &item.Status, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil { if err := rows.Scan(&item.AppID, &item.AppCode, &item.AppName, &item.LogoURL, &item.PackageName, &item.Platform, &item.Status, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil {
return nil, err return nil, err
} }
items = append(items, item) items = append(items, item)

View File

@ -0,0 +1,31 @@
package appregistry
import (
"context"
"testing"
"github.com/DATA-DOG/go-sqlmock"
)
func TestListAppsReturnsLogoURL(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
defer db.Close()
mock.ExpectQuery(`SELECT app_id, app_code, app_name, logo_url, package_name, platform, status, created_at_ms, updated_at_ms`).
WillReturnRows(sqlmock.NewRows([]string{"app_id", "app_code", "app_name", "logo_url", "package_name", "platform", "status", "created_at_ms", "updated_at_ms"}).
AddRow(1, "lalu", "Lalu", "https://media.haiyihy.com/admin/apps/logos/lalu.png", "com.org.laluparty", "", "active", 10, 20))
items, err := NewService(db).ListApps(context.Background())
if err != nil {
t.Fatalf("list apps: %v", err)
}
if len(items) != 1 || items[0].LogoURL != "https://media.haiyihy.com/admin/apps/logos/lalu.png" {
t.Fatalf("unexpected app items: %+v", items)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}

View File

@ -240,6 +240,11 @@ func (s *MongoRechargeBillSource) AppName() string {
return s.config.AppCode return s.config.AppCode
} }
// LogoURL 与 legacy 账单源同配置生命周期;这类 App 不是 HyApp gateway 租户,不为了展示 Logo 向 apps 注册表塞入虚假 active 记录。
func (s *MongoRechargeBillSource) LogoURL() string {
return strings.TrimSpace(s.config.LogoURL)
}
type legacyCoinSellerRechargeStats struct { type legacyCoinSellerRechargeStats struct {
TotalUSDMinor int64 TotalUSDMinor int64
DailyUSDMinor map[string]int64 DailyUSDMinor map[string]int64

View File

@ -16,6 +16,7 @@ func yumiBillSourceConfig() config.FinanceBillSourceConfig {
Enabled: true, Enabled: true,
AppCode: "yumi", AppCode: "yumi",
AppName: "Yumi", AppName: "Yumi",
LogoURL: "https://media.haiyihy.com/admin/apps/logos/yumi.png",
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
MongoDatabase: "test", MongoDatabase: "test",
MongoCollection: "in_app_purchase_details", MongoCollection: "in_app_purchase_details",
@ -23,6 +24,13 @@ func yumiBillSourceConfig() config.FinanceBillSourceConfig {
} }
} }
func TestLegacyRechargeBillSourceExposesConfiguredLogoURL(t *testing.T) {
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
if source.LogoURL() != "https://media.haiyihy.com/admin/apps/logos/yumi.png" {
t.Fatalf("logo url=%q", source.LogoURL())
}
}
func aslanBillSourceConfig() config.FinanceBillSourceConfig { func aslanBillSourceConfig() config.FinanceBillSourceConfig {
cfg := yumiBillSourceConfig() cfg := yumiBillSourceConfig()
cfg.AppCode = "aslan" cfg.AppCode = "aslan"

View File

@ -46,6 +46,7 @@ type moneyAppDTO struct {
AppID int64 `json:"appId"` AppID int64 `json:"appId"`
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
AppName string `json:"appName"` AppName string `json:"appName"`
LogoURL string `json:"logoUrl"`
PackageName string `json:"packageName"` PackageName string `json:"packageName"`
Platform string `json:"platform"` Platform string `json:"platform"`
Status string `json:"status"` Status string `json:"status"`
@ -456,7 +457,7 @@ func (h *Handler) loadMoneyMasterData(ctx context.Context, access repository.Mon
func (h *Handler) listMoneyApps(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, error) { func (h *Handler) listMoneyApps(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, error) {
where, args := scopedAppWhere(access) where, args := scopedAppWhere(access)
rows, err := h.userDB.QueryContext(ctx, ` rows, err := h.userDB.QueryContext(ctx, `
SELECT app_id, app_code, app_name, package_name, platform, status SELECT app_id, app_code, app_name, logo_url, package_name, platform, status
FROM apps FROM apps
WHERE status = 'active'`+where+` WHERE status = 'active'`+where+`
ORDER BY app_name ASC, app_code ASC`, args...) ORDER BY app_name ASC, app_code ASC`, args...)
@ -467,7 +468,7 @@ func (h *Handler) listMoneyApps(ctx context.Context, access repository.MoneyAcce
items := []moneyAppDTO{} items := []moneyAppDTO{}
for rows.Next() { for rows.Next() {
var item moneyAppDTO var item moneyAppDTO
if err := rows.Scan(&item.AppID, &item.AppCode, &item.AppName, &item.PackageName, &item.Platform, &item.Status); err != nil { if err := rows.Scan(&item.AppID, &item.AppCode, &item.AppName, &item.LogoURL, &item.PackageName, &item.Platform, &item.Status); err != nil {
return nil, err return nil, err
} }
items = append(items, item) items = append(items, item)

View File

@ -255,6 +255,8 @@ func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfi
return []moneyAppDTO{{ return []moneyAppDTO{{
AppCode: appCode, AppCode: appCode,
AppName: appName, AppName: appName,
// Legacy App 没有 apps 行;其 Logo 与区域源一起配置,避免为了 UI 展示伪造活跃租户。
LogoURL: strings.TrimSpace(sourceConfig.LogoURL),
Platform: "legacy", Platform: "legacy",
Status: "active", Status: "active",
}}, regions, countries, nil }}, regions, countries, nil

View File

@ -24,6 +24,7 @@ func TestLegacyRegionDocumentsToMoneyDataFiltersYumiRegions(t *testing.T) {
cfg := config.MoneyRegionSourceConfig{ cfg := config.MoneyRegionSourceConfig{
AppCode: "yumi", AppCode: "yumi",
AppName: "Yumi", AppName: "Yumi",
LogoURL: "https://media.example.com/yumi.png",
SysOrigin: "LIKEI", SysOrigin: "LIKEI",
} }
apps, regions, countries, err := legacyRegionDocumentsToMoneyData(cfg, []legacyRegionDocument{ apps, regions, countries, err := legacyRegionDocumentsToMoneyData(cfg, []legacyRegionDocument{
@ -35,7 +36,7 @@ func TestLegacyRegionDocumentsToMoneyDataFiltersYumiRegions(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("legacy money data failed: %v", err) t.Fatalf("legacy money data failed: %v", err)
} }
if len(apps) != 1 || apps[0].AppCode != "yumi" || apps[0].AppName != "Yumi" || apps[0].Platform != "legacy" { if len(apps) != 1 || apps[0].AppCode != "yumi" || apps[0].AppName != "Yumi" || apps[0].LogoURL != cfg.LogoURL || apps[0].Platform != "legacy" {
t.Fatalf("apps mismatch: %+v", apps) t.Fatalf("apps mismatch: %+v", apps)
} }
if len(regions) != 2 || regions[0].RegionID < legacySyntheticRegionIDBase || regions[0].RegionCode != "OTHER" || regions[1].RegionID != 1001 || regions[1].RegionCode != "MIDDLE_EAST" { if len(regions) != 2 || regions[0].RegionID < legacySyntheticRegionIDBase || regions[0].RegionCode != "OTHER" || regions[1].RegionID != 1001 || regions[1].RegionCode != "MIDDLE_EAST" {
@ -159,7 +160,7 @@ func newMoneyRegionSourceUserSQLMock(t *testing.T) (*sql.DB, sqlmock.Sqlmock, fu
} }
func expectEmptyMoneyApps(mock sqlmock.Sqlmock, appCodes ...string) { func expectEmptyMoneyApps(mock sqlmock.Sqlmock, appCodes ...string) {
query := mock.ExpectQuery(`(?s)SELECT app_id, app_code, app_name, package_name, platform, status\s+FROM apps\s+WHERE status = 'active'`) query := mock.ExpectQuery(`(?s)SELECT app_id, app_code, app_name, logo_url, package_name, platform, status\s+FROM apps\s+WHERE status = 'active'`)
if len(appCodes) > 0 { if len(appCodes) > 0 {
args := make([]driver.Value, 0, len(appCodes)) args := make([]driver.Value, 0, len(appCodes))
for _, appCode := range appCodes { for _, appCode := range appCodes {
@ -167,12 +168,12 @@ func expectEmptyMoneyApps(mock sqlmock.Sqlmock, appCodes ...string) {
} }
query.WithArgs(args...) query.WithArgs(args...)
} }
query.WillReturnRows(sqlmock.NewRows([]string{"app_id", "app_code", "app_name", "package_name", "platform", "status"})) query.WillReturnRows(sqlmock.NewRows([]string{"app_id", "app_code", "app_name", "logo_url", "package_name", "platform", "status"}))
} }
func TestMoneyRegionSourceConfigDefaults(t *testing.T) { func TestMoneyRegionSourceConfigDefaults(t *testing.T) {
cfg := config.Default() cfg := config.Default()
if len(cfg.MoneyRegionSources) != 2 || cfg.MoneyRegionSources[0].RequestTimeout != 5*time.Second || cfg.MoneyRegionSources[1].AppCode != "aslan" { if len(cfg.MoneyRegionSources) != 2 || cfg.MoneyRegionSources[0].RequestTimeout != 5*time.Second || cfg.MoneyRegionSources[0].LogoURL == "" || cfg.MoneyRegionSources[1].AppCode != "aslan" {
t.Fatalf("default money region source mismatch: %+v", cfg.MoneyRegionSources) t.Fatalf("default money region source mismatch: %+v", cfg.MoneyRegionSources)
} }
} }

View File

@ -0,0 +1,47 @@
package payment
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
func TestListRechargeBillAppsReturnsStoredLogoURL(t *testing.T) {
gin.SetMode(gin.TestMode)
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
defer db.Close()
mock.ExpectQuery(`SELECT app_code, app_name, logo_url`).
WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).
AddRow("lalu", "Lalu", "https://media.haiyihy.com/admin/apps/logos/lalu.png"))
recorder := httptest.NewRecorder()
// 直接使用 recorder 承载 Gin 响应,测试只关注 App 目录映射,不绕过真实 handler 序列化。
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest("GET", "/api/v1/admin/payment/recharge-apps", nil)
(&Handler{userDB: db, billSources: map[string]RechargeBillSource{}}).ListRechargeBillApps(context)
if recorder.Code != 200 {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var payload struct {
Data struct {
Items []rechargeBillAppDTO `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Data.Items) != 1 || payload.Data.Items[0].LogoURL != "https://media.haiyihy.com/admin/apps/logos/lalu.png" {
t.Fatalf("unexpected apps: %+v", payload.Data.Items)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}

View File

@ -279,6 +279,12 @@ func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
type rechargeBillAppDTO struct { type rechargeBillAppDTO struct {
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
AppName string `json:"appName"` AppName string `json:"appName"`
LogoURL string `json:"logoUrl"`
}
// rechargeBillSourceLogo 是兼容扩展:历史或测试账单源不实现时仍可列出,新的 Mongo 源则补充 Logo。
type rechargeBillSourceLogo interface {
LogoURL() string
} }
// ListRechargeBillApps 返回充值详情可选的 App 目录hyapp 在册应用 + 配置的 legacy 账单源Yumi 等)。 // ListRechargeBillApps 返回充值详情可选的 App 目录hyapp 在册应用 + 配置的 legacy 账单源Yumi 等)。
@ -287,7 +293,7 @@ func (h *Handler) ListRechargeBillApps(c *gin.Context) {
seen := map[string]struct{}{} seen := map[string]struct{}{}
if h.userDB != nil { if h.userDB != nil {
rows, err := h.userDB.QueryContext(c.Request.Context(), ` rows, err := h.userDB.QueryContext(c.Request.Context(), `
SELECT app_code, app_name SELECT app_code, app_name, logo_url
FROM apps FROM apps
WHERE status = 'active' WHERE status = 'active'
ORDER BY app_name ASC, app_code ASC`) ORDER BY app_name ASC, app_code ASC`)
@ -298,7 +304,7 @@ func (h *Handler) ListRechargeBillApps(c *gin.Context) {
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var item rechargeBillAppDTO var item rechargeBillAppDTO
if err := rows.Scan(&item.AppCode, &item.AppName); err != nil { if err := rows.Scan(&item.AppCode, &item.AppName, &item.LogoURL); err != nil {
response.ServerError(c, "获取 App 列表失败") response.ServerError(c, "获取 App 列表失败")
return return
} }
@ -317,7 +323,11 @@ func (h *Handler) ListRechargeBillApps(c *gin.Context) {
if _, ok := seen[appCode]; ok { if _, ok := seen[appCode]; ok {
continue continue
} }
legacyApps = append(legacyApps, rechargeBillAppDTO{AppCode: appCode, AppName: source.AppName()}) logoURL := ""
if logoSource, ok := source.(rechargeBillSourceLogo); ok {
logoURL = strings.TrimSpace(logoSource.LogoURL())
}
legacyApps = append(legacyApps, rechargeBillAppDTO{AppCode: appCode, AppName: source.AppName(), LogoURL: logoURL})
} }
sort.Slice(legacyApps, func(i, j int) bool { return legacyApps[i].AppName < legacyApps[j].AppName }) sort.Slice(legacyApps, func(i, j int) bool { return legacyApps[i].AppName < legacyApps[j].AppName })
items = append(items, legacyApps...) items = append(items, legacyApps...)

View File

@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS apps (
app_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '应用 ID', app_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '应用 ID',
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
app_name VARCHAR(128) NOT NULL COMMENT '应用名称', app_name VARCHAR(128) NOT NULL COMMENT '应用名称',
logo_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '应用 Logo 的公网访问地址',
package_name VARCHAR(191) NOT NULL COMMENT '包名称', package_name VARCHAR(191) NOT NULL COMMENT '包名称',
platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT '平台', platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT '平台',
status VARCHAR(32) NOT NULL COMMENT '业务状态', status VARCHAR(32) NOT NULL COMMENT '业务状态',
@ -20,14 +21,15 @@ CREATE TABLE IF NOT EXISTS apps (
KEY idx_apps_status (status) KEY idx_apps_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用注册表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用注册表';
INSERT INTO apps (app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms) INSERT INTO apps (app_code, app_name, logo_url, package_name, platform, status, created_at_ms, updated_at_ms)
VALUES VALUES
('lalu', 'Lalu', 'com.org.laluparty', '', 'active', 0, 0), ('lalu', 'Lalu', 'https://media.haiyihy.com/admin/apps/logos/lalu.png', 'com.org.laluparty', '', 'active', 0, 0),
('huwaa', 'Huwaa', 'com.app.huwaa', '', 'active', 0, 0), ('huwaa', 'Huwaa', 'https://media.haiyihy.com/admin/apps/logos/huwaa.png', 'com.app.huwaa', '', 'active', 0, 0),
('fami', 'Fami', 'com.chat.fami', '', 'active', 0, 0), ('fami', 'Fami', 'https://media.haiyihy.com/admin/apps/logos/fami.png', 'com.chat.fami', '', 'active', 0, 0),
('yumi', 'Yumi', 'com.org.yumi', '', 'active', 0, 0) ('yumi', 'Yumi', 'https://media.haiyihy.com/admin/apps/logos/yumi.png', 'com.org.yumi', '', 'active', 0, 0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
app_name = VALUES(app_name), app_name = VALUES(app_name),
logo_url = VALUES(logo_url),
package_name = VALUES(package_name), package_name = VALUES(package_name),
platform = VALUES(platform), platform = VALUES(platform),
status = VALUES(status), status = VALUES(status),

View File

@ -0,0 +1,43 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- apps 是 App 目录于后台与 gateway 共用的事实源Logo 只是稳定展示元数据,不参与租户解析或鉴权。
SET @apps_logo_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'apps'
AND COLUMN_NAME = 'logo_url'
);
SET @apps_logo_ddl = IF(
@apps_logo_column_exists = 0,
'ALTER TABLE apps ADD COLUMN logo_url VARCHAR(1024) NOT NULL DEFAULT '''' COMMENT ''应用 Logo 的公网访问地址'', ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE apps_logo_stmt FROM @apps_logo_ddl;
EXECUTE apps_logo_stmt;
DEALLOCATE PREPARE apps_logo_stmt;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
-- 用 app_code 唯一索引定点更新Aslan/Yumi 若仅作为 legacy 财务源不在表内,本语句不会为其创建可被 gateway 解析的租户记录。
UPDATE apps
SET logo_url = CASE app_code
WHEN 'lalu' THEN 'https://media.haiyihy.com/admin/apps/logos/lalu.png'
WHEN 'huwaa' THEN 'https://media.haiyihy.com/admin/apps/logos/huwaa.png'
WHEN 'fami' THEN 'https://media.haiyihy.com/admin/apps/logos/fami.png'
WHEN 'aslan' THEN 'https://media.haiyihy.com/admin/apps/logos/aslan.png'
WHEN 'yumi' THEN 'https://media.haiyihy.com/admin/apps/logos/yumi.png'
ELSE logo_url
END,
updated_at_ms = @now_ms
WHERE app_code IN ('lalu', 'huwaa', 'fami', 'aslan', 'yumi')
AND logo_url <> CASE app_code
WHEN 'lalu' THEN 'https://media.haiyihy.com/admin/apps/logos/lalu.png'
WHEN 'huwaa' THEN 'https://media.haiyihy.com/admin/apps/logos/huwaa.png'
WHEN 'fami' THEN 'https://media.haiyihy.com/admin/apps/logos/fami.png'
WHEN 'aslan' THEN 'https://media.haiyihy.com/admin/apps/logos/aslan.png'
WHEN 'yumi' THEN 'https://media.haiyihy.com/admin/apps/logos/yumi.png'
ELSE logo_url
END;