每天打开手机 App,我们总会面对一堆授权弹窗:允许获取位置吗?允许读取通讯录吗?允许使用麦克风吗?大多数时候我们随手点一个“允许”就过去了。但在欧洲,GDPR 就像一个严格的考官,随时盯着企业有没有把用户的“同意”当回事。如果用户在一台设备上收回了授权,另一台设备却还在继续收集数据,这就不是功能 bug,而是合规事故。

一、从生活场景说起:为什么同步这么难

1.1 一个让人头疼的例子

你用手机上的健身软件记录跑步,顺手给了位置权限。中午在平板上打开同一个账号,结果平板上的 App 也在上传你的位置信息。你翻遍设置,发现授权状态根本没有同步。手机明明已经改过设置,平板却还拿着旧的授权结果继续干活。

这个例子说明:授权状态并不是一条安安静静躺在数据库里的记录,而是一个需要在多台设备之间不断传播的消息。

1.2 GDPR 到底要求什么

GDPR 没有规定你必须用哪一种技术,但它给的底线很明确:

第一,用户说“同意”的时候,企业要能拿出证据。

第二,用户说“撤回”的时候,撤回操作要像当初点击同意一样简单。

第三,撤回之后,企业就不能再继续处理对应的个人数据。

如果手机和平板共用同一个账号,手机已经撤回了位置授权,平板却还在采集,那么在 GDPR 视角下,企业就是违法处理个人数据。

1.3 典型的应用场景

多终端授权同步听起来是个抽象的技术话题,但落地的场景非常多。电商 App 的手机端和网页端、视频会员的电视端和手机端、智能家居里的音箱和摄像头、健康管理软件的手表和手机、车联网的车机和手机 App,都会遇到同一个问题:用户在一个终端上改了授权,其他终端必须立刻知道。

比如用户在家里对着智能音箱说“关闭语音记录”,那他手机上对应 App 里的语音记录开关也必须跟着变成关闭。如果电视和音箱还在默默录音,这就会变成一个非常严重的隐私问题。

二、核心思路:把授权状态当成一条会传播的消息

2.1 传统做法为什么不行

很多团队的第一反应是把授权状态存到数据库里,用户每次打开 App 就去数据库查一次。单终端还行,多终端就会出问题。

终端 A 改完授权之后,终端 B 并不知道数据已经变了。靠用户手动刷新不现实,每隔几秒轮询一次数据库又费流量、又费电,还会给数据库造成莫名其妙的压力。

2.2 换个思路:事件驱动

我建议把授权状态当成一条消息来设计。用户每做一次授权操作,系统就生成一个事件,比如“用户 123 撤回了位置授权”,然后把这个事件广播给所有正在线的设备。设备收到事件后,立刻更新自己页面上的状态。

这套思路不复杂,但能解决一个关键问题:所有终端不再依赖自己去查数据库,而是等服务器主动告诉它们“状态变了”。

2.3 关联技术:Redis 发布订阅和 SSE

这里用到的不是高不可攀的新技术。

Redis 有一个很实用的发布订阅功能,作用有点像广播电台。一个进程把消息放进频道,所有订阅这个频道的进程都能收到。它非常适合用来在多个服务实例之间传播授权事件。

SSE 则是服务器向浏览器持续推送消息的方案。和 WebSocket 相比,SSE 更像一个单向广播,服务器说话,浏览器听着。WebSocket 适合聊天室、在线白板这种双向实时应用,而授权状态同步主要是服务器往终端推,所以 SSE 更轻量,部署也更方便,浏览器断线之后还会自动重连。

三、方案设计:Node.js + Redis + SSE

3.1 技术栈怎么选

下面的示例统一使用 Node.js + Express + Redis + SSE。前端使用浏览器里的 JavaScript 接收消息。选这套组合的原因很简单:Node.js 处理大量长连接很顺手,Express 写接口很简洁,Redis 做消息中转非常成熟,SSE 在 HTTP 协议上跑,不需要额外引入复杂框架。

3.2 整体流程

先理一下完整的数据流。

用户在一台设备上点击“撤销位置授权”,手机把请求发送到后端接口。后端收到请求之后,给这个用户的位置授权生成一个新的版本号,然后把最新授权状态保存到 Redis。紧接着,后端把授权事件发布到 Redis 的某个频道里。所有后端实例都订阅了这个频道,谁收到事件,谁就给当前在线的、属于该用户的 SSE 连接推送消息。最后,用户的其他设备从 SSE 通道收到消息,立刻更新页面状态,停止对应的数据采集。

