[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:
2026-10-29 20:18:36 +08:00
co-authored by Claude Opus 5
parent 3dcf6930d6
commit acb9a34520
5 changed files with 799 additions and 8 deletions
+134
View File
@@ -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。
+567
View File
@@ -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)
}
+77
View File
@@ -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
View File
@@ -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)),
+9
View File
@@ -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 {