代理活动修复

This commit is contained in:
zhx 2026-06-18 14:19:47 +08:00
parent c93a30f3db
commit a0c9d055f0
19 changed files with 2407 additions and 1296 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2191,6 +2191,10 @@ message AgencyOpeningApplication {
int64 granted_at_ms = 16;
int64 updated_at_ms = 17;
int64 reward_threshold_coin_spent = 18;
int64 approved_at_ms = 19;
int64 score_start_ms = 20;
int64 score_end_ms = 21;
int64 reviewed_by_admin_id = 22;
}
// AgencyOpeningAgencySnapshot 使
@ -2263,6 +2267,17 @@ message ListAgencyOpeningApplicationsResponse {
int64 total = 2;
}
message ReviewAgencyOpeningApplicationRequest {
RequestMeta meta = 1;
string application_id = 2;
string action = 3;
int64 operator_admin_id = 4;
}
message ReviewAgencyOpeningApplicationResponse {
AgencyOpeningApplication application = 1;
}
message GetAgencyOpeningStatusRequest {
RequestMeta meta = 1;
int64 user_id = 2;
@ -2483,6 +2498,7 @@ service AdminAgencyOpeningService {
rpc UpdateAgencyOpeningCycle(UpsertAgencyOpeningCycleRequest) returns (UpsertAgencyOpeningCycleResponse);
rpc SetAgencyOpeningCycleStatus(SetAgencyOpeningCycleStatusRequest) returns (SetAgencyOpeningCycleStatusResponse);
rpc ListAgencyOpeningApplications(ListAgencyOpeningApplicationsRequest) returns (ListAgencyOpeningApplicationsResponse);
rpc ReviewAgencyOpeningApplication(ReviewAgencyOpeningApplicationRequest) returns (ReviewAgencyOpeningApplicationResponse);
}
// SevenDayCheckInService App

View File

@ -4962,12 +4962,13 @@ var AdminCPWeeklyRankService_ServiceDesc = grpc.ServiceDesc{
}
const (
AdminAgencyOpeningService_ListAgencyOpeningCycles_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/ListAgencyOpeningCycles"
AdminAgencyOpeningService_CreateAgencyOpeningCycle_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/CreateAgencyOpeningCycle"
AdminAgencyOpeningService_GetAgencyOpeningCycle_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/GetAgencyOpeningCycle"
AdminAgencyOpeningService_UpdateAgencyOpeningCycle_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/UpdateAgencyOpeningCycle"
AdminAgencyOpeningService_SetAgencyOpeningCycleStatus_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/SetAgencyOpeningCycleStatus"
AdminAgencyOpeningService_ListAgencyOpeningApplications_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/ListAgencyOpeningApplications"
AdminAgencyOpeningService_ListAgencyOpeningCycles_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/ListAgencyOpeningCycles"
AdminAgencyOpeningService_CreateAgencyOpeningCycle_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/CreateAgencyOpeningCycle"
AdminAgencyOpeningService_GetAgencyOpeningCycle_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/GetAgencyOpeningCycle"
AdminAgencyOpeningService_UpdateAgencyOpeningCycle_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/UpdateAgencyOpeningCycle"
AdminAgencyOpeningService_SetAgencyOpeningCycleStatus_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/SetAgencyOpeningCycleStatus"
AdminAgencyOpeningService_ListAgencyOpeningApplications_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/ListAgencyOpeningApplications"
AdminAgencyOpeningService_ReviewAgencyOpeningApplication_FullMethodName = "/hyapp.activity.v1.AdminAgencyOpeningService/ReviewAgencyOpeningApplication"
)
// AdminAgencyOpeningServiceClient is the client API for AdminAgencyOpeningService service.
@ -4982,6 +4983,7 @@ type AdminAgencyOpeningServiceClient interface {
UpdateAgencyOpeningCycle(ctx context.Context, in *UpsertAgencyOpeningCycleRequest, opts ...grpc.CallOption) (*UpsertAgencyOpeningCycleResponse, error)
SetAgencyOpeningCycleStatus(ctx context.Context, in *SetAgencyOpeningCycleStatusRequest, opts ...grpc.CallOption) (*SetAgencyOpeningCycleStatusResponse, error)
ListAgencyOpeningApplications(ctx context.Context, in *ListAgencyOpeningApplicationsRequest, opts ...grpc.CallOption) (*ListAgencyOpeningApplicationsResponse, error)
ReviewAgencyOpeningApplication(ctx context.Context, in *ReviewAgencyOpeningApplicationRequest, opts ...grpc.CallOption) (*ReviewAgencyOpeningApplicationResponse, error)
}
type adminAgencyOpeningServiceClient struct {
@ -5052,6 +5054,16 @@ func (c *adminAgencyOpeningServiceClient) ListAgencyOpeningApplications(ctx cont
return out, nil
}
func (c *adminAgencyOpeningServiceClient) ReviewAgencyOpeningApplication(ctx context.Context, in *ReviewAgencyOpeningApplicationRequest, opts ...grpc.CallOption) (*ReviewAgencyOpeningApplicationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ReviewAgencyOpeningApplicationResponse)
err := c.cc.Invoke(ctx, AdminAgencyOpeningService_ReviewAgencyOpeningApplication_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminAgencyOpeningServiceServer is the server API for AdminAgencyOpeningService service.
// All implementations must embed UnimplementedAdminAgencyOpeningServiceServer
// for forward compatibility.
@ -5064,6 +5076,7 @@ type AdminAgencyOpeningServiceServer interface {
UpdateAgencyOpeningCycle(context.Context, *UpsertAgencyOpeningCycleRequest) (*UpsertAgencyOpeningCycleResponse, error)
SetAgencyOpeningCycleStatus(context.Context, *SetAgencyOpeningCycleStatusRequest) (*SetAgencyOpeningCycleStatusResponse, error)
ListAgencyOpeningApplications(context.Context, *ListAgencyOpeningApplicationsRequest) (*ListAgencyOpeningApplicationsResponse, error)
ReviewAgencyOpeningApplication(context.Context, *ReviewAgencyOpeningApplicationRequest) (*ReviewAgencyOpeningApplicationResponse, error)
mustEmbedUnimplementedAdminAgencyOpeningServiceServer()
}
@ -5092,6 +5105,9 @@ func (UnimplementedAdminAgencyOpeningServiceServer) SetAgencyOpeningCycleStatus(
func (UnimplementedAdminAgencyOpeningServiceServer) ListAgencyOpeningApplications(context.Context, *ListAgencyOpeningApplicationsRequest) (*ListAgencyOpeningApplicationsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListAgencyOpeningApplications not implemented")
}
func (UnimplementedAdminAgencyOpeningServiceServer) ReviewAgencyOpeningApplication(context.Context, *ReviewAgencyOpeningApplicationRequest) (*ReviewAgencyOpeningApplicationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReviewAgencyOpeningApplication not implemented")
}
func (UnimplementedAdminAgencyOpeningServiceServer) mustEmbedUnimplementedAdminAgencyOpeningServiceServer() {
}
func (UnimplementedAdminAgencyOpeningServiceServer) testEmbeddedByValue() {}
@ -5222,6 +5238,24 @@ func _AdminAgencyOpeningService_ListAgencyOpeningApplications_Handler(srv interf
return interceptor(ctx, in, info, handler)
}
func _AdminAgencyOpeningService_ReviewAgencyOpeningApplication_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReviewAgencyOpeningApplicationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminAgencyOpeningServiceServer).ReviewAgencyOpeningApplication(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminAgencyOpeningService_ReviewAgencyOpeningApplication_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminAgencyOpeningServiceServer).ReviewAgencyOpeningApplication(ctx, req.(*ReviewAgencyOpeningApplicationRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminAgencyOpeningService_ServiceDesc is the grpc.ServiceDesc for AdminAgencyOpeningService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -5253,6 +5287,10 @@ var AdminAgencyOpeningService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListAgencyOpeningApplications",
Handler: _AdminAgencyOpeningService_ListAgencyOpeningApplications_Handler,
},
{
MethodName: "ReviewAgencyOpeningApplication",
Handler: _AdminAgencyOpeningService_ReviewAgencyOpeningApplication_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",

View File

@ -48,6 +48,7 @@ type Client interface {
UpdateAgencyOpeningCycle(ctx context.Context, req *activityv1.UpsertAgencyOpeningCycleRequest) (*activityv1.UpsertAgencyOpeningCycleResponse, error)
SetAgencyOpeningCycleStatus(ctx context.Context, req *activityv1.SetAgencyOpeningCycleStatusRequest) (*activityv1.SetAgencyOpeningCycleStatusResponse, error)
ListAgencyOpeningApplications(ctx context.Context, req *activityv1.ListAgencyOpeningApplicationsRequest) (*activityv1.ListAgencyOpeningApplicationsResponse, error)
ReviewAgencyOpeningApplication(ctx context.Context, req *activityv1.ReviewAgencyOpeningApplicationRequest) (*activityv1.ReviewAgencyOpeningApplicationResponse, error)
GetCPWeeklyRankConfig(ctx context.Context, req *activityv1.GetCPWeeklyRankConfigRequest) (*activityv1.GetCPWeeklyRankConfigResponse, error)
UpdateCPWeeklyRankConfig(ctx context.Context, req *activityv1.UpdateCPWeeklyRankConfigRequest) (*activityv1.UpdateCPWeeklyRankConfigResponse, error)
ListCPWeeklyRankSettlements(ctx context.Context, req *activityv1.ListCPWeeklyRankSettlementsRequest) (*activityv1.ListCPWeeklyRankSettlementsResponse, error)
@ -258,6 +259,10 @@ func (c *GRPCClient) ListAgencyOpeningApplications(ctx context.Context, req *act
return c.agencyOpeningClient.ListAgencyOpeningApplications(ctx, req)
}
func (c *GRPCClient) ReviewAgencyOpeningApplication(ctx context.Context, req *activityv1.ReviewAgencyOpeningApplicationRequest) (*activityv1.ReviewAgencyOpeningApplicationResponse, error) {
return c.agencyOpeningClient.ReviewAgencyOpeningApplication(ctx, req)
}
func (c *GRPCClient) GetCPWeeklyRankConfig(ctx context.Context, req *activityv1.GetCPWeeklyRankConfigRequest) (*activityv1.GetCPWeeklyRankConfigResponse, error) {
return c.cpWeeklyRankClient.GetCPWeeklyRankConfig(ctx, req)
}

View File

@ -81,6 +81,10 @@ type applicationDTO struct {
WalletTransactionID string `json:"wallet_transaction_id"`
FailureReason string `json:"failure_reason"`
AppliedAtMS int64 `json:"applied_at_ms"`
ApprovedAtMS int64 `json:"approved_at_ms"`
ScoreStartMS int64 `json:"score_start_ms"`
ScoreEndMS int64 `json:"score_end_ms"`
ReviewedByAdminID int64 `json:"reviewed_by_admin_id"`
GrantedAtMS int64 `json:"granted_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
@ -201,6 +205,23 @@ func (h *Handler) ListApplications(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) ApproveApplication(c *gin.Context) {
applicationID := strings.TrimSpace(c.Param("application_id"))
resp, err := h.activity.ReviewAgencyOpeningApplication(c.Request.Context(), &activityv1.ReviewAgencyOpeningApplicationRequest{
Meta: h.meta(c),
ApplicationId: applicationID,
Action: "approve",
OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := applicationFromProto(resp.GetApplication())
shared.OperationLogWithResourceID(c, h.audit, "approve-agency-opening-application", "agency_opening_applications", item.ApplicationID, "success", "")
response.OK(c, item)
}
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: middleware.CurrentRequestID(c),
@ -288,6 +309,10 @@ func applicationFromProto(item *activityv1.AgencyOpeningApplication) application
WalletTransactionID: item.GetWalletTransactionId(),
FailureReason: item.GetFailureReason(),
AppliedAtMS: item.GetAppliedAtMs(),
ApprovedAtMS: item.GetApprovedAtMs(),
ScoreStartMS: item.GetScoreStartMs(),
ScoreEndMS: item.GetScoreEndMs(),
ReviewedByAdminID: item.GetReviewedByAdminId(),
GrantedAtMS: item.GetGrantedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}

View File

@ -0,0 +1,102 @@
package agencyopening
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/middleware"
activityv1 "hyapp.local/api/proto/activity/v1"
"github.com/gin-gonic/gin"
)
func TestApproveApplicationForwardsReviewRequest(t *testing.T) {
activity := &fakeAgencyOpeningActivityClient{}
router := newAgencyOpeningHandlerTestRouter(New(activity, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/activity/agency-opening/applications/application-1/approve", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
}
if activity.lastReview == nil ||
activity.lastReview.GetApplicationId() != "application-1" ||
activity.lastReview.GetAction() != "approve" ||
activity.lastReview.GetOperatorAdminId() != 7 ||
activity.lastReview.GetMeta().GetAppCode() != "lalu" ||
activity.lastReview.GetMeta().GetCaller() != "admin-server" {
t.Fatalf("review request mismatch: %+v", activity.lastReview)
}
var envelope struct {
Code int `json:"code"`
Data struct {
ApplicationID string `json:"application_id"`
Status string `json:"status"`
ApprovedAtMS int64 `json:"approved_at_ms"`
ScoreStartMS int64 `json:"score_start_ms"`
ScoreEndMS int64 `json:"score_end_ms"`
ReviewedByAdminID int64 `json:"reviewed_by_admin_id"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != 0 ||
envelope.Data.ApplicationID != "application-1" ||
envelope.Data.Status != "approved" ||
envelope.Data.ApprovedAtMS != 2000 ||
envelope.Data.ScoreStartMS != 2000 ||
envelope.Data.ScoreEndMS != 2000+24*60*60*1000 ||
envelope.Data.ReviewedByAdminID != 7 {
t.Fatalf("approve response mismatch: %+v", envelope)
}
}
func newAgencyOpeningHandlerTestRouter(handler *Handler) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
// 这里模拟后台鉴权中间件已经把 app_code、request_id 和操作者写入上下文,测试只关注接口参数透传。
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu"))
c.Set(middleware.ContextRequestID, "agency-opening-handler-test")
c.Set(middleware.ContextUserID, uint(7))
c.Set(middleware.ContextUsername, "tester")
c.Next()
})
router.POST("/admin/activity/agency-opening/applications/:application_id/approve", handler.ApproveApplication)
return router
}
type fakeAgencyOpeningActivityClient struct {
activityclient.Client
lastReview *activityv1.ReviewAgencyOpeningApplicationRequest
}
func (f *fakeAgencyOpeningActivityClient) ReviewAgencyOpeningApplication(_ context.Context, req *activityv1.ReviewAgencyOpeningApplicationRequest) (*activityv1.ReviewAgencyOpeningApplicationResponse, error) {
f.lastReview = req
return &activityv1.ReviewAgencyOpeningApplicationResponse{
Application: &activityv1.AgencyOpeningApplication{
ApplicationId: req.GetApplicationId(),
CycleId: "cycle-1",
AgencyId: 8001,
AgencyOwnerUserId: 42001,
AgencyName: "Window Agency",
HostCount: 12,
Status: "approved",
AppliedAtMs: 1000,
ApprovedAtMs: 2000,
ScoreStartMs: 2000,
ScoreEndMs: 2000 + int64((24 * 60 * 60 * 1000)),
ReviewedByAdminId: req.GetOperatorAdminId(),
},
}, nil
}

View File

@ -16,4 +16,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.PUT("/admin/activity/agency-opening/cycles/:cycle_id", middleware.RequirePermission("agency-opening:update"), h.UpdateCycle)
protected.PATCH("/admin/activity/agency-opening/cycles/:cycle_id/status", middleware.RequirePermission("agency-opening:update"), h.SetCycleStatus)
protected.GET("/admin/activity/agency-opening/applications", middleware.RequirePermission("agency-opening:view"), h.ListApplications)
protected.POST("/admin/activity/agency-opening/applications/:application_id/approve", middleware.RequirePermission("agency-opening:update"), h.ApproveApplication)
}

View File

@ -1352,12 +1352,13 @@ CREATE TABLE IF NOT EXISTS agency_opening_cycles (
CREATE TABLE IF NOT EXISTS agency_opening_cycle_rewards (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
cycle_id VARCHAR(96) NOT NULL COMMENT '代理开业周期 ID',
rank_no INT NOT NULL COMMENT '榜单名次',
rank_no INT NOT NULL COMMENT '档位序号',
threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '命中该档需要的代理房间流水金币',
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '奖励金币',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, cycle_id, rank_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业榜单奖励';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理开业流水奖励档位';
CREATE TABLE IF NOT EXISTS agency_opening_applications (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
@ -1368,14 +1369,19 @@ CREATE TABLE IF NOT EXISTS agency_opening_applications (
agency_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '代理名称快照',
host_count INT NOT NULL DEFAULT 0 COMMENT '申请时有效主播数快照',
agency_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '代理创建时间快照',
status VARCHAR(32) NOT NULL DEFAULT 'applied' COMMENT 'applied/pending/granted/failed',
status VARCHAR(32) NOT NULL DEFAULT 'applied' COMMENT 'applied/approved/pending/granted/failed/expired',
score_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '周期内开业流水金币',
reward_rank_no INT NOT NULL DEFAULT 0 COMMENT '结算榜单名次',
reward_threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '结算命中的流水阈值',
reward_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '结算奖励金币',
wallet_command_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包幂等命令',
wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
applied_at_ms BIGINT NOT NULL COMMENT '申请时间',
approved_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '后台同意时间',
score_start_ms BIGINT NOT NULL DEFAULT 0 COMMENT '计分开始时间',
score_end_ms BIGINT NOT NULL DEFAULT 0 COMMENT '计分截止时间',
reviewed_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '审核管理员',
granted_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发奖时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',

View File

@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS agency_opening_applications (
agency_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '代理名称快照',
host_count INT NOT NULL DEFAULT 0 COMMENT '申请时有效主播数快照',
agency_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '代理创建时间快照',
status VARCHAR(32) NOT NULL DEFAULT 'applied' COMMENT 'applied/pending/granted/failed',
status VARCHAR(32) NOT NULL DEFAULT 'applied' COMMENT 'applied/approved/pending/granted/failed/expired',
score_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '周期内开业流水金币',
reward_rank_no INT NOT NULL DEFAULT 0 COMMENT '结算命中的档位序号',
reward_threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '结算命中的流水阈值',
@ -49,6 +49,10 @@ CREATE TABLE IF NOT EXISTS agency_opening_applications (
wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
applied_at_ms BIGINT NOT NULL COMMENT '申请时间',
approved_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '后台同意时间',
score_start_ms BIGINT NOT NULL DEFAULT 0 COMMENT '计分开始时间',
score_end_ms BIGINT NOT NULL DEFAULT 0 COMMENT '计分截止时间',
reviewed_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '审核管理员',
granted_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发奖时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',

View File

@ -9,10 +9,12 @@ const (
StatusSettling = "settling"
StatusSettled = "settled"
ApplicationStatusApplied = "applied"
ApplicationStatusPending = "pending"
ApplicationStatusGranted = "granted"
ApplicationStatusFailed = "failed"
ApplicationStatusApplied = "applied"
ApplicationStatusApproved = "approved"
ApplicationStatusPending = "pending"
ApplicationStatusGranted = "granted"
ApplicationStatusFailed = "failed"
ApplicationStatusExpired = "expired"
EventStatusConsumed = "consumed"
EventStatusDuplicate = "duplicate"
@ -86,6 +88,10 @@ type Application struct {
WalletTransactionID string
FailureReason string
AppliedAtMS int64
ApprovedAtMS int64
ScoreStartMS int64
ScoreEndMS int64
ReviewedByAdminID int64
GrantedAtMS int64
CreatedAtMS int64
UpdatedAtMS int64

View File

@ -27,6 +27,7 @@ type Repository interface {
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)
@ -163,6 +164,21 @@ func (s *Service) Apply(ctx context.Context, userID int64, commandID string) (do
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

View File

@ -141,6 +141,9 @@ func (r *agencyOpeningFakeRepo) GetAgencyOpeningApplicationByOwner(_ context.Con
func (r *agencyOpeningFakeRepo) CreateAgencyOpeningApplication(context.Context, domain.Cycle, domain.AgencySnapshot, int64) (domain.Application, bool, error) {
return domain.Application{}, false, nil
}
func (r *agencyOpeningFakeRepo) ApproveAgencyOpeningApplication(context.Context, string, int64, int64) (domain.Application, error) {
return domain.Application{}, nil
}
func (r *agencyOpeningFakeRepo) ListAgencyOpeningApplications(context.Context, domain.ApplicationQuery) ([]domain.Application, int64, error) {
return nil, 0, nil
}

View File

@ -44,7 +44,7 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error {
agency_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '代理名称快照',
host_count INT NOT NULL DEFAULT 0 COMMENT '申请时有效主播数快照',
agency_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '代理创建时间快照',
status VARCHAR(32) NOT NULL DEFAULT 'applied' COMMENT 'applied/pending/granted/failed',
status VARCHAR(32) NOT NULL DEFAULT 'applied' COMMENT 'applied/approved/pending/granted/failed/expired',
score_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '历史字段名周期内开业房间贡献值 HeatValue',
reward_rank_no INT NOT NULL DEFAULT 0 COMMENT '结算命中的档位序号',
reward_threshold_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '结算命中的流水阈值',
@ -53,6 +53,10 @@ func (r *Repository) ensureAgencyOpeningTables(ctx context.Context) error {
wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
failure_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '失败原因',
applied_at_ms BIGINT NOT NULL COMMENT '申请时间',
approved_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '后台同意时间',
score_start_ms BIGINT NOT NULL DEFAULT 0 COMMENT '计分开始时间',
score_end_ms BIGINT NOT NULL DEFAULT 0 COMMENT '计分截止时间',
reviewed_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '审核管理员',
granted_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '发奖时间',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
@ -105,6 +109,34 @@ func (r *Repository) ensureAgencyOpeningRewardTierColumns(ctx context.Context) e
return err
}
}
if exists, err := r.columnExists(ctx, "agency_opening_applications", "approved_at_ms"); err != nil {
return err
} else if !exists {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_applications ADD COLUMN approved_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '后台同意时间' AFTER applied_at_ms`); err != nil {
return err
}
}
if exists, err := r.columnExists(ctx, "agency_opening_applications", "score_start_ms"); err != nil {
return err
} else if !exists {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_applications ADD COLUMN score_start_ms BIGINT NOT NULL DEFAULT 0 COMMENT '计分开始时间' AFTER approved_at_ms`); err != nil {
return err
}
}
if exists, err := r.columnExists(ctx, "agency_opening_applications", "score_end_ms"); err != nil {
return err
} else if !exists {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_applications ADD COLUMN score_end_ms BIGINT NOT NULL DEFAULT 0 COMMENT '计分截止时间' AFTER score_start_ms`); err != nil {
return err
}
}
if exists, err := r.columnExists(ctx, "agency_opening_applications", "reviewed_by_admin_id"); err != nil {
return err
} else if !exists {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE agency_opening_applications ADD COLUMN reviewed_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '审核管理员' AFTER score_end_ms`); err != nil {
return err
}
}
return nil
}

View File

@ -217,8 +217,8 @@ func (r *Repository) CreateAgencyOpeningApplication(ctx context.Context, cycle d
INSERT IGNORE INTO agency_opening_applications (
app_code, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms,
status, score_coin_amount, reward_rank_no, reward_threshold_coin_spent, reward_coin_amount, wallet_command_id, wallet_transaction_id, failure_reason,
applied_at_ms, granted_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, '', '', '', ?, 0, ?, ?)`,
applied_at_ms, approved_at_ms, score_start_ms, score_end_ms, reviewed_by_admin_id, granted_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, '', '', '', ?, 0, 0, 0, 0, 0, ?, ?)`,
appcode.FromContext(ctx), applicationID, cycle.CycleID, agency.AgencyID, agency.OwnerUserID, agency.Name, agency.HostCount, agency.AgencyCreatedAtMS,
domain.ApplicationStatusApplied, nowMS, nowMS, nowMS)
if err != nil {
@ -229,6 +229,71 @@ func (r *Repository) CreateAgencyOpeningApplication(ctx context.Context, cycle d
return item, affected > 0 && ok, err
}
func (r *Repository) ApproveAgencyOpeningApplication(ctx context.Context, applicationID string, operatorAdminID int64, nowMS int64) (domain.Application, error) {
if r == nil || r.db == nil {
return domain.Application{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return domain.Application{}, err
}
defer tx.Rollback()
var app domain.Application
var cycleEndMS int64
var cycleStatus string
err = tx.QueryRowContext(ctx, agencyOpeningApplicationSelect()+`
WHERE app_code = ? AND application_id = ?
FOR UPDATE`, appcode.FromContext(ctx), strings.TrimSpace(applicationID)).Scan(
&app.AppCode, &app.ApplicationID, &app.CycleID, &app.AgencyID, &app.AgencyOwnerUserID, &app.AgencyName,
&app.HostCount, &app.AgencyCreatedAtMS, &app.Status, &app.ScoreCoinAmount, &app.RewardRankNo, &app.RewardThresholdCoin, &app.RewardCoinAmount,
&app.WalletCommandID, &app.WalletTransactionID, &app.FailureReason, &app.AppliedAtMS, &app.ApprovedAtMS, &app.ScoreStartMS, &app.ScoreEndMS,
&app.ReviewedByAdminID, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.Application{}, xerr.New(xerr.NotFound, "agency opening application not found")
}
return domain.Application{}, err
}
if err := tx.QueryRowContext(ctx, `
SELECT status, end_ms
FROM agency_opening_cycles
WHERE app_code = ? AND cycle_id = ? AND activity_code = ?`, appcode.FromContext(ctx), app.CycleID, domain.ActivityCode).Scan(&cycleStatus, &cycleEndMS); err != nil {
return domain.Application{}, err
}
if app.Status == domain.ApplicationStatusApproved {
if err := tx.Commit(); err != nil {
return domain.Application{}, err
}
return r.getAgencyOpeningApplication(ctx, app.ApplicationID)
}
if app.Status != domain.ApplicationStatusApplied {
return domain.Application{}, xerr.New(xerr.Conflict, "application is not waiting for approval")
}
if cycleStatus != domain.StatusActive {
return domain.Application{}, xerr.New(xerr.Conflict, "activity is not active")
}
scoreEndMS := nowMS + int64((24 * time.Hour).Milliseconds())
if cycleEndMS > 0 && cycleEndMS < scoreEndMS {
scoreEndMS = cycleEndMS
}
if scoreEndMS <= nowMS {
return domain.Application{}, xerr.New(xerr.Conflict, "activity period is already ended")
}
if _, err := tx.ExecContext(ctx, `
UPDATE agency_opening_applications
SET status = ?, approved_at_ms = ?, score_start_ms = ?, score_end_ms = ?, reviewed_by_admin_id = ?, score_coin_amount = 0,
failure_reason = '', updated_at_ms = ?
WHERE app_code = ? AND application_id = ? AND status = ?`,
domain.ApplicationStatusApproved, nowMS, nowMS, scoreEndMS, operatorAdminID, nowMS,
appcode.FromContext(ctx), app.ApplicationID, domain.ApplicationStatusApplied); err != nil {
return domain.Application{}, err
}
if err := tx.Commit(); err != nil {
return domain.Application{}, err
}
return r.getAgencyOpeningApplication(ctx, app.ApplicationID)
}
func (r *Repository) ListAgencyOpeningApplications(ctx context.Context, query domain.ApplicationQuery) ([]domain.Application, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -274,17 +339,18 @@ func (r *Repository) ConsumeAgencyOpeningGiftEvent(ctx context.Context, event do
err = tx.QueryRowContext(ctx, `
SELECT app.app_code, app.application_id, app.cycle_id, app.agency_id, app.agency_owner_user_id, app.agency_name, app.host_count, app.agency_created_at_ms,
app.status, app.score_coin_amount, app.reward_rank_no, app.reward_threshold_coin_spent, app.reward_coin_amount, app.wallet_command_id, app.wallet_transaction_id, app.failure_reason,
app.applied_at_ms, app.granted_at_ms, app.created_at_ms, app.updated_at_ms
app.applied_at_ms, app.approved_at_ms, app.score_start_ms, app.score_end_ms, app.reviewed_by_admin_id, app.granted_at_ms, app.created_at_ms, app.updated_at_ms
FROM agency_opening_applications app
INNER JOIN agency_opening_cycles cycle ON cycle.app_code = app.app_code AND cycle.cycle_id = app.cycle_id
WHERE app.app_code = ? AND app.agency_owner_user_id = ? AND app.status IN (?, ?, ?) AND app.applied_at_ms <= ?
WHERE app.app_code = ? AND app.agency_owner_user_id = ? AND app.status = ? AND app.score_start_ms <= ? AND app.score_end_ms > ?
AND cycle.activity_code = ? AND cycle.status IN (?, ?) AND cycle.start_ms <= ? AND cycle.end_ms > ?
ORDER BY app.applied_at_ms DESC
LIMIT 1`, appcode.FromContext(ctx), event.RoomOwnerUserID, domain.ApplicationStatusApplied, domain.ApplicationStatusPending, domain.ApplicationStatusFailed, event.OccurredAtMS,
LIMIT 1`, appcode.FromContext(ctx), event.RoomOwnerUserID, domain.ApplicationStatusApproved, event.OccurredAtMS, event.OccurredAtMS,
domain.ActivityCode, domain.StatusActive, domain.StatusSettling, event.OccurredAtMS, event.OccurredAtMS).Scan(
&app.AppCode, &app.ApplicationID, &app.CycleID, &app.AgencyID, &app.AgencyOwnerUserID, &app.AgencyName,
&app.HostCount, &app.AgencyCreatedAtMS, &app.Status, &app.ScoreCoinAmount, &app.RewardRankNo, &app.RewardThresholdCoin, &app.RewardCoinAmount,
&app.WalletCommandID, &app.WalletTransactionID, &app.FailureReason, &app.AppliedAtMS, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS)
&app.WalletCommandID, &app.WalletTransactionID, &app.FailureReason, &app.AppliedAtMS, &app.ApprovedAtMS, &app.ScoreStartMS, &app.ScoreEndMS,
&app.ReviewedByAdminID, &app.GrantedAtMS, &app.CreatedAtMS, &app.UpdatedAtMS)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, tx.Commit()
@ -370,10 +436,18 @@ func (r *Repository) PrepareAgencyOpeningSettlements(ctx context.Context, cycle
return nil, err
}
defer tx.Rollback()
// 周期结算时仍未后台同意的申请没有有效 24 小时计分窗口,直接过期,不进入发奖队列。
if _, err := tx.ExecContext(ctx, `
UPDATE agency_opening_applications
SET status = ?, failure_reason = 'not approved before cycle ended', updated_at_ms = ?
WHERE app_code = ? AND cycle_id = ? AND status = ?`,
domain.ApplicationStatusExpired, nowMS, appcode.FromContext(ctx), cycle.CycleID, domain.ApplicationStatusApplied); err != nil {
return nil, err
}
rows, err := tx.QueryContext(ctx, agencyOpeningApplicationSelect()+`
WHERE app_code = ? AND cycle_id = ? AND status IN (?, ?)
ORDER BY score_coin_amount DESC, applied_at_ms ASC, agency_id ASC`,
appcode.FromContext(ctx), cycle.CycleID, domain.ApplicationStatusApplied, domain.ApplicationStatusFailed)
appcode.FromContext(ctx), cycle.CycleID, domain.ApplicationStatusApproved, domain.ApplicationStatusFailed)
if err != nil {
return nil, err
}
@ -405,7 +479,7 @@ func (r *Repository) PrepareAgencyOpeningSettlements(ctx context.Context, cycle
WHERE app_code = ? AND application_id = ? AND status IN (?, ?)`,
domain.ApplicationStatusGranted, domain.ApplicationStatusPending,
reward.RankNo, reward.ThresholdCoinSpent, reward.RewardCoinAmount, walletCommandID, nowMS,
appcode.FromContext(ctx), application.ApplicationID, domain.ApplicationStatusApplied, domain.ApplicationStatusFailed); err != nil {
appcode.FromContext(ctx), application.ApplicationID, domain.ApplicationStatusApproved, domain.ApplicationStatusFailed); err != nil {
return nil, err
}
}
@ -578,7 +652,7 @@ func agencyOpeningApplicationWhere(ctx context.Context, query domain.Application
func agencyOpeningApplicationSelect() string {
return `SELECT app_code, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms,
status, score_coin_amount, reward_rank_no, reward_threshold_coin_spent, reward_coin_amount, wallet_command_id, wallet_transaction_id, failure_reason,
applied_at_ms, granted_at_ms, created_at_ms, updated_at_ms FROM agency_opening_applications`
applied_at_ms, approved_at_ms, score_start_ms, score_end_ms, reviewed_by_admin_id, granted_at_ms, created_at_ms, updated_at_ms FROM agency_opening_applications`
}
func scanAgencyOpeningCycle(row interface{ Scan(...any) error }) (domain.Cycle, error) {
@ -593,7 +667,8 @@ func scanAgencyOpeningApplication(row interface{ Scan(...any) error }) (domain.A
var item domain.Application
err := row.Scan(&item.AppCode, &item.ApplicationID, &item.CycleID, &item.AgencyID, &item.AgencyOwnerUserID, &item.AgencyName,
&item.HostCount, &item.AgencyCreatedAtMS, &item.Status, &item.ScoreCoinAmount, &item.RewardRankNo, &item.RewardThresholdCoin, &item.RewardCoinAmount,
&item.WalletCommandID, &item.WalletTransactionID, &item.FailureReason, &item.AppliedAtMS, &item.GrantedAtMS, &item.CreatedAtMS, &item.UpdatedAtMS)
&item.WalletCommandID, &item.WalletTransactionID, &item.FailureReason, &item.AppliedAtMS, &item.ApprovedAtMS, &item.ScoreStartMS, &item.ScoreEndMS,
&item.ReviewedByAdminID, &item.GrantedAtMS, &item.CreatedAtMS, &item.UpdatedAtMS)
return item, err
}

View File

@ -0,0 +1,114 @@
package mysql
import (
"context"
"testing"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
domain "hyapp/services/activity-service/internal/domain/agencyopening"
)
func TestAgencyOpeningScoresOnlyAfterApprovalWithinWindow(t *testing.T) {
schema := mysqlschema.New(t, mysqlschema.Config{
EnvVar: "ACTIVITY_SERVICE_MYSQL_TEST_DSN",
InitDBPath: mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_activity_service.sql"),
DatabasePrefix: "hyapp_activity_agency_opening_test",
})
repository, err := Open(context.Background(), schema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
ctx := appcode.WithContext(context.Background(), "lalu")
const (
cycleID = "cycle-window"
applicationID = "application-window"
ownerUserID = int64(42001)
nowMS = int64(10_000)
)
_, err = repository.db.ExecContext(ctx, `
INSERT INTO agency_opening_cycles (
app_code, cycle_id, activity_code, title, status, start_ms, end_ms, min_host_count, max_agency_age_days,
created_by_admin_id, updated_by_admin_id, settled_at_ms, locked_by, lock_until_ms, created_at_ms, updated_at_ms
) VALUES ('lalu', ?, ?, 'window test', ?, 1, ?, 10, 30, 1, 1, 0, '', 0, ?, ?)`,
cycleID, domain.ActivityCode, domain.StatusActive, nowMS+int64((48*60*60*1000)), nowMS, nowMS,
)
if err != nil {
t.Fatalf("seed cycle: %v", err)
}
_, err = repository.db.ExecContext(ctx, `
INSERT INTO agency_opening_applications (
app_code, application_id, cycle_id, agency_id, agency_owner_user_id, agency_name, host_count, agency_created_at_ms,
status, score_coin_amount, reward_rank_no, reward_threshold_coin_spent, reward_coin_amount,
wallet_command_id, wallet_transaction_id, failure_reason, applied_at_ms, approved_at_ms,
score_start_ms, score_end_ms, reviewed_by_admin_id, granted_at_ms, created_at_ms, updated_at_ms
) VALUES ('lalu', ?, ?, 8001, ?, 'Window Agency', 12, 1, ?, 0, 0, 0, 0, '', '', '', ?, 0, 0, 0, 0, 0, ?, ?)`,
applicationID, cycleID, ownerUserID, domain.ApplicationStatusApplied, nowMS-1000, nowMS, nowMS,
)
if err != nil {
t.Fatalf("seed application: %v", err)
}
beforeApproval, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{
EventID: "gift-before-approval",
RoomOwnerUserID: ownerUserID,
TargetUserID: 99,
CoinSpent: 700,
OccurredAtMS: nowMS + 100,
}, nowMS+100)
if err != nil {
t.Fatalf("consume before approval: %v", err)
}
if beforeApproval.Status != domain.EventStatusSkipped {
t.Fatalf("event before approval must be skipped, got %s", beforeApproval.Status)
}
approved, err := repository.ApproveAgencyOpeningApplication(ctx, applicationID, 77, nowMS+200)
if err != nil {
t.Fatalf("approve application: %v", err)
}
wantScoreEnd := nowMS + 200 + int64((24 * 60 * 60 * 1000))
if approved.Status != domain.ApplicationStatusApproved || approved.ScoreStartMS != nowMS+200 || approved.ScoreEndMS != wantScoreEnd || approved.ReviewedByAdminID != 77 {
t.Fatalf("unexpected approved application: %+v", approved)
}
insideWindow, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{
EventID: "gift-inside-window",
RoomOwnerUserID: ownerUserID,
TargetUserID: 100,
CoinSpent: 900,
OccurredAtMS: nowMS + 300,
}, nowMS+300)
if err != nil {
t.Fatalf("consume inside window: %v", err)
}
if insideWindow.Status != domain.EventStatusConsumed {
t.Fatalf("event inside approved window must be consumed, got %s", insideWindow.Status)
}
afterWindow, err := repository.ConsumeAgencyOpeningGiftEvent(ctx, domain.GiftEvent{
EventID: "gift-after-window",
RoomOwnerUserID: ownerUserID,
TargetUserID: 101,
CoinSpent: 1200,
OccurredAtMS: wantScoreEnd,
}, wantScoreEnd)
if err != nil {
t.Fatalf("consume after window: %v", err)
}
if afterWindow.Status != domain.EventStatusSkipped {
t.Fatalf("event at score_end must be skipped, got %s", afterWindow.Status)
}
var score int64
if err := repository.db.QueryRowContext(ctx, `
SELECT score_coin_amount FROM agency_opening_applications
WHERE app_code = 'lalu' AND application_id = ?`, applicationID).Scan(&score); err != nil {
t.Fatalf("read final score: %v", err)
}
if score != 900 {
t.Fatalf("only the inside-window event should score, got %d", score)
}
}

View File

@ -0,0 +1,216 @@
package grpc
import (
"context"
"net"
"testing"
"time"
grpcpkg "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/proto"
activityv1 "hyapp.local/api/proto/activity/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
agencydomain "hyapp/services/activity-service/internal/domain/agencyopening"
agencyservice "hyapp/services/activity-service/internal/service/agencyopening"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
"hyapp/services/activity-service/internal/testutil/mysqltest"
)
func TestAgencyOpeningGRPCInterfaceFlow(t *testing.T) {
repository := mysqltest.NewRepository(t)
now := time.Now().UTC()
ownerUserID := int64(42001)
agencySvc := agencyservice.New(repository, nil, fakeAgencyOpeningFlowSource{
snapshot: agencydomain.AgencySnapshot{
AgencyID: 8001,
OwnerUserID: ownerUserID,
Name: "Window Agency",
HostCount: 12,
AgencyCreatedAtMS: now.Add(-time.Hour).UnixMilli(),
Status: "active",
},
}, &fakeAgencyOpeningFlowRoom{ownerUserID: ownerUserID})
conn := newAgencyOpeningFlowGRPCConn(t, agencySvc, repository)
adminClient := activityv1.NewAdminAgencyOpeningServiceClient(conn)
appClient := activityv1.NewAgencyOpeningServiceClient(conn)
roomEventClient := activityv1.NewRoomEventConsumerServiceClient(conn)
ctx := context.Background()
createdCycle, err := adminClient.CreateAgencyOpeningCycle(ctx, &activityv1.UpsertAgencyOpeningCycleRequest{
Meta: agencyOpeningFlowMeta("agency-opening-cycle-create"),
Cycle: &activityv1.AgencyOpeningCycle{
Title: "Agency Opening Interface Flow",
Status: "active",
StartMs: now.Add(-time.Minute).UnixMilli(),
EndMs: now.Add(48 * time.Hour).UnixMilli(),
MinHostCount: 10,
MaxAgencyAgeDays: 30,
Rewards: []*activityv1.AgencyOpeningReward{{
RankNo: 1,
ThresholdCoinSpent: 500,
RewardCoinAmount: 50,
}},
},
OperatorAdminId: 7,
})
if err != nil {
t.Fatalf("create agency opening cycle failed: %v", err)
}
if !createdCycle.GetCreated() || createdCycle.GetCycle().GetCycleId() == "" {
t.Fatalf("create cycle response mismatch: %+v", createdCycle)
}
beforeApply, err := appClient.GetAgencyOpeningStatus(ctx, &activityv1.GetAgencyOpeningStatusRequest{
Meta: agencyOpeningFlowMeta("agency-opening-status-before-apply"),
UserId: ownerUserID,
})
if err != nil {
t.Fatalf("get status before apply failed: %v", err)
}
if beforeApply.GetJoined() || !beforeApply.GetEligible() {
t.Fatalf("owner should be eligible before apply: %+v", beforeApply)
}
applied, err := appClient.ApplyAgencyOpening(ctx, &activityv1.ApplyAgencyOpeningRequest{
Meta: agencyOpeningFlowMeta("agency-opening-apply"),
UserId: ownerUserID,
CommandId: "cmd-agency-opening-flow-apply",
})
if err != nil {
t.Fatalf("apply agency opening failed: %v", err)
}
if !applied.GetCreated() || applied.GetApplication().GetStatus() != agencydomain.ApplicationStatusApplied || applied.GetApplication().GetScoreStartMs() != 0 {
t.Fatalf("apply response mismatch: %+v", applied)
}
consumeAgencyOpeningFlowGift(t, ctx, roomEventClient, "gift-before-approval", "room-flow-1", ownerUserID, now.UnixMilli(), 700)
approved, err := adminClient.ReviewAgencyOpeningApplication(ctx, &activityv1.ReviewAgencyOpeningApplicationRequest{
Meta: agencyOpeningFlowMeta("agency-opening-approve"),
ApplicationId: applied.GetApplication().GetApplicationId(),
Action: "approve",
OperatorAdminId: 7,
})
if err != nil {
t.Fatalf("approve agency opening failed: %v", err)
}
if approved.GetApplication().GetStatus() != agencydomain.ApplicationStatusApproved ||
approved.GetApplication().GetScoreStartMs() != approved.GetApplication().GetApprovedAtMs() ||
approved.GetApplication().GetScoreEndMs() <= approved.GetApplication().GetScoreStartMs() {
t.Fatalf("approve response should open a scoring window: %+v", approved)
}
insideWindowMS := approved.GetApplication().GetScoreStartMs() + int64(time.Second.Milliseconds())
consumeAgencyOpeningFlowGift(t, ctx, roomEventClient, "gift-inside-window", "room-flow-1", ownerUserID, insideWindowMS, 900)
afterInside, err := appClient.GetAgencyOpeningStatus(ctx, &activityv1.GetAgencyOpeningStatusRequest{
Meta: agencyOpeningFlowMeta("agency-opening-status-after-inside"),
UserId: ownerUserID,
})
if err != nil {
t.Fatalf("get status after inside-window gift failed: %v", err)
}
if afterInside.GetApplication().GetScoreCoinAmount() != 900 ||
afterInside.GetApplication().GetScoreEndMs() != approved.GetApplication().GetScoreEndMs() {
t.Fatalf("inside-window gift should be counted once: %+v", afterInside.GetApplication())
}
consumeAgencyOpeningFlowGift(t, ctx, roomEventClient, "gift-at-window-end", "room-flow-1", ownerUserID, approved.GetApplication().GetScoreEndMs(), 1200)
afterWindow, err := appClient.GetAgencyOpeningStatus(ctx, &activityv1.GetAgencyOpeningStatusRequest{
Meta: agencyOpeningFlowMeta("agency-opening-status-after-window"),
UserId: ownerUserID,
})
if err != nil {
t.Fatalf("get status after window-end gift failed: %v", err)
}
if afterWindow.GetApplication().GetScoreCoinAmount() != 900 {
t.Fatalf("window-end gift must not be counted: %+v", afterWindow.GetApplication())
}
}
func newAgencyOpeningFlowGRPCConn(t *testing.T, agencySvc *agencyservice.Service, repository *mysqltest.Repository) *grpcpkg.ClientConn {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
server := grpcpkg.NewServer()
activityv1.RegisterAgencyOpeningServiceServer(server, NewAgencyOpeningServer(agencySvc))
activityv1.RegisterAdminAgencyOpeningServiceServer(server, NewAdminAgencyOpeningServer(agencySvc))
broadcastSvc := broadcastservice.New(broadcastservice.Config{SuperGiftMinValue: 1_000_000_000}, repository, nil, nil)
activityv1.RegisterRoomEventConsumerServiceServer(server, NewBroadcastServer(broadcastSvc, nil, nil, nil, nil, agencySvc))
go func() {
_ = server.Serve(listener)
}()
t.Cleanup(func() {
server.Stop()
_ = listener.Close()
})
conn, err := grpcpkg.NewClient("passthrough:///agency-opening-flow-bufconn",
grpcpkg.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}),
grpcpkg.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dial agency opening flow grpc failed: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return conn
}
func consumeAgencyOpeningFlowGift(t *testing.T, ctx context.Context, client activityv1.RoomEventConsumerServiceClient, eventID string, roomID string, ownerUserID int64, occurredAtMS int64, giftValue int64) {
t.Helper()
body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{
SenderUserId: 10001,
TargetUserId: ownerUserID,
GiftValue: giftValue,
CoinSpent: giftValue,
})
if err != nil {
t.Fatalf("marshal room gift event failed: %v", err)
}
if _, err := client.ConsumeRoomEvent(ctx, &activityv1.ConsumeRoomEventRequest{
Meta: agencyOpeningFlowMeta("consume-" + eventID),
Envelope: &roomeventsv1.EventEnvelope{
EventId: eventID,
RoomId: roomID,
EventType: "RoomGiftSent",
OccurredAtMs: occurredAtMS,
AppCode: "lalu",
Body: body,
},
}); err != nil {
t.Fatalf("consume room event %s failed: %v", eventID, err)
}
}
func agencyOpeningFlowMeta(requestID string) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: requestID,
Caller: "agency-opening-interface-flow-test",
AppCode: "lalu",
SentAtMs: time.Now().UTC().UnixMilli(),
}
}
type fakeAgencyOpeningFlowSource struct {
snapshot agencydomain.AgencySnapshot
}
func (f fakeAgencyOpeningFlowSource) ResolveOwnerAgency(context.Context, int64) (agencydomain.AgencySnapshot, bool, error) {
return f.snapshot, true, nil
}
type fakeAgencyOpeningFlowRoom struct {
ownerUserID int64
}
func (r *fakeAgencyOpeningFlowRoom) AdminGetRoom(context.Context, *roomv1.AdminGetRoomRequest, ...grpcpkg.CallOption) (*roomv1.AdminGetRoomResponse, error) {
return &roomv1.AdminGetRoomResponse{Room: &roomv1.AdminRoomListItem{OwnerUserId: r.ownerUserID}}, nil
}

View File

@ -122,6 +122,18 @@ func (s *AdminAgencyOpeningServer) ListAgencyOpeningApplications(ctx context.Con
return agencyOpeningApplicationsResponse(items, total), nil
}
func (s *AdminAgencyOpeningServer) ReviewAgencyOpeningApplication(ctx context.Context, req *activityv1.ReviewAgencyOpeningApplicationRequest) (*activityv1.ReviewAgencyOpeningApplicationResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if req.GetAction() != "approve" {
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "review action is invalid"))
}
item, err := s.svc.ApproveApplication(ctx, req.GetApplicationId(), req.GetOperatorAdminId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.ReviewAgencyOpeningApplicationResponse{Application: agencyOpeningApplicationToProto(item)}, nil
}
func agencyOpeningCycleFromProto(item *activityv1.AgencyOpeningCycle) domain.Cycle {
if item == nil {
return domain.Cycle{}
@ -200,6 +212,10 @@ func agencyOpeningApplicationToProto(item domain.Application) *activityv1.Agency
WalletTransactionId: item.WalletTransactionID,
FailureReason: item.FailureReason,
AppliedAtMs: item.AppliedAtMS,
ApprovedAtMs: item.ApprovedAtMS,
ScoreStartMs: item.ScoreStartMS,
ScoreEndMs: item.ScoreEndMS,
ReviewedByAdminId: item.ReviewedByAdminID,
GrantedAtMs: item.GrantedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}

View File

@ -65,6 +65,7 @@ type agencyOpeningRecordData struct {
CreatedAt string `json:"created_at,omitempty"`
OpeningRevenue string `json:"opening_revenue,omitempty"`
Reward int64 `json:"reward"`
ScoreEndMS int64 `json:"score_end_ms,omitempty"`
}
type agencyOpeningApplicationData struct {
@ -84,6 +85,10 @@ type agencyOpeningApplicationData struct {
WalletTransactionID string `json:"wallet_transaction_id,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
AppliedAtMS int64 `json:"applied_at_ms"`
ApprovedAtMS int64 `json:"approved_at_ms,omitempty"`
ScoreStartMS int64 `json:"score_start_ms,omitempty"`
ScoreEndMS int64 `json:"score_end_ms,omitempty"`
ReviewedByAdminID int64 `json:"reviewed_by_admin_id,omitempty"`
GrantedAtMS int64 `json:"granted_at_ms,omitempty"`
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
}
@ -221,11 +226,8 @@ func agencyOpeningQualificationFromProto(status *activityv1.GetAgencyOpeningStat
agencyName = application.GetAgencyName()
}
}
openingStatus := "Not Joined"
if status.GetJoined() {
openingStatus = "Joined"
}
if !status.GetEnabled() {
openingStatus := agencyOpeningStatusLabel(application)
if !status.GetEnabled() && !status.GetJoined() {
openingStatus = "Disabled"
}
return agencyOpeningQualificationData{
@ -266,6 +268,7 @@ func agencyOpeningRecordFromProto(application *activityv1.AgencyOpeningApplicati
CreatedAt: agencyOpeningDate(application.GetAgencyCreatedAtMs()),
OpeningRevenue: fmt.Sprintf("%d", application.GetScoreCoinAmount()),
Reward: application.GetRewardCoinAmount(),
ScoreEndMS: application.GetScoreEndMs(),
}
}
@ -298,11 +301,37 @@ func agencyOpeningApplicationFromProto(item *activityv1.AgencyOpeningApplication
WalletTransactionID: item.GetWalletTransactionId(),
FailureReason: item.GetFailureReason(),
AppliedAtMS: item.GetAppliedAtMs(),
ApprovedAtMS: item.GetApprovedAtMs(),
ScoreStartMS: item.GetScoreStartMs(),
ScoreEndMS: item.GetScoreEndMs(),
ReviewedByAdminID: item.GetReviewedByAdminId(),
GrantedAtMS: item.GetGrantedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func agencyOpeningStatusLabel(application *activityv1.AgencyOpeningApplication) string {
if application == nil {
return "Not Joined"
}
switch application.GetStatus() {
case "applied":
return "Pending Review"
case "approved":
return "Approved"
case "pending":
return "Pending Reward"
case "granted":
return "Rewarded"
case "failed":
return "Reward Failed"
case "expired":
return "Expired"
default:
return "Joined"
}
}
func formatAgencyOpeningCoinLabel(value int64) string {
if value <= 0 {
return "0"
@ -322,7 +351,8 @@ func agencyOpeningRules(cycle *activityv1.AgencyOpeningCycle) []string {
}
return []string{
fmt.Sprintf("Agency must have more than %d hosts before applying.", cycle.GetMinHostCount()),
"Opening revenue comes from the applicant user's room gift COIN revenue during the configured period.",
"Opening revenue starts from admin approval and only counts the applicant user's room gift COIN revenue within the next 24 hours.",
"If the 24-hour scoring window passes the activity end time, the activity end time is used as the cutoff.",
"Reward tier is matched by opening revenue after the period ends; it is not based on Top ranking.",
"Rewards are granted automatically after the period ends.",
}

View File

@ -0,0 +1,215 @@
package activityapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
)
func TestGetAgencyOpeningStatusReturnsReviewWindowFields(t *testing.T) {
client := &fakeAgencyOpeningClient{statusResp: agencyOpeningApprovedStatusResponse()}
handler := New(Config{AgencyOpening: client})
request := httptest.NewRequest(http.MethodGet, "/api/v1/activities/agency-opening-event", nil)
request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "lalu"), 42001))
recorder := httptest.NewRecorder()
handler.getAgencyOpeningStatus(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
}
if client.lastStatus == nil || client.lastStatus.GetUserId() != 42001 || client.lastStatus.GetMeta().GetAppCode() != "lalu" {
t.Fatalf("status request mismatch: %+v", client.lastStatus)
}
var envelope struct {
Code string `json:"code"`
Data struct {
Joined bool `json:"joined"`
Qualification struct {
OpeningStatus string `json:"opening_status"`
} `json:"qualification"`
Application struct {
Status string `json:"status"`
ApprovedAtMS int64 `json:"approved_at_ms"`
ScoreStartMS int64 `json:"score_start_ms"`
ScoreEndMS int64 `json:"score_end_ms"`
ReviewedByAdminID int64 `json:"reviewed_by_admin_id"`
} `json:"application"`
Rules []string `json:"rules"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != "OK" || !envelope.Data.Joined || envelope.Data.Qualification.OpeningStatus != "Approved" {
t.Fatalf("status envelope mismatch: %+v", envelope)
}
if envelope.Data.Application.Status != "approved" ||
envelope.Data.Application.ApprovedAtMS != 2000 ||
envelope.Data.Application.ScoreStartMS != 2000 ||
envelope.Data.Application.ScoreEndMS != 2000+24*60*60*1000 ||
envelope.Data.Application.ReviewedByAdminID != 7 {
t.Fatalf("application fields mismatch: %+v", envelope.Data.Application)
}
if len(envelope.Data.Rules) < 2 || !strings.Contains(envelope.Data.Rules[1], "24 hours") {
t.Fatalf("rules should explain 24-hour scoring window: %+v", envelope.Data.Rules)
}
}
func TestApplyAgencyOpeningReturnsPendingReviewStatus(t *testing.T) {
client := &fakeAgencyOpeningClient{
applyResp: agencyOpeningApplyResponse(),
statusResp: agencyOpeningAppliedStatusResponse(),
}
handler := New(Config{AgencyOpening: client})
request := httptest.NewRequest(http.MethodPost, "/api/v1/activities/agency-opening-event/apply", strings.NewReader(`{"command_id":"cmd-apply-1"}`))
request = request.WithContext(auth.WithUserID(appcode.WithContext(request.Context(), "lalu"), 42001))
recorder := httptest.NewRecorder()
handler.applyAgencyOpening(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
}
if client.lastApply == nil || client.lastApply.GetUserId() != 42001 || client.lastApply.GetCommandId() != "cmd-apply-1" {
t.Fatalf("apply request mismatch: %+v", client.lastApply)
}
var envelope struct {
Code string `json:"code"`
Data struct {
Joined bool `json:"joined"`
Qualification struct {
OpeningStatus string `json:"opening_status"`
} `json:"qualification"`
Application struct {
Status string `json:"status"`
AppliedAtMS int64 `json:"applied_at_ms"`
ScoreStartMS int64 `json:"score_start_ms"`
} `json:"application"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != "OK" || !envelope.Data.Joined || envelope.Data.Qualification.OpeningStatus != "Pending Review" {
t.Fatalf("apply response should show pending review: %+v", envelope)
}
if envelope.Data.Application.Status != "applied" || envelope.Data.Application.AppliedAtMS != 1000 || envelope.Data.Application.ScoreStartMS != 0 {
t.Fatalf("apply application fields mismatch: %+v", envelope.Data.Application)
}
}
type fakeAgencyOpeningClient struct {
statusResp *activityv1.GetAgencyOpeningStatusResponse
applyResp *activityv1.ApplyAgencyOpeningResponse
lastStatus *activityv1.GetAgencyOpeningStatusRequest
lastApply *activityv1.ApplyAgencyOpeningRequest
}
func (f *fakeAgencyOpeningClient) GetAgencyOpeningStatus(_ context.Context, req *activityv1.GetAgencyOpeningStatusRequest) (*activityv1.GetAgencyOpeningStatusResponse, error) {
f.lastStatus = req
if f.statusResp != nil {
return f.statusResp, nil
}
return &activityv1.GetAgencyOpeningStatusResponse{}, nil
}
func (f *fakeAgencyOpeningClient) ApplyAgencyOpening(_ context.Context, req *activityv1.ApplyAgencyOpeningRequest) (*activityv1.ApplyAgencyOpeningResponse, error) {
f.lastApply = req
if f.applyResp != nil {
return f.applyResp, nil
}
return &activityv1.ApplyAgencyOpeningResponse{}, nil
}
func (f *fakeAgencyOpeningClient) ListAgencyOpeningLeaderboard(context.Context, *activityv1.ListAgencyOpeningApplicationsRequest) (*activityv1.ListAgencyOpeningApplicationsResponse, error) {
return &activityv1.ListAgencyOpeningApplicationsResponse{}, nil
}
func agencyOpeningApplyResponse() *activityv1.ApplyAgencyOpeningResponse {
return &activityv1.ApplyAgencyOpeningResponse{
Created: true,
Application: &activityv1.AgencyOpeningApplication{
ApplicationId: "application-1",
CycleId: "cycle-1",
AgencyId: 8001,
AgencyOwnerUserId: 42001,
AgencyName: "Window Agency",
HostCount: 12,
Status: "applied",
AppliedAtMs: 1000,
},
}
}
func agencyOpeningAppliedStatusResponse() *activityv1.GetAgencyOpeningStatusResponse {
resp := agencyOpeningBaseStatusResponse()
resp.Joined = true
resp.Eligible = false
resp.Application = agencyOpeningApplyResponse().GetApplication()
return resp
}
func agencyOpeningApprovedStatusResponse() *activityv1.GetAgencyOpeningStatusResponse {
resp := agencyOpeningBaseStatusResponse()
resp.Joined = true
resp.Eligible = false
resp.Application = &activityv1.AgencyOpeningApplication{
ApplicationId: "application-1",
CycleId: "cycle-1",
AgencyId: 8001,
AgencyOwnerUserId: 42001,
AgencyName: "Window Agency",
HostCount: 12,
Status: "approved",
ScoreCoinAmount: 900,
RewardThresholdCoinSpent: 500,
RewardCoinAmount: 50,
AppliedAtMs: 1000,
ApprovedAtMs: 2000,
ScoreStartMs: 2000,
ScoreEndMs: 2000 + int64((24 * 60 * 60 * 1000)),
ReviewedByAdminId: 7,
}
return resp
}
func agencyOpeningBaseStatusResponse() *activityv1.GetAgencyOpeningStatusResponse {
return &activityv1.GetAgencyOpeningStatusResponse{
ServerTimeMs: 3000,
Enabled: true,
Eligible: true,
Joined: false,
Cycle: &activityv1.AgencyOpeningCycle{
CycleId: "cycle-1",
Title: "Agency Opening",
Status: "active",
StartMs: 1,
EndMs: 2000 + int64((48 * 60 * 60 * 1000)),
MinHostCount: 10,
Rewards: []*activityv1.AgencyOpeningReward{{
RankNo: 1,
ThresholdCoinSpent: 500,
RewardCoinAmount: 50,
}},
},
Agency: &activityv1.AgencyOpeningAgencySnapshot{
AgencyId: 8001,
OwnerUserId: 42001,
Name: "Window Agency",
HostCount: 12,
AgencyCreatedAtMs: 1,
Status: "active",
},
}
}