442 lines
17 KiB
Go
442 lines
17 KiB
Go
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
|
||
}
|
||
}
|