2026-06-18 14:19:47 +08:00

442 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package agencyopening
import (
"context"
"log/slog"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/agencyopening"
)
const rewardReason = "agency opening reward"
type Repository interface {
ListAgencyOpeningCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error)
GetAgencyOpeningCycle(ctx context.Context, cycleID string) (domain.Cycle, error)
UpsertAgencyOpeningCycle(ctx context.Context, command domain.CycleCommand, nowMS int64) (domain.Cycle, bool, error)
SetAgencyOpeningCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64, nowMS int64) (domain.Cycle, error)
FindCurrentAgencyOpeningCycle(ctx context.Context, nowMS int64) (domain.Cycle, bool, error)
GetAgencyOpeningApplicationByOwner(ctx context.Context, ownerUserID int64) (domain.Application, bool, error)
CreateAgencyOpeningApplication(ctx context.Context, cycle domain.Cycle, agency domain.AgencySnapshot, nowMS int64) (domain.Application, bool, error)
ApproveAgencyOpeningApplication(ctx context.Context, applicationID string, operatorAdminID int64, nowMS int64) (domain.Application, error)
ListAgencyOpeningApplications(ctx context.Context, query domain.ApplicationQuery) ([]domain.Application, int64, error)
ConsumeAgencyOpeningGiftEvent(ctx context.Context, event domain.GiftEvent, nowMS int64) (domain.EventResult, error)
ClaimDueAgencyOpeningCycles(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.Cycle, error)
PrepareAgencyOpeningSettlements(ctx context.Context, cycle domain.Cycle, nowMS int64) ([]domain.Application, error)
MarkAgencyOpeningApplicationGranted(ctx context.Context, applicationID string, walletTransactionID string, grantedAtMS int64) (domain.Application, error)
MarkAgencyOpeningApplicationFailed(ctx context.Context, applicationID string, reason string, nowMS int64) error
FinishAgencyOpeningCycleIfComplete(ctx context.Context, cycleID string, nowMS int64) (bool, error)
}
type AgencySource interface {
ResolveOwnerAgency(ctx context.Context, userID int64) (domain.AgencySnapshot, bool, error)
}
type WalletClient interface {
CreditAgencyOpeningReward(ctx context.Context, req *walletv1.CreditAgencyOpeningRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditAgencyOpeningRewardResponse, error)
}
type RoomQueryClient interface {
AdminGetRoom(ctx context.Context, req *roomv1.AdminGetRoomRequest, opts ...grpc.CallOption) (*roomv1.AdminGetRoomResponse, error)
}
type Service struct {
repository Repository
agency AgencySource
room RoomQueryClient
wallet WalletClient
now func() time.Time
}
func New(repository Repository, wallet WalletClient, agency AgencySource, room RoomQueryClient) *Service {
return &Service{repository: repository, wallet: wallet, agency: agency, room: room, now: time.Now}
}
func (s *Service) ListCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
query.Status = normalizeStatus(query.Status)
return s.repository.ListAgencyOpeningCycles(ctx, query)
}
func (s *Service) GetCycle(ctx context.Context, cycleID string) (domain.Cycle, error) {
if err := s.requireRepository(); err != nil {
return domain.Cycle{}, err
}
cycleID = strings.TrimSpace(cycleID)
if cycleID == "" {
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "cycle_id is required")
}
return s.repository.GetAgencyOpeningCycle(ctx, cycleID)
}
func (s *Service) CreateCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
command.RequireNewRecord = true
return s.upsertCycle(ctx, command)
}
func (s *Service) UpdateCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
command.RequireNewRecord = false
return s.upsertCycle(ctx, command)
}
func (s *Service) SetCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64) (domain.Cycle, error) {
if err := s.requireRepository(); err != nil {
return domain.Cycle{}, err
}
status = normalizeStatus(status)
if !validStatus(status) || status == domain.StatusSettling {
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "status is invalid")
}
if operatorAdminID <= 0 {
return domain.Cycle{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
return s.repository.SetAgencyOpeningCycleStatus(ctx, strings.TrimSpace(cycleID), status, operatorAdminID, s.now().UTC().UnixMilli())
}
func (s *Service) GetStatus(ctx context.Context, userID int64) (domain.Qualification, error) {
if err := s.requireRepository(); err != nil {
return domain.Qualification{}, err
}
nowMS := s.now().UTC().UnixMilli()
cycle, exists, err := s.repository.FindCurrentAgencyOpeningCycle(ctx, nowMS)
if err != nil {
return domain.Qualification{}, err
}
result := domain.Qualification{Cycle: cycle, Enabled: exists, ServerTimeMS: nowMS}
if !exists {
result.IneligibleReason = "activity_disabled"
return result, nil
}
agency, ok, err := s.resolveOwnerAgency(ctx, userID)
if err != nil {
return domain.Qualification{}, err
}
result.Agency = agency
if !ok {
result.IneligibleReason = "active_agency_owner_required"
return result, nil
}
application, joined, err := s.repository.GetAgencyOpeningApplicationByOwner(ctx, agency.OwnerUserID)
if err != nil {
return domain.Qualification{}, err
}
result.Application = application
result.Joined = joined
result.Eligible, result.IneligibleReason = eligible(cycle, agency, nowMS)
if joined {
result.Eligible = false
result.IneligibleReason = "already_applied"
}
return result, nil
}
func (s *Service) Apply(ctx context.Context, userID int64, commandID string) (domain.Application, bool, error) {
if err := s.requireRepository(); err != nil {
return domain.Application{}, false, err
}
if strings.TrimSpace(commandID) == "" {
return domain.Application{}, false, xerr.New(xerr.InvalidArgument, "command_id is required")
}
qualification, err := s.GetStatus(ctx, userID)
if err != nil {
return domain.Application{}, false, err
}
if !qualification.Enabled {
return domain.Application{}, false, xerr.New(xerr.Conflict, "activity is not active")
}
if qualification.Joined {
return qualification.Application, false, nil
}
if !qualification.Eligible {
return domain.Application{}, false, xerr.New(xerr.Conflict, qualification.IneligibleReason)
}
return s.repository.CreateAgencyOpeningApplication(ctx, qualification.Cycle, qualification.Agency, s.now().UTC().UnixMilli())
}
func (s *Service) ApproveApplication(ctx context.Context, applicationID string, operatorAdminID int64) (domain.Application, error) {
if err := s.requireRepository(); err != nil {
return domain.Application{}, err
}
applicationID = strings.TrimSpace(applicationID)
if applicationID == "" {
return domain.Application{}, xerr.New(xerr.InvalidArgument, "application_id is required")
}
if operatorAdminID <= 0 {
return domain.Application{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
// 开业积分必须从后台同意这一刻重新开始计算,不能把用户申请后、审核前的房间礼物补进活动分。
return s.repository.ApproveAgencyOpeningApplication(ctx, applicationID, operatorAdminID, s.now().UTC().UnixMilli())
}
func (s *Service) ListApplications(ctx context.Context, query domain.ApplicationQuery) ([]domain.Application, int64, error) {
if err := s.requireRepository(); err != nil {
return nil, 0, err
}
query.Status = normalizeApplicationStatus(query.Status)
return s.repository.ListAgencyOpeningApplications(ctx, query)
}
func (s *Service) HandleGiftEvent(ctx context.Context, event domain.GiftEvent) (int32, error) {
if err := s.requireRepository(); err != nil {
return 0, err
}
if event.EventID == "" || event.RoomOwnerUserID <= 0 || event.CoinSpent <= 0 {
return 0, nil
}
result, err := s.repository.ConsumeAgencyOpeningGiftEvent(ctx, event, s.now().UTC().UnixMilli())
if err != nil {
return 0, err
}
if result.Status == domain.EventStatusConsumed {
return 1, nil
}
return 0, nil
}
// HandleRoomEvent consumes committed room gift facts and projects revenue to the applicant room owner.
// 机器人礼物、非礼物事件和无扣费金额事件都跳过;房间 owner 从 room-service 当前事实解析,保证“开业流水”只统计申请人自己房间内发生的真实扣费。
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (int32, error) {
if s == nil || envelope == nil || envelope.GetEventType() != "RoomGiftSent" {
return 0, nil
}
var gift roomeventsv1.RoomGiftSent
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return 0, err
}
roomID := strings.TrimSpace(envelope.GetRoomId())
contributionValue := gift.GetGiftValue()
if gift.GetIsRobotGift() || roomID == "" || contributionValue <= 0 {
return 0, nil
}
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
ownerUserID, err := s.fetchRoomOwner(eventCtx, roomID)
if err != nil || ownerUserID <= 0 {
return 0, err
}
return s.HandleGiftEvent(eventCtx, domain.GiftEvent{
EventID: strings.TrimSpace(envelope.GetEventId()),
RoomID: roomID,
RoomOwnerUserID: ownerUserID,
TargetUserID: gift.GetTargetUserId(),
// 开业流水只消费 wallet 结算后的 HeatValue也就是 RoomGiftSent.gift_value原始扣费 coin_spent 不再参与奖励达标。
CoinSpent: contributionValue,
OccurredAtMS: envelope.GetOccurredAtMs(),
})
}
func (s *Service) ProcessSettlementBatch(ctx context.Context, runID string, workerID string, batchSize int32, lockTTL time.Duration) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
if err := s.requireRepository(); err != nil {
return 0, 0, 0, 0, false, err
}
if batchSize <= 0 {
batchSize = 50
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
if strings.TrimSpace(workerID) == "" {
workerID = "activity-agency-opening"
}
cycles, err := s.repository.ClaimDueAgencyOpeningCycles(ctx, workerID, s.now().UTC().UnixMilli(), lockTTL, batchSize)
if err != nil {
return 0, 0, 0, 0, false, err
}
for _, cycle := range cycles {
settlements, prepareErr := s.repository.PrepareAgencyOpeningSettlements(ctx, cycle, s.now().UTC().UnixMilli())
if prepareErr != nil {
logx.Error(ctx, "agency_opening_prepare_settlements_failed", prepareErr, slog.String("cycle_id", cycle.CycleID), slog.String("run_id", runID))
failure++
continue
}
if len(settlements) == 0 {
if _, finishErr := s.repository.FinishAgencyOpeningCycleIfComplete(ctx, cycle.CycleID, s.now().UTC().UnixMilli()); finishErr != nil {
failure++
continue
}
success++
continue
}
for _, settlement := range settlements {
processed++
if _, grantErr := s.grantSettlement(ctx, settlement); grantErr != nil {
logx.Error(ctx, "agency_opening_grant_failed", grantErr, slog.String("application_id", settlement.ApplicationID), slog.String("cycle_id", settlement.CycleID))
failure++
continue
}
success++
}
if _, finishErr := s.repository.FinishAgencyOpeningCycleIfComplete(ctx, cycle.CycleID, s.now().UTC().UnixMilli()); finishErr != nil {
failure++
}
}
return int32(len(cycles)), processed, success, failure, len(cycles) >= int(batchSize), nil
}
func (s *Service) grantSettlement(ctx context.Context, settlement domain.Application) (domain.Application, error) {
if settlement.Status == domain.ApplicationStatusGranted {
return settlement, nil
}
if settlement.RewardCoinAmount <= 0 {
nowMS := s.now().UTC().UnixMilli()
return s.repository.MarkAgencyOpeningApplicationGranted(ctx, settlement.ApplicationID, "", nowMS)
}
if s.wallet == nil {
err := xerr.New(xerr.Unavailable, "wallet client is not configured")
_ = s.repository.MarkAgencyOpeningApplicationFailed(ctx, settlement.ApplicationID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Application{}, err
}
resp, err := s.wallet.CreditAgencyOpeningReward(ctx, &walletv1.CreditAgencyOpeningRewardRequest{
CommandId: settlement.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: settlement.AgencyOwnerUserID,
Amount: settlement.RewardCoinAmount,
ApplicationId: settlement.ApplicationID,
CycleId: settlement.CycleID,
AgencyId: settlement.AgencyID,
RankNo: settlement.RewardRankNo,
ScoreCoins: settlement.ScoreCoinAmount,
Reason: rewardReason,
})
if err != nil {
_ = s.repository.MarkAgencyOpeningApplicationFailed(ctx, settlement.ApplicationID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
return domain.Application{}, err
}
return s.repository.MarkAgencyOpeningApplicationGranted(ctx, settlement.ApplicationID, resp.GetTransactionId(), resp.GetGrantedAtMs())
}
func (s *Service) upsertCycle(ctx context.Context, command domain.CycleCommand) (domain.Cycle, bool, error) {
if err := s.requireRepository(); err != nil {
return domain.Cycle{}, false, err
}
command.Cycle.Status = normalizeStatus(command.Cycle.Status)
if err := validateCycle(command.Cycle, command.OperatorAdminID); err != nil {
return domain.Cycle{}, false, err
}
return s.repository.UpsertAgencyOpeningCycle(ctx, command, s.now().UTC().UnixMilli())
}
func (s *Service) resolveOwnerAgency(ctx context.Context, userID int64) (domain.AgencySnapshot, bool, error) {
if s.agency == nil {
return domain.AgencySnapshot{}, false, xerr.New(xerr.Unavailable, "agency source is not configured")
}
return s.agency.ResolveOwnerAgency(ctx, userID)
}
func (s *Service) fetchRoomOwner(ctx context.Context, roomID string) (int64, error) {
if s.room == nil {
return 0, xerr.New(xerr.Unavailable, "room query client is not configured")
}
roomID = strings.TrimSpace(roomID)
if roomID == "" {
return 0, xerr.New(xerr.InvalidArgument, "room_id is required")
}
resp, err := s.room.AdminGetRoom(ctx, &roomv1.AdminGetRoomRequest{
Meta: &roomv1.RequestMeta{
// 事件没有携带 owner 快照,按 room_id 生成稳定 request_id方便追踪某次流水为何命中或跳过申请人。
RequestId: "agency-opening-room-owner:" + roomID,
AppCode: appcode.FromContext(ctx),
SentAtMs: s.now().UTC().UnixMilli(),
},
RoomId: roomID,
})
if err != nil {
return 0, err
}
return resp.GetRoom().GetOwnerUserId(), nil
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "agency opening repository is not configured")
}
return nil
}
func validateCycle(cycle domain.Cycle, operatorAdminID int64) error {
if strings.TrimSpace(cycle.Title) == "" {
return xerr.New(xerr.InvalidArgument, "title is required")
}
if !validStatus(cycle.Status) || cycle.Status == domain.StatusSettling || cycle.Status == domain.StatusSettled {
return xerr.New(xerr.InvalidArgument, "status is invalid")
}
if cycle.StartMS <= 0 || cycle.EndMS <= cycle.StartMS {
return xerr.New(xerr.InvalidArgument, "cycle period is invalid")
}
if cycle.MinHostCount <= 0 {
return xerr.New(xerr.InvalidArgument, "min_host_count must be positive")
}
if cycle.MaxAgencyAgeDays < 0 {
return xerr.New(xerr.InvalidArgument, "max_agency_age_days must not be negative")
}
if len(cycle.Rewards) == 0 {
return xerr.New(xerr.InvalidArgument, "reward tiers are required")
}
seen := map[int32]bool{}
previousThreshold := int64(0)
for _, reward := range cycle.Rewards {
if reward.RankNo <= 0 || reward.ThresholdCoinSpent <= 0 || reward.RewardCoinAmount < 0 {
return xerr.New(xerr.InvalidArgument, "reward tier threshold or amount is invalid")
}
if seen[reward.RankNo] {
return xerr.New(xerr.InvalidArgument, "reward tier duplicated")
}
if previousThreshold > 0 && reward.ThresholdCoinSpent <= previousThreshold {
return xerr.New(xerr.InvalidArgument, "reward tier threshold must increase")
}
seen[reward.RankNo] = true
previousThreshold = reward.ThresholdCoinSpent
}
if operatorAdminID <= 0 {
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
}
return nil
}
func eligible(cycle domain.Cycle, agency domain.AgencySnapshot, nowMS int64) (bool, string) {
if agency.Status != "active" {
return false, "active_agency_required"
}
if agency.HostCount <= cycle.MinHostCount {
return false, "host_count_not_enough"
}
if cycle.MaxAgencyAgeDays > 0 && agency.AgencyCreatedAtMS > 0 {
maxAgeMS := int64(cycle.MaxAgencyAgeDays) * int64(24*time.Hour/time.Millisecond)
if nowMS-agency.AgencyCreatedAtMS > maxAgeMS {
return false, "agency_too_old"
}
}
return true, ""
}
func normalizeStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return domain.StatusDraft
}
return value
}
func normalizeApplicationStatus(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func validStatus(value string) bool {
switch value {
case domain.StatusDraft, domain.StatusActive, domain.StatusDisabled, domain.StatusSettling, domain.StatusSettled:
return true
default:
return false
}
}