[AI] feat: dramacmt 加补齐与重算两个模式

--top-up:--min~--max 当成目标总条数按差额补,已经落在区间内的不动。
拿每次新摇的目标值比对会让重复跑把条数一路往上限顶,所以按 min 判定。

--recount:把 countComment 重算成 App 列表口径的实际可见条数,铺完自动跑一次。
这个字段只会飘不会自己回来,两个来源:发评论时无条件 +1 而审核不通过和删除都
不回减(后端唯一的重算函数明确跳过 drama);contentsync 用 $setOnInsert 赋初值
时 countComment 在清单里,源库新剧带着源站的评论数进来而本地一条评论都没有。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 11:36:29 +08:00
co-authored by Claude Opus 5
parent 96caa5cda0
commit 6f5b0ecb77
2 changed files with 187 additions and 4 deletions
+37
View File
@@ -98,12 +98,49 @@ go build -o /tmp/dramacmt ./scripts/dramacmt/
/tmp/dramacmt --dst "$DST_HUANGGUO" --db huangguo --users 3000 \ /tmp/dramacmt --dst "$DST_HUANGGUO" --db huangguo --users 3000 \
--min 50 --max 200 --only-empty=false --min 50 --max 200 --only-empty=false
# 补齐模式:把每部剧补到 50~200 条总量(tangguo 2026-10-30 实际用的参数)
/tmp/dramacmt --dst "$DST_TANGGUO" --db tangguo --users 5000 \
--min 50 --max 200 --only-empty=false --top-up
# 只重算 countComment,不铺评论(每次 contentsync 带进新剧之后都要跑)
/tmp/dramacmt --dst "$DST_TANGGUO" --db tangguo --recount
# 调试:只跑 3 部,固定种子便于复现 # 调试:只跑 3 部,固定种子便于复现
/tmp/dramacmt --dst "$DST_TANGGUO" --db tangguo --limit 3 --seed 42 /tmp/dramacmt --dst "$DST_TANGGUO" --db tangguo --limit 3 --seed 42
``` ```
`--db` 必须显式传:URI 里的 `authSource` 通常是 `admin`,推不出业务库名。 `--db` 必须显式传:URI 里的 `authSource` 通常是 `admin`,推不出业务库名。
### `--top-up`
默认模式下 `--min~--max` 是**新增**条数,`--top-up` 把它当成**目标总条数**,
按差额补,已经够的跳过。
区别在于底子不空的站:tangguo 的短剧先前已经铺过 20~50 条,照新增算会冲到
250 条,和其余四站的 50~200 对不上。代价是每部剧多一次 `CountDocuments`
往返(773 部约两分钟,远端副本集的网络延迟占大头)。
现有条数按 App 列表第 1 页的口径数,不读 `countComment`——那个字段可能是飘的。
### `--recount`
把每部短剧的 `countComment` 重算成 App 列表口径的实际可见条数。铺完会自动跑一次,
`--no-recount` 可以跳过,`--recount` 则是只重算不铺。
**这个字段只会飘,不会自己回来**,有两个来源:
1. 发评论时 `incCommentCount` 无条件 `+1`(`app/service/commentser/cmtsend.go:224`),
审核不通过和删除都不回减。后端唯一的重算函数
`web/service/commentser.rebuildSingleObjCommentCount` **明确跳过 drama**
(`commentAgent.go:268` 只认 video 和 cartoon),所以线上没有任何一条路会修它
2. `contentsync` 用 `$setOnInsert` 给新文档的本地字段赋初值,`countComment` 在
那份清单里(`scripts/contentsync/spec.go`),于是**源库新剧带着源站的评论数
进来,本地却一条评论都没有**
第 2 条意味着每次 contentsync 带进新短剧之后都要重算一次,否则新剧在 App 里显示
「N 条评论」但列表是空的。2026-11-03 实测:一次增量同步带进 16 部新短剧,
全都带着源站的 `countComment`。
## 撤回 ## 撤回
机器人用户和评论都带标记,撤回是两条命令: 机器人用户和评论都带标记,撤回是两条命令:
+150 -4
View File
@@ -43,6 +43,9 @@ func main() {
minCmt = flag.Int("min", 20, "每部剧最少评论数") minCmt = flag.Int("min", 20, "每部剧最少评论数")
maxCmt = flag.Int("max", 50, "每部剧最多评论数") maxCmt = flag.Int("max", 50, "每部剧最多评论数")
onlyEmpty = flag.Bool("only-empty", true, "只给 countComment=0 的剧铺") onlyEmpty = flag.Bool("only-empty", true, "只给 countComment=0 的剧铺")
topUp = flag.Bool("top-up", false, "补齐模式:--min~--max 当成目标总条数,按差额补,已经够的跳过")
recount = flag.Bool("recount", false, "只重算 countComment,不铺评论")
noRecount = flag.Bool("no-recount", false, "铺完后不重算 countComment")
limit = flag.Int("limit", 0, "最多处理多少部剧,0 为不限(调试用)") limit = flag.Int("limit", 0, "最多处理多少部剧,0 为不限(调试用)")
dryRun = flag.Bool("dry-run", false, "只统计不写入") dryRun = flag.Bool("dry-run", false, "只统计不写入")
seed = flag.Int64("seed", 0, "随机种子,0 用当前时间") seed = flag.Int64("seed", 0, "随机种子,0 用当前时间")
@@ -64,7 +67,6 @@ func main() {
*seed = time.Now().UnixNano() *seed = time.Now().UnixNano()
} }
rnd := rand.New(rand.NewSource(*seed)) rnd := rand.New(rand.NewSource(*seed))
fmt.Printf("随机种子 %d(复现同一批数据可用 --seed)\n", *seed)
ctx, cancel := context.WithTimeout(context.Background(), *timeout) ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel() defer cancel()
@@ -81,6 +83,15 @@ func main() {
} }
db := cli.Database(*dbName) db := cli.Database(*dbName)
if *recount {
if err := recountAll(ctx, db, *dryRun); err != nil {
fatal("重算 countComment 失败: %v", err)
}
return
}
fmt.Printf("随机种子 %d(复现同一批数据可用 --seed)\n", *seed)
portraits, err := samplePortraits(ctx, db) portraits, err := samplePortraits(ctx, db)
if err != nil { if err != nil {
fatal("取头像素材失败: %v", err) fatal("取头像素材失败: %v", err)
@@ -102,9 +113,26 @@ func main() {
} }
fmt.Printf("待铺评论的在线短剧 %d 部\n\n", len(dramas)) fmt.Printf("待铺评论的在线短剧 %d 部\n\n", len(dramas))
total, done := 0, 0 total, done, skipped := 0, 0, 0
for _, d := range dramas { for _, d := range dramas {
n := *minCmt + rnd.Intn(*maxCmt-*minCmt+1) n := *minCmt + rnd.Intn(*maxCmt-*minCmt+1)
if *topUp {
// 补齐模式下 --min~--max 是目标总条数,不是新增条数。
// 糖果的短剧已经有 20~50 条,照新增算会冲到 250 条,和其余站对不上。
cur, err := visibleCount(ctx, db, d.ID)
if err != nil {
fmt.Printf("[FAIL] %s 查现有条数: %v\n", d.Title, err)
continue
}
// 已经落在 [min, max] 区间内的一律不动。
// 拿每次新摇的目标值去比对会让重复跑把条数一路往 max 顶
// (已有 60 条的剧遇到目标 180 还会再补 120),这样跑几次就全挤到上限了。
if int(cur) >= *minCmt {
skipped++
continue
}
n -= int(cur)
}
cmts := buildComments(d, n, uids, rnd) cmts := buildComments(d, n, uids, rnd)
total += len(cmts) total += len(cmts)
if *dryRun { if *dryRun {
@@ -121,11 +149,129 @@ func main() {
} }
} }
tail := ""
if *topUp {
tail = fmt.Sprintf(",另有 %d 部已经够了跳过", skipped)
}
if *dryRun { if *dryRun {
fmt.Printf("\nDRY-RUN:%d 部剧,将写入 %d 条评论(未写入)\n", done, total) fmt.Printf("\nDRY-RUN:%d 部剧,将写入 %d 条评论(未写入)%s\n", done, total, tail)
return return
} }
fmt.Printf("\n完成:%d 部剧,共写入 %d 条评论\n", done, total) fmt.Printf("\n完成:%d 部剧,共写入 %d 条评论%s\n", done, total, tail)
if *noRecount {
return
}
fmt.Println()
if err := recountAll(ctx, db, false); err != nil {
fatal("重算 countComment 失败: %v", err)
}
}
// recountAll 把每部短剧的 countComment 重算成 App 列表口径的实际可见条数。
//
// 两个地方会让这个字段飘,所以铺完必须重算一次:
// - 发评论时 incCommentCount 无条件 +1(app/service/commentser/cmtsend.go),
// 审核不通过和删除都不回减,后端也没有任何一条路会重算短剧的这个字段
// - contentsync 用 $setOnInsert 给新文档的本地字段赋初值,countComment 在列表里,
// 所以源库新剧带着源站的评论数进来,本地却一条评论都没有
//
// 口径与 app/service/commentser.GetTotalComments 一致——App 评论列表第 2 页之后
// 就是用它实时算总数的,第 1 页读 countComment,两者必须对得上。
func recountAll(ctx context.Context, db *mongo.Database, dryRun bool) error {
cur, err := db.Collection("comment").Aggregate(ctx, []bson.M{
{"$match": bson.M{"objType": "drama", "level": 1, "status": 1, "isDelete": false,
"isAdvertiser": bson.M{"$in": []any{nil, false}}}},
{"$group": bson.M{"_id": "$objID", "n": bson.M{"$sum": 1}}},
}, options.Aggregate().SetAllowDiskUse(true))
if err != nil {
return err
}
real := map[primitive.ObjectID]int64{}
for cur.Next(ctx) {
var row struct {
ID primitive.ObjectID `bson:"_id"`
N int64 `bson:"n"`
}
if err := cur.Decode(&row); err != nil {
cur.Close(ctx)
return err
}
real[row.ID] = row.N
}
cur.Close(ctx)
if err := cur.Err(); err != nil {
return err
}
mcur, err := db.Collection("media").Find(ctx, bson.M{"mediaType": "drama"},
options.Find().SetProjection(bson.M{"_id": 1, "countComment": 1}))
if err != nil {
return err
}
defer mcur.Close(ctx)
var (
models []mongo.WriteModel
scanned, up, down int
sumOld, sumNew int64
)
for mcur.Next(ctx) {
var row struct {
ID primitive.ObjectID `bson:"_id"`
Count int64 `bson:"countComment"`
}
if err := mcur.Decode(&row); err != nil {
return err
}
scanned++
want := real[row.ID]
if row.Count == want {
continue
}
if want > row.Count {
up++
} else {
down++
}
sumOld += row.Count
sumNew += want
models = append(models, mongo.NewUpdateOneModel().
SetFilter(bson.M{"_id": row.ID}).
SetUpdate(bson.M{"$set": bson.M{"countComment": want}}))
}
if err := mcur.Err(); err != nil {
return err
}
fmt.Printf("重算 countComment:扫 %d 部短剧,不一致 %d 部(调高 %d,调低 %d),合计 %d -> %d\n",
scanned, len(models), up, down, sumOld, sumNew)
if dryRun {
fmt.Println(" [dry-run] 未写入")
return nil
}
if len(models) == 0 {
return nil
}
res, err := db.Collection("media").BulkWrite(ctx, models, options.BulkWrite().SetOrdered(false))
if err != nil {
return err
}
fmt.Printf(" 已写入:matched=%d modified=%d\n", res.MatchedCount, res.ModifiedCount)
return nil
}
// visibleCount 返回一部剧当前在 App 评论列表里能看到的一级评论条数。
// 口径与 app/service/commentser.GetTotalComments 一致。
func visibleCount(ctx context.Context, db *mongo.Database, mediaID primitive.ObjectID) (int64, error) {
return db.Collection("comment").CountDocuments(ctx, bson.M{
"objID": mediaID,
"objType": "drama",
"level": 1,
"status": 1,
"isDelete": false,
"isAdvertiser": bson.M{"$in": []any{nil, false}},
})
} }
// samplePortraits 从库里现有用户取头像路径,避免把素材路径写死在代码里。 // samplePortraits 从库里现有用户取头像路径,避免把素材路径写死在代码里。