小程序、App、H5微应用:企业培训场景下的三端登录态同步方案
一、问题全景:三套登录体系,一个用户
先说清楚三端的登录机制差异,这是所有问题的根源。
| 维度 | 微信小程序 | 原生App | H5微应用(钉钉/企微/飞书) |
|---|---|---|---|
| 登录方式 | wx.login获取code → 后端换openid | 账号密码 / 手机号验证码 | OAuth免登(授权码模式) |
| 身份凭证 | openid + unionid | JWT access_token + refresh_token | 平台access_token(钉钉/企微/飞书各自签发) |
| 会话生命周期 | 受微信session管理,可能被系统回收 | App前台/后台切换,可能长时间存活 | 跟随宿主App生命周期,关闭Tab即失效 |
| 多设备 | 同一微信号只能在一个设备登录 | 可以多设备同时登录(手机+平板) | 跟随宿主App的设备 |
| 退出登录 | 用户主动退出 / 长期未使用 | 用户主动退出 / Token过期 | 用户退出宿主App / Token过期 |
这三套完全不同的登录机制,背后对应的是三个不同的身份源:微信体系的用户身份、企学宝自有账户体系的用户身份、以及企业OA平台(钉钉/企微/飞书)的员工身份。
核心挑战是:这三个身份源必须映射到同一个"企学宝用户"上。 一个员工在钉钉里叫"张三(工号10086)“,在微信小程序里叫"微信用户oXXXX”,在App里用手机号13800138000登录——系统必须知道这三个是同一个张三。
二、统一身份层:三源归一
2.1 账号关联模型
我们设计了一个"主账号 + 关联身份"的模型:
-- 主账号:企学宝内部唯一用户标识
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
account_uid VARCHAR(64) UNIQUE NOT NULL, -- 全局唯一用户标识
display_name VARCHAR(128),
avatar_url VARCHAR(512),
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 关联身份:每个外部身份源对应一条记录
CREATE TABLE account_identities (
id BIGINT PRIMARY KEY,
account_uid VARCHAR(64) NOT NULL REFERENCES accounts(account_uid),
identity_type VARCHAR(32) NOT NULL, -- 'wechat' / 'mobile' / 'dingtalk' / 'wecom' / 'feishu'
identity_id VARCHAR(256) NOT NULL, -- 各平台的用户唯一标识
identity_name VARCHAR(256), -- 该平台上的用户名
corp_id VARCHAR(128), -- 所属企业ID(OA平台场景)
extra_data JSONB, -- 平台特有的扩展信息
bound_at TIMESTAMP DEFAULT NOW(),
last_login_at TIMESTAMP,
-- 同一identity_type + identity_id + corp_id 唯一
UNIQUE(identity_type, identity_id, corp_id)
);
-- 索引:通过外部身份快速查找主账号
CREATE INDEX idx_identity_lookup ON account_identities(identity_type, identity_id, corp_id);
CREATE INDEX idx_identity_account ON account_identities(account_uid);
这个模型的关键设计点:
第一,account_uid是系统内部的全局唯一标识。 所有业务数据(学习记录、考试成绩、证书)都关联account_uid,而不是关联某个外部平台的用户ID。这样无论用户从哪个端登录,只要映射到同一个account_uid,数据就是通的。
第二,corp_id解决了一个常见难题——同一个员工在不同企业里可能有不同的OA身份。 比如张三同时在A集团(钉钉)和B子公司(企微)任职,他在两个OA平台上的员工ID不同,但可能映射到同一个企学宝账号(如果A集团和B子公司是同一个客户),也可能映射到不同账号(如果是两个独立客户)。corp_id让这个区分成为可能。
2.2 身份绑定流程
用户第一次从某个端登录时,系统需要完成"身份绑定"——把外部身份关联到主账号上。绑定的触发条件因端而异:
class IdentityBinder:
"""身份绑定器:处理首次登录的身份关联"""
async def bind_on_first_login(self, identity_type: str,
identity_id: str,
corp_id: str = None,
profile: dict = None) -> str:
"""
首次登录时的身份绑定逻辑
返回 account_uid
"""
# 1. 先查是否已有绑定
existing = await self.db.find_identity(
identity_type=identity_type,
identity_id=identity_id,
corp_id=corp_id
)
if existing:
# 已有绑定,更新最后登录时间,返回主账号
await self.db.update_last_login(existing.account_uid)
return existing.account_uid
# 2. 没有绑定,尝试自动关联
account_uid = await self._try_auto_bind(
identity_type, identity_id, corp_id, profile
)
return account_uid
async def _try_auto_bind(self, identity_type: str,
identity_id: str,
corp_id: str = None,
profile: dict = None) -> str:
"""
自动绑定策略:按优先级尝试关联到已有主账号
"""
# 策略1:通过手机号关联
# 如果当前身份携带了手机号,且该手机号已注册过主账号
if profile and profile.get("mobile"):
existing_account = await self.db.find_account_by_mobile(
profile["mobile"]
)
if existing_account:
await self._create_identity_link(
existing_account.account_uid,
identity_type, identity_id, corp_id, profile
)
return existing_account.account_uid
# 策略2:通过企业邮箱关联
# OA平台(钉钉/企微/飞书)通常能提供员工的企业邮箱
if profile and profile.get("email"):
existing_account = await self.db.find_account_by_email(
profile["email"]
)
if existing_account:
await self._create_identity_link(
existing_account.account_uid,
identity_type, identity_id, corp_id, profile
)
return existing_account.account_uid
# 策略3:通过企业通讯录匹配
# 如果corp_id对应的企业已有员工列表,通过工号/姓名匹配
if corp_id and profile and profile.get("employee_no"):
existing_account = await self.db.find_account_by_employee_no(
corp_id, profile["employee_no"]
)
if existing_account:
await self._create_identity_link(
existing_account.account_uid,
identity_type, identity_id, corp_id, profile
)
return existing_account.account_uid
# 策略4:无法自动关联,创建新主账号
new_account = await self._create_new_account(profile)
await self._create_identity_link(
new_account.account_uid,
identity_type, identity_id, corp_id, profile
)
return new_account.account_uid
自动绑定的优先级设计很关键——手机号是最强的关联信号(一个人通常只有一个手机号),企业邮箱次之,工号再次之。如果所有策略都匹配不上,才创建新账号。
但自动绑定不是万能的。有一种常见场景:员工先用手机号在App上注册了账号,后来企业在钉钉上开通了企学宝,员工通过钉钉H5免登进入——此时系统可以通过手机号自动关联(如果钉钉通讯录里有手机号)。但如果钉钉通讯录里没有手机号(很多企业不填),自动绑定就失败了,需要走手动绑定流程。
2.3 手动绑定:用户主动关联多端身份
当自动绑定失败时,需要用户在端上主动完成绑定:
class ManualBindFlow:
"""手动绑定流程"""
async def initiate_bind(self, account_uid: str,
target_identity_type: str) -> dict:
"""
发起绑定:生成绑定凭证
场景:用户在App上扫描小程序二维码,或在小程序上输入手机号验证码
"""
bind_token = generate_secure_token(ttl=300) # 5分钟有效
await self.redis.setex(
f"bind:{bind_token}",
300,
json.dumps({
"account_uid": account_uid,
"target_type": target_identity_type,
"created_at": datetime.utcnow().isoformat()
})
)
return {
"bind_token": bind_token,
"expires_in": 300,
"bind_url": f"https://qxb.example.com/bind/{bind_token}"
}
async def confirm_bind(self, bind_token: str,
identity_type: str,
identity_id: str,
verification: dict) -> bool:
"""
确认绑定:验证用户身份后完成关联
"""
# 1. 验证bind_token有效性
bind_data = await self.redis.get(f"bind:{bind_token}")
if not bind_data:
raise BindTokenExpiredError("绑定凭证已过期")
bind_info = json.loads(bind_data)
account_uid = bind_info["account_uid"]
# 2. 验证用户身份(手机号验证码 / 密码验证)
if verification.get("type") == "mobile_sms":
is_valid = await self._verify_sms_code(
verification["mobile"],
verification["code"]
)
if not is_valid:
raise VerificationFailedError("验证码错误")
# 确认该手机号属于目标账号
account = await self.db.get_account(account_uid)
if account.mobile != verification["mobile"]:
raise VerificationFailedError("手机号不匹配")
elif verification.get("type") == "password":
is_valid = await self._verify_password(
account_uid,
verification["password"]
)
if not is_valid:
raise VerificationFailedError("密码错误")
# 3. 检查目标身份是否已被其他账号绑定
existing_link = await self.db.find_identity(
identity_type=identity_type,
identity_id=identity_id
)
if existing_link and existing_link.account_uid != account_uid:
raise IdentityAlreadyBoundError(
"该身份已关联其他账号,请先解绑"
)
# 4. 创建绑定
await self._create_identity_link(
account_uid, identity_type, identity_id
)
# 5. 清理bind_token
await self.redis.delete(f"bind:{bind_token}")
return True
三、Token管理:三端各自的会话策略
登录态同步不只是"识别用户是谁",还要管理好每一端的Token生命周期。三端的Token策略差异很大。
3.1 统一的Token结构
不管用户从哪个端登录,后端签发的Token结构是统一的:
import jwt
import time
from dataclasses import dataclass
@dataclass
class TokenPayload:
"""统一的Token载荷结构"""
account_uid: str # 主账号标识
device_type: str # 'app_ios' / 'app_android' / 'miniapp' / 'h5_dingtalk' / 'h5_wecom' / 'h5_feishu'
session_id: str # 会话ID(用于单点踢出)
identity_type: str # 本次登录使用的身份类型
corp_id: str # 所属企业
issued_at: int # 签发时间
expires_at: int # 过期时间
def to_dict(self) -> dict:
return {
"uid": self.account_uid,
"dev": self.device_type,
"sid": self.session_id,
"idp": self.identity_type,
"corp": self.corp_id,
"iat": self.issued_at,
"exp": self.expires_at,
}
class TokenService:
"""Token签发与验证服务"""
ACCESS_TOKEN_TTL = 7200 # access_token 2小时
REFRESH_TOKEN_TTL = 2592000 # refresh_token 30天
def __init__(self, secret_key: str, algorithm: str = "HS256"):
self.secret_key = secret_key
self.algorithm = algorithm
def issue_token_pair(self, account_uid: str, device_type: str,
identity_type: str, corp_id: str) -> dict:
"""签发 access_token + refresh_token 对"""
now = int(time.time())
session_id = generate_session_id()
# access_token
access_payload = TokenPayload(
account_uid=account_uid,
device_type=device_type,
session_id=session_id,
identity_type=identity_type,
corp_id=corp_id,
issued_at=now,
expires_at=now + self.ACCESS_TOKEN_TTL,
)
access_token = jwt.encode(
access_payload.to_dict(),
self.secret_key,
algorithm=self.algorithm
)
# refresh_token(更长有效期,存储在Redis中可主动吊销)
refresh_token = generate_secure_token()
await self.redis.setex(
f"refresh:{refresh_token}",
self.REFRESH_TOKEN_TTL,
json.dumps({
"account_uid": account_uid,
"session_id": session_id,
"device_type": device_type,
})
)
return {
"access_token": access_token,
"access_token_expires_in": self.ACCESS_TOKEN_TTL,
"refresh_token": refresh_token,
"refresh_token_expires_in": self.REFRESH_TOKEN_TTL,
"session_id": session_id,
}
def verify_access_token(self, token: str) -> TokenPayload:
"""验证access_token"""
try:
payload = jwt.decode(token, self.secret_key,
algorithms=[self.algorithm])
return TokenPayload(**payload)
except jwt.ExpiredSignatureError:
raise TokenExpiredError()
except jwt.InvalidTokenError:
raise TokenInvalidError()
3.2 各端的Token刷新策略
三端的Token刷新策略必须差异化设计,因为各端的生命周期管理方式完全不同。
App端:后台保活 + 静默刷新
class AppTokenRefresher:
"""
App端Token刷新策略
特点:App可能长时间在后台,恢复前台时Token可能已过期
"""
async def on_app_foreground(self, current_token: str,
refresh_token: str) -> dict:
"""App回到前台时的Token处理"""
# 1. 尝试用当前access_token发一个轻量请求(如获取用户信息)
try:
token_payload = self.token_service.verify_access_token(current_token)
# Token未过期,继续使用
return {"token": current_token, "refreshed": False}
except TokenExpiredError:
pass
# 2. access_token过期,用refresh_token静默刷新
try:
new_tokens = await self._refresh_with_token(refresh_token)
return {
"token": new_tokens["access_token"],
"refresh_token": new_tokens["refresh_token"],
"refreshed": True
}
except RefreshTokenExpiredError:
# 3. refresh_token也过期,需要重新登录
return {"need_relogin": True}
async def _refresh_with_token(self, refresh_token: str) -> dict:
"""用refresh_token换取新的token对"""
# 验证refresh_token有效性
stored = await self.redis.get(f"refresh:{refresh_token}")
if not stored:
raise RefreshTokenExpiredError()
stored_data = json.loads(stored)
# 签发新的token对
new_tokens = self.token_service.issue_token_pair(
account_uid=stored_data["account_uid"],
device_type=stored_data["device_type"],
identity_type="refreshed",
corp_id=stored_data.get("corp_id", "")
)
# 旧的refresh_token失效(rotation策略)
await self.redis.delete(f"refresh:{refresh_token}")
return new_tokens
App端的关键设计是refresh_token rotation——每次用refresh_token换新token时,旧的refresh_token立即失效。这样即使refresh_token泄露,攻击者最多只能用一次。
小程序端:冷启动重建 + 静默登录
class MiniAppSessionManager:
"""
小程序Token管理策略
特点:小程序可能被微信系统回收(冷启动),需要无感重建会话
"""
async def on_launch(self, wx_code: str,
cached_token: str = None) -> dict:
"""
小程序启动时的会话管理
"""
# 1. 如果有缓存的token,先验证是否有效
if cached_token:
try:
payload = self.token_service.verify_access_token(cached_token)
return {
"token": cached_token,
"account_uid": payload.account_uid,
"source": "cache"
}
except (TokenExpiredError, TokenInvalidError):
pass
# 2. 缓存token无效,用wx.code静默登录
# 小程序的wx.login是静默的,用户无感知
wx_session = await self.wechat_api.code2session(wx_code)
openid = wx_session["openid"]
# 3. 通过openid查找关联的主账号
identity = await self.db.find_identity(
identity_type="wechat",
identity_id=openid
)
if identity:
# 已有账号,签发新token
tokens = self.token_service.issue_token_pair(
account_uid=identity.account_uid,
device_type="miniapp",
identity_type="wechat",
corp_id=identity.corp_id or ""
)
return {
"token": tokens["access_token"],
"account_uid": identity.account_uid,
"source": "silent_login"
}
else:
# 新用户,需要走注册/绑定流程
return {"need_register": True, "wx_session": wx_session}
async def on_check_session(self) -> bool:
"""
微信session过期检查
微信的session可能会在以下情况失效:
- 用户长时间未使用小程序
- 微信系统主动回收
- 用户在其他设备登录同一微信号
"""
try:
await self.wechat_api.check_session()
return True
except WxSessionExpiredError:
# 需要重新wx.login
return False
小程序端的关键设计是静默登录——用户打开小程序时,通过wx.login自动获取code,后端通过code换openid,再通过openid找到主账号,整个过程用户完全无感知。只有首次使用(openid未绑定账号)时才需要用户交互。
H5微应用端:跟随宿主 + 免登刷新
class H5MicroAppSessionManager:
"""
H5微应用Token管理策略
特点:生命周期跟随宿主App(钉钉/企微/飞书),关闭Tab即失效
"""
async def on_page_load(self, platform: str,
auth_code: str = None,
cached_token: str = None) -> dict:
"""
H5页面加载时的会话管理
"""
# 1. 检查缓存token
if cached_token:
try:
payload = self.token_service.verify_access_token(cached_token)
return {"token": cached_token, "source": "cache"}
except (TokenExpiredError, TokenInvalidError):
pass
# 2. 使用OA平台的免登授权码
if not auth_code:
# 需要前端跳转到OA平台的授权页面获取auth_code
return {
"need_auth": True,
"auth_url": self._build_auth_url(platform)
}
# 3. 用auth_code换取OA平台的用户信息
user_info = await self._get_oauth_user_info(platform, auth_code)
# 4. 通过OA平台身份查找主账号
identity_type = self._platform_to_identity_type(platform)
identity = await self.db.find_identity(
identity_type=identity_type,
identity_id=user_info["user_id"],
corp_id=user_info["corp_id"]
)
if identity:
tokens = self.token_service.issue_token_pair(
account_uid=identity.account_uid,
device_type=f"h5_{platform}",
identity_type=identity_type,
corp_id=identity.corp_id
)
return {
"token": tokens["access_token"],
"account_uid": identity.account_uid,
"source": "oauth_login"
}
else:
# OA平台用户未绑定企学宝账号
return {
"need_bindaccount": True,
"user_info": user_info
}
def _platform_to_identity_type(self, platform: str) -> str:
mapping = {
"dingtalk": "dingtalk",
"wecom": "wecom",
"feishu": "feishu",
}
return mapping.get(platform, platform)
async def _get_oauth_user_info(self, platform: str,
auth_code: str) -> dict:
"""用授权码换取OA平台用户信息"""
if platform == "dingtalk":
return await self._dingtalk_get_user(auth_code)
elif platform == "wecom":
return await self._wecom_get_user(auth_code)
elif platform == "feishu":
return await self._feishu_get_user(auth_code)
async def _dingtalk_get_user(self, auth_code: str) -> dict:
"""钉钉免登流程"""
# 1. 用auth_code获取钉钉用户access_token
token_resp = await self.dingtalk_api.get_access_token(auth_code)
# 2. 用access_token获取用户信息
user_resp = await self.dingtalk_api.get_user_info(
token_resp["access_token"]
)
return {
"user_id": user_resp["userid"],
"name": user_resp["name"],
"corp_id": user_resp["corp_id"],
"mobile": user_resp.get("mobile"),
"email": user_resp.get("email"),
"avatar": user_resp.get("avatar"),
}
async def _wecom_get_user(self, auth_code: str) -> dict:
"""企业微信免登流程"""
# 企微的免登流程和钉钉类似但细节不同
token_resp = await self.wecom_api.get_access_token()
user_resp = await self.wecom_api.get_user_info(
token_resp["access_token"], auth_code
)
return {
"user_id": user_resp["UserId"],
"name": user_resp.get("name"),
"corp_id": token_resp["corp_id"],
"mobile": user_resp.get("mobile"),
"email": user_resp.get("email"),
}
async def _feishu_get_user(self, auth_code: str) -> dict:
"""飞书免登流程"""
# 飞书的流程又不一样——需要先获取app_access_token再获取user_access_token
app_token = await self.feishu_api.get_app_access_token()
user_token = await self.feishu_api.get_user_access_token(
app_token, auth_code
)
user_info = await self.feishu_api.get_user_info(
user_token["user_access_token"]
)
return {
"user_id": user_info["open_id"],
"name": user_info.get("name"),
"corp_id": user_info.get("tenant_key"),
"mobile": user_info.get("mobile"),
"email": user_info.get("enterprise_email"),
}
H5端的关键特点是生命周期短——用户关闭钉钉里的H5 Tab,会话就没了。所以H5端每次打开都需要重新走免登流程,但因为免登是静默的(用户不需要输入密码),体验上还是"打开即用"。
3.3 跨端Token互不影响
一个重要的设计原则:各端的Token互相独立,一端登出不影响其他端。
class SessionManager:
"""会话管理:支持多端同时在线"""
async def logout(self, account_uid: str, device_type: str = None,
session_id: str = None):
"""
登出策略:
- 指定session_id:只登出该会话
- 指定device_type:登出该类型的所有会话
- 都不指定:登出所有端(全量登出)
"""
if session_id:
# 单会话登出
await self._invalidate_session(session_id)
elif device_type:
# 按设备类型登出
sessions = await self.redis.smembers(
f"sessions:{account_uid}:{device_type}"
)
for sid in sessions:
await self._invalidate_session(sid)
else:
# 全量登出
all_sessions = await self.redis.smembers(
f"sessions:{account_uid}:*"
)
for sid in all_sessions:
await self._invalidate_session(sid)
async def _invalidate_session(self, session_id: str):
"""使一个会话失效"""
# 1. 删除session对应的refresh_token
refresh_tokens = await self.redis.smembers(
f"session:{session_id}:refresh_tokens"
)
for rt in refresh_tokens:
await self.redis.delete(f"refresh:{rt}")
# 2. 将session标记为失效(access_token可能还没过期)
await self.redis.setex(
f"session:{session_id}:revoked",
7200, # 和access_token的TTL对齐
"1"
)
# 3. 从用户的会话集合中移除
session_meta = await self.redis.hget(f"session:{session_id}:meta", "account_uid")
device_type = await self.redis.hget(f"session:{session_id}:meta", "device_type")
if session_meta:
await self.redis.srem(f"sessions:{session_meta}:{device_type}", session_id)
await self.redis.delete(f"session:{session_id}:meta")
await self.redis.delete(f"session:{session_id}:refresh_tokens")
async def is_session_valid(self, session_id: str) -> bool:
"""检查会话是否有效"""
revoked = await self.redis.get(f"session:{session_id}:revoked")
return revoked is None
这个设计让用户可以在App上保持登录的同时,单独退出H5端的会话——比如员工下班后退出钉钉里的H5微应用,但不影响App上的学习进度。
四、学习进度跨端同步:真正的难题
登录态同步只是基础。用户真正关心的是:我在App上看到第3章,打开小程序应该也从第3章继续。 这就是学习进度的跨端同步。
4.1 进度数据模型
-- 学习进度主表
CREATE TABLE learning_progress (
id BIGINT PRIMARY KEY,
account_uid VARCHAR(64) NOT NULL,
course_id VARCHAR(64) NOT NULL,
device_type VARCHAR(32) NOT NULL, -- 最后更新进度的设备类型
current_chapter_id VARCHAR(64),
current_position_sec INT DEFAULT 0, -- 当前播放位置(秒)
completion_ratio DECIMAL(5,4) DEFAULT 0, -- 完成比例(0.0000 - 1.0000)
total_study_duration_sec INT DEFAULT 0, -- 累计学习时长
last_study_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
-- 一个用户一门课只有一条进度记录
UNIQUE(account_uid, course_id)
);
-- 章节级进度明细
CREATE TABLE chapter_progress (
id BIGINT PRIMARY KEY,
account_uid VARCHAR(64) NOT NULL,
course_id VARCHAR(64) NOT NULL,
chapter_id VARCHAR(64) NOT NULL,
status VARCHAR(20) DEFAULT 'not_started', -- not_started / in_progress / completed
position_sec INT DEFAULT 0,
duration_sec INT DEFAULT 0, -- 本章累计学习时长
score DECIMAL(5,2), -- 章节测验分数(如有)
completed_at TIMESTAMP,
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(account_uid, course_id, chapter_id)
);
-- 进度同步日志(用于冲突检测和审计)
CREATE TABLE progress_sync_log (
id BIGINT PRIMARY KEY,
account_uid VARCHAR(64) NOT NULL,
course_id VARCHAR(64) NOT NULL,
device_type VARCHAR(32) NOT NULL,
sync_type VARCHAR(20) NOT NULL, -- 'push' / 'pull' / 'merge'
progress_snapshot JSONB, -- 同步时的进度快照
server_version INT, -- 服务端版本号(乐观锁)
created_at TIMESTAMP DEFAULT NOW()
);
4.2 冲突场景与合并策略
跨端同步最头疼的是冲突——两端同时学习同一门课,各自推进了进度,然后都要同步到服务端。
场景1:顺序冲突
用户在App上学到了第3章第15分钟,同时在小程序上也学到了第3章第20分钟。两个进度不一致,以哪个为准?
场景2:分支冲突
用户在App上完成了第3章的测验(得分85分),同时在H5上完成了第4章的视频观看。两个进度不矛盾,但需要同步合并。
场景3:回退冲突
用户在App上看到了第5章,然后退出。后来在小程序上重新打开,但小程序的本地缓存还停留在第3章。此时应该用服务端的第5章覆盖小程序的本地缓存,还是保留小程序的状态?
class ProgressSyncService:
"""学习进度同步服务:处理跨端冲突"""
async def sync_progress(self, account_uid: str,
course_id: str,
client_progress: dict,
device_type: str,
client_version: int) -> dict:
"""
同步学习进度
采用"服务端优先 + 乐观合并"策略
"""
# 1. 获取服务端当前进度
server_progress = await self.db.get_progress(account_uid, course_id)
if not server_progress:
# 服务端没有进度记录,直接以客户端为准
await self._save_progress(account_uid, course_id,
client_progress, device_type)
return {
"progress": client_progress,
"conflict": False,
"source": "client"
}
# 2. 检测冲突
conflict = self._detect_conflict(
server_progress, client_progress, client_version
)
if not conflict:
# 无冲突:客户端进度是服务端进度的延续
# 直接更新
await self._update_progress(
account_uid, course_id,
client_progress, device_type,
client_version
)
return {
"progress": client_progress,
"conflict": False,
"source": "merged"
}
# 3. 有冲突:执行合并策略
merged = self._merge_progress(
server_progress, client_progress, device_type
)
await self._save_progress(
account_uid, course_id,
merged, device_type
)
# 4. 记录同步日志
await self._log_sync(
account_uid, course_id,
device_type, "merge",
merged, client_version
)
return {
"progress": merged,
"conflict": True,
"resolution": "server_wins_with_merge",
"source": "server"
}
def _detect_conflict(self, server: dict, client: dict,
client_version: int) -> bool:
"""
检测冲突:
- 如果client_version == server_version,说明客户端拿到的是最新版本,无冲突
- 如果client_version < server_version,说明服务端在客户端拉取后又有更新,可能有冲突
"""
server_version = server.get("version", 0)
return client_version < server_version
def _merge_progress(self, server: dict, client: dict,
device_type: str) -> dict:
"""
合并策略:
1. 章节进度取"最远"——哪个进度走得远就用哪个
2. 视频位置取"最远"——同一章节内,播放位置靠后的为准
3. 测验成绩取"最高"——如果两端都做了测验,取高分
4. 学习时长取"累加"——两端的学习时长相加(去重)
"""
merged = {}
# 章节进度:取最远
server_chapter = server.get("current_chapter_id", "")
client_chapter = client.get("current_chapter_id", "")
if self._chapter_order(client_chapter) >= self._chapter_order(server_chapter):
merged["current_chapter_id"] = client_chapter
merged["current_position_sec"] = client.get("current_position_sec", 0)
else:
merged["current_chapter_id"] = server_chapter
merged["current_position_sec"] = server.get("current_position_sec", 0)
# 完成比例:取较大值
merged["completion_ratio"] = max(
server.get("completion_ratio", 0),
client.get("completion_ratio", 0)
)
# 学习时长:取较大值(不是累加,因为可能重叠)
# 更精确的做法是按时段合并,但工程上取较大值已经够用
merged["total_study_duration_sec"] = max(
server.get("total_study_duration_sec", 0),
client.get("total_study_duration_sec", 0)
)
merged["device_type"] = device_type
merged["last_study_at"] = datetime.utcnow().isoformat()
return merged
def _chapter_order(self, chapter_id: str) -> int:
"""获取章节的顺序号(用于比较先后)"""
# 从课程结构中获取章节顺序
return self.chapter_index.get(chapter_id, 0)
4.3 各端的同步时机
三端的同步时机不同,这直接影响用户体验:
class SyncTimingStrategy:
"""各端的同步时机策略"""
STRATEGIES = {
"app": {
# App端:实时同步 + 后台批量同步
"on_chapter_complete": "immediate", # 完成一章立即同步
"on_video_position_change": "throttle_30s", # 视频位置变更每30秒同步一次
"on_app_background": "immediate", # App进入后台时立即同步
"on_app_foreground": "pull_then_push", # App回到前台时先拉取再推送
"periodic": "interval_60s", # 每60秒定期同步
},
"miniapp": {
# 小程序端:事件驱动同步
"on_chapter_complete": "immediate",
"on_video_position_change": "throttle_60s", # 节流到60秒(小程序request有并发限制)
"on_hide": "immediate", # 小程序切后台时立即同步
"on_show": "pull_then_push", # 小程序切前台时先拉后推
"on_share": "immediate", # 分享前同步(确保进度最新)
},
"h5": {
# H5端:页面生命周期驱动
"on_chapter_complete": "immediate",
"on_video_position_change": "throttle_30s",
"on_page_unload": "immediate", # 页面关闭前同步(用sendBeacon)
"on_page_load": "pull", # 页面加载时拉取最新进度
"on_visibility_change": "throttle_10s", # 页面可见性变化时同步
},
}
App端可以做得最激进——实时同步,因为App有稳定的网络连接和后台运行能力。
小程序端需要节制一些——微信对小程序的wx.request有并发限制(同时最多5-10个),过于频繁的同步请求可能影响正常的业务请求。所以视频播放位置的同步节流到60秒。
H5端最特殊——页面随时可能被关闭(用户切Tab、关闭钉钉),所以必须在beforeunload/visibilitychange事件里用sendBeacon做最后一次同步。sendBeacon的特点是即使页面已经关闭,请求也会发出去,非常适合这个场景。
4.4 离线学习后的同步
最复杂的场景是离线学习——用户在App上离线看了一节课,期间没有网络。恢复网络后,需要把离线期间的进度同步到服务端。
class OfflineProgressSync:
"""离线学习进度同步"""
async def sync_offline_progress(self, account_uid: str,
offline_records: list[dict]) -> dict:
"""
同步离线期间积累的进度记录
offline_records: 离线期间产生的进度变更列表,按时间排序
"""
results = []
for record in offline_records:
course_id = record["course_id"]
# 获取服务端当前进度
server_progress = await self.db.get_progress(
account_uid, course_id
)
if not server_progress:
# 服务端没有进度,直接写入
await self._save_progress(
account_uid, course_id,
record["progress"], "app_offline"
)
results.append({
"course_id": course_id,
"status": "saved",
"conflict": False
})
continue
# 检查是否有冲突
# 离线期间的进度和服务端进度可能不一致
# (比如用户在另一端也学了同一门课)
if self._has_conflict(server_progress, record):
# 有冲突:合并
merged = self._merge_offline_with_server(
server_progress, record
)
await self._save_progress(
account_uid, course_id,
merged, "app_offline_merged"
)
results.append({
"course_id": course_id,
"status": "merged",
"conflict": True,
"merged_progress": merged
})
else:
# 无冲突:直接追加
updated = self._apply_offline_record(
server_progress, record
)
await self._update_progress(
account_uid, course_id,
updated, "app_offline"
)
results.append({
"course_id": course_id,
"status": "updated",
"conflict": False
})
return {
"synced_count": len(results),
"conflict_count": sum(1 for r in results if r.get("conflict")),
"details": results
}
def _merge_offline_with_server(self, server: dict,
offline_record: dict) -> dict:
"""
合并离线进度和服务端进度
策略:离线进度的"完成状态"优先保留,
但"播放位置"以服务端为准(如果服务端更新)
"""
merged = dict(server)
offline_progress = offline_record["progress"]
# 完成比例:取较大值
if offline_progress.get("completion_ratio", 0) > merged.get("completion_ratio", 0):
merged["completion_ratio"] = offline_progress["completion_ratio"]
merged["current_chapter_id"] = offline_progress.get("current_chapter_id")
# 章节完成状态:离线标记为completed的章节,不会被服务端覆盖
offline_chapters = offline_progress.get("chapter_details", {})
for ch_id, ch_progress in offline_chapters.items():
if ch_progress.get("status") == "completed":
merged.setdefault("chapter_details", {})
merged["chapter_details"][ch_id] = ch_progress
# 学习时长:累加(离线期间的时长是真实的增量)
offline_duration = offline_record.get("duration_added_sec", 0)
merged["total_study_duration_sec"] = (
merged.get("total_study_duration_sec", 0) + offline_duration
)
return merged
五、前端SDK:统一的登录与同步接口
三端的前端实现各不相同,但对外暴露的接口应该统一。我们封装了一个跨端的SDK:
// qxb-sdk.ts - 企学宝跨端SDK核心接口
interface QXBAccount {
accountUid: string;
displayName: string;
avatarUrl: string;
corpId?: string;
}
interface QXBLearningProgress {
courseId: string;
chapterId: string;
positionSec: number;
completionRatio: number;
totalDurationSec: number;
}
interface IQXBSDK {
// ── 登录相关 ──
login(): Promise<QXBAccount>;
logout(): Promise<void>;
getAccount(): QXBAccount | null;
onAccountChange(callback: (account: QXBAccount | null) => void): void;
// ── 学习进度 ──
getProgress(courseId: string): Promise<QXBLearningProgress>;
syncProgress(progress: QXBLearningProgress): Promise<void>;
onProgressUpdate(callback: (progress: QXBLearningProgress) => void): void;
// ── 事件 ──
on(event: 'login' | 'logout' | 'progress_sync' | 'conflict',
callback: (...args: any[]) => void): void;
}
各端的实现:
// app-sdk.ts - App端实现
class AppQXBSDK implements IQXBSDK {
private tokenStorage: SecureStorage; // Keychain / Keystore
private syncTimer: number | null = null;
async login(): Promise<QXBAccount> {
// App端:检查本地Token → 静默刷新 → 弹出登录页
const cachedToken = this.tokenStorage.getAccessToken();
if (cachedToken) {
try {
const account = await this.verifyToken(cachedToken);
this.startPeriodicSync();
return account;
} catch (e) {
// Token过期,尝试刷新
const refreshToken = this.tokenStorage.getRefreshToken();
if (refreshToken) {
try {
const newTokens = await this.refreshTokens(refreshToken);
this.tokenStorage.save(newTokens);
const account = await this.verifyToken(newTokens.accessToken);
this.startPeriodicSync();
return account;
} catch (e) {
// refresh也失败,需要重新登录
}
}
}
}
// 弹出登录页
const result = await Navigation.push('LoginPage');
this.tokenStorage.save(result.tokens);
this.startPeriodicSync();
return result.account;
}
async syncProgress(progress: QXBLearningProgress): Promise<void> {
// App端:实时同步,带节流
this.throttledSync(progress, 30000); // 30秒节流
}
private startPeriodicSync() {
// 每60秒定期同步一次进度
this.syncTimer = setInterval(() => {
const currentProgress = this.localProgressManager.getCurrent();
if (currentProgress) {
this.syncProgress(currentProgress);
}
}, 60000);
}
}
// miniapp-sdk.ts - 小程序端实现
class MiniAppQXBSDK implements IQXBSDK {
async login(): Promise<QXBAccount> {
// 小程序端:wx.login静默获取code → 后端换token
const loginRes = await wx.login();
const code = loginRes.code;
const result = await wx.request({
url: 'https://api.qxb.example.com/auth/miniapp/login',
method: 'POST',
data: { code }
});
if (result.data.needRegister) {
// 新用户,需要注册/绑定
wx.navigateTo({ url: '/pages/bindAccount/index' });
throw new Error('Need registration');
}
// 保存Token到小程序Storage
wx.setStorageSync('access_token', result.data.access_token);
wx.setStorageSync('refresh_token', result.data.refresh_token);
return result.data.account;
}
async syncProgress(progress: QXBLearningProgress): Promise<void> {
// 小程序端:节流到60秒,避免触发并发限制
this.throttledSync(progress, 60000);
}
}
// h5-sdk.ts - H5微应用端实现
class H5QXBSDK implements IQXBSDK {
async login(): Promise<QXBAccount> {
// H5端:检测宿主平台 → 获取免登授权码 → 后端换token
const platform = this.detectPlatform(); // dingtalk / wecom / feishu
let authCode: string;
if (platform === 'dingtalk') {
authCode = await dd.runtime.permission.requestAuthCode({
corpId: this.config.corpId
});
} else if (platform === 'wecom') {
// 企微的免登需要OAuth重定向
const url = new URL(window.location.href);
authCode = url.searchParams.get('code');
if (!authCode) {
// 重定向到企微授权页
window.location.href = this.buildWecomAuthUrl();
throw new Error('Redirecting to auth');
}
} else if (platform === 'feishu') {
// 飞书类似
const url = new URL(window.location.href);
authCode = url.searchParams.get('code');
if (!authCode) {
window.location.href = this.buildFeishuAuthUrl();
throw new Error('Redirecting to auth');
}
}
const result = await fetch('/api/auth/h5/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform, authCode })
}).then(r => r.json());
// Token存在sessionStorage中(关闭Tab即清除)
sessionStorage.setItem('access_token', result.access_token);
return result.account;
}
async syncProgress(progress: QXBLearningProgress): Promise<void> {
// H5端:页面关闭前用sendBeacon做最后同步
this.throttledSync(progress, 30000);
}
private setupUnloadSync() {
// 页面关闭前的最后同步
window.addEventListener('beforeunload', () => {
const currentProgress = this.getCurrentProgress();
if (currentProgress) {
navigator.sendBeacon(
'/api/progress/sync',
JSON.stringify(currentProgress)
);
}
});
// 页面可见性变化时也同步
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
const currentProgress = this.getCurrentProgress();
if (currentProgress) {
navigator.sendBeacon(
'/api/progress/sync',
JSON.stringify(currentProgress)
);
}
}
});
}
}
六、效果数据与踩坑实录
6.1 上线效果
三端登录态统一方案上线6个月后的数据:
| 指标 | 上线前 | 上线后 |
|---|---|---|
| 跨端用户占比 | 12%(手动注册多账号) | 68%(自动关联) |
| 登录失败率 | 8.5% | 2.1% |
| 跨端进度丢失投诉 | 每月40+件 | 每月3件 |
| 身份绑定成功率 | - | 91%(自动绑定76% + 手动绑定15%) |
| Token刷新成功率 | - | 99.2% |
6.2 踩过的坑
坑1:微信openid和unionid的混淆。
早期版本用openid作为微信用户的唯一标识。但同一个小程序和同一个公众号(如果有的话)获取到的openid是不同的——只有unionid才是跨应用统一的。我们有一个客户同时运营着企学宝小程序和企学宝公众号,导致同一个用户在两个渠道的openid不同,被识别为两个账号。
修复方案:统一使用unionid作为微信体系的用户标识。但unionid需要用户关注过同主体的公众号或用过同主体的小程序才能获取,所以还需要openid作为兜底。最终方案是同时存储openid和unionid,优先用unionid匹配,匹配不上再用openid。
坑2:钉钉H5的免登code有效期只有5分钟。
钉钉的免登授权码有效期很短(5分钟),如果前端获取code后没有及时调用后端接口(比如网络慢、页面渲染延迟),code就过期了。用户看到的就是"登录失败"。
修复方案:前端获取code后立即调用后端接口,不做任何延迟。同时后端对code过期返回明确的错误码,前端收到后自动重新获取code并重试(用户无感知)。
async function loginWithRetry(maxRetries = 2): Promise<QXBAccount> {
for (let i = 0; i <= maxRetries; i++) {
try {
const authCode = await dd.runtime.permission.requestAuthCode({
corpId: config.corpId
});
return await api.login(authCode);
} catch (e) {
if (e.code === 'AUTH_CODE_EXPIRED' && i < maxRetries) {
console.warn('Auth code expired, retrying...');
continue;
}
throw e;
}
}
}
坑3:小程序Storage被微信清理导致频繁重新登录。
微信小程序的Storage不是永久可靠的——当手机存储空间不足时,微信可能主动清理小程序的Storage。用户再次打开小程序时,Token没了,需要重新登录。
修复方案:不完全依赖Storage。在wx.login的基础上,增加一层"服务端Session"——小程序启动时先尝试用本地的session_key向服务端换取新Token,如果session_key也失效了(说明微信已经回收了session),才走完整的wx.login流程。大多数情况下,session_key是有效的(只要微信没回收session),可以做到无感恢复。
坑4:企微H5的OAuth重定向在iOS和Android上行为不一致。
企业微信的OAuth授权重定向,在Android上能正常跳转回来,但在iOS上有时候会被企微内置浏览器的缓存拦截——用户看到的是上一次的授权页面,而不是最新的。
修复方案:在OAuth重定向URL上添加时间戳参数(&t={timestamp}),破坏缓存。同时在iOS上检测code参数是否存在,如果不存在则强制刷新页面。
坑5:学习进度同步的"惊群效应"。
有一次大促活动(全员必修的合规考试),几千人同时打开考试页面,每端都在做进度同步请求。服务端瞬间收到了数万并发请求,数据库连接池被打满,正常业务请求也受影响了。
修复方案:
- 进度同步请求和业务请求走不同的API路径,方便独立限流
- 进度同步使用异步队列——前端发请求后服务端立即返回"已接收",实际写入异步处理
- 前端增加随机抖动(jitter),避免所有客户端在同一时刻发起同步
class ProgressSyncAPI:
"""异步进度同步接口"""
async def post(self, request):
# 1. 验证Token
payload = self.verify_token(request)
# 2. 快速校验数据格式
progress_data = self.validate_progress(request.body)
# 3. 放入异步队列,立即返回
await self.sync_queue.put({
"account_uid": payload.account_uid,
"course_id": progress_data["course_id"],
"progress": progress_data,
"device_type": payload.device_type,
"timestamp": datetime.utcnow().isoformat(),
})
# 4. 立即返回(不等待实际写入完成)
return {"status": "accepted", "server_time": datetime.utcnow().isoformat()}
# 异步消费者
async def progress_sync_consumer():
"""后台消费进度同步队列"""
while True:
batch = []
# 批量取出最多100条
for _ in range(100):
try:
item = await sync_queue.get_nowait()
batch.append(item)
except asyncio.QueueEmpty:
break
if batch:
# 批量写入数据库
await db.batch_upsert_progress(batch)
# 等待下一批(100ms间隔)
await asyncio.sleep(0.1)
坑6:飞书的tenant_key和corp_id不是同一个东西。
我们在设计corp_id时,假设所有OA平台都有一个"企业ID"的概念。但飞书的tenant_key和钉钉的corpId、企微的corpId在语义上不完全一样——飞书的tenant_key是应用维度的,不是企业维度的。同一个企业在飞书上安装多个应用,每个应用的tenant_key不同。
这导致同一个企业在飞书上的不同应用中登录,被识别为不同的corp_id,进而映射到不同的主账号。
修复方案:为飞书单独维护一个feishu_corp_mapping表,把tenant_key映射到统一的corp_id。映射关系在企业管理员首次配置飞书集成时建立。
class FeishuCorpMapping:
"""飞书tenant_key到统一corp_id的映射"""
async def resolve_corp_id(self, tenant_key: str) -> str:
"""将飞书tenant_key解析为统一的corp_id"""
mapping = await self.db.get_feishu_mapping(tenant_key)
if mapping:
return mapping.corp_id
# 未找到映射:尝试通过飞书API获取企业信息
tenant_info = await self.feishu_api.get_tenant_info(tenant_key)
if tenant_info:
# 用企业名+管理员信息匹配已有的企业
matched_corp = await self._match_existing_corp(
tenant_info["company_name"],
tenant_info.get("admin_email")
)
if matched_corp:
# 建立映射
await self.db.save_feishu_mapping(tenant_key, matched_corp)
return matched_corp
raise CorpMappingNotFoundError(
f"Cannot resolve feishu tenant_key: {tenant_key}"
)
七、总结
三端登录态同步的本质是三个问题:
“你是谁” — 统一身份层。三套外部身份(微信/OA平台/自有账号)映射到一个主账号。自动绑定(手机号/邮箱/工号匹配)解决80%的场景,手动绑定兜底剩余的20%。
“你的凭证有效吗” — Token管理。三端各自有不同的Token生命周期和刷新策略,但后端的Token结构和验证逻辑是统一的。各端Token互不影响,一端登出不影响其他端。
“你的数据跟得上吗” — 进度同步。采用"服务端优先 + 乐观合并"策略,各端按自己的节奏上报进度,服务端负责冲突检测和合并。离线学习是最复杂的场景,需要本地存储 + 恢复网络后批量同步。
如果只能记住一句话:多端同步的核心不是技术实现,而是冲突策略的设计。 技术实现(Token签发、身份绑定、进度上报)都是确定性的,但冲突策略(两端进度不一致时以谁为准)需要根据业务场景仔细定义。我们在"学习进度取最远"“测验成绩取最高”"学习时长取最大"这三条规则上花了远比代码实现更多的讨论时间——但这些讨论是值得的,因为它们直接决定了用户体验。
作者注:本文涉及的代码为简化版本,生产环境需要补充完整的异常处理、安全校验和监控埋点。各OA平台的API调用方式可能随版本更新变化,请以官方最新文档为准。
更多推荐




所有评论(0)