package activitytemplate import ( "context" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/statisticsclient" "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" activityv1 "hyapp.local/api/proto/activity/v1" "github.com/gin-gonic/gin" "google.golang.org/grpc/codes" grpcstatus "google.golang.org/grpc/status" ) type Handler struct { activity activityclient.Client stats statisticsclient.ActivityTemplateDataClient users UserBatchGetter audit shared.OperationLogger } type UserBatchGetter interface { BatchGetUsers(context.Context, userclient.BatchGetUsersRequest) (map[int64]*userclient.User, error) } func New(activity activityclient.Client, audit shared.OperationLogger, dataClients ...statisticsclient.ActivityTemplateDataClient) *Handler { handler := &Handler{activity: activity, audit: audit} if len(dataClients) > 0 { handler.stats = dataClients[0] } return handler } func (h *Handler) BindUserBatchGetter(users UserBatchGetter) *Handler { if h != nil { h.users = users } return h } func (h *Handler) List(c *gin.Context) { options := shared.ListOptions(c) startMS, startOK := parseOptionalNonNegativeInt64Query(c, "start_ms") endMS, endOK := parseOptionalNonNegativeInt64Query(c, "end_ms") if !startOK || !endOK { response.BadRequest(c, "start_ms 或 end_ms 参数不正确") return } req := &activityv1.ListActivityTemplatesRequest{ Meta: h.meta(c), Keyword: strings.TrimSpace(options.Keyword), Status: strings.TrimSpace(options.Status), StartMs: startMS, EndMs: endMS, Page: int32(options.Page), PageSize: int32(options.PageSize), } if raw, exists := c.GetQuery("region_id"); exists { regionID, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil || regionID <= 0 { response.BadRequest(c, "region_id 参数不正确") return } req.RegionId = ®ionID } if raw, exists := c.GetQuery("all_regions"); exists { allRegions, err := strconv.ParseBool(strings.TrimSpace(raw)) if err != nil { response.BadRequest(c, "all_regions 参数不正确") return } req.AllRegions = &allRegions } resp, err := h.activity.ListActivityTemplates(c.Request.Context(), req) if err != nil { h.writeGRPCError(c, err, "获取活动模版失败") return } items := make([]templateSummaryDTO, 0, len(resp.GetTemplates())) for _, item := range resp.GetTemplates() { items = append(items, summaryFromProto(item)) } response.OK(c, pageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal(), ServerTimeMS: resp.GetServerTimeMs()}) } func (h *Handler) Create(c *gin.Context) { var req templateRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "活动模版参数不正确") return } resp, err := h.activity.CreateActivityTemplate(c.Request.Context(), &activityv1.CreateActivityTemplateRequest{ Meta: h.meta(c), Template: req.toProto(""), OperatorAdminId: int64(middleware.CurrentUserID(c)), }) if err != nil { h.writeGRPCError(c, err, "创建活动模版失败") return } item := templateFromProto(resp.GetTemplate()) shared.OperationLogWithResourceID(c, h.audit, "create-activity-template", "activity_templates", item.TemplateID, "success", "") response.Created(c, item) } func (h *Handler) Get(c *gin.Context) { templateID := strings.TrimSpace(c.Param("template_id")) resp, err := h.activity.GetActivityTemplate(c.Request.Context(), &activityv1.GetActivityTemplateRequest{Meta: h.meta(c), TemplateId: templateID}) if err != nil { h.writeGRPCError(c, err, "获取活动模版详情失败") return } response.OK(c, templateFromProto(resp.GetTemplate())) } func (h *Handler) Update(c *gin.Context) { var req templateRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "活动模版参数不正确") return } resp, err := h.activity.UpdateActivityTemplate(c.Request.Context(), &activityv1.UpdateActivityTemplateRequest{ Meta: h.meta(c), Template: req.toProto(strings.TrimSpace(c.Param("template_id"))), ExpectedRevision: req.ExpectedRevision, OperatorAdminId: int64(middleware.CurrentUserID(c)), }) if err != nil { h.writeGRPCError(c, err, "更新活动模版失败") return } item := templateFromProto(resp.GetTemplate()) shared.OperationLogWithResourceID(c, h.audit, "update-activity-template", "activity_templates", item.TemplateID, "success", "") response.OK(c, item) } func (h *Handler) SetStatus(c *gin.Context) { var req statusRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "活动模版状态参数不正确") return } templateID := strings.TrimSpace(c.Param("template_id")) resp, err := h.activity.SetActivityTemplateStatus(c.Request.Context(), &activityv1.SetActivityTemplateStatusRequest{ Meta: h.meta(c), TemplateId: templateID, Status: strings.TrimSpace(req.Status), ExpectedRevision: req.ExpectedRevision, OperatorAdminId: int64(middleware.CurrentUserID(c)), }) if err != nil { h.writeGRPCError(c, err, "更新活动模版状态失败") return } item := templateFromProto(resp.GetTemplate()) shared.OperationLogWithResourceID(c, h.audit, "set-activity-template-status", "activity_templates", item.TemplateID, "success", "") response.OK(c, item) } func (h *Handler) Delete(c *gin.Context) { expectedRevision := parseInt64Query(c, "revision") if expectedRevision <= 0 { response.BadRequest(c, "revision 参数不正确") return } templateID := strings.TrimSpace(c.Param("template_id")) resp, err := h.activity.DeleteActivityTemplate(c.Request.Context(), &activityv1.DeleteActivityTemplateRequest{ Meta: h.meta(c), TemplateId: templateID, ExpectedRevision: expectedRevision, OperatorAdminId: int64(middleware.CurrentUserID(c)), }) if err != nil { h.writeGRPCError(c, err, "删除活动模版失败") return } item := templateFromProto(resp.GetTemplate()) shared.OperationLogWithResourceID(c, h.audit, "archive-activity-template", "activity_templates", item.TemplateID, "success", "") response.OK(c, gin.H{"template": item, "archived": resp.GetArchived()}) } func (h *Handler) Versions(c *gin.Context) { options := shared.ListOptions(c) resp, err := h.activity.ListActivityTemplateVersions(c.Request.Context(), &activityv1.ListActivityTemplateVersionsRequest{ Meta: h.meta(c), TemplateId: strings.TrimSpace(c.Param("template_id")), Page: int32(options.Page), PageSize: int32(options.PageSize), }) if err != nil { h.writeGRPCError(c, err, "获取活动模版版本失败") return } items := make([]versionDTO, 0, len(resp.GetVersions())) for _, version := range resp.GetVersions() { items = append(items, versionDTO{ TemplateID: version.GetTemplateId(), VersionNo: version.GetVersionNo(), Snapshot: templateFromProto(version.GetSnapshot()), PublishedByAdminID: version.GetPublishedByAdminId(), PublishedAtMS: version.GetPublishedAtMs(), RuntimeFromMS: version.GetRuntimeFromMs(), RuntimeToMS: version.GetRuntimeToMs(), }) } response.OK(c, pageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } func (h *Handler) Clone(c *gin.Context) { var req cloneRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "复制活动模版参数不正确") return } resp, err := h.activity.CloneActivityTemplate(c.Request.Context(), &activityv1.CloneActivityTemplateRequest{ Meta: h.meta(c), SourceTemplateId: strings.TrimSpace(c.Param("template_id")), SourceVersion: req.SourceVersion, TemplateCode: strings.TrimSpace(req.TemplateCode), Name: strings.TrimSpace(req.Name), OperatorAdminId: int64(middleware.CurrentUserID(c)), }) if err != nil { h.writeGRPCError(c, err, "复制活动模版失败") return } item := templateFromProto(resp.GetTemplate()) shared.OperationLogWithResourceID(c, h.audit, "clone-activity-template", "activity_templates", item.TemplateID, "success", "") response.Created(c, item) } func (h *Handler) Validate(c *gin.Context) { var req validateRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "活动模版校验参数不正确") return } resp, err := h.activity.ValidateActivityTemplate(c.Request.Context(), &activityv1.ValidateActivityTemplateRequest{ Meta: h.meta(c), Template: req.Template.toProto(""), ForPublish: req.ForPublish, }) if err != nil { h.writeGRPCError(c, err, "校验活动模版失败") return } issues := make([]validationIssueDTO, 0, len(resp.GetIssues())) for _, issue := range resp.GetIssues() { issues = append(issues, validationIssueDTO{Field: issue.GetField(), Code: issue.GetCode(), Message: issue.GetMessage()}) } response.OK(c, gin.H{"valid": resp.GetValid(), "issues": issues}) } func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta { return &activityv1.RequestMeta{ RequestId: middleware.CurrentRequestID(c), Caller: "admin-server", AppCode: appctx.FromContext(c.Request.Context()), SentAtMs: time.Now().UTC().UnixMilli(), } } // writeGRPCError 把 owner service 的并发和状态冲突稳定映射为 HTTP 409; // 不能把 revision 冲突降级成 400,否则编辑器无法区分字段错误与“别人已经保存过”。 func (h *Handler) writeGRPCError(c *gin.Context, err error, fallback string) { status := grpcstatus.Convert(err) switch status.Code() { case codes.InvalidArgument: response.BadRequest(c, status.Message()) case codes.NotFound: response.NotFound(c, status.Message()) case codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted: response.Conflict(c, status.Message()) default: response.ServerError(c, fallback) } } func parseInt64Query(c *gin.Context, key string) int64 { value, _ := strconv.ParseInt(strings.TrimSpace(c.Query(key)), 10, 64) return value } func parseOptionalNonNegativeInt64Query(c *gin.Context, key string) (int64, bool) { raw, exists := c.GetQuery(key) if !exists { return 0, true } value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) return value, err == nil && value >= 0 }