package portal import ( "context" "encoding/json" "net/url" "regexp" "strings" "git.118im.com/im_team/olons_server/internal/accounts" "git.118im.com/im_team/olons_server/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) LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error) MarkMemberNoticeRead(context.Context, int64, int64, int64) error MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error) ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, 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"` BodyHTML string `json:"body_html,omitempty"` SourceType string `json:"source_type"` StartAt string `json:"start_at"` EndAt string `json:"end_at"` CreatedAt string `json:"created_at"` } type NoticePage struct { Items []Notice `json:"items"` Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` } 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"` } type LeaderboardEntry struct { Rank int `json:"rank"` Username string `json:"username"` AvatarURL string `json:"avatar_url"` ClaimedPoints int64 `json:"claimed_points"` Status string `json:"status"` } type CustomerService struct { ID int64 `json:"id"` ServiceCode string `json:"service_code"` Name string `json:"name"` AvatarURL string `json:"avatar_url"` WorkTime string `json:"work_time"` OnlineStatus string `json:"online_status"` ServiceURL string `json:"service_url"` BackupContact string `json:"backup_contact"` PrivacyNotice string `json:"privacy_notice"` IMEnabled bool `json:"im_enabled"` } 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) Notices(ctx context.Context, tenantID, memberID int64, page, pageSize int) (*NoticePage, error) { if page == 0 { page = 1 } if pageSize == 0 { pageSize = 20 } if page < 1 { return nil, ErrPageInvalid } if pageSize < 1 || pageSize > 100 { return nil, ErrPageSizeInvalid } rows, err := s.repository.MemberNotices(ctx, tenantID, memberID, (page-1)*pageSize, pageSize) if err != nil { return nil, err } items := make([]Notice, 0, len(rows.Items)) for _, row := range rows.Items { items = append(items, Notice{ID: row.ID, Title: row.Title, Content: row.Content, SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt, CreatedAt: row.CreatedAt}) } return &NoticePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil } func (s *Service) PopupNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) { row, err := s.repository.PublishedContent(ctx, tenantID, "home_popup_content") if err != nil { return nil, err } content := strings.TrimSpace(row.BodyText) if content == "" { content = strings.TrimSpace(row.Summary) } return &Notice{ID: 0, Title: row.Title, Content: content, BodyHTML: row.BodyHTML, SourceType: "managed_content", CreatedAt: row.PublishedAt}, nil } func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error { return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID) } 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 } requestedKey := key titleOverride := "" switch key { case "platform_intro", "user_agreement": key = "platform_introduction" if requestedKey == "user_agreement" { titleOverride = "用户协议" } case "privacy_policy": // 当前后台只维护原型的 12 条正式文本,隐私条款沿用平台协议正文, // 避免为了前端路由额外制造一条无法在原型中对应的初始化数据。 key = "platform_introduction" titleOverride = "隐私政策" } 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) } title := row.Title if titleOverride != "" { title = titleOverride } return &Content{ContentKey: requestedKey, ContentType: row.ContentType, Title: 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 (s *Service) Leaderboard(ctx context.Context, tenantID int64) ([]LeaderboardEntry, error) { rows, err := s.repository.TaskLeaderboard(ctx, tenantID, 50) if err != nil { return nil, err } items := make([]LeaderboardEntry, 0, len(rows)) for index, row := range rows { items = append(items, LeaderboardEntry{Rank: index + 1, Username: maskedUsername(row.Username), AvatarURL: row.AvatarURL, ClaimedPoints: row.ClaimedPoints, Status: "领取成功"}) } return items, nil } func (s *Service) CustomerServices(ctx context.Context, tenantID int64) ([]CustomerService, error) { rows, err := s.repository.ActiveCustomerServices(ctx, tenantID) if err != nil { return nil, err } items := make([]CustomerService, 0, len(rows)) for _, row := range rows { items = append(items, CustomerService{ ID: row.ID, ServiceCode: row.ServiceCode, Name: row.Name, AvatarURL: safeExternalURL(row.AvatarURL), WorkTime: row.WorkTime, OnlineStatus: customerServiceStatus(row.OnlineStatus), ServiceURL: safeExternalURL(row.ServiceURL), BackupContact: row.BackupContact, PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码", IMEnabled: strings.TrimSpace(row.IMTenantCode) != "" && strings.TrimSpace(row.IMUserID) != "", }) } return items, nil } func maskedUsername(value string) string { runes := []rune(strings.TrimSpace(value)) switch len(runes) { case 0: return "会员" case 1: return string(runes[0]) + "*" case 2: return string(runes[0]) + "*" default: return string(runes[0]) + "***" + string(runes[len(runes)-1]) } } 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 safeExternalURL(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } parsed, err := url.Parse(value) if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { return "" } return parsed.String() } func customerServiceStatus(status int) string { switch status { case 1: return "online" case 3: return "busy" default: return "offline" } } func noticeSourceName(source int) string { if source == 2 { return "operational_simulation" } return "real" }