Initial commit: web_hghl_server

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 16:28:14 +08:00
co-authored by Claude Opus 4.7
commit 65fca60622
653 changed files with 135901 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
package srv_seopush
import (
"context"
"time"
"videoweb/pub/models/playletmod"
"videoweb/pub/pubdef"
"videoweb/pub/seo/sitemapdirty"
)
// MarkSitemapDirty 通知 app 进程重新生成 sitemap。
func MarkSitemapDirty(ctx context.Context) {
sitemapdirty.Mark(ctx)
}
// NotifyPlayletAfterWrite 短剧写入后:刷新 sitemap 标记。
// 搜索引擎只在新上架(含新增即上架)或从已上架变为下架时推送,普通改内容不推。
func NotifyPlayletAfterWrite(ctx context.Context, playletId int64, prevStatus *playletmod.Status, status playletmod.Status, onlineAt time.Time) {
MarkSitemapDirty(ctx)
wasUp := prevStatus != nil && *prevStatus == playletmod.StatusUp
if status == playletmod.StatusUp {
if !wasUp {
EnqueueUpdated(ctx, KindPlaylet, playletId, PageVisiblePlaylet(status, onlineAt))
}
return
}
if wasUp {
EnqueueDeleted(ctx, KindPlaylet, playletId)
}
}
// NotifyPlayletAfterStatus 批量上下架后的 SEO 通知
func NotifyPlayletAfterStatus(ctx context.Context, ids []int64, status playletmod.Status, onlineAt time.Time) {
MarkSitemapDirty(ctx)
if status == playletmod.StatusUp {
for _, id := range ids {
EnqueueUpdated(ctx, KindPlaylet, id, PageVisiblePlaylet(status, onlineAt))
}
return
}
for _, id := range ids {
EnqueueDeleted(ctx, KindPlaylet, id)
}
}
// NotifyInfoAfterWrite 视频写入后的 SEO 通知。
// 搜索引擎只在新上架或从已上架变为下架时推送,普通改内容不推。
func NotifyInfoAfterWrite(ctx context.Context, kind string, id int64, prevStatus, status pubdef.InformationStatus) {
MarkSitemapDirty(ctx)
wasUp := prevStatus == pubdef.InformationStatusVerifyOK
if PageVisibleInfo(status) {
if !wasUp {
EnqueueUpdated(ctx, kind, id, true)
}
return
}
if wasUp {
EnqueueDeleted(ctx, kind, id)
}
}
+347
View File
@@ -0,0 +1,347 @@
// Package srv_seopush 负责内容新上架/下架时向搜索引擎推送页面地址。
// 业务回调里即时推送一次,失败的任务由 web/skd/job/seopush 兜底重试。
package srv_seopush
import (
"context"
"errors"
"strings"
"time"
"videoweb/pub/common/godef"
"videoweb/pub/common/log"
"videoweb/pub/common/utils"
"videoweb/pub/models/playletmod"
"videoweb/pub/models/seopushmod"
"videoweb/pub/models/videoinformationmod"
"videoweb/pub/pubdef"
"videoweb/pub/seo/googleindex"
seoindexnow "videoweb/pub/seo/indexnow"
"videoweb/web/service/srv_cfg"
"videoweb/web/webg"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
KindVideo = "video"
KindPlaylet = "playlet"
)
// channelFields 把两个推送通道的字段名抽象出来,共用同一套状态更新逻辑
type channelFields struct {
name string
status string
retry string
errMsg string
at string
}
var googleChannel = channelFields{
name: "google",
status: seopushmod.FN.SeoPushTask.GoogleStatus,
retry: seopushmod.FN.SeoPushTask.GoogleRetry,
errMsg: seopushmod.FN.SeoPushTask.GoogleErr,
at: seopushmod.FN.SeoPushTask.GoogleAt,
}
var indexNowChannel = channelFields{
name: "indexnow",
status: seopushmod.FN.SeoPushTask.IndexNowStatus,
retry: seopushmod.FN.SeoPushTask.IndexNowRetry,
errMsg: seopushmod.FN.SeoPushTask.IndexNowErr,
at: seopushmod.FN.SeoPushTask.IndexNowAt,
}
func kindPrefix(kind string) string {
switch kind {
case KindVideo:
return "/video-detail"
case KindPlaylet:
return "/drama-detail"
default:
return ""
}
}
// DetailURL 拼装详情页完整地址,路径与 sitemap 一致:/{prefix}/{slug},无 slug 回退 ID。
func DetailURL(webAddr, prefix, slug string, id int64) string {
seg := utils.PathSegment(slug, id)
if seg == "" {
return ""
}
return utils.JoinUrlPathKeepSlash(webAddr, strings.TrimRight(prefix, "/")+"/"+seg)
}
// PageVisiblePlaylet 判断短剧详情页当前是否对外可访问。
func PageVisiblePlaylet(status playletmod.Status, onlineAt time.Time) bool {
if status != playletmod.StatusUp {
return false
}
if onlineAt.IsZero() {
return true
}
return !onlineAt.After(time.Now())
}
// PageVisibleInfo 判断视频详情页是否已上架。
func PageVisibleInfo(status pubdef.InformationStatus) bool {
return status == pubdef.InformationStatusVerifyOK
}
// SEOBaseURL 取站点对外地址
func SEOBaseURL(ctx context.Context) string {
sysCfg, serr := srv_cfg.GetSystemConfig(ctx)
if serr != nil || sysCfg == nil {
log.Warn("seopush: get system config failed", log.E(serr))
return ""
}
base := sysCfg.SEOBaseURL()
if base == "" {
log.Warn("seopush: SEO base URL is empty, skip push")
}
return base
}
// Enabled 业务开关是否打开(不含环境判断)。
func Enabled() bool {
return pubdef.Enabled()
}
func canPushExternally() bool {
return pubdef.Enabled() && webg.Conf.RunMod == "prod"
}
// EnqueueUpdated 内容新上架后登记并立即推送,仅在页面已可访问时生效
func EnqueueUpdated(ctx context.Context, kind string, id int64, visible bool) {
if !pubdef.Enabled() || !visible {
return
}
enqueue(ctx, kind, id, seopushmod.ActionUpdated, true)
}
// RegisterUpdated 只登记不立即推送,供批量补推使用
func RegisterUpdated(ctx context.Context, kind string, id int64, visible bool) {
if !pubdef.Enabled() || !visible {
return
}
enqueue(ctx, kind, id, seopushmod.ActionUpdated, false)
}
// EnqueueDeleted 内容下架/删除后登记并立即推送
func EnqueueDeleted(ctx context.Context, kind string, id int64) {
if !pubdef.Enabled() {
return
}
enqueue(ctx, kind, id, seopushmod.ActionDeleted, true)
}
func enqueue(ctx context.Context, kind string, id int64, action string, pushNow bool) {
webAddr := SEOBaseURL(ctx)
if webAddr == "" {
return
}
prefix := kindPrefix(kind)
if prefix == "" {
return
}
pageURL := DetailURL(webAddr, prefix, loadSlug(ctx, kind, id), id)
if pageURL == "" {
return
}
now := time.Now()
task := &seopushmod.SeoPushTask{
Url: pageURL,
VideoId: id,
Action: action,
CreatedAt: now,
UpdatedAt: now,
}
if action == seopushmod.ActionDeleted {
_, _ = seopushmod.DelManySeoPushTask(ctx, bson.M{
seopushmod.FN.SeoPushTask.Url: pageURL,
seopushmod.FN.SeoPushTask.Action: seopushmod.ActionUpdated,
})
}
if serr := seopushmod.UpsertPendingSeoPushTask(ctx, task); serr != nil {
log.Warn("seopush: enqueue task failed", log.Any("url", pageURL), log.E(serr))
return
}
if action == seopushmod.ActionUpdated {
markQueued(ctx, kind, id)
}
if !pushNow {
return
}
godef.Go(func() {
PushTask(context.Background(), webAddr, task)
})
}
func loadSlug(ctx context.Context, kind string, id int64) string {
if id <= 0 {
return ""
}
switch kind {
case KindVideo:
item, err := videoinformationmod.FindOneVideoInformation(ctx,
bson.M{videoinformationmod.FN.VideoInformation.Id: id},
options.FindOne().SetProjection(bson.D{{Key: videoinformationmod.FN.VideoInformation.Slug, Value: 1}}))
if err != nil || item == nil {
return ""
}
return strings.TrimSpace(item.Slug)
case KindPlaylet:
item, err := playletmod.FindOnePlaylet(ctx, bson.M{playletmod.FN.Playlet.Id: id},
options.FindOne().SetProjection(bson.D{{Key: playletmod.FN.Playlet.Slug, Value: 1}}))
if err != nil || item == nil {
return ""
}
return strings.TrimSpace(item.Slug)
default:
return ""
}
}
func markQueued(ctx context.Context, kind string, id int64) {
var serr error
switch kind {
case KindVideo:
serr = videoinformationmod.UpdateOneVideoInformation(ctx,
bson.M{videoinformationmod.FN.VideoInformation.Id: id},
bson.M{"$set": bson.M{videoinformationmod.FN.VideoInformation.IndexNow: int64(1)}})
case KindPlaylet:
serr = playletmod.UpdateOnePlaylet(ctx,
bson.M{playletmod.FN.Playlet.Id: id},
bson.M{"$set": bson.M{playletmod.FN.Playlet.IndexNow: int64(1)}})
}
if serr != nil {
log.Warn("seopush: mark queued failed", log.Any("kind", kind), log.Any("id", id), log.E(serr))
}
}
// PushTask 推送任务中尚未成功的通道
func PushTask(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) {
if task == nil || !pubdef.Enabled() {
return
}
if strings.Contains(task.Url, "?") {
log.Warn("seopush: skip query-url task", log.Any("url", task.Url), log.Any("action", task.Action))
return
}
if !canPushExternally() {
dryRun(ctx, task)
return
}
if task.GoogleStatus != seopushmod.PushStatusSuccess {
pushGoogle(ctx, task)
}
if task.IndexNowStatus != seopushmod.PushStatusSuccess {
pushIndexNow(ctx, webAddr, task)
}
}
func dryRun(ctx context.Context, task *seopushmod.SeoPushTask) {
log.Info("seopush: dry-run, external push skipped in non-prod",
log.Any("url", task.Url), log.Any("action", task.Action))
if task.GoogleStatus != seopushmod.PushStatusSuccess {
markSuccess(ctx, task, googleChannel)
}
if task.IndexNowStatus != seopushmod.PushStatusSuccess {
markSuccess(ctx, task, indexNowChannel)
}
}
func pushGoogle(ctx context.Context, task *seopushmod.SeoPushTask) {
notifyType := googleindex.TypeUpdated
if task.Action == seopushmod.ActionDeleted {
notifyType = googleindex.TypeDeleted
}
resp, err := googleindex.Publish(ctx, task.Url, notifyType)
if err == nil {
markSuccess(ctx, task, googleChannel)
log.Info("seopush=google=pushed",
log.Any("url", task.Url),
log.Any("type", notifyType),
log.Any("resp", resp))
return
}
if errors.Is(err, googleindex.ErrQuotaExceeded) {
log.Warn("seopush=google=quota_exceeded",
log.Any("url", task.Url), log.Any("resp", err.Error()))
return
}
if errors.Is(err, googleindex.ErrNotEnabled) {
log.Warn("seopush=google=not_ready", log.Any("url", task.Url))
return
}
markFailed(ctx, task, googleChannel, err)
}
func pushIndexNow(ctx context.Context, webAddr string, task *seopushmod.SeoPushTask) {
if strings.TrimSpace(webAddr) == "" {
return
}
config, err := seoindexnow.BuildConfig(webAddr)
if err != nil {
log.Warn("seopush: build indexnow config failed", log.Any("webAddr", webAddr), log.E(err))
return
}
result := seoindexnow.NewBatchIndexNowSubmitter(config).SubmitBatch([]string{task.Url})
if result.SuccessCount > 0 {
markSuccess(ctx, task, indexNowChannel)
log.Info("seopush=indexnow=pushed",
log.Any("url", task.Url),
log.Any("statusCode", result.StatusCodes))
return
}
markFailed(ctx, task, indexNowChannel, errors.New(strings.Join(result.Errors, "; ")))
}
func markSuccess(ctx context.Context, task *seopushmod.SeoPushTask, ch channelFields) {
now := time.Now()
if serr := seopushmod.UpdateOneSeoPushTask(ctx, taskFilter(task), bson.M{
"$set": bson.M{
ch.status: seopushmod.PushStatusSuccess,
ch.errMsg: "",
ch.at: now,
seopushmod.FN.SeoPushTask.UpdatedAt: now,
},
}); serr != nil {
log.Warn("seopush: mark success failed",
log.Any("channel", ch.name), log.Any("url", task.Url), log.E(serr))
}
}
func markFailed(ctx context.Context, task *seopushmod.SeoPushTask, ch channelFields, pushErr error) {
now := time.Now()
errText := ""
if pushErr != nil {
errText = pushErr.Error()
}
log.Warn("seopush: push failed",
log.Any("channel", ch.name), log.Any("url", task.Url), log.Any("err", errText))
if serr := seopushmod.UpdateOneSeoPushTask(ctx, taskFilter(task), bson.M{
"$set": bson.M{
ch.errMsg: errText,
ch.at: now,
seopushmod.FN.SeoPushTask.UpdatedAt: now,
},
"$inc": bson.M{ch.retry: 1},
}); serr != nil {
log.Warn("seopush: mark failed failed",
log.Any("channel", ch.name), log.Any("url", task.Url), log.E(serr))
}
}
func taskFilter(task *seopushmod.SeoPushTask) bson.M {
return bson.M{
seopushmod.FN.SeoPushTask.Url: task.Url,
seopushmod.FN.SeoPushTask.Action: task.Action,
}
}