From 4c9bf507a7add1564bc4c02c790648df3d0081fb Mon Sep 17 00:00:00 2001 From: guanchao <18612113337@163.com> Date: Thu, 24 Sep 2026 20:13:29 +0800 Subject: [PATCH] fix(cloud-rise): allow index setup with duplicate player ids --- laf-cloud/functions/cloudRise/README.md | 2 +- laf-cloud/functions/cloudRise/admin.ts | 9 +++- .../cloudRise/tests/cloud-rise.test.mjs | 48 ++++++++++++++++++- laf-cloud/百人赛生产发布清单.md | 6 +-- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/laf-cloud/functions/cloudRise/README.md b/laf-cloud/functions/cloudRise/README.md index f8b072a..e9f86c4 100644 --- a/laf-cloud/functions/cloudRise/README.md +++ b/laf-cloud/functions/cloudRise/README.md @@ -116,7 +116,7 @@ cloudRise/ 1. 按本次发布清单发布新增云函数及对应 YAML,更新 `login`。相对 main,`userCoin` 和 `jungleTreasure` 没有变化,无需重复发布。 2. 设置服务端环境变量 `ADMIN_TOKEN`,仅用于运营配置,不下发客户端。 -3. 对 `cloudRise/admin` 发 POST:`{ "action": "setup_indexes", "adminToken": "<运营密钥>" }`。 +3. 对 `cloudRise/admin` 发 POST:`{ "action": "setup_indexes", "adminToken": "<运营密钥>" }`。`users.onlyId` 缺少索引时创建普通索引,已有可用普通或唯一索引则保留,允许历史重复编号下完成初始化;其他唯一约束不变。此操作不修复重复编号及其账号定位影响。 4. 参考 `cloudRise-config.example.json` 设置 `periodId/startsAt/endsAt/durationHours/unlockLevel/pools`。时间均为 Unix 毫秒。 5. POST `cloudRise/admin`:`{ "action": "save", "adminToken": "<运营密钥>", "configVersion": "cloudRiseTestV1", "enabled": true, "config": { ... } }`。startsAt 必须在未来;版本标识同一活动内唯一,重复同内容可安全重试,不能覆盖已有版本。 diff --git a/laf-cloud/functions/cloudRise/admin.ts b/laf-cloud/functions/cloudRise/admin.ts index c84eb6b..04afadb 100644 --- a/laf-cloud/functions/cloudRise/admin.ts +++ b/laf-cloud/functions/cloudRise/admin.ts @@ -27,7 +27,14 @@ export default async function (ctx: FunctionContext) { } if (body.action === "setup_indexes") { await mongo.collection("users").createIndex({ taskTime: 1 }); - await mongo.collection("users").createIndex({ onlyId: 1 }, { unique: true, partialFilterExpression: { onlyId: { $type: "number" } } }); + // Legacy users can share onlyId. Prepare lookup indexes without repairing accounts + // or weakening an existing unique index in environments where it already succeeded. + const userIndexes = await mongo.collection("users").listIndexes().toArray(); + const hasOnlyIdIndex = userIndexes.some((index: any) => + Object.keys(index.key).length === 1 && index.key.onlyId === 1 && !index.hidden + && (!index.partialFilterExpression + || JSON.stringify(index.partialFilterExpression) === JSON.stringify({ onlyId: { $type: "number" } }))); + if (!hasOnlyIdIndex) await mongo.collection("users").createIndex({ onlyId: 1 }); await ConfigStore.setupIndexes(); await mongo.collection("cloudRiseSamples").createIndex({ "samples.stage": 1, "samples.start_level": 1, "samples.success_num": 1 }); await mongo.collection("cloudRiseSamples").createIndex({ uid: 1, periodId: 1 }, { diff --git a/laf-cloud/functions/cloudRise/tests/cloud-rise.test.mjs b/laf-cloud/functions/cloudRise/tests/cloud-rise.test.mjs index f0317af..9be5e77 100644 --- a/laf-cloud/functions/cloudRise/tests/cloud-rise.test.mjs +++ b/laf-cloud/functions/cloudRise/tests/cloud-rise.test.mjs @@ -212,7 +212,21 @@ globalThis.__cloudRiseCloud = { mongo: { db: { collection(name) { return { } return { matchedCount: count, modifiedCount: count }; }, - async createIndex(spec, options) { indexes.push({ name, spec, options }); return 'test_index'; }, + listIndexes() { return { async toArray() { + return indexes.filter(i => i.name === name).map(i => ({ key: i.spec, ...i.options })); + } }; }, + async createIndex(spec, options) { + if (name === 'users' && spec.onlyId === 1 && options?.unique) { + const ids = [...db.users.values()].map(user => user.onlyId).filter(id => typeof id === 'number'); + if (new Set(ids).size !== ids.length) throw Object.assign(new Error('duplicate onlyId'), { code: 11000 }); + } + const existing = indexes.find(i => i.name === name && isDeepStrictEqual(i.spec, spec)); + if (existing) { + if (!isDeepStrictEqual(existing.options, options)) throw new Error('IndexOptionsConflict'); + return 'test_index'; + } + indexes.push({ name, spec, options }); return 'test_index'; + }, }; } } } }; globalThis.__cloudRiseCloud.database = () => ({ collection(name) { return { where(query) { return { async getOne() { return { data: await globalThis.__cloudRiseCloud.mongo.db.collection(name).findOne(query) }; }, @@ -393,6 +407,38 @@ test('admin requires configured secret, validates periods, and preserves enrolle } finally { delete process.env.ADMIN_TOKEN; } }); +test('index setup tolerates duplicate onlyId, prepares all indexes and can be retried', async () => { + setup(); process.env.ADMIN_TOKEN = 'test-only'; + db.users.set('duplicate', { _id: 'duplicate', onlyId: 1001, token: 'other-token' }); + const before = structuredClone([...db.users]); + const request = { body: { action: 'setup_indexes', adminToken: 'test-only' } }; + try { + assert.equal((await adminApi(request)).code, 1); + assert.equal(indexes.length, 8); + const lookup = indexes.find(i => i.name === 'users' && i.spec.onlyId === 1); + assert(lookup && !lookup.options?.unique); + assert(indexes.some(i => i.name === 'activityConfigs' && i.options?.unique)); + assert(indexes.some(i => i.name === 'cloudRiseSamples' && i.options?.unique)); + assert(indexes.some(i => i.name === 'cloudRiseRuns')); + assert.equal((await adminApi(request)).code, 1); + assert.equal(indexes.length, 8); + assert.deepEqual([...db.users], before); + } finally { delete process.env.ADMIN_TOKEN; } +}); + +test('index setup preserves existing ordinary and unique onlyId indexes', async () => { + for (const options of [undefined, { unique: true }, + { unique: true, partialFilterExpression: { onlyId: { $type: 'number' } } }]) { + setup(); process.env.ADMIN_TOKEN = 'test-only'; + indexes.push({ name: 'users', spec: { onlyId: 1 }, options }); + try { + assert.equal((await adminApi({ body: { action: 'setup_indexes', adminToken: 'test-only' } })).code, 1); + assert.equal(indexes.length, 8); + assert.deepEqual(indexes.find(i => i.name === 'users' && i.spec.onlyId === 1).options, options); + } finally { delete process.env.ADMIN_TOKEN; } + } +}); + test('real client service talks to real activity API: manual stages, client awards, and offline win recovery', async () => { const { createRequire } = await import('node:module'); const { runInNewContext } = await import('node:vm'); diff --git a/laf-cloud/百人赛生产发布清单.md b/laf-cloud/百人赛生产发布清单.md index cef5e0f..65c36b1 100644 --- a/laf-cloud/百人赛生产发布清单.md +++ b/laf-cloud/百人赛生产发布清单.md @@ -56,12 +56,12 @@ { "action": "setup_indexes", "adminToken": "" } ``` -该操作会调用以下 9 项索引创建;只有返回 `code: 1` 才算完成。 +该操作会准备以下 8 项索引;只有返回 `code: 1` 才算完成。已有可用的 `users.onlyId` 索引直接复用。 | 集合 | 索引 | 附加约束 | | --- | --- | --- | | `users` | `{ taskTime: 1 }` | 匹配资料池查询使用,不是启用每日任务 | -| `users` | `{ onlyId: 1 }` | unique;部分条件 `onlyId: { $type: "number" }` | +| `users` | `{ onlyId: 1 }` | 缺失时创建普通索引;已有普通或覆盖数字编号的唯一索引则保留 | | `activityConfigs` | `{ activityId: 1, configVersion: 1 }` | unique;部分条件 `recordType: "version"`;名称 `activity_config_version` | | `activityConfigs` | `{ activityId: 1, recordType: 1, status: 1, effectiveFrom: -1, publishedAt: -1 }` | 普通索引 | | `activityConfigs` | `{ activityId: 1, "config.periodId": 1, publishedAt: -1 }` | 普通索引 | @@ -69,7 +69,7 @@ | `cloudRiseSamples` | `{ uid: 1, periodId: 1 }` | unique;部分条件 `schemaVersion: 2`;名称 `cloudRiseSamples_uid_period` | | `cloudRiseRuns` | `{ uid: 1, periodId: 1 }` | 普通索引 | -上线前检查 `users.onlyId` 数字值没有重复,否则唯一索引会失败;不要为使索引通过而直接删除用户。配置、活动状态和样本由服务端维护,不给客户端直写权限。 +生产已有重复 `users.onlyId`,索引初始化暂不强制该字段唯一,不删除用户、不重分配编号,也不移除已有唯一索引。之前因 `onlyId` 重复失败的环境,更新 `cloudRise/admin` 后重试原 `setup_indexes` 请求即可。此变更只解除索引初始化阻塞,账号定位对 `onlyId` 唯一性的依赖仍待后续修复。配置、活动状态和样本由服务端维护,不给客户端直写权限。 main 中不存在百人赛模块,按本次首次上线场景无需运行配置迁移、旧样本迁移或历史归档精简。如果生产曾有 main 之外的人工部署记录,应先核对存量再决定,不能直接运行同时处理黄金矿工的 `activityConfig/migrate.cjs`。