# presence-service 在线状态服务

## 服务定位

在线状态服务负责玩家上线、心跳、离线和 TTL 过期状态，是游戏会话、聊天室在线名单、排行榜实时活跃判断之间的轻量状态层。

第一阶段实现已具备可运行 HTTP 服务，使用内存 repository 模拟 Redis presence key 与 TTL 过期落状态。该服务只表达“玩家当前是否在线”，不能替代会话鉴权、注单状态或资金事务判断。

## 核心职责

- 接收玩家上线请求，记录 `merchant_id/player_id/session_id/device_id/game_code` 作用域。
- 接收玩家心跳，刷新 `last_seen_at` 并按 `ttl_seconds` 续期。
- 接收玩家离线请求，将 `ONLINE` 转为 `OFFLINE`，重复离线保持幂等。
- 查询玩家在线状态；若超过 `expires_at`，将 `ONLINE` 落为 `EXPIRED`。
- 为 chat-service、ranking-service、后台实时监控提供在线状态视图。

## 明确边界

- 负责字段：`merchant_id`、`player_id`、`session_id`、`device_id`、`game_code`、`status`、`last_seen_at`、`ttl_seconds`、`expires_at`、`close_reason`。
- 状态枚举：`ONLINE`、`OFFLINE`、`EXPIRED`。
- 上线幂等：同一 `merchant_id/player_id/session_id/device_id/game_code` 在未过期 `ONLINE` 状态下重复上线，返回原状态并标记 `data.code=IDEMPOTENT_HIT`。
- TTL 规则：上线时 `expires_at = now + ttl_seconds`；heartbeat 时刷新 `last_seen_at`，并将 `expires_at` 延长为 `now + ttl_seconds`；查询/心跳发现超时会落为 `EXPIRED`；第一阶段单次 TTL 上限为 `86400` 秒，超过上限返回参数错误，避免时间溢出和长期在线脏状态。

## 禁止事项

- 不能作为资金事务判断依据。
- 不能直接踢掉玩家资金中的未完成订单。
- 不能保存无过期时间的在线状态。
- 不能绕过 game-session-service 的 `session_id/session_token` 鉴权边界。

## 本地命令

```bash
cd /Users/amumu/Desktop/beifen/golang新架构/实时服务/presence-service
go test ./...
go build ./cmd/presence-service
PORT=8102 go run ./cmd/presence-service
```

## 健康检查

```bash
curl http://127.0.0.1:8102/health
curl http://127.0.0.1:8102/ready
```

## HTTP 接口

### 玩家上线

```bash
curl -X POST http://127.0.0.1:8102/presence/online \
  -H 'Content-Type: application/json' \
  -H 'X-Request-Id: req_presence_online_001' \
  -d '{
    "trace_id": "trace_001",
    "merchant_id": "DEMO",
    "player_id": "player_001",
    "session_id": "sess_001",
    "device_id": "device_ios_001",
    "game_code": "demo-slots",
    "ttl_seconds": 300
  }'
```

成功返回 `data.status=ONLINE`、`data.last_seen_at`、`data.expires_at`、`data.redis_key`。同一 session 重复上线返回 `data.code=IDEMPOTENT_HIT`。

### 玩家心跳

```bash
curl -X POST http://127.0.0.1:8102/presence/heartbeat \
  -H 'Content-Type: application/json' \
  -d '{
    "trace_id": "trace_heartbeat_001",
    "merchant_id": "DEMO",
    "player_id": "player_001",
    "session_id": "sess_001",
    "device_id": "device_ios_001",
    "ttl_seconds": 300
  }'
```

只允许当前 `ONLINE` 且 `session_id/device_id` 匹配的在线状态续期。成功后刷新 `last_seen_at`，并重算 `expires_at = now + ttl_seconds`。

### 玩家离线

```bash
curl -X POST http://127.0.0.1:8102/presence/offline \
  -H 'Content-Type: application/json' \
  -d '{
    "trace_id": "trace_offline_001",
    "merchant_id": "DEMO",
    "player_id": "player_001",
    "session_id": "sess_001",
    "reason": "PLAYER_EXIT"
  }'
```

首次离线将状态置为 `OFFLINE`，写入 `close_reason` 和 `offline_at`。重复离线返回同一终态，视为幂等命中。

### 查询在线状态

```bash
curl http://127.0.0.1:8102/presence/DEMO/player_001
```

查询时如果当前时间已经超过 `expires_at`，服务会将 `ONLINE` 状态落为 `EXPIRED`，并写入 `close_reason=TTL_EXPIRED`。

## Redis key 设计

第一阶段代码使用 `internal/repository/redis.MemoryPresenceRepository` 模拟 Redis key，后续接 Redis 时建议保持以下结构：

```text
presence:player:{merchant_id}:{player_id}
  type: HASH / JSON
  ttl: ttl_seconds + grace_seconds
  fields: merchant_id, player_id, session_id, device_id, game_code, status,
          ttl_seconds, last_seen_at, expires_at, offline_at, close_reason

presence:session:{session_id}
  type: STRING
  value: presence:player:{merchant_id}:{player_id}
  ttl: 与 player_presence key 对齐

presence:merchant:{merchant_id}:online
  type: ZSET
  member: player_id
  score: expires_at unix seconds
```

写入建议使用 Redis Lua 或事务同时更新 player key、session index 和 merchant online set。heartbeat 只延长 `ONLINE` 状态；`OFFLINE/EXPIRED` 不允许靠 heartbeat 重新激活，必须重新走 `/presence/online`。

## 与周边服务关系

- `game-session-service`：该服务是 session 鉴权和游戏内会话 TTL 的来源。presence-service 只记录在线态；上线/心跳请求应携带已由 game-session-service 或 game-gateway 校验过的 `session_id`。
- `chat-service`：聊天室可读取 presence-service 判断玩家是否仍在线，并据此维护在线成员列表、断线提示和房间人数。
- `ranking-service`：排行榜可读取 presence-service 的 `ONLINE/EXPIRED` 结果做实时活跃过滤，但最终排行分数仍以订单、结算或数仓事件为准。
