用户信息托管
IM 服务端只保存一层很薄的用户资料(userId / name / avatarUrl / 状态位),没有独立的“用户中心”产品。是否把 IM 当作用户资料的权威源,是应用架构选择;本页给出两种托管模型、真实字段与写入路径、key 规范、同步 API 与限制。
资料模型
| 字段 | 类型 | 写入路径 | 客户端可见 |
|---|---|---|---|
userId | string | 注册时确定,不可修改(改 ID = 新用户) | 是 |
name | string | user.register / user.update / 客户端改本人资料 / profile.clean 回退 | 是(空则回落为 userId) |
avatarUrl | string | user.register / user.update(客户端改资料接口不接受 avatarUrl) | Open API 可见;客户端 GET profile 不返回 |
createdAt | int64 | 注册时写入 | 是 |
banned | bool | user.ban / 管理端封禁 | 是 |
mutedUntil | uint64 | user.chat.mute / 群禁言不在此字段 | 是 |
deactivated | bool | user.deactivate / user.reactivate | 通过 1016 体现 |
客户端 GET /api/apps/{appkey}/users/{userId}/profile 只返回 userId/name/createdAt,不返回 avatarUrl;头像要由业务侧自己分发或走 Open API 读取。
user.update(服务端)不触发 user.profile_changed 回调;只有客户端自助修改本人 name 时才触发。需要审计资料变更时,请在自己的同步层记录。
两种托管模型
| 模型 | 权威源 | 适合 | 同步方向 |
|---|---|---|---|
| A. 自托管用户表 | IM 服务端 | 独立 IM 应用、没有既有账号体系 | 注册/改资料都调 Open API;业务库按需拉取 |
| B. 外部用户系统为主 | 你的业务库 / IdP | 已有账号体系、需要与组织架构/SSO 对齐 | 业务库 → 调 user.register/user.update 同步到 IM;IM 只做展示副本 |
推荐把「B」作为长期模型:IM 不提供目录同步连接器、LDAP/SCIM、组织架构或用户导入向导;同步逻辑必须在你自己的服务里实现。
同步时序(外部为主)
- 初始化:批量导入
按
user.batch.get校验已存在用户(上限 100/次),缺失的用user.register创建;user.register对已存在 userId 是幂等的(更新 name/avatarUrl)。 - 增量:业务库变更时推送
改昵称/头像 →
user.update;封禁/注销 →user.ban/user.deactivate。保留业务侧事件 ID 以便补偿。 - 反向:客户端自助改昵称
客户端只能改 name;如需同步回业务库,订阅
user.profile_changed回调(载荷{userId,changed:{name},timestamp})后写回。 - 审核回滚
接内容审核时,审核拒绝可调
user.profile.moderation.sync(status=rejected + fields)清空 name/avatarUrl;清空后 name 回落为 userId。 - 对账
IM 用户没有
updatedAt,无法按时间戳做增量对账;用业务库版本号 + 定期全量比对(batch.get 100/次)兜底。
key 规范
| 项 | 规则 | 证据 / 说明 |
|---|---|---|
| userId 长度 | 1–64 字符(UTF-8 字符数) | openapi.rs / user.register;push deliveries 亦限制 ≤64 |
| appkey 长度 | 1–64 字符 | admin_ext.rs |
| 应用内唯一 | 同一 appkey 下唯一;不同应用可有同名 userId | 内部键为 {appkey}:{userId} |
| 禁止冒号 | 业务约定不得包含 : | 内部键用冒号拼接;当前实现未显式拒绝,含冒号会造成键歧义/越权风险 |
| 字符集 | 除长度外未校验 | 建议限定 [A-Za-z0-9_-],避免 URL/日志/导出歧义 |
| name 长度 | 1–64 字符 | user.register / user.update / 客户端改资料 |
| avatarUrl | 未校验长度/格式 | 建议 https CDN URL,并在客户端做兜底头像 |
邮箱含 @ 尚可,但手机号/邮箱中的 : 或超长值会触发键冲突或 1001。: 是当前实现未拦截的真实风险,业务侧必须先做映射(如 u_ + 内部 ID)。
Open API
| 能力 | 接口 | 关键限制 / 语义 |
|---|---|---|
| 注册 / 更新 | user.register / user.update | register 幂等(存在即更新 name/avatarUrl);update 对封禁用户返回 1016 |
| 查询 | user.get / user.batch.get | batch 上限 100;不存在的 userId 在 missing 中返回 |
| 清理托管资料 | user.profile.clean | name 回退为 userId、avatarUrl 清空;保留账号与关系 |
| 审核回滚 | user.profile.moderation.sync | status=approved / rejected;rejected 时按 fields 清空 name/avatarUrl |
| 标签 | user.tag.set/get/batch.set | 全量覆盖,≤20 个,每个 ≤32 字符;批量 ≤100 条 |
| 订阅关系 | user.subscription.set/get | 全量覆盖,≤100 个 userId |
| 状态 | user.ban / user.deactivate / user.reactivate / user.deactivated.list | 注销保留记录但禁止登录/收发并踢下线 |
| 删除 | user.delete | 物理删除(开发/测试环境用);生产建议注销而非删除 |
| 所属群 | user.groups.list | page/size 分页;缺省返回前 5000 个 |
注册 + 查询 + 批量(Node SDK)
import { AppClient } from '@ni-paas-im/server-sdk';
const app = new AppClient({
appKey: process.env.IM_APP_KEY,
appSecret: process.env.IM_APP_SECRET,
baseUrl: 'http://127.0.0.1:8080',
});
await app.registerUser({ userId: 'u_10086', name: '张三', avatarUrl: 'https://cdn.example.com/u/10086.png' });
await app.updateUser({ userId: 'u_10086', name: '张三(产品)' });
const { users, missing } = await app.batchGetUsers({ userIds: ['u_10086', 'u_99999'] });
console.log(users, missing); // u_99999 在 missing审核回滚(curl,Open API 需签名)
APP_KEY="demo"; APP_SECRET="YOUR_APP_SECRET"; BASE_URL="http://127.0.0.1:8080"
NONCE="$(openssl rand -hex 16)"; TIMESTAMP="$(date +%s%3N)"
SIGNATURE="$(printf '%s' "$APP_SECRET$NONCE$TIMESTAMP" | openssl sha1 | awk '{print $2}')"
curl -sS -X POST "$BASE_URL/docs/platform-chat-api/user/profile/moderation/sync" \
-H "content-type: application/json" \
-H "App-Key: $APP_KEY" -H "Nonce: $NONCE" \
-H "Timestamp: $TIMESTAMP" -H "Signature: $SIGNATURE" \
-d '{"userId":"u_10086","status":"rejected","reason":"nickname_violation","fields":{"name":"","avatarUrl":""}}'客户端读取路径
- Web SDK:
client.getUserProfile(userId)→GET /api/apps/{appkey}/users/{userId}/profile,返回{userId,name,createdAt}。 - Web SDK:
client.updateMyUserProfile({name})→POST /api/apps/{appkey}/users/{myUserId}/profile;只能改自己的 name,长度 1–64,成功后触发user.profile_changed。 - 其它端(Android/Flutter/Electron/Native)按
sdk-imlib.json的getUserProfile/updateMyUserProfile语义实现;头像不在客户端写入路径。
错误码与限制
| 码 | 名称 | 场景 |
|---|---|---|
1001 | INVALID_PARAMETER | userId/name 超长、空值、字段缺失 |
1004 | USER_NOT_FOUND | 查询/更新/清理/审核同步的目标不存在 |
1016 | USER_BANNED | 封禁用户不可更新资料/签发 token;注销用户签发 token 亦返回 1016 |
1021 | SERVICE_NOT_ACTIVATED | 相关服务未开通 |
1003 | FORBIDDEN | 客户端改他人资料(仅本人) |
| 限制项 | 值 |
|---|---|
| userId | 1–64 字符,应用内唯一,禁止冒号(业务约定) |
| name | 1–64 字符 |
| batch.get | ≤100 个 userId |
| tags | ≤20 个,每个 ≤32 字符;batch ≤100 条 |
| subscriptions | ≤100 个 userId |
| avatarUrl | 服务端不校验长度/格式 |
| 资料更新时间 | 无 updatedAt 字段;无法按时间戳增量对账 |