[AI] fix: 修复短剧剧集重复显示
两处原因,分别处理: 1. contentsync 只按 _id 水位线插新增,源库事后打上的 isDelete 传不过来, 源站删掉的重复集在目标库一直留着。每轮同步后补扫一次 media / media_content / video_info,把源库的软删除标记同步过去,并按 web/service/mediacontentser 的同一口径重算父级 currentEpisode / freeEpisode。只做「源库删了→目标也删」一个方向,不与本地下架保护冲突。 2. 各站 skd 的媒资库同步会为同一集(mediaId+hashId 相同)另插一条自己的 _id, 与 contentsync 带过来的源库那条并存。媒资库那条在 SaleCoin=0 时 listenPermission=2,本该收费的集可以白看。插入时若发现本地已有同一集, 把本地那条 isActive:false 下架;并新增 skd 配置开关 switch.mediaSyncDisabled,非源站可直接关掉这条同步链。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# contentsync
|
||||
|
||||
把源库 `91porn_video` 的**帖子 / 视频 / 短剧**增量同步到 `91fans`、`huangguo`、`tangguo` 三个库。
|
||||
|
||||
## 为什么不能直接整文档覆盖
|
||||
|
||||
对源库与目标库做过逐字段抽样比对(video_info 400 条 / 其余各 300 条),
|
||||
74 个字段里只有 9 个分叉,且全部是**站点本地产生**的数据:
|
||||
|
||||
| 集合 | 分叉字段 | 分叉比例 |
|
||||
|---|---|---|
|
||||
| `video_info` | pageViewCount / playCount / fakePlayCount / likeCount / fakeLikeCount / collectCount / forwardCount / purchaseCount / updatedAt | 最高 88% |
|
||||
| `media` | countBrowse / countCollect / countLike / countPurchases / hot / updateTime | 最高 39% |
|
||||
| `tag` | tPlayCount / vPlayCount / playRating / videoCount / updatedAt | 最高 42% |
|
||||
| `media_content` / `media_tag` / `media_tag_dimension` | 无 | 0% |
|
||||
|
||||
内容字段(title、cover、tags、status、newsType、coins…)**完全一致**。
|
||||
|
||||
所以同步必须是字段级的。本地字段分三类,都不能被源库覆盖:
|
||||
|
||||
1. **运行时计数器** —— 各站点自己累积的播放 / 点赞 / 收藏 / 购买数
|
||||
2. **本地定时任务写入** —— `skd/job/hotvalue/video.go` 会重算并写回 `hot`
|
||||
3. **后台运营编排** —— `web/service/vidser/vid.go` 写 `happinessPlazaTop`、`liaoBaTopSort`
|
||||
|
||||
清单见 `spec.go`,新增字段前先确认它属于「源库内容」还是「站点本地」。
|
||||
|
||||
## 写入语义
|
||||
|
||||
一条 upsert 同时处理新增和更新:
|
||||
|
||||
```
|
||||
UpdateOne({_id}, {
|
||||
$set: <内容字段>, // 每次同步都以源库为准
|
||||
$setOnInsert: <本地字段>, // 只在首次插入时给初值,之后永不再碰
|
||||
}, upsert: true)
|
||||
```
|
||||
|
||||
只更新不删除。源库下架通过 `status` 字段自然同步过去。
|
||||
|
||||
## 本地下架保护
|
||||
|
||||
每批写入前先查目标库这批 `_id` 里哪些是 `status: 0`,**整条踢出**——连内容字段
|
||||
也不写。站点运营下架之后,源库无权再把它改回上架。
|
||||
|
||||
水位线仍会越过被跳过的文档,否则它会永久卡住水位线导致每次重扫。
|
||||
|
||||
没有 `status` 字段的集合(media_tag / media_tag_dimension / tag)自然查不出东西,
|
||||
等于不生效。
|
||||
|
||||
## 软删除补扫
|
||||
|
||||
水位线只管新增,所以源库**事后**打上的 `isDelete: true` 永远传不过来:源站把重复
|
||||
剧集软删掉了,目标库却还留着,App 剧集列表就出现「1 1 1 2 2 2」这种重复集。
|
||||
2026-10-29 排查 `琼明神女录` 显示 6 集(实际 2 集)就是这个原因。
|
||||
|
||||
每轮同步结束后对 `media` / `media_content` / `video_info` 补扫一次:把源库
|
||||
`isDelete: true` 的文档在目标库也标成已删除。`--sweep-deleted` 默认开,三个库 6 秒。
|
||||
|
||||
- **只做「源库删了 → 目标也删」这一个方向**,绝不把目标库已删的恢复回来,所以和
|
||||
上面的本地下架保护不冲突
|
||||
- 只写 `isDelete`,不碰 `updateTime`(那是站点本地字段)
|
||||
- 顺带按 `web/service/mediacontentser` 的同一口径重算父级 `currentEpisode` /
|
||||
`freeEpisode`——这两个字段原本只在后台编辑子集时才重算,不补的话集数会停在
|
||||
含重复集的旧值
|
||||
|
||||
一次性修复历史欠账时实测:三个库各 261 条 `media_content` + 18 条 `media`,
|
||||
涉及 28 部短剧。`freeEpisode` 只减不增(减少量正好等于被去掉的重复免费集数)。
|
||||
|
||||
## 媒资库重复集去重
|
||||
|
||||
各站点的 skd 自己也在跑媒资库同步(`common/media/syncMedia.go`,每 30 分钟),
|
||||
它按 `(mediaId, hashId)` 去重、用**自己的 `_id`** 为同一集插一条;本同步再把源库
|
||||
那条插进来,同一集就成了两条。两个源库的 `(mediaId, hashId)` 重复组都是 0,
|
||||
只有目标站有——这是本同步引入的。
|
||||
|
||||
更要紧的是媒资库那条在 `SaleCoin = 0` 时 `listenPermission = 2`(免费),
|
||||
**本该收费的集可以白看**。2026-10-29 的存量:
|
||||
|
||||
| 站点 | 重复组 | 其中免费副本 |
|
||||
|---|---|---|
|
||||
| tangguo / huangguo / 91fans | 各 268 | 各 241 |
|
||||
| hgmj | 97 | 84 |
|
||||
| 51dj | 102 | 97 |
|
||||
|
||||
购买记录全部挂在源库那条上(本地副本购买恒为 0,因为它免费、没人需要买),
|
||||
所以**以源库那条为准**:插入时若发现本地已有同一集,把本地那条 `isActive: false`
|
||||
下架(不删除,便于回查)。
|
||||
|
||||
根治办法是关掉各站 skd 的媒资库同步:`skd.json` 里 `switch.mediaSyncDisabled: true`
|
||||
(见 `skd/job/job.go`)。**源站必须保持开启**,否则新剧集无从进库。
|
||||
关掉之后新剧集改由本同步每天 04:00 从源站带过来,延迟从 30 分钟变成最多 24 小时。
|
||||
|
||||
## 增量水位线
|
||||
|
||||
每个「目标库 × 集合」各记一份水位线,存在 `state.json`:
|
||||
|
||||
常规同步**只取新增**:`_id > lastID`,按 `_id` 升序。ObjectId 单调递增且天然有索引,
|
||||
所以每次只读上次之后产生的文档,耗时与集合总量无关(实测 1~3 秒)。
|
||||
|
||||
**刻意不做存量修改比对。** 源库改了已存在的文档——改标题、下架、调价、换封面——
|
||||
不会同步过来。需要补齐时手动跑 `--full` 重扫全部(三个库约 17 分钟起)。
|
||||
|
||||
这是明确的取舍。参考量级:一次 `--full` 里 `video_info` 更新了约 7300 条
|
||||
(占 273K 的 2.7%)、`media` 更新了约 5900 条(占 20K 的 29%)。
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
# 增量:只取新增。cron 每天北京时间 04:00 跑的就是这个,耗时数秒
|
||||
/server/sync/run.sh
|
||||
|
||||
# 全量:忽略水位线重扫,补齐存量修改。约 17 分钟起,手动跑
|
||||
/server/sync/run.sh --full
|
||||
|
||||
# 只跑某几个集合 / 干跑
|
||||
/server/sync/contentsync --src "$SRC_URI" --dst "tangguo=$DST_TANGGUO" \
|
||||
--only video_info,media --dry-run --state /server/sync/state.json
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
服务器上无 Go / node / mongosh,所以本地交叉编译后 scp 静态二进制:
|
||||
|
||||
```bash
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o contentsync ./scripts/contentsync/
|
||||
scp contentsync root@172.104.39.32:/server/sync/
|
||||
```
|
||||
|
||||
`/server/sync/env.sh`(600,含连接串)由 `run.sh` source。不走 Jenkins。
|
||||
|
||||
cron:`0 20 * * * /server/sync/run.sh`
|
||||
|
||||
注意机器系统时区是 **UTC**,且 Debian 系的 `cron 3.0pl1` **不支持 `CRON_TZ`**
|
||||
(那是 cronie 的特性),所以只能直接写 UTC 时刻:20:00 UTC = 北京时间 04:00。
|
||||
@@ -0,0 +1,567 @@
|
||||
// contentsync 把源库(91porn_video)的帖子/视频/短剧增量同步到若干目标库。
|
||||
//
|
||||
// 增量策略(每个集合、每个目标库各自记水位线):
|
||||
// - 新增:按 _id 升序游标,_id > lastID。ObjectId 单调递增且天然有索引。
|
||||
// - 修改:按 tsField > lastTS。仅对有该字段且建了索引的集合启用。
|
||||
// 没有可靠时间字段的集合(media_content/media_tag/tag)靠定期 --full 兜底。
|
||||
//
|
||||
// 写入语义:$set 内容字段 + $setOnInsert 站点本地字段(见 spec.go)。
|
||||
// 只更新不删除;源库下架通过 status 字段自然同步。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// 源库名由 --src-db 指定。两个源库 91porn_video 与 91dj_video 老数据同源但已分叉
|
||||
// (最新 500 条零重叠),所以每个目标库只能配一个源,不能混。
|
||||
const defaultSrcDBName = "91porn_video"
|
||||
|
||||
type mark struct {
|
||||
LastID string `json:"lastId"`
|
||||
LastTS time.Time `json:"lastTs"`
|
||||
}
|
||||
|
||||
// state: target -> collection -> mark
|
||||
type state map[string]map[string]*mark
|
||||
|
||||
func loadState(p string) state {
|
||||
s := state{}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
_ = json.Unmarshal(b, &s)
|
||||
return s
|
||||
}
|
||||
|
||||
func saveState(p string, s state) error {
|
||||
b, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p) // 原子替换,避免崩溃时留下半截状态
|
||||
}
|
||||
|
||||
func (s state) get(target, coll string) *mark {
|
||||
if s[target] == nil {
|
||||
s[target] = map[string]*mark{}
|
||||
}
|
||||
if s[target][coll] == nil {
|
||||
s[target][coll] = &mark{}
|
||||
}
|
||||
return s[target][coll]
|
||||
}
|
||||
|
||||
// dstList 让 --dst 可以重复出现。MongoDB URI 自身含逗号(副本集主机列表),
|
||||
// 所以不能用逗号分隔多个目标。
|
||||
type dstList []string
|
||||
|
||||
func (d *dstList) String() string { return strings.Join(*d, " ") }
|
||||
func (d *dstList) Set(v string) error {
|
||||
*d = append(*d, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type target struct {
|
||||
name string
|
||||
db *mongo.Database
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
srcURI = flag.String("src", "", "源库 URI")
|
||||
dsts dstList
|
||||
srcDBName = flag.String("src-db", defaultSrcDBName, "源库名")
|
||||
statePath = flag.String("state", "/server/sync/state.json", "水位线状态文件")
|
||||
full = flag.Bool("full", false, "忽略水位线做全量扫描(用于兜底修复)")
|
||||
dryRun = flag.Bool("dry-run", false, "只统计不写入")
|
||||
only = flag.String("only", "", "只同步指定集合,逗号分隔")
|
||||
batchSize = flag.Int("batch", 500, "每批写入条数")
|
||||
sweep = flag.Bool("sweep-deleted", true, "回传源库软删除标记(清理重复剧集)")
|
||||
timeout = flag.Duration("timeout", 30*time.Minute, "整体超时")
|
||||
)
|
||||
flag.Var(&dsts, "dst", "目标库,格式 name=uri;可重复指定多个")
|
||||
flag.Parse()
|
||||
|
||||
if *srcURI == "" || len(dsts) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "必须指定 --src 和 --dst")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
|
||||
srcCli, err := mongo.Connect(ctx, options.Client().ApplyURI(*srcURI))
|
||||
if err != nil {
|
||||
fatal("连接源库失败: %v", err)
|
||||
}
|
||||
defer srcCli.Disconnect(context.Background())
|
||||
srcDB := srcCli.Database(*srcDBName)
|
||||
|
||||
var targets []target
|
||||
for _, part := range dsts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
name, uri, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
fatal("--dst 格式错误,应为 name=uri: %s", part)
|
||||
}
|
||||
cli, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
|
||||
if err != nil {
|
||||
fatal("连接目标库 %s 失败: %v", name, err)
|
||||
}
|
||||
defer cli.Disconnect(context.Background())
|
||||
targets = append(targets, target{name: name, db: cli.Database(name)})
|
||||
}
|
||||
|
||||
wanted := map[string]bool{}
|
||||
for _, n := range strings.Split(*only, ",") {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
wanted[n] = true
|
||||
}
|
||||
}
|
||||
|
||||
st := loadState(*statePath)
|
||||
started := time.Now()
|
||||
var totalUp, totalIns int
|
||||
failed := false
|
||||
|
||||
for _, spec := range specs {
|
||||
if len(wanted) > 0 && !wanted[spec.Name] {
|
||||
continue
|
||||
}
|
||||
for _, t := range targets {
|
||||
ins, up, err := syncOne(ctx, srcDB, t, spec, st, *full, *dryRun, *batchSize)
|
||||
if err != nil {
|
||||
fmt.Printf("[FAIL] %-20s -> %-10s %v\n", spec.Name, t.name, err)
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
totalIns += ins
|
||||
totalUp += up
|
||||
fmt.Printf("[ok] %-20s -> %-10s 新增 %6d 更新 %6d\n", spec.Name, t.name, ins, up)
|
||||
if !*dryRun {
|
||||
if err := saveState(*statePath, st); err != nil {
|
||||
fmt.Printf("[WARN] 写状态文件失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *sweep {
|
||||
for _, coll := range tombstoneColls {
|
||||
if len(wanted) > 0 && !wanted[coll] {
|
||||
continue
|
||||
}
|
||||
for _, t := range targets {
|
||||
n, fixed, err := sweepDeleted(ctx, srcDB, t, coll, *dryRun)
|
||||
if err != nil {
|
||||
fmt.Printf("[FAIL] 补扫软删除 %-14s -> %-10s %v\n", coll, t.name, err)
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
fmt.Printf("[ok] 补扫软删除 %-14s -> %-10s 标记 %5d 重算父级 %4d\n", coll, t.name, n, fixed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n合计 新增 %d 更新 %d,耗时 %s\n", totalIns, totalUp, time.Since(started).Round(time.Second))
|
||||
if failed {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func syncOne(ctx context.Context, srcDB *mongo.Database, t target, spec CollSpec,
|
||||
st state, full, dryRun bool, batchSize int) (ins, up int, err error) {
|
||||
|
||||
m := st.get(t.name, spec.Name)
|
||||
local := map[string]bool{}
|
||||
for _, f := range spec.LocalFields {
|
||||
local[f] = true
|
||||
}
|
||||
|
||||
// 只按 _id 水位线取新增。ObjectId 单调递增且天然有索引,
|
||||
// 所以每次只读上次同步之后产生的文档,跟集合总量无关。
|
||||
//
|
||||
// 刻意不做存量修改比对:源库改了已存在的文档不会同步过来。
|
||||
// 需要补齐时用 --full 手动重扫一次。
|
||||
filter := bson.M{}
|
||||
if !full && m.LastID != "" {
|
||||
oid, e := primitive.ObjectIDFromHex(m.LastID)
|
||||
if e == nil {
|
||||
filter["_id"] = bson.M{"$gt": oid}
|
||||
}
|
||||
}
|
||||
ins, up, maxID, err := runPass(ctx, srcDB, t, spec, local, filter,
|
||||
bson.D{{Key: "_id", Value: 1}}, dryRun, batchSize)
|
||||
if err != nil {
|
||||
return ins, up, err
|
||||
}
|
||||
// 水位线只在真跑时推进,干跑不能改状态
|
||||
if !dryRun && maxID != primitive.NilObjectID {
|
||||
m.LastID = maxID.Hex()
|
||||
}
|
||||
return ins, up, nil
|
||||
}
|
||||
|
||||
func runPass(ctx context.Context, srcDB *mongo.Database, t target, spec CollSpec,
|
||||
local map[string]bool, filter bson.M, sortBy bson.D, dryRun bool, batchSize int,
|
||||
) (ins, up int, maxID primitive.ObjectID, err error) {
|
||||
|
||||
srcCur, err := srcDB.Collection(spec.Name).Find(ctx, filter,
|
||||
options.Find().SetSort(sortBy).SetBatchSize(int32(batchSize)).SetNoCursorTimeout(true))
|
||||
if err != nil {
|
||||
return 0, 0, maxID, err
|
||||
}
|
||||
defer srcCur.Close(ctx)
|
||||
|
||||
dst := t.db.Collection(spec.Name)
|
||||
pending := make([]bson.M, 0, batchSize)
|
||||
scanned, skipped, deduped := 0, 0, 0
|
||||
lastLog := time.Now()
|
||||
|
||||
// blockedIDs 查出这批里哪些在目标库已被本地下架(status:0)。
|
||||
// 目标库没有 status 字段的集合自然查不出东西,等于不生效。
|
||||
blockedIDs := func(docs []bson.M) (map[any]bool, error) {
|
||||
out := map[any]bool{}
|
||||
ids := make([]any, 0, len(docs))
|
||||
for _, d := range docs {
|
||||
ids = append(ids, d["_id"])
|
||||
}
|
||||
c, e := dst.Find(ctx, bson.M{"_id": bson.M{"$in": ids}, "status": 0},
|
||||
options.Find().SetProjection(bson.M{"_id": 1}))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer c.Close(ctx)
|
||||
for c.Next(ctx) {
|
||||
var row bson.M
|
||||
if e := c.Decode(&row); e == nil {
|
||||
out[row["_id"]] = true
|
||||
}
|
||||
}
|
||||
return out, c.Err()
|
||||
}
|
||||
|
||||
// localTwins 查出这批源文档在目标库的「同一集另一条」:
|
||||
// (mediaId, hashId) 相同但 _id 不同。
|
||||
//
|
||||
// 各站点自己也在跑媒资库同步(common/media/syncMedia.go),它按
|
||||
// (mediaId, hashId) 去重、用自己的 _id 为同一集插一条;本同步再把源库那条
|
||||
// 插进来,同一集就变成两条,前端剧集列表显示「1 1 2 2」。
|
||||
// 更要紧的是媒资库那条在 SaleCoin=0 时 listenPermission=2(免费),
|
||||
// 等于用户可以白看本该收费的集。
|
||||
//
|
||||
// 以源库那条为准(购买记录都挂在它上面),把本地那条下架 isActive:false,
|
||||
// 不删除,便于事后回查。
|
||||
localTwins := func(docs []bson.M) (map[any]any, error) {
|
||||
hashes := make([]any, 0, len(docs))
|
||||
want := map[string]any{} // mediaId|hashId -> 源库 _id
|
||||
for _, d := range docs {
|
||||
h, ok := d["hashId"].(string)
|
||||
if !ok || h == "" {
|
||||
continue
|
||||
}
|
||||
mid, ok := d["mediaId"].(primitive.ObjectID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hashes = append(hashes, h)
|
||||
want[mid.Hex()+"|"+h] = d["_id"]
|
||||
}
|
||||
if len(hashes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// hashId 上有索引,按它捞再在内存里配 mediaId
|
||||
c, e := dst.Find(ctx, bson.M{"hashId": bson.M{"$in": hashes}, "isActive": true},
|
||||
options.Find().SetProjection(bson.M{"_id": 1, "mediaId": 1, "hashId": 1}))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer c.Close(ctx)
|
||||
out := map[any]any{}
|
||||
for c.Next(ctx) {
|
||||
var row struct {
|
||||
ID primitive.ObjectID `bson:"_id"`
|
||||
MediaID primitive.ObjectID `bson:"mediaId"`
|
||||
HashID string `bson:"hashId"`
|
||||
}
|
||||
if e := c.Decode(&row); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
srcID, ok := want[row.MediaID.Hex()+"|"+row.HashID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if sid, ok := srcID.(primitive.ObjectID); ok && sid == row.ID {
|
||||
continue // 就是源库那条自己,不是重复
|
||||
}
|
||||
out[srcID] = row.ID
|
||||
}
|
||||
return out, c.Err()
|
||||
}
|
||||
|
||||
flush := func() error {
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
docs := pending
|
||||
pending = make([]bson.M, 0, batchSize)
|
||||
|
||||
blocked, e := blockedIDs(docs)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
twins, e := localTwins(docs)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
models := make([]mongo.WriteModel, 0, len(docs))
|
||||
for _, doc := range docs {
|
||||
id := doc["_id"]
|
||||
// 站点运营下架之后,源库无权再改它——整条跳过,内容字段也不写。
|
||||
if blocked[id] {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
set := bson.M{}
|
||||
onInsert := bson.M{}
|
||||
for k, v := range doc {
|
||||
if k == "_id" {
|
||||
continue
|
||||
}
|
||||
if local[k] {
|
||||
onInsert[k] = v
|
||||
} else {
|
||||
set[k] = v
|
||||
}
|
||||
}
|
||||
update := bson.M{}
|
||||
if len(set) > 0 {
|
||||
update["$set"] = set
|
||||
}
|
||||
if len(onInsert) > 0 {
|
||||
update["$setOnInsert"] = onInsert
|
||||
}
|
||||
if len(update) == 0 {
|
||||
continue
|
||||
}
|
||||
models = append(models, mongo.NewUpdateOneModel().
|
||||
SetFilter(bson.M{"_id": id}).SetUpdate(update).SetUpsert(true))
|
||||
// 同一集本地已有另一条(媒资库同步插的),把它下架
|
||||
if twinID, ok := twins[id]; ok {
|
||||
models = append(models, mongo.NewUpdateOneModel().
|
||||
SetFilter(bson.M{"_id": twinID}).
|
||||
SetUpdate(bson.M{"$set": bson.M{"isActive": false}}))
|
||||
deduped++
|
||||
}
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
if dryRun {
|
||||
up += len(models)
|
||||
return nil
|
||||
}
|
||||
res, e := dst.BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false))
|
||||
if e != nil {
|
||||
if res != nil {
|
||||
ins += int(res.UpsertedCount)
|
||||
up += int(res.ModifiedCount)
|
||||
}
|
||||
return e
|
||||
}
|
||||
ins += int(res.UpsertedCount)
|
||||
up += int(res.ModifiedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
for srcCur.Next(ctx) {
|
||||
var doc bson.M
|
||||
if e := srcCur.Decode(&doc); e != nil {
|
||||
return ins, up, maxID, e
|
||||
}
|
||||
scanned++
|
||||
// 大集合动辄几十万条,cron 里长时间无输出无法判断死活
|
||||
if time.Since(lastLog) >= 30*time.Second {
|
||||
fmt.Printf(" %-20s -> %-10s 已扫描 %d 条…\n", spec.Name, t.name, scanned)
|
||||
lastLog = time.Now()
|
||||
}
|
||||
// 水位线推进到扫过的最大 _id。即使这条因本地下架被跳过也要推进,
|
||||
// 否则它会永久卡住水位线,导致每次都从它开始重扫。
|
||||
if id, ok := doc["_id"].(primitive.ObjectID); ok && !id.IsZero() {
|
||||
if maxID.IsZero() || id.Hex() > maxID.Hex() {
|
||||
maxID = id
|
||||
}
|
||||
}
|
||||
pending = append(pending, doc)
|
||||
if len(pending) >= batchSize {
|
||||
if e := flush(); e != nil {
|
||||
return ins, up, maxID, e
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := srcCur.Err(); e != nil {
|
||||
return ins, up, maxID, e
|
||||
}
|
||||
if e := flush(); e != nil {
|
||||
return ins, up, maxID, e
|
||||
}
|
||||
if skipped > 0 {
|
||||
fmt.Printf(" %-20s -> %-10s 跳过 %d 条(本地已下架)\n", spec.Name, t.name, skipped)
|
||||
}
|
||||
if deduped > 0 {
|
||||
fmt.Printf(" %-20s -> %-10s 下架 %d 条本地重复集(媒资库同步插的同一集)\n", spec.Name, t.name, deduped)
|
||||
}
|
||||
return ins, up, maxID, nil
|
||||
}
|
||||
|
||||
// tombstoneColls 需要回传源库软删除标记的集合。
|
||||
//
|
||||
// 常规同步只按 _id 水位线插新增,源库事后打上的 isDelete 永远传不过来:
|
||||
// 源站把重复剧集软删掉了,目标库却还留着,App 的剧集列表就会出现
|
||||
// 「1 1 1 2 2 2」这种重复集。每轮补扫一次把标记补齐。
|
||||
//
|
||||
// 只做「源库删了 → 目标库也删」这一个方向,绝不把目标库已删的恢复回来,
|
||||
// 所以和「本地下架后不再同步」不冲突。
|
||||
var tombstoneColls = []string{"media", "media_content", "video_info"}
|
||||
|
||||
// sweepDeleted 把源库 isDelete:true 的文档在目标库也标成已删除,
|
||||
// 并按 web/service/mediacontentser 的同一口径重算父级媒体的集数。
|
||||
func sweepDeleted(ctx context.Context, srcDB *mongo.Database, t target, coll string, dryRun bool,
|
||||
) (marked, fixed int, err error) {
|
||||
|
||||
cur, err := srcDB.Collection(coll).Find(ctx, bson.M{"isDelete": true},
|
||||
options.Find().SetProjection(bson.M{"_id": 1}).SetNoCursorTimeout(true))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
|
||||
dst := t.db.Collection(coll)
|
||||
parents := map[primitive.ObjectID]bool{}
|
||||
batch := make([]primitive.ObjectID, 0, 1000)
|
||||
|
||||
flush := func() error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := batch
|
||||
batch = make([]primitive.ObjectID, 0, 1000)
|
||||
|
||||
// 先把本轮真正要改的那几条捞出来,顺带记下父级,用于之后重算集数
|
||||
hit := make([]primitive.ObjectID, 0, len(ids))
|
||||
c, e := dst.Find(ctx, bson.M{"_id": bson.M{"$in": ids}, "isDelete": false},
|
||||
options.Find().SetProjection(bson.M{"_id": 1, "mediaId": 1}))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer c.Close(ctx)
|
||||
for c.Next(ctx) {
|
||||
var row struct {
|
||||
ID primitive.ObjectID `bson:"_id"`
|
||||
MediaID primitive.ObjectID `bson:"mediaId"`
|
||||
}
|
||||
if e := c.Decode(&row); e != nil {
|
||||
return e
|
||||
}
|
||||
hit = append(hit, row.ID)
|
||||
if coll == "media_content" && !row.MediaID.IsZero() {
|
||||
parents[row.MediaID] = true
|
||||
} else if coll == "media" {
|
||||
parents[row.ID] = true
|
||||
}
|
||||
}
|
||||
if e := c.Err(); e != nil {
|
||||
return e
|
||||
}
|
||||
if len(hit) == 0 {
|
||||
return nil
|
||||
}
|
||||
if dryRun {
|
||||
marked += len(hit)
|
||||
return nil
|
||||
}
|
||||
// 只写 isDelete,不碰 updateTime —— 那是站点本地字段
|
||||
res, e := dst.UpdateMany(ctx, bson.M{"_id": bson.M{"$in": hit}},
|
||||
bson.M{"$set": bson.M{"isDelete": true}})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
marked += int(res.ModifiedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
for cur.Next(ctx) {
|
||||
var row struct {
|
||||
ID primitive.ObjectID `bson:"_id"`
|
||||
}
|
||||
if e := cur.Decode(&row); e != nil {
|
||||
return marked, fixed, e
|
||||
}
|
||||
batch = append(batch, row.ID)
|
||||
if len(batch) >= 1000 {
|
||||
if e := flush(); e != nil {
|
||||
return marked, fixed, e
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := cur.Err(); e != nil {
|
||||
return marked, fixed, e
|
||||
}
|
||||
if e := flush(); e != nil {
|
||||
return marked, fixed, e
|
||||
}
|
||||
|
||||
if dryRun || len(parents) == 0 {
|
||||
return marked, len(parents), nil
|
||||
}
|
||||
|
||||
// 父级的 currentEpisode/freeEpisode 原本只在后台编辑子集时重算,
|
||||
// 这里补上,否则集数会停留在含重复集的旧值(如 2 集的剧显示 6 集)。
|
||||
mc := t.db.Collection("media_content")
|
||||
media := t.db.Collection("media")
|
||||
for mid := range parents {
|
||||
base := bson.M{"mediaId": mid, "isActive": true, "isDelete": false}
|
||||
cnt, e := mc.CountDocuments(ctx, base)
|
||||
if e != nil {
|
||||
return marked, fixed, e
|
||||
}
|
||||
free, e := mc.CountDocuments(ctx, bson.M{"mediaId": mid, "listenPermission": 2,
|
||||
"isActive": true, "isDelete": false})
|
||||
if e != nil {
|
||||
return marked, fixed, e
|
||||
}
|
||||
res, e := media.UpdateOne(ctx, bson.M{"_id": mid},
|
||||
bson.M{"$set": bson.M{"currentEpisode": cnt, "freeEpisode": free}})
|
||||
if e != nil {
|
||||
return marked, fixed, e
|
||||
}
|
||||
fixed += int(res.ModifiedCount)
|
||||
}
|
||||
return marked, fixed, nil
|
||||
}
|
||||
|
||||
func fatal(f string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, f+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
// CollSpec 描述一个集合怎么同步。
|
||||
//
|
||||
// localFields 是「站点本地字段」:目标库自己产生、源库无权覆盖的字段。
|
||||
// 判定依据有三类,改动前请先确认属于哪一类:
|
||||
// 1. 运行时计数器 —— 各站点自己累积的播放/点赞/收藏/购买数
|
||||
// 2. 本地定时任务写入 —— 如 skd/job/hotvalue 会重算并写回 hot
|
||||
// 3. 后台运营编排 —— 如 web/service/vidser 写的 happinessPlazaTop、liaoBaTopSort
|
||||
//
|
||||
// 这些字段走 $setOnInsert:新文档插入时给初值,之后永不再覆盖。
|
||||
// 其余字段走 $set。
|
||||
//
|
||||
// 注意:常规同步只按 _id 水位线取新增,不比对存量修改,所以 $set 实际只在
|
||||
// 新文档插入、或手动 --full 重扫时才起作用。
|
||||
type CollSpec struct {
|
||||
Name string
|
||||
// LocalFields 走 $setOnInsert,只在首次插入时写入,之后永不覆盖。
|
||||
LocalFields []string
|
||||
}
|
||||
|
||||
// 各集合的本地字段清单,来自对源库与目标库 400/300 条抽样的逐字段比对,
|
||||
// 并交叉核对了 web/ 与 skd/ 中的写入点。
|
||||
var specs = []CollSpec{
|
||||
{
|
||||
Name: "video_info",
|
||||
LocalFields: []string{
|
||||
// 运行时计数器
|
||||
"playCount", "fakePlayCount", "effectivePlayCount", "pageViewCount",
|
||||
"likeCount", "fakeLikeCount", "collectCount", "forwardCount",
|
||||
"shareCount", "fakeShareCount", "commentCount", "fakeCommentCount",
|
||||
"validCommentCount", "purchaseCount", "rewarded", "fakeRewarded",
|
||||
// 本地 skd 重算:skd/job/hotvalue/video.go
|
||||
"hot", "hotCommentExposureUntil", "lastHotTriggerAt",
|
||||
// 后台运营编排:web/service/vidser/vid.go
|
||||
"happinessPlazaTop", "liaoBaTop", "liaoBaTopSort",
|
||||
"isTopping", "isRecommend", "chosen", "chosenDate",
|
||||
"sortCode", "tagSort", "recoWeight", "upTag", "shareSort", "worksSort",
|
||||
// 本地修改时间戳
|
||||
"updatedAt",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "media",
|
||||
LocalFields: []string{
|
||||
"countBrowse", "countCollect", "countComment", "countContentPurchases",
|
||||
"countDisLike", "countLike", "countPurchases", "countView",
|
||||
"hot",
|
||||
// 短剧在各站点的编排位
|
||||
"choice", "choiceSort", "general", "generalSort", "sectionSort",
|
||||
"updateTime", "updatedAt", "updatedAct", "updatedDesc",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "media_content",
|
||||
LocalFields: []string{
|
||||
"countBrowse", "countLike", "countPurchases", "fakeCountBrowse",
|
||||
"updateTime", "updatedAt", "updatedAct",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "media_tag",
|
||||
LocalFields: []string{"updatedAt"},
|
||||
},
|
||||
{
|
||||
Name: "media_tag_dimension",
|
||||
LocalFields: []string{"updatedAt"},
|
||||
},
|
||||
{
|
||||
Name: "tag",
|
||||
LocalFields: []string{
|
||||
"collCount", "fakeCollCount", "fakePlayCount", "likeCount",
|
||||
"playRating", "tPlayCount", "vPlayCount", "videoCount", "hotMark",
|
||||
"updatedAt",
|
||||
},
|
||||
},
|
||||
}
|
||||
+12
-8
@@ -93,14 +93,18 @@ func start() {
|
||||
RunPendingSyncIMUsers()
|
||||
})
|
||||
// 同步acg订阅的动漫
|
||||
_, _ = statCron.AddFunc("0 0/30 * * * ?", func() { //每30分钟执行一次
|
||||
ms := mediaService.NewMediaSync(skdg.Redis, mediaCenter.NewMediaCenterService(mediaCenter.Config{
|
||||
Url: skdg.Conf.MediaResourcesCfg.UploadMediaUrl,
|
||||
ProId: skdg.Conf.MediaResourcesCfg.ProId,
|
||||
Secret: skdg.Conf.MediaResourcesCfg.AppSecret,
|
||||
}))
|
||||
ms.SyncAll("系统")
|
||||
})
|
||||
if skdg.Conf.Switch.MediaSyncDisabled {
|
||||
log.Info("【ACG同步任务】已按配置关闭(switch.mediaSyncDisabled),内容改由 contentsync 从源站同步")
|
||||
} else {
|
||||
_, _ = statCron.AddFunc("0 0/30 * * * ?", func() { //每30分钟执行一次
|
||||
ms := mediaService.NewMediaSync(skdg.Redis, mediaCenter.NewMediaCenterService(mediaCenter.Config{
|
||||
Url: skdg.Conf.MediaResourcesCfg.UploadMediaUrl,
|
||||
ProId: skdg.Conf.MediaResourcesCfg.ProId,
|
||||
Secret: skdg.Conf.MediaResourcesCfg.AppSecret,
|
||||
}))
|
||||
ms.SyncAll("系统")
|
||||
})
|
||||
}
|
||||
common.Go(func() {
|
||||
err := aiService.NewAiService(
|
||||
aiService.AppId(int(commod.KFK_APPID)),
|
||||
|
||||
@@ -79,6 +79,15 @@ type GlobalConfig struct {
|
||||
} `json:"url"`
|
||||
Switch struct {
|
||||
NotifyExpire bool `json:"notifyExpire"`
|
||||
// MediaSyncDisabled 关掉 skd 每 30 分钟的媒资库同步。
|
||||
//
|
||||
// 本站内容由 scripts/contentsync 从源站整体同步过来,源站自己会跑媒资库
|
||||
// 同步;本站再跑一遍会为同一集(mediaId+hashId 相同)另插一条自己的 _id,
|
||||
// 剧集列表就出现「1 1 2 2」,而且媒资库那条在 SaleCoin=0 时
|
||||
// listenPermission=2(免费),等于本该收费的集可以白看。
|
||||
//
|
||||
// 留空/false 表示照旧开启——源站必须保持开启,否则新剧集无从进库。
|
||||
MediaSyncDisabled bool `json:"mediaSyncDisabled"`
|
||||
} `json:"switch"`
|
||||
StartDate string `json:"startDate"`
|
||||
MediaResourcesCfg struct {
|
||||
|
||||
Reference in New Issue
Block a user