Files
hfb_sys/backend/internal/modules/distribution/service.go
T
yml2213 2a76d59a30 feat(distribution): 公告中心支持分站自定义
公告中心(前台公告列表/详情)此前完全没有站点概念,分站访客只能看到主站公告。
本次按「默认继承主站、站长可切仅本站」的口径支持分站自定义。

数据与口径:
- 新增 announcements.distribution_site_id(0=主站),前台按站点隔离
- 新增 distribution_sites.announcement_mode:inherit(默认,主站+本站)/ own_only(仅本站)

后端:
- 公告模块新增 ResolvePublicSiteScope:主站 [0]、inherit [0, siteID]、own_only [siteID],
  站点编码无效/已停用回落主站;列表与详情都按该范围过滤,详情不再允许跨站按 ID 读取
- 管理端公告列表支持按站点筛选(site_id:不传=全部,0=主站,>0=分站)并带出站点名
- 分销模块新增本站公告 CRUD(distribution/announcement.go):强制 distribution_site_id=本站,
  先过 ensureSiteAccess,跨站更新/删除/上下架一律 ErrAnnouncementNotFound
- UpdateSiteBranding 支持 announcement_mode 切换(非法值报错,读侧非法历史值回落 inherit)
- 新增权限 distribution:announcement_view|manage 并授予 super_admin/ops/finance,
  站长开通时继承 ops 角色即自动获得
- 路由:站长侧 /api/distribution/announcements*,平台侧 /api/admin/distribution/announcements*

前端:
- 分销后台新增「公告中心」页:来源开关(继承主站+本站 / 仅本站)、本站公告列表与
  新建/编辑/发布/下架/归档/恢复/删除,空态按来源模式给出引导
- 平台后台公告页新增「站点」列与筛选,新建公告可选择归属站点(主站或指定分站代发)
- 前台 PC/移动无需改动:请求已带 X-Distribution-Site-Code,后端按站点隔离,页面已有空态

测试:
- 公告模块:主站/继承/仅本站三种可见范围、跨站详情拦截、无效站点回落、管理端站点筛选
- 分销模块:本站公告 CRUD 与跨站防护、公告来源开关校验与非法值容错
2026-10-01 10:02:31 +08:00

484 lines
16 KiB
Go

