diff --git a/api/proto/cpcDownloadCfg.go b/api/proto/cpcDownloadCfg.go new file mode 100644 index 0000000..4e6b561 --- /dev/null +++ b/api/proto/cpcDownloadCfg.go @@ -0,0 +1,46 @@ +package proto + +import "stat/models/cpcDownloadCfg" + +// CpcDownloadCfgListReq 列表请求 +type CpcDownloadCfgListReq struct { + AppId *int32 `json:"appId" form:"appId"` // 可选,按 appId 过滤 + Channel string `json:"channel" form:"channel"` // 可选,按 channel 模糊匹配 + Status *bool `json:"status" form:"status"` // 可选,按状态过滤 + PageNum int64 `json:"pageNum" form:"pageNum" binding:"required,gte=1"` + PageSize int64 `json:"pageSize" form:"pageSize" binding:"required,gte=1,lte=200"` +} + +// CpcDownloadCfgListResp 列表返回 +type CpcDownloadCfgListResp struct { + Total int64 `json:"total"` + List cpcDownloadCfg.CpcDownloadCfgList `json:"list"` +} + +// AddCpcDownloadCfgReq 新增请求 +type AddCpcDownloadCfgReq struct { + AppId int32 `json:"appId" binding:"required"` + Channel string `json:"channel"` + LandingPage string `json:"landingPage"` + DownloadUrl string `json:"downloadUrl"` + IosDownSuff string `json:"iosDownSuff"` + Remark string `json:"remark"` + Status bool `json:"status"` +} + +// UpdateCpcDownloadCfgReq 更新请求 +type UpdateCpcDownloadCfgReq struct { + Id int64 `json:"id" binding:"required"` + AppId *int32 `json:"appId"` + Channel *string `json:"channel"` + LandingPage *string `json:"landingPage"` + DownloadUrl *string `json:"downloadUrl"` + IosDownSuff *string `json:"iosDownSuff"` + Remark *string `json:"remark"` + Status *bool `json:"status"` +} + +// DelCpcDownloadCfgReq 删除请求 +type DelCpcDownloadCfgReq struct { + Id int64 `json:"id" binding:"required"` +} diff --git a/api/versionApi/cpcDownloadCfg.go b/api/versionApi/cpcDownloadCfg.go new file mode 100644 index 0000000..7b1cbac --- /dev/null +++ b/api/versionApi/cpcDownloadCfg.go @@ -0,0 +1,122 @@ +package versionApi + +import ( + "stat/api/proto" + "stat/common/stdLog" + "stat/common/utils" + "stat/service/cpcDownloadCfgSer" + "stat/service/opLogSer" + + "github.com/gin-gonic/gin" +) + +// @Tags CPC下载配置 +// @Summary CPC下载配置列表 +// @Description CPC下载配置列表 +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param param body proto.CpcDownloadCfgListReq true "参数列表" +// @Success 200 {object} proto.CpcDownloadCfgListResp "成功" +// @Failure 400 {string} string "失败" +// @Router /api/stat/cpc/download/list [POST] +func CpcDownloadCfgList(ctx *gin.Context) { + req := &proto.CpcDownloadCfgListReq{} + if err := ctx.ShouldBindJSON(req); err != nil { + stdLog.ServerJSON(ctx, stdLog.ParamError, err.Error()) + return + } + resp, err := cpcDownloadCfgSer.List(ctx, req) + if err != nil { + stdLog.ServerJSON(ctx, stdLog.Failure, err.Error()) + return + } + stdLog.ServerJSON(ctx, stdLog.Success, resp) +} + +// @Tags CPC下载配置 +// @Summary 新增CPC下载配置 +// @Description 新增CPC下载配置 +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param param body proto.AddCpcDownloadCfgReq true "参数" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Router /api/stat/cpc/download/add [POST] +func CpcDownloadCfgAdd(ctx *gin.Context) { + account, err := utils.GetAdmin(ctx) + if err != nil { + stdLog.ServerJSON(ctx, stdLog.NoToken, err.Error()) + return + } + req := &proto.AddCpcDownloadCfgReq{} + if err := ctx.ShouldBindJSON(req); err != nil { + stdLog.ServerJSON(ctx, stdLog.ParamError, err.Error()) + return + } + if err := cpcDownloadCfgSer.Add(ctx, req); err != nil { + stdLog.ServerJSON(ctx, stdLog.Failure, err.Error()) + return + } + opLogSer.AddOpLog(ctx, account, true, req) + stdLog.ServerJSON(ctx, stdLog.Success, "") +} + +// @Tags CPC下载配置 +// @Summary 更新CPC下载配置 +// @Description 更新CPC下载配置 +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param param body proto.UpdateCpcDownloadCfgReq true "参数" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Router /api/stat/cpc/download/update [POST] +func CpcDownloadCfgUpdate(ctx *gin.Context) { + account, err := utils.GetAdmin(ctx) + if err != nil { + stdLog.ServerJSON(ctx, stdLog.NoToken, err.Error()) + return + } + req := &proto.UpdateCpcDownloadCfgReq{} + if err := ctx.ShouldBindJSON(req); err != nil { + stdLog.ServerJSON(ctx, stdLog.ParamError, err.Error()) + return + } + if err := cpcDownloadCfgSer.Update(ctx, req); err != nil { + stdLog.ServerJSON(ctx, stdLog.Failure, err.Error()) + return + } + opLogSer.AddOpLog(ctx, account, true, req) + stdLog.ServerJSON(ctx, stdLog.Success, "") +} + +// @Tags CPC下载配置 +// @Summary 删除CPC下载配置 +// @Description 删除CPC下载配置 +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param param body proto.DelCpcDownloadCfgReq true "参数" +// @Success 200 {string} string "成功" +// @Failure 400 {string} string "失败" +// @Router /api/stat/cpc/download/del [POST] +func CpcDownloadCfgDel(ctx *gin.Context) { + account, err := utils.GetAdmin(ctx) + if err != nil { + stdLog.ServerJSON(ctx, stdLog.NoToken, err.Error()) + return + } + req := &proto.DelCpcDownloadCfgReq{} + if err := ctx.ShouldBindJSON(req); err != nil { + stdLog.ServerJSON(ctx, stdLog.ParamError, err.Error()) + return + } + if err := cpcDownloadCfgSer.Del(ctx, req.Id); err != nil { + stdLog.ServerJSON(ctx, stdLog.Failure, err.Error()) + return + } + opLogSer.AddOpLog(ctx, account, true, req) + stdLog.ServerJSON(ctx, stdLog.Success, "") +} diff --git a/api/versionApi/cpcdownload.go b/api/versionApi/cpcdownload.go new file mode 100644 index 0000000..b43751a --- /dev/null +++ b/api/versionApi/cpcdownload.go @@ -0,0 +1,89 @@ +package versionApi + +import ( + "net/http" + "stat/common/stdLog" + "stat/service/cpcDownloadCfgSer" + "stat/service/versionSer" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +// CpcDownload 双端下载 +// @Tags 双端下载 +// @Summary 双端下载 +// @Description 记录下载历史。android:302跳转到下载地址(优先按appId+channel取配置,否则取最新);ios:返回mobileconfig配置文件 +// @Produce octet-stream +// @Param appId path int true "产品AppId" +// @Param channel path string true "渠道(json字符串)" +// @Param tid query string false "埋点traceID" +// @Success 200 {file} file "ios返回mobileconfig文件" +// @Success 302 {string} string "android跳转下载地址" +// @Failure 400 {string} string "失败" +// @Router /rsdownload/{appId}/{channel} [get] +func CpcDownload(ctx *gin.Context) { + // 第一个参数 appId + appId, err := strconv.Atoi(ctx.Param("appId")) + if err != nil { + stdLog.ServerJSON(ctx, stdLog.ParamError, err.Error()) + return + } + // 第二个参数 channel(渠道,json字符串) + channel := ctx.Param("channel") + + userAgent := ctx.Request.Header.Get("User-Agent") + isIos := strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "iPad") + + // 记录下载历史(双端都记) + versionSer.RecordCpcDownload(ctx, isIos, int32(appId), channel, ctx.ClientIP(), userAgent) + + // ios: 优先按appId和渠道查询双端配置的落地页,配置了落地页则301跳转,否则返回mobileconfig配置文件 + if isIos { + landingPage := cpcDownloadCfgSer.GetLandingPage(ctx, int32(appId), channel) + if len(landingPage) > 0 { + // 落地页未带dc参数时,拼上当前渠道 + if !strings.Contains(landingPage, "dc=") { + if strings.Contains(landingPage, "?") { + landingPage = landingPage + "&dc=" + channel + } else { + landingPage = landingPage + "?dc=" + channel + } + } + ctx.Redirect(http.StatusMovedPermanently, landingPage) + return + } + cfg := versionSer.IosMobileconfigByAppId(int32(appId)) + if cfg == nil { + stdLog.ServerJSON(ctx, stdLog.Failure, "ios config不存在") + return + } + pc, dc, _ := versionSer.ParseChannel(channel) + writeMobileconfig(ctx, cfg, dc, pc) + return + } + + // android: 优先按appId和channel查询配置的下载链接,没有则使用默认最新下载地址 + downloadUrl := cpcDownloadCfgSer.GetDownloadUrl(ctx, int32(appId), channel) + if downloadUrl == "" { + downloadUrl = versionSer.LatestAndroidDownloadUrl(int32(appId)) + } + if downloadUrl == "" { + stdLog.ServerJSON(ctx, stdLog.Failure, "android下载地址不存在") + return + } + ctx.Redirect(http.StatusFound, downloadUrl) +} + +// writeMobileconfig 将 mobileconfig 文件以附件形式写入响应 +func writeMobileconfig(ctx *gin.Context, cfg *versionSer.IosMobileconfigCfg, dc, pc string) { + data, fileName := versionSer.BuildMobileconfig(cfg, dc, pc) + if fileName == "" { + fileName = "madou.mobileconfig" + } + ctx.Header("Content-Type", "application/octet-stream") + ctx.Header("Content-Disposition", "attachment; filename="+fileName) + ctx.Header("Content-Transfer-Encoding", "binary") + _, _ = ctx.Writer.Write(data) +} diff --git a/common/genId/lid.go b/common/genId/lid.go index a015593..006c0e1 100644 --- a/common/genId/lid.go +++ b/common/genId/lid.go @@ -20,6 +20,7 @@ var audioBook *lid.Lid // 有声小说自增器 var landpageConfig *lid.Lid // 落地页配置id var appCategory *lid.Lid // var appLocLid *lid.Lid // 应用中心位置id自增器 +var cpcDownloadCfgLid *lid.Lid // CPC下载配置id自增器 func Start() { var err error @@ -98,6 +99,11 @@ func Start() { if err != nil { panic(err) } + + cpcDownloadCfgLid, err = lid.NewLid(collection, "cpc_download_cfg_id", 100) + if err != nil { + panic(err) + } } func GetNextRoleId() int64 { @@ -159,3 +165,7 @@ func GetAppCategoryId() int64 { func GetNextAppLocId() int64 { return appLocLid.Get() } + +func GetCpcDownloadCfgId() int64 { + return cpcDownloadCfgLid.Get() +} diff --git a/middleware/auth/http.go b/middleware/auth/http.go index 25893c4..7fda456 100644 --- a/middleware/auth/http.go +++ b/middleware/auth/http.go @@ -86,6 +86,7 @@ var ( "/api/stat/daily-stat/hour", "/api/stat/daily-stat/rank", "/api/stat/daily-stat/total", + "/rsdownload/", } ) diff --git a/models/cpcDownloadCfg/cpcDownloadCfg.go b/models/cpcDownloadCfg/cpcDownloadCfg.go new file mode 100644 index 0000000..7861d4e --- /dev/null +++ b/models/cpcDownloadCfg/cpcDownloadCfg.go @@ -0,0 +1,81 @@ +package cpcDownloadCfg + +import ( + "context" + "fmt" + "stat/common/log" + "stat/common/mongos" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +func Init(ctx context.Context) { + db = mongos.New(mongos.MgoManager, TN.CpcDownloadCfg) + indexes := []mongo.IndexModel{ + { + Keys: bson.D{ + {Key: FN.CpcDownloadCfg.AppId, Value: 1}, + {Key: FN.CpcDownloadCfg.Channel, Value: 1}, + }, + }, + { + Keys: bson.D{{Key: FN.CpcDownloadCfg.Status, Value: 1}}, + }, + } + if _, err := db.CreateIndex(ctx, indexes); err != nil { + panic(fmt.Sprintf("(cpcDownloadCfg:Init) CreateIndex failed: %+v", err)) + } +} + +func InsertOneCpcDownloadCfg(ctx context.Context, doc *CpcDownloadCfg) error { + if _, err := db.InsertOne(ctx, doc); err != nil { + log.Warn("cpcDownloadCfg InsertOne failed", log.E(err)) + return err + } + return nil +} + +func FindOneCpcDownloadCfg(ctx context.Context, filter bson.M) (*CpcDownloadCfg, error) { + var doc CpcDownloadCfg + if err := db.FindOne(ctx, &doc, filter); err != nil { + return nil, err + } + return &doc, nil +} + +func DelOneCpcDownloadCfg(ctx context.Context, filter bson.M) (int64, error) { + count, err := db.DeleteOne(ctx, filter) + if err != nil { + log.Warn("cpcDownloadCfg DeleteOne failed", log.E(err), log.Any("filter", filter)) + return 0, err + } + return count, nil +} + +func UpdateOneCpcDownloadCfg(ctx context.Context, filter bson.M, update bson.M) error { + if _, err := db.UpdateOne(ctx, filter, update); err != nil { + log.Warn("cpcDownloadCfg UpdateOne failed", log.E(err), log.Any("filter", filter)) + return err + } + return nil +} + +func CountCpcDownloadCfgs(ctx context.Context, filter bson.M) (int64, error) { + count, err := db.Count(ctx, filter) + if err != nil { + log.Warn("cpcDownloadCfg Count failed", log.E(err), log.Any("filter", filter)) + return 0, err + } + return count, nil +} + +func FindManyCpcDownloadCfg(ctx context.Context, filter bson.M, opts ...*options.FindOptions) (CpcDownloadCfgList, error) { + list := make(CpcDownloadCfgList, 0) + if err := db.Find(ctx, &list, filter, opts...); err != nil { + log.Warn("cpcDownloadCfg Find failed", log.E(err), log.Any("filter", filter)) + return nil, err + } + return list, nil +} diff --git a/models/cpcDownloadCfg/tag.go b/models/cpcDownloadCfg/tag.go new file mode 100644 index 0000000..286caa2 --- /dev/null +++ b/models/cpcDownloadCfg/tag.go @@ -0,0 +1,36 @@ +package cpcDownloadCfg + +type TagCpcDownloadCfg struct { + Id string + AppId string + Channel string + LandingPage string + DownloadUrl string + IosDownSuff string + Remark string + Status string + CreatedAt string + UpdatedAt string +} + +var FN struct { + CpcDownloadCfg TagCpcDownloadCfg +} + +var TN struct { + CpcDownloadCfg string +} + +func init() { + FN.CpcDownloadCfg.Id = "_id" + FN.CpcDownloadCfg.AppId = "appId" + FN.CpcDownloadCfg.Channel = "channel" + FN.CpcDownloadCfg.LandingPage = "landingPage" + FN.CpcDownloadCfg.DownloadUrl = "downloadUrl" + FN.CpcDownloadCfg.IosDownSuff = "iosDownSuff" + FN.CpcDownloadCfg.Remark = "remark" + FN.CpcDownloadCfg.Status = "status" + FN.CpcDownloadCfg.CreatedAt = "createdAt" + FN.CpcDownloadCfg.UpdatedAt = "updatedAt" + TN.CpcDownloadCfg = "cpc_download_cfg" +} diff --git a/models/cpcDownloadCfg/type.go b/models/cpcDownloadCfg/type.go new file mode 100644 index 0000000..8764bda --- /dev/null +++ b/models/cpcDownloadCfg/type.go @@ -0,0 +1,25 @@ +package cpcDownloadCfg + +import ( + "stat/common/mongos" + "time" +) + +var db *mongos.MongoTool + +// CpcDownloadCfg CPC 下载配置: 双端下载落地页 + Android 下载地址 + iOS mobileconfig 文件名 +// @table:cpcDownloadCfg +type CpcDownloadCfg struct { + Id int64 `json:"id" bson:"_id"` + AppId int32 `json:"appId" bson:"appId"` // appId + Channel string `json:"channel" bson:"channel"` // 渠道(json字符串) + LandingPage string `json:"landingPage" bson:"landingPage"` // iOS 落地页 URL,优先于 mobileconfig + DownloadUrl string `json:"downloadUrl" bson:"downloadUrl"` // Android 下载地址 + IosDownSuff string `json:"iosDownSuff" bson:"iosDownSuff"` // iOS mobileconfig 文件名(下载后缀) + Remark string `json:"remark" bson:"remark"` // 备注 + Status bool `json:"status" bson:"status"` // 是否启用 + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` // 创建时间 + UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` // 更新时间 +} + +type CpcDownloadCfgList []*CpcDownloadCfg diff --git a/models/index.go b/models/index.go index 932f874..e7b537d 100644 --- a/models/index.go +++ b/models/index.go @@ -23,6 +23,7 @@ import ( "stat/models/channelmod" "stat/models/channelpackage" "stat/models/channelsettlementrecord" + "stat/models/cpcDownloadCfg" "stat/models/dailyhourstatmod" "stat/models/dailystatadvertise" "stat/models/dailystatmod" @@ -107,4 +108,5 @@ func InitIndex() { applicationstat.Init(ctx) applicationLocMod.Init(ctx) channelpackage.Init(ctx) + cpcDownloadCfg.Init(ctx) } diff --git a/router/base.go b/router/base.go index 38c5d6c..e402434 100644 --- a/router/base.go +++ b/router/base.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "stat/api/versionApi" "stat/common/enum/consts" "stat/middleware/auth" "stat/middleware/cors" @@ -103,6 +104,9 @@ func registerRouter(router *gin.Engine) { appcategoryRouter(rootRouter) // app分类 userInviteRouter(rootRouter) // 兼容 } + + // 双端下载落地页(无需登陆鉴权) + router.GET("/rsdownload/:appId/:channel", versionApi.CpcDownload) } func setGinSwagger(router *gin.Engine) { diff --git a/router/version.go b/router/version.go index 2a20f9d..02c2108 100644 --- a/router/version.go +++ b/router/version.go @@ -43,4 +43,13 @@ func versionRouter(router *gin.RouterGroup) { picture.POST("/del", versionApi.DelOneLandpage) picture.POST("/update", versionApi.UpdateOneLandpage) } + + // CPC 下载配置管理 + cpc := router.Group("/cpc/download") + { + cpc.POST("/list", versionApi.CpcDownloadCfgList) + cpc.POST("/add", versionApi.CpcDownloadCfgAdd) + cpc.POST("/update", versionApi.CpcDownloadCfgUpdate) + cpc.POST("/del", versionApi.CpcDownloadCfgDel) + } } diff --git a/service/cpcDownloadCfgSer/cpcDownloadCfg.go b/service/cpcDownloadCfgSer/cpcDownloadCfg.go new file mode 100644 index 0000000..986bcb5 --- /dev/null +++ b/service/cpcDownloadCfgSer/cpcDownloadCfg.go @@ -0,0 +1,204 @@ +package cpcDownloadCfgSer + +import ( + "context" + "fmt" + "stat/api/proto" + "stat/common/genId" + "stat/common/log" + "stat/models/cpcDownloadCfg" + "stat/service/cacheSer" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + cacheTTL = 2 * time.Minute +) + +func cacheKey(appId int32, channel string) string { + return fmt.Sprintf("cpc_download_cfg#%d#%s", appId, channel) +} + +// findEnabled 按 (appId, channel) 命中启用的配置;命中缓存(包含未找到的负缓存) +func findEnabled(ctx context.Context, appId int32, channel string) *cpcDownloadCfg.CpcDownloadCfg { + key := cacheKey(appId, channel) + if v, ok := cacheSer.Cache.Get(key); ok { + if doc, ok := v.(*cpcDownloadCfg.CpcDownloadCfg); ok { + return doc + } + // 负缓存 + return nil + } + filter := bson.M{ + cpcDownloadCfg.FN.CpcDownloadCfg.AppId: appId, + cpcDownloadCfg.FN.CpcDownloadCfg.Channel: channel, + cpcDownloadCfg.FN.CpcDownloadCfg.Status: true, + } + doc, err := cpcDownloadCfg.FindOneCpcDownloadCfg(ctx, filter) + if err != nil { + // 写入负缓存,避免高频未命中击穿 + cacheSer.Cache.Set(key, false, cacheTTL) + return nil + } + cacheSer.Cache.Set(key, doc, cacheTTL) + return doc +} + +// GetLandingPage 按 appId+channel 获取 iOS 落地页 URL,无配置返回空字符串 +func GetLandingPage(ctx context.Context, appId int32, channel string) string { + if doc := findEnabled(ctx, appId, channel); doc != nil { + return doc.LandingPage + } + return "" +} + +// GetDownloadUrl 按 appId+channel 获取 Android 下载地址,无配置返回空字符串 +func GetDownloadUrl(ctx context.Context, appId int32, channel string) string { + if doc := findEnabled(ctx, appId, channel); doc != nil { + return doc.DownloadUrl + } + return "" +} + +// GetIosDownSuff 按 appId+channel 获取 mobileconfig 文件名 +func GetIosDownSuff(ctx context.Context, appId int32, channel string) string { + if doc := findEnabled(ctx, appId, channel); doc != nil { + return doc.IosDownSuff + } + return "" +} + +func invalidateCache(appId int32, channel string) { + cacheSer.Cache.Delete(cacheKey(appId, channel)) +} + +// List 后台列表 +func List(ctx context.Context, req *proto.CpcDownloadCfgListReq) (*proto.CpcDownloadCfgListResp, error) { + resp := &proto.CpcDownloadCfgListResp{ + Total: 0, + List: make(cpcDownloadCfg.CpcDownloadCfgList, 0), + } + filter := bson.M{} + if req.AppId != nil { + filter[cpcDownloadCfg.FN.CpcDownloadCfg.AppId] = *req.AppId + } + if req.Channel != "" { + filter[cpcDownloadCfg.FN.CpcDownloadCfg.Channel] = req.Channel + } + if req.Status != nil { + filter[cpcDownloadCfg.FN.CpcDownloadCfg.Status] = *req.Status + } + + total, err := cpcDownloadCfg.CountCpcDownloadCfgs(ctx, filter) + if err != nil { + return nil, err + } + opt := &options.FindOptions{} + opt.SetSort(bson.D{{Key: cpcDownloadCfg.FN.CpcDownloadCfg.CreatedAt, Value: -1}}) + opt.SetSkip((req.PageNum - 1) * req.PageSize) + opt.SetLimit(req.PageSize) + list, err := cpcDownloadCfg.FindManyCpcDownloadCfg(ctx, filter, opt) + if err != nil { + return nil, err + } + resp.Total = total + resp.List = list + return resp, nil +} + +// Add 新增 +func Add(ctx context.Context, req *proto.AddCpcDownloadCfgReq) error { + now := time.Now() + doc := &cpcDownloadCfg.CpcDownloadCfg{ + Id: genId.GetCpcDownloadCfgId(), + AppId: req.AppId, + Channel: req.Channel, + LandingPage: req.LandingPage, + DownloadUrl: req.DownloadUrl, + IosDownSuff: req.IosDownSuff, + Remark: req.Remark, + Status: req.Status, + CreatedAt: now, + UpdatedAt: now, + } + if err := cpcDownloadCfg.InsertOneCpcDownloadCfg(ctx, doc); err != nil { + return err + } + invalidateCache(doc.AppId, doc.Channel) + return nil +} + +// Update 更新 +func Update(ctx context.Context, req *proto.UpdateCpcDownloadCfgReq) error { + old, err := cpcDownloadCfg.FindOneCpcDownloadCfg(ctx, bson.M{ + cpcDownloadCfg.FN.CpcDownloadCfg.Id: req.Id, + }) + if err != nil { + return err + } + set := bson.M{ + cpcDownloadCfg.FN.CpcDownloadCfg.UpdatedAt: time.Now(), + } + if req.AppId != nil { + set[cpcDownloadCfg.FN.CpcDownloadCfg.AppId] = *req.AppId + } + if req.Channel != nil { + set[cpcDownloadCfg.FN.CpcDownloadCfg.Channel] = *req.Channel + } + if req.LandingPage != nil { + set[cpcDownloadCfg.FN.CpcDownloadCfg.LandingPage] = *req.LandingPage + } + if req.DownloadUrl != nil { + set[cpcDownloadCfg.FN.CpcDownloadCfg.DownloadUrl] = *req.DownloadUrl + } + if req.IosDownSuff != nil { + set[cpcDownloadCfg.FN.CpcDownloadCfg.IosDownSuff] = *req.IosDownSuff + } + if req.Remark != nil { + set[cpcDownloadCfg.FN.CpcDownloadCfg.Remark] = *req.Remark + } + if req.Status != nil { + set[cpcDownloadCfg.FN.CpcDownloadCfg.Status] = *req.Status + } + if err := cpcDownloadCfg.UpdateOneCpcDownloadCfg(ctx, bson.M{ + cpcDownloadCfg.FN.CpcDownloadCfg.Id: req.Id, + }, bson.M{"$set": set}); err != nil { + return err + } + // 同时清空旧 key 与新 key + invalidateCache(old.AppId, old.Channel) + if req.AppId != nil || req.Channel != nil { + newAppId := old.AppId + newChannel := old.Channel + if req.AppId != nil { + newAppId = *req.AppId + } + if req.Channel != nil { + newChannel = *req.Channel + } + invalidateCache(newAppId, newChannel) + } + return nil +} + +// Del 删除 +func Del(ctx context.Context, id int64) error { + old, err := cpcDownloadCfg.FindOneCpcDownloadCfg(ctx, bson.M{ + cpcDownloadCfg.FN.CpcDownloadCfg.Id: id, + }) + if err != nil { + log.Warn("cpcDownloadCfg Del FindOne failed", log.E(err)) + } + if _, err := cpcDownloadCfg.DelOneCpcDownloadCfg(ctx, bson.M{ + cpcDownloadCfg.FN.CpcDownloadCfg.Id: id, + }); err != nil { + return err + } + if old != nil { + invalidateCache(old.AppId, old.Channel) + } + return nil +} diff --git a/service/versionSer/cpcdownload.go b/service/versionSer/cpcdownload.go new file mode 100644 index 0000000..3df4810 --- /dev/null +++ b/service/versionSer/cpcdownload.go @@ -0,0 +1,110 @@ +package versionSer + +import ( + "context" + "encoding/json" + "stat/common/log" + "stat/common/utils" + "stat/models/downloadCpc" + "stat/models/versionMod" + "stat/service/cacheSer" + "stat/statg" + "strings" + "time" +) + +// IosMobileconfigCfg iOS mobileconfig 生成所需的应用级配置 +type IosMobileconfigCfg struct { + AppId int32 + IosDownSuff string // 下载文件名(如 madou.mobileconfig) + H5Origin string + H5Target string + H5Config string +} + +// ParseChannel 解析 channel(json 字符串)为 pc/dc/ch;非法 JSON 时返回空字符串,dc 走 RepairDC +func ParseChannel(channel string) (pc, dc, ch string) { + if channel == "" { + return + } + var p ChannelParams + _ = json.Unmarshal([]byte(channel), &p) + dc = utils.RepairDC(p.DC) + pc = p.PC + ch = p.CH + return +} + +// RecordCpcDownload 记录 cpc 下载到 landingpage_cpc 表(走异步 writer);相同 IP 1 小时内只记一次 +func RecordCpcDownload(ctx context.Context, isIos bool, appId int32, channel, ip, userAgent string) { + if ip == "" { + return + } + pc, dc, ch := ParseChannel(channel) + sysType := "android" + if isIos { + sysType = "ios" + } + devType := "" + if strings.Contains(userAgent, "iPhone") { + devType = "iphone" + } else if strings.Contains(userAgent, "iPad") { + devType = "ipad" + } else if strings.Contains(userAgent, "Android") { + devType = "android" + } + info := &downloadCpc.DownloadCpc{ + AppId: appId, + PC: pc, + DC: dc, + CH: ch, + IP: ip, + DevType: devType, + SysType: sysType, + PeerIP: ip, + CreatedAt: time.Now(), + } + if landPageVisit.TryLock(ip, time.Now().Add(landPageVisitIpTimeout)) { + cacheSer.Writers.LandPageVisitWriter.Add(info) + } +} + +// LatestAndroidDownloadUrl 取应用最新 Android 版本的下载链接(第一个 Link),没有返回空字符串 +func LatestAndroidDownloadUrl(appId int32) string { + ctx := context.Background() + data, err := versionMod.ButtonClick(ctx, appId, "android", 0) + if err != nil || data == nil { + log.Warn("LatestAndroidDownloadUrl ButtonClick failed", log.E(err), log.Any("appId", appId)) + return "" + } + if len(data.Link) == 0 { + return "" + } + return data.Link[0] +} + +// IosMobileconfigByAppId 取应用的 iOS mobileconfig 配置;endpoint 未配置 H5 三件套则返回 nil +func IosMobileconfigByAppId(appId int32) *IosMobileconfigCfg { + endpoint := statg.GlobalAppIdEndpoints[appId] + if endpoint == nil { + return nil + } + if endpoint.H5Origin == "" || endpoint.H5Target == "" || endpoint.H5Config == "" { + return nil + } + return &IosMobileconfigCfg{ + AppId: appId, + IosDownSuff: endpoint.H5Config, + H5Origin: endpoint.H5Origin, + H5Target: endpoint.H5Target, + H5Config: endpoint.H5Config, + } +} + +// BuildMobileconfig 用 dc/pc 生成 mobileconfig 字节流和文件名 +func BuildMobileconfig(cfg *IosMobileconfigCfg, dc, pc string) ([]byte, string) { + if cfg == nil { + return nil, "" + } + return GenerateConfigFile(cfg.H5Origin, cfg.H5Target, cfg.H5Config, dc, pc) +}