2026-07-20 13:28:13 +08:00

334 lines
12 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 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
landing LandingPageURLBuilder
banners BannerTargetDisabler
audit shared.OperationLogger
}
type LandingPageURLBuilder interface {
URL(appCode string, templateCode string) (string, error)
}
type BannerTargetDisabler interface {
DisableActivityTemplateBanners(appCode string, templateID string, nowMS int64) error
}
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) BindLandingPageURLBuilder(builder LandingPageURLBuilder) *Handler {
if h != nil {
h.landing = builder
}
return h
}
func (h *Handler) BindBannerTargetDisabler(disabler BannerTargetDisabler) *Handler {
if h != nil {
h.banners = disabler
}
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 = &regionID
}
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, h.withSummaryLandingURL(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 := h.withTemplateLandingURL(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, h.withTemplateLandingURL(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 := h.withTemplateLandingURL(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 := h.withTemplateLandingURL(templateFromProto(resp.GetTemplate()))
if item.Status == "disabled" && h.banners != nil {
// 模板停用成功后立刻关闭后台里仍指向它的 BannerApp 查询只读取 active
// 行,因此不会继续把用户送到已经没有运行态的 H5。
if err := h.banners.DisableActivityTemplateBanners(item.AppCode, item.TemplateID, time.Now().UTC().UnixMilli()); err != nil {
response.ServerError(c, "活动模版已停用,但关联 BANNER 关闭失败")
return
}
}
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 := h.withTemplateLandingURL(templateFromProto(resp.GetTemplate()))
if resp.GetArchived() && h.banners != nil {
if err := h.banners.DisableActivityTemplateBanners(item.AppCode, item.TemplateID, time.Now().UTC().UnixMilli()); err != nil {
response.ServerError(c, "活动模版已归档,但关联 BANNER 关闭失败")
return
}
}
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: h.withTemplateLandingURL(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 := h.withTemplateLandingURL(templateFromProto(resp.GetTemplate()))
shared.OperationLogWithResourceID(c, h.audit, "clone-activity-template", "activity_templates", item.TemplateID, "success", "")
response.Created(c, item)
}
func (h *Handler) withSummaryLandingURL(item templateSummaryDTO) templateSummaryDTO {
if h == nil || h.landing == nil || item.TemplateCode == "" {
return item
}
if landingURL, err := h.landing.URL(item.AppCode, item.TemplateCode); err == nil {
item.LandingPageURL = landingURL
}
return item
}
func (h *Handler) withTemplateLandingURL(item templateDTO) templateDTO {
if h == nil || h.landing == nil || item.TemplateCode == "" {
return item
}
if landingURL, err := h.landing.URL(item.AppCode, item.TemplateCode); err == nil {
item.LandingPageURL = landingURL
}
return 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
}