425 lines
13 KiB
Go
425 lines
13 KiB
Go
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 {
|
|
config = map[string]any{}
|
|
}
|
|
return Features{
|
|
TaskEnabled: findBoolDefault(config, true, "task_enabled", "taskEnabled", "TASK"),
|
|
RechargeEnabled: findBoolDefault(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
|
|
WithdrawalEnabled: findBoolDefault(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
|
|
ManualMallEnabled: findBoolDefault(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
|
|
}
|
|
}
|
|
|
|
func findBoolDefault(config map[string]any, defaultValue bool, keys ...string) bool {
|
|
value, found := findBoolValue(config, keys...)
|
|
if !found {
|
|
return defaultValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
func findBoolValue(config map[string]any, keys ...string) (bool, bool) {
|
|
for _, key := range keys {
|
|
for candidate, value := range config {
|
|
if strings.EqualFold(candidate, key) {
|
|
return boolValue(value), true
|
|
}
|
|
}
|
|
}
|
|
for _, value := range config {
|
|
if nested, ok := value.(map[string]any); ok {
|
|
if result, found := findBoolValue(nested, keys...); found {
|
|
return result, true
|
|
}
|
|
}
|
|
}
|
|
return false, 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"
|
|
}
|