feat: migrate workbench services
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
package portal
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrPositionInvalid = errors.New("banner position invalid")
|
||||
ErrContentKeyInvalid = errors.New("content key invalid")
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
package portal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
positionPattern = regexp.MustCompile(`^[a-z0-9_-]{1,32}$`)
|
||||
contentKeyPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
||||
ActiveBanners(context.Context, int64, string) ([]store.Banner, error)
|
||||
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
||||
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
||||
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
type Features struct {
|
||||
TaskEnabled bool `json:"task_enabled"`
|
||||
RechargeEnabled bool `json:"recharge_enabled"`
|
||||
WithdrawalEnabled bool `json:"withdrawal_enabled"`
|
||||
ManualMallEnabled bool `json:"manual_mall_enabled"`
|
||||
}
|
||||
|
||||
type Banner struct {
|
||||
ID int64 `json:"id"`
|
||||
ImageURL string `json:"image_url"`
|
||||
AltText string `json:"alt_text"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetValue string `json:"target_value,omitempty"`
|
||||
}
|
||||
|
||||
type Notice struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
SourceType string `json:"source_type"`
|
||||
StartAt string `json:"start_at"`
|
||||
EndAt string `json:"end_at"`
|
||||
}
|
||||
|
||||
type Home struct {
|
||||
Profile HomeProfile `json:"profile"`
|
||||
Wallet accounts.ProfileWallet `json:"wallet"`
|
||||
Features Features `json:"features"`
|
||||
Banners []Banner `json:"banners"`
|
||||
Notices []Notice `json:"notices"`
|
||||
TaskSummary store.TaskSummary `json:"task_summary"`
|
||||
}
|
||||
|
||||
type HomeProfile struct {
|
||||
Username string `json:"username"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
LevelName string `json:"level_name"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
ContentKey string `json:"content_key"`
|
||||
ContentType string `json:"content_type"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
BodyText string `json:"body_text"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
AssetIDs []int64 `json:"asset_ids"`
|
||||
Version int `json:"version"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
}
|
||||
|
||||
type MemberLevel struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"icon_url"`
|
||||
UpgradeAmount int64 `json:"upgrade_amount"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
DailyOrderLimit int `json:"daily_order_limit"`
|
||||
CommissionRate int `json:"commission_rate"`
|
||||
DailyWithdrawLimit int `json:"daily_withdraw_limit"`
|
||||
Rules json.RawMessage `json:"rules"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func New(repository Repository) *Service {
|
||||
return &Service{repository: repository}
|
||||
}
|
||||
|
||||
func (s *Service) Home(ctx context.Context, tenantID, memberID int64, profile *accounts.Profile) (*Home, error) {
|
||||
config, err := s.repository.CurrentSiteConfig(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
banners, err := s.Banners(ctx, tenantID, "home_top")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
noticeRows, err := s.repository.ActiveMemberNotices(ctx, tenantID, memberID, 5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := s.repository.MemberTaskSummary(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notices := make([]Notice, 0, len(noticeRows))
|
||||
for _, row := range noticeRows {
|
||||
notices = append(notices, Notice{ID: row.ID, Title: row.Title, Content: row.Content,
|
||||
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt})
|
||||
}
|
||||
return &Home{
|
||||
Profile: HomeProfile{Username: profile.Username, AvatarURL: profile.AvatarURL, LevelName: profile.Level.Name},
|
||||
Wallet: profile.Wallet, Features: parseFeatures(config), Banners: banners, Notices: notices,
|
||||
TaskSummary: *summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Banners(ctx context.Context, tenantID int64, position string) ([]Banner, error) {
|
||||
position = strings.ToLower(strings.TrimSpace(position))
|
||||
if !positionPattern.MatchString(position) {
|
||||
return nil, ErrPositionInvalid
|
||||
}
|
||||
rows, err := s.repository.ActiveBanners(ctx, tenantID, position)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Banner, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
targetType, targetValue := bannerTarget(row.TargetType, row.TargetValue)
|
||||
items = append(items, Banner{ID: row.ID, ImageURL: row.ImageURL, AltText: row.AltText,
|
||||
TargetType: targetType, TargetValue: targetValue})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if !contentKeyPattern.MatchString(key) {
|
||||
return nil, ErrContentKeyInvalid
|
||||
}
|
||||
row, err := s.repository.PublishedContent(ctx, tenantID, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assetIDs := make([]int64, 0)
|
||||
if row.AssetIDs.Valid && strings.TrimSpace(row.AssetIDs.String) != "" {
|
||||
_ = json.Unmarshal([]byte(row.AssetIDs.String), &assetIDs)
|
||||
}
|
||||
return &Content{ContentKey: row.ContentKey, ContentType: row.ContentType, Title: row.Title,
|
||||
Summary: row.Summary, BodyHTML: row.BodyHTML, BodyText: row.BodyText, CoverURL: row.CoverURL,
|
||||
AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MemberLevels(ctx context.Context, tenantID int64) ([]MemberLevel, error) {
|
||||
rows, err := s.repository.ActiveMemberLevels(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]MemberLevel, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
rules := row.RuleJSON
|
||||
if len(rules) == 0 {
|
||||
rules = json.RawMessage(`{}`)
|
||||
}
|
||||
items = append(items, MemberLevel{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL,
|
||||
UpgradeAmount: row.UpgradeAmount, GiftAmount: row.GiftAmount, DailyOrderLimit: row.DailyOrderLimit,
|
||||
CommissionRate: row.CommissionRate, DailyWithdrawLimit: row.DailyWithdrawLimit,
|
||||
Rules: rules, Version: row.Version})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func parseFeatures(raw json.RawMessage) Features {
|
||||
var config map[string]any
|
||||
if json.Unmarshal(raw, &config) != nil {
|
||||
return Features{}
|
||||
}
|
||||
return Features{
|
||||
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"),
|
||||
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
|
||||
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
|
||||
ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
|
||||
}
|
||||
}
|
||||
|
||||
func findBool(config map[string]any, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
if value, ok := config[key]; ok {
|
||||
return boolValue(value)
|
||||
}
|
||||
}
|
||||
if nested, ok := config["features"].(map[string]any); ok {
|
||||
for _, key := range keys {
|
||||
if value, found := nested[key]; found {
|
||||
return boolValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func boolValue(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case float64:
|
||||
return typed == 1
|
||||
case string:
|
||||
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func bannerTarget(kind int, value string) (string, string) {
|
||||
value = strings.TrimSpace(value)
|
||||
switch kind {
|
||||
case 2:
|
||||
if strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "//") {
|
||||
return "internal_route", value
|
||||
}
|
||||
case 3:
|
||||
parsed, err := url.Parse(value)
|
||||
if err == nil && parsed.Scheme == "https" && parsed.Host != "" {
|
||||
return "external_url", value
|
||||
}
|
||||
}
|
||||
return "none", ""
|
||||
}
|
||||
|
||||
func noticeSourceName(source int) string {
|
||||
if source == 2 {
|
||||
return "operational_simulation"
|
||||
}
|
||||
return "real"
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package portal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
config json.RawMessage
|
||||
banners []store.Banner
|
||||
content *store.Content
|
||||
levels []store.MemberLevel
|
||||
notices []store.MemberNotice
|
||||
summary *store.TaskSummary
|
||||
}
|
||||
|
||||
func (r fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
||||
return r.config, nil
|
||||
}
|
||||
func (r fakeRepository) ActiveBanners(context.Context, int64, string) ([]store.Banner, error) {
|
||||
return r.banners, nil
|
||||
}
|
||||
func (r fakeRepository) PublishedContent(context.Context, int64, string) (*store.Content, error) {
|
||||
if r.content == nil {
|
||||
return nil, store.ErrResourceNotFound
|
||||
}
|
||||
return r.content, nil
|
||||
}
|
||||
func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error) {
|
||||
return r.levels, nil
|
||||
}
|
||||
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
|
||||
return r.notices, nil
|
||||
}
|
||||
func (r fakeRepository) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) {
|
||||
return r.summary, nil
|
||||
}
|
||||
|
||||
func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
|
||||
features := parseFeatures(json.RawMessage(`{
|
||||
"features":{"task_enabled":true,"rechargeEnabled":1},
|
||||
"WITHDRAWAL":"on","SHOP_STATUS":"false"
|
||||
}`))
|
||||
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || features.ManualMallEnabled {
|
||||
t.Fatalf("unexpected features: %+v", features)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannersSanitizeTargets(t *testing.T) {
|
||||
service := New(fakeRepository{banners: []store.Banner{
|
||||
{ID: 1, TargetType: 2, TargetValue: "/receive"},
|
||||
{ID: 2, TargetType: 2, TargetValue: "//evil.example"},
|
||||
{ID: 3, TargetType: 3, TargetValue: "https://example.com/path"},
|
||||
{ID: 4, TargetType: 3, TargetValue: "javascript:alert(1)"},
|
||||
}})
|
||||
items, err := service.Banners(context.Background(), 0, "home_top")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTypes := []string{"internal_route", "none", "external_url", "none"}
|
||||
for i, want := range wantTypes {
|
||||
if items[i].TargetType != want {
|
||||
t.Errorf("item %d target type = %q, want %q", i, items[i].TargetType, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannersRejectInvalidPosition(t *testing.T) {
|
||||
service := New(fakeRepository{})
|
||||
if _, err := service.Banners(context.Background(), 0, "../home"); !errors.Is(err, ErrPositionInvalid) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrPositionInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentMapsAssets(t *testing.T) {
|
||||
service := New(fakeRepository{content: &store.Content{
|
||||
ContentKey: "privacy_policy", Title: "Policy", AssetIDs: sql.NullString{String: `[1,2]`, Valid: true},
|
||||
}})
|
||||
content, err := service.Content(context.Background(), 0, "privacy_policy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(content.AssetIDs) != 2 || content.AssetIDs[0] != 1 || content.AssetIDs[1] != 2 {
|
||||
t.Fatalf("unexpected asset IDs: %v", content.AssetIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberLevelsAlwaysReturnJSONObjectRules(t *testing.T) {
|
||||
service := New(fakeRepository{levels: []store.MemberLevel{{ID: 1, Code: "VIP1", Name: "Normal"}}})
|
||||
levels, err := service.MemberLevels(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(levels[0].Rules) != `{}` {
|
||||
t.Fatalf("rules = %s, want {}", levels[0].Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeUsesSettledSummaryAndSnakeCaseJSON(t *testing.T) {
|
||||
service := New(fakeRepository{
|
||||
config: json.RawMessage(`{"features":{"task_enabled":true}}`),
|
||||
summary: &store.TaskSummary{TodayCount: 2, TodayCommissionAmount: 150},
|
||||
})
|
||||
home, err := service.Home(context.Background(), 0, 7, &accounts.Profile{
|
||||
Username: "alice", Level: accounts.ProfileLevel{Name: "VIP1"},
|
||||
Wallet: accounts.ProfileWallet{Currency: "CNY"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := json.Marshal(home)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(encoded)
|
||||
for _, field := range []string{`"today_count":2`, `"today_commission_amount":150`, `"task_enabled":true`} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Errorf("home JSON %s does not contain %s", text, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user