package distribution
import (
"context"
"errors"
"strings"
smsprovider "hfb_sys/backend/internal/integrations/sms"
"hfb_sys/backend/internal/modules/auth"
)
var ErrDependencyUnavailable = errors.New("distribution dependency unavailable")
type Service struct {
repo *Repository
jwt *auth.JWTManager
smsResolver *SMSResolver
}
func NewService(repo *Repository, jwt ...*auth.JWTManager) *Service {
s := &Service{repo: repo}
if len(jwt) > 0 {
s.jwt = jwt[0]
}
return s
}
func (s *Service) SetSMSResolver(resolver *SMSResolver) {
s.smsResolver = resolver
}
func (s *Service) SMSResolver() *SMSResolver {
return s.smsResolver
}
// ResolveSMSProvider 实现 auth.SiteSMSProviderResolver 接口
func (s *Service) ResolveSMSProvider(ctx context.Context, siteCode, domain string) (smsprovider.Provider, error) {
if s == nil || s.smsResolver == nil {
return nil, nil
}
return s.smsResolver.ResolveSMSProvider(ctx, siteCode, domain)
}
func (s *Service) available() error {
if s == nil || s.repo == nil {
return ErrDependencyUnavailable
}
return nil
}
func (s *Service) ListSites(ctx context.Context) ([]SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSites(ctx)
}
func (s *Service) ListSitesForAdmin(ctx context.Context, adminID uint64) ([]SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
items, err := s.repo.ListSites(ctx)
if err != nil {
return nil, err
}
global, err := s.repo.hasGlobalDistributionAccess(ctx, adminID)
if err != nil {
return nil, err
}
if global {
return items, nil
}
siteIDs, err := s.repo.listActiveMemberSiteIDs(ctx, adminID)
if err != nil {
return nil, err
}
if len(siteIDs) == 0 {
return []SiteDTO{}, nil
}
allowed := make(map[uint64]struct{}, len(siteIDs))
for _, siteID := range siteIDs {
allowed[siteID] = struct{}{}
}
filtered := make([]SiteDTO, 0, len(allowed))
for _, item := range items {
if _, ok := allowed[item.ID]; ok {
filtered = append(filtered, item)
}
}
return filtered, nil
}
func (s *Service) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) {
if err := s.available(); err != nil {
return 0, err
}
return s.repo.ScopeSiteID(ctx, adminID, requested)
}
func (s *Service) FindSite(ctx context.Context, adminID, id uint64) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.FindSite(ctx, adminID, id)
}
func (s *Service) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateSite(ctx, req)
}
func (s *Service) DefaultSettlementHoldDays(ctx context.Context) (int, error) {
if err := s.available(); err != nil {
return 0, err
}
return s.repo.DefaultSettlementHoldDays(ctx), nil
}
func (s *Service) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateSite(ctx, adminID, id, req)
}
func (s *Service) DeleteSite(ctx context.Context, adminID, siteID uint64) error {
if err := s.available(); err != nil {
return err
}
return s.repo.DeleteSite(ctx, adminID, siteID)
}
func (s *Service) ResetSiteAdminPassword(ctx context.Context, operatorAdminID, siteID uint64, newPassword string) error {
if err := s.available(); err != nil {
return err
}
return s.repo.ResetSiteAdminPassword(ctx, operatorAdminID, siteID, newPassword)
}
func (s *Service) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListRules(ctx, adminID, siteID, mode)
}
func (s *Service) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateRule(ctx, adminID, req)
}
func (s *Service) UpdateRuleStatus(ctx context.Context, adminID, id uint64, status string) (*RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateRuleStatus(ctx, adminID, id, status)
}
func (s *Service) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListOrders(ctx, query)
}
func (s *Service) FindOrder(ctx context.Context, adminID, id uint64) (*DistributionOrderDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.FindOrder(ctx, adminID, id)
}
func (s *Service) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListAccountDisplays(ctx, siteID, status)
}
func (s *Service) UpdateAccountDisplay(ctx context.Context, adminID, id uint64, status string) (*AccountDisplayDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateAccountDisplay(ctx, adminID, id, status)
}
func (s *Service) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListPaymentChannels(ctx, siteID)
}
func (s *Service) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.BindPaymentChannel(ctx, siteID, req)
}
func (s *Service) UnbindPaymentChannel(ctx context.Context, adminID, id uint64) error {
if err := s.available(); err != nil {
return err
}
return s.repo.UnbindPaymentChannel(ctx, adminID, id)
}
func (s *Service) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSettlementBatches(ctx, siteID)
}
func (s *Service) CreateSettlementBatch(ctx context.Context, adminID uint64, req CreateSettlementBatchRequest) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateSettlementBatch(ctx, adminID, req)
}
func (s *Service) ListSettlementItems(ctx context.Context, adminID, batchID uint64) ([]SettlementItemDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSettlementItems(ctx, adminID, batchID)
}
func (s *Service) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.MarkSettlementBatch(ctx, adminID, id)
}
func (s *Service) CancelSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CancelSettlementBatch(ctx, adminID, id)
}
func (s *Service) CreateFinancialSnapshot(ctx context.Context, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateFinancialSnapshot(ctx, adminID, orderID, req)
}
func (s *Service) Login(ctx context.Context, req DistributionLoginRequest, clientIP string) (*DistributionLoginResponse, error) {
if err := s.available(); err != nil {
return nil, err
}
if s.jwt == nil {
return nil, ErrDependencyUnavailable
}
admin, member, err := s.repo.LoginMember(ctx, req.Username, req.Password)
if err != nil {
return nil, err
}
pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
if err != nil {
return nil, err
}
return &DistributionLoginResponse{
Token: pair.AccessToken,
RefreshToken: pair.RefreshToken,
Member: *member,
}, nil
}
func (s *Service) Me(ctx context.Context, adminID uint64) (*DistributionCurrentMemberDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.GetMemberProfile(ctx, adminID)
}
func (s *Service) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*OnboardSiteResponse, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.OnboardSite(ctx, req)
}
func (s *Service) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64, req SyncSiteAccountsRequest) (*SyncSiteAccountsResult, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.SyncSiteAccounts(ctx, adminID, siteID, req)
}
func (s *Service) ListPublicSiteListings(ctx context.Context, siteCode string, page, pageSize int) (*PublicDistributionListingsResult, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListPublicSiteListings(ctx, siteCode, page, pageSize)
}
func (s *Service) GetPublicSiteInfo(ctx context.Context, code, domain string) (*PublicSiteInfoDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.GetPublicSiteInfo(ctx, code, domain)
}
func (s *Service) UpdateSiteBranding(ctx context.Context, adminID, siteID uint64, req UpdateSiteBrandingRequest) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateSiteBranding(ctx, adminID, siteID, req)
}
func (s *Service) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, ratioDelta float64) (*AccountDisplayDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateAccountMarkup(ctx, adminID, id, ratioDelta)
}
func (s *Service) BatchUpdateAccountMarkup(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountMarkupRequest) error {
if err := s.available(); err != nil {
return err
}
return s.repo.BatchUpdateAccountMarkup(ctx, adminID, siteID, req)
}
func (s *Service) BatchUpdateAccountDisplay(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountDisplayRequest) error {
if err := s.available(); err != nil {
return err
}
return s.repo.BatchUpdateAccountDisplay(ctx, adminID, siteID, req)
}
func (s *Service) ListSiteAnnouncements(ctx context.Context, adminID, siteID uint64, status string, page, pageSize int) (*SiteAnnouncementPage, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSiteAnnouncements(ctx, adminID, siteID, status, page, pageSize)
}
func (s *Service) CreateSiteAnnouncement(ctx context.Context, adminID, siteID uint64, req CreateSiteAnnouncementRequest) (*SiteAnnouncementDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateSiteAnnouncement(ctx, adminID, siteID, req)
}
func (s *Service) UpdateSiteAnnouncement(ctx context.Context, adminID, siteID, id uint64, req UpdateSiteAnnouncementRequest) (*SiteAnnouncementDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateSiteAnnouncement(ctx, adminID, siteID, id, req)
}
func (s *Service) UpdateSiteAnnouncementStatus(ctx context.Context, adminID, siteID, id uint64, action string) (*SiteAnnouncementDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateSiteAnnouncementStatus(ctx, adminID, siteID, id, action)
}
func (s *Service) DeleteSiteAnnouncement(ctx context.Context, adminID, siteID, id uint64) error {
if err := s.available(); err != nil {
return err
}
return s.repo.DeleteSiteAnnouncement(ctx, adminID, siteID, id)
}
func (s *Service) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListReceivedListings(ctx, adminID, siteID, q)
}
func (s *Service) UpdateReceivedListingStatus(ctx context.Context, adminID, listingID uint64, status string) error {
if err := s.available(); err != nil {
return err
}
return s.repo.UpdateReceivedListingStatus(ctx, adminID, listingID, status)
}
func (s *Service) GetSiteWallet(ctx context.Context, adminID, siteID uint64) (*SiteWalletDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.GetSiteWallet(ctx, adminID, siteID)
}
func (s *Service) ApplySiteWithdrawal(ctx context.Context, adminID, siteID uint64, req ApplySiteWithdrawalRequest) (*SiteWithdrawalDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ApplySiteWithdrawal(ctx, adminID, siteID, req)
}
func (s *Service) ListSiteWithdrawals(ctx context.Context, adminID, siteID uint64, page, size int) (*SiteWithdrawalPage, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSiteWithdrawals(ctx, adminID, siteID, page, size)
}
func (s *Service) GetDashboardAnalytics(ctx context.Context, adminID, siteID uint64) (*DistributionAnalyticsDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.GetDashboardAnalytics(ctx, adminID, siteID)
}
func (s *Service) GetSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfigDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.GetSiteSMSConfig(ctx, adminID, siteID)
}
func (s *Service) UpdateSiteSMSConfig(ctx context.Context, adminID, siteID uint64, req UpdateSiteSMSConfigRequest) (*SiteSMSConfigDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
dto, err := s.repo.UpdateSiteSMSConfig(ctx, adminID, siteID, req)
if err != nil {
return nil, err
}
if s.smsResolver != nil {
s.smsResolver.InvalidateCache(siteID)
}
return dto, nil
}
func (s *Service) TestSiteSMSConfig(ctx context.Context, adminID, siteID uint64, req TestSiteSMSConfigRequest) error {
if err := s.available(); err != nil {
return err
}
if s.smsResolver == nil {
return errors.New("sms resolver unavailable")
}
phone := strings.TrimSpace(req.Phone)
if phone == "" {
return errors.New("测试手机号不能为空")
}
// 先获取当前站点已持久化的配置及已解密的密钥
existingCfg, err := s.repo.GetDecryptedSiteSMSConfig(ctx, adminID, siteID)
if err != nil {
return err
}
// 构造测试配置(优先取请求参数,缺省回退至已有配置)
testCfg := SiteSMSConfig{
Enabled: true,
}
if req.Provider != nil && strings.TrimSpace(*req.Provider) != "" {
testCfg.Provider = strings.TrimSpace(*req.Provider)
} else if existingCfg != nil && existingCfg.Provider != "" {
testCfg.Provider = existingCfg.Provider
}
if req.AccessKeyID != nil && strings.TrimSpace(*req.AccessKeyID) != "" {
testCfg.AccessKeyID = strings.TrimSpace(*req.AccessKeyID)
} else if existingCfg != nil {
testCfg.AccessKeyID = existingCfg.AccessKeyID
}
if req.AccessKeySecret != nil && strings.TrimSpace(*req.AccessKeySecret) != "" {
testCfg.AccessKeySecret = strings.TrimSpace(*req.AccessKeySecret)
} else if existingCfg != nil {
testCfg.AccessKeySecret = existingCfg.AccessKeySecret
}
if req.Endpoint != nil && strings.TrimSpace(*req.Endpoint) != "" {
testCfg.Endpoint = strings.TrimSpace(*req.Endpoint)
} else if existingCfg != nil {
testCfg.Endpoint = existingCfg.Endpoint
}
if req.SignName != nil && strings.TrimSpace(*req.SignName) != "" {
testCfg.SignName = strings.TrimSpace(*req.SignName)
} else if existingCfg != nil {
testCfg.SignName = existingCfg.SignName
}
if req.LoginTemplateCode != nil && strings.TrimSpace(*req.LoginTemplateCode) != "" {
testCfg.LoginTemplateCode = strings.TrimSpace(*req.LoginTemplateCode)
} else if existingCfg != nil {
testCfg.LoginTemplateCode = existingCfg.LoginTemplateCode
}
providerType, err := s.repo.validateSMSProvider(testCfg.Provider)
if err != nil {
return err
}
testCfg.Provider = providerType
if providerType == "aliyun" {
if strings.TrimSpace(testCfg.AccessKeyID) == "" ||
strings.TrimSpace(testCfg.AccessKeySecret) == "" ||
strings.TrimSpace(testCfg.SignName) == "" ||
strings.TrimSpace(testCfg.LoginTemplateCode) == "" {
return ErrSMSConfigInvalid
}
}
return s.smsResolver.TestSendSMS(ctx, siteID, testCfg, phone)
}