这样做的核心价值是:授权状态从“静止的值”变成了“流动的消息”。

四、代码示例:搭建可用的授权同步服务

4.1 服务端完整示例

下面是一个完整的服务端入口文件。技术栈:Node.js + Express + Redis + SSE。

// 技术栈:Node.js + Express + Redis + SSE
// 完整示例:服务端入口文件 app.js
const express = require('express');
const redis = require('redis');

const app = express();
app.use(express.json());

const redisClient = redis.createClient();

// 保存每个用户当前所有 SSE 连接
// 格式:Map<userId, Set<response>>
const sseClients = new Map();

function addClient(userId, res) {
  if (!sseClients.has(userId)) {
    sseClients.set(userId, new Set());
  }
  sseClients.get(userId).add(res);
}

function removeClient(userId, res) {
  sseClients.get(userId)?.delete(res);
}

async function main() {
  await redisClient.connect();

  // 复制一个客户端专门用于订阅,避免占用主连接
  const subClient = redisClient.duplicate();
  await subClient.connect();

  // 用户点击授权或撤回之后,前端调用这个接口
  app.post('/api/consent/change', async (req, res) => {
    const { userId, clientId, resource, purpose, action } = req.body;

    // 同一个资源、同一个用途,共用一条版本号
    // Redis incr 是原子操作,并发调用不会拿到相同的版本号
    const versionKey = `consent:version:${userId}:${resource}:${purpose}`;
    const version = await redisClient.incr(versionKey);

    const event = {
      eventId: `evt_${userId}_${resource}_${purpose}_${version}`,
      userId,
      clientId,
      resource,
      purpose,
      action,
      changedAt: new Date().toISOString(),
      version
    };

    // 保存最新快照,新设备一上线就能读到当前状态
    const stateKey = `${resource}:${purpose}`;
    await redisClient.hSet(
      `consent:state:${userId}`,
      stateKey,
      JSON.stringify(event)
    );

    // 把事件发布到 Redis 频道,所有服务实例都会收到
    await redisClient.publish('consent:events', JSON.stringify(event));

    res.json({ ok: true, event });
  });

  // 设备通过 SSE 长连接订阅当前账号的授权变化
  app.get('/api/consent/stream', async (req, res) => {
    const userId = req.query.userId;

    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    addClient(userId, res);

    req.on('close', () => removeClient(userId, res));

    // 先把当前所有授权记录发给新设备,然后继续等待后续变化
    const state = await redisClient.hGetAll(`consent:state:${userId}`);
    for (const key in state) {
      res.write(`data: ${state[key]}\n\n`);
    }
  });

  // 订阅 Redis 里的授权事件,然后把消息推给在线的所有终端
  await subClient.subscribe('consent:events', (message) => {
    const event = JSON.parse(message);
    const clients = sseClients.get(event.userId);
    if (!clients) return;

    for (const res of clients) {
      res.write(`data: ${JSON.stringify(event)}\n\n`);
    }
  });

  app.listen(3000, () => {
    console.log('授权同步服务已启动');
  });
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

这段代码里有一个容易被忽略的细节:事件里面多带了一个 purpose 字段。GDPR 很看重授权的“目的”,比如“用于位置推荐”和“用于个性化广告”是两个不同的授权目的,不能混在一起。

4.2 前端接收示例

接下来看浏览器这边怎么接收。技术栈:JavaScript(浏览器端)。

// 技术栈:JavaScript(浏览器端)
// 在授权设置页里监听变化,并更新页面上的开关状态
const stream = new EventSource('/api/consent/stream?userId=user_123');

// 用 Map 记录每个资源 + 用途的版本号
// 避免服务器推送旧消息时覆盖新状态
const lastVersions = new Map();

stream.onmessage = (event) => {
  const consent = JSON.parse(event.data);
  const key = `${consent.resource}:${consent.purpose}`;

  // 如果这条消息比当前版本旧,直接忽略
  if (consent.version <= (lastVersions.get(key) || 0)) {
    return;
  }
  lastVersions.set(key, consent.version);

  const statusText = consent.action === 'grant' ? '已授权' : '已撤回';

  console.log(
    `设备 ${consent.clientId} 对 ${consent.resource}(用途:${consent.purpose})的状态:${statusText}`
  );

  updateUI(key, statusText);
};

// 实际项目里,这里会同步修改页面文字、关闭摄像头、停止定位等
function updateUI(key, statusText) {
  const el = document.querySelector(`[data-consent-key="${key}"]`);
  if (el) {
    el.textContent = statusText;
  }
}

这里用版本号来防止乱序。因为不同资源的授权状态变化互相独立,所以版本号也要按资源加用途分别保存。否则一个高版本号的“广告授权事件”会把另一个低版本号的“位置授权事件”挤掉。

4.3 冲突处理

用户家里的智能电视和手机可能同时发起对同一个授权目的的变更。手机说“撤回”,电视说“允许”,这时候听谁的?

最简单的做法是最后写入获胜。Redis incr 能保证每次请求拿到的版本号一定不同,谁后到达服务器,谁的版本号就更大,客户端收到之后就会用版本号大的消息覆盖旧消息。

如果你需要更严格的授权策略,比如两个设备要合并授权结果,那就要引入更复杂的状态机或者冲突解决算法。对于大部分授权场景,最后写入获胜已经足够。

五、技术优缺点分析

5.1 优点

第一,实时性好。用户一操作,其他设备几乎是立刻收到推送,不需要手动刷新。

第二,横向扩展容易。后端服务可以部署多个实例,通过 Redis 订阅同一个频道,消息不会发丢到某一个实例内部。

第三,事件本身可以当审计日志来用。每一条授权事件都包含用户、设备、资源、用途、时间、版本号,这是 GDPR 里“能够证明用户同意”的重要基础。

第四,SSE 实现起来很轻。浏览器原生支持,断线之后会自动重连,不需要像 WebSocket 那样处理很多握手状态。

5.2 缺点

Redis 的发布订阅默认不持久化。如果 Redis 在消息发布前重启,那这条消息就丢了。所以示例中同时保存了最新快照,设备重连之后可以先拉快照,再接收新事件。

如果服务需要跨多个数据中心部署,Redis 订阅很难直接打通不同机房。这种情况更适合用 Kafka、RabbitMQ、MQTT 这类消息中间件。

SSE 是单向的,服务端可以推给浏览器,但浏览器不能通过同一条通道随时传给服务端。如果产品还需要频繁从终端上传状态,WebSocket 会更合适。

并发冲突用最后写入获胜比较简单,但不能覆盖所有业务场景。如果真的需要把多个设备上的复杂授权规则合并起来,就得换更高级的方案。

六、注意事项

6.1 不要把原始个人数据塞进事件里

授权事件只需要描述“谁对什么资源做了什么决定”。事件里可以放 userId、clientId、resource、purpose、action、timestamp,但不要把定位坐标、通讯录内容、聊天记录放进去。否则每一条消息都会变成一个新的数据泄露风险点。

6.2 一定不要忽略身份认证

生产环境里的 SSE 接口不能只靠 userId 查询参数来识别用户。任何人拿到 userId 都有可能订阅这个用户的授权变化。建议使用带签名的临时 URL,或者把 userId 放在 token 里,在建立连接之前先做身份校验。

6.3 授权日志和业务数据分开存

GDPR 要求企业保留授权证明,所以授权记录需要防篡改,不能因为用户删除业务数据就顺手把授权记录也删了。但用户行使删除权的时候,业务侧的数据又必须真正删除。把授权日志和普通业务数据库分开存放,会更容易处理这种看起来有点矛盾的要求。

6.4 删除权也要实时同步

用户可能直接在网页上要求“删除所有个人数据”。删除操作同样应该变成一个事件,广播到所有终端设备。因为用户要求删除的那一刻,不只是数据库要清理,已经登录的设备也要立刻丢掉本地缓存。

6.5 离线设备怎么办

设备没有网络时是收不到同步消息的。所以设备每次联网启动之后,应该先请求一次最新授权快照,再建立 SSE 长连接。如果担心连接断开期间错过关键事件,还可以在 Redis 里额外保存最近一段时间的事件流,设备重连后按版本号补拉漏掉的消息。

七、总结

多终端环境下的授权同步,本质上是一个消息同步问题。用户每改变一次授权状态,系统就生成一个事件,通过 Redis 在中转,通过 SSE 在终端之间传播,最后用版本号解决并发冲突。这套方案不一定适用于所有团队,但它足够简单,也足够贴近 GDPR 对授权记录可追溯、可撤回的要求。

真正重要的不是某个具体的库或框架,而是想清楚一件事:授权状态不是一条静态的数据,而是每一个在线终端都必须响应、执行的指令。把这一点做好,GDPR 的“同意”和“撤回”才能真正落地。这不仅是产品体验问题,也是企业守住用户信任和合规底线的关键。