Files
Genarrative/server-rs/crates/module-auth/src/creator.rs
T
lhk229 51cb05f418
Project CI / AI game creator shell Rust crates (push) Successful in 5m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 7m37s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / Frontend tests (push) Successful in 4m40s
Project CI / AI game creator shell web tests (push) Successful in 3m50s
Project CI / Repository checks (push) Successful in 6m30s
Project CI / Native shell tests (push) Successful in 8m32s
补充创作者主页与关注粉丝工程文档 (#638)
本 PR 补齐创作者主页与关注粉丝功能的产品合同和工程设计,供后续按里程碑实施;本次只修改文档。

桌面导航第四项进入自己的创作者主页,“我的”移到第五项。游戏详情可进入作者主页并关注/取消关注;自己或他人的关注、粉丝列表均公开可查看,点击用户头像或昵称可进入其主页。本人支持取消/重新关注、回关和移除粉丝,并明确两个关注方向独立。

工程设计包含关系表与 DTO、认证和事务边界、跨页状态同步、共享组件、深链及验收要求,拆分关系查询和页面交互两个里程碑。按用户要求,游戏列表沿用最多 48 项的现有限制,不增加游戏目录分页改造。

验证:
- 已合入最新 origin/master(21c79f54a);共享决策记录冲突保留双方新增内容。
- npm run check:encoding 通过。
- npm run check:doc-index 通过。
- git diff --check 与暂存差异检查通过。
- 相对 master 仅六份文档发生变化;没有业务代码或 schema 修改,未执行业务测试或部署。

Reviewed-on: #638
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-10-05 22:05:11 +08:00

285 lines
10 KiB
Rust

//! 创作者关系纯规则;不依赖账号服务、数据库或 HTTP。
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
pub const MAX_USER_ID_BYTES: usize = 256;
pub const MAX_CURSOR_BYTES: usize = 4096;
pub const DEFAULT_LIST_LIMIT: usize = 20;
pub const MAX_LIST_LIMIT: usize = 50;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CreatorInputError {
InvalidUserId,
SelfRelationship,
InvalidLimit,
InvalidCursor,
}
impl std::fmt::Display for CreatorInputError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::InvalidUserId => "用户 ID 不合法",
Self::SelfRelationship => "不能对自己操作关注关系",
Self::InvalidLimit => "列表数量必须是 1 到 50 的整数",
Self::InvalidCursor => "分页游标不合法或不属于当前列表",
})
}
}
impl std::error::Error for CreatorInputError {}
pub fn normalize_user_id(raw: &str) -> Result<String, CreatorInputError> {
let id = raw.trim();
if id.is_empty() || id.len() > MAX_USER_ID_BYTES || id.chars().any(char::is_control) {
return Err(CreatorInputError::InvalidUserId);
}
Ok(id.to_owned())
}
/// 调用方先规范化 ID;字节长度前缀避免分隔符或多字节 ID 碰撞。
pub fn relationship_id(follower: &str, followee: &str) -> String {
format!("{}:{follower}{followee}", follower.len())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CreatorAction {
Follow,
Unfollow,
RemoveFollower,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreatorMutation {
pub actor_id: String,
pub target_id: String,
pub follower_id: String,
pub followee_id: String,
pub relationship_id: String,
pub insert: bool,
}
pub fn prepare_mutation(
actor: &str,
target: &str,
action: CreatorAction,
) -> Result<CreatorMutation, CreatorInputError> {
let actor_id = normalize_user_id(actor)?;
let target_id = normalize_user_id(target)?;
if actor_id == target_id {
return Err(CreatorInputError::SelfRelationship);
}
let (follower_id, followee_id) = match action {
CreatorAction::RemoveFollower => (target_id.clone(), actor_id.clone()),
_ => (actor_id.clone(), target_id.clone()),
};
Ok(CreatorMutation {
relationship_id: relationship_id(&follower_id, &followee_id),
actor_id,
target_id,
follower_id,
followee_id,
insert: action == CreatorAction::Follow,
})
}
pub fn parse_list_limit(raw: Option<&str>) -> Result<usize, CreatorInputError> {
let Some(raw) = raw else {
return Ok(DEFAULT_LIST_LIMIT);
};
if raw.is_empty() || raw.len() > 2 || !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(CreatorInputError::InvalidLimit);
}
let limit: usize = raw.parse().map_err(|_| CreatorInputError::InvalidLimit)?;
if !(1..=MAX_LIST_LIMIT).contains(&limit) {
return Err(CreatorInputError::InvalidLimit);
}
Ok(limit)
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ConnectionKind {
Following,
Followers,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ConnectionCursor {
version: u8,
owner_id: String,
kind: ConnectionKind,
created_at_micros: String,
relationship_id: String,
}
impl ConnectionCursor {
pub fn new(
owner_id: String,
kind: ConnectionKind,
micros: i64,
relationship_id: String,
) -> Self {
Self {
version: 1,
owner_id,
kind,
created_at_micros: micros.to_string(),
relationship_id,
}
}
pub fn encode(&self) -> String {
URL_SAFE_NO_PAD.encode(serde_json::to_vec(self).expect("固定结构的游标可序列化"))
}
pub fn decode(raw: &str, owner: &str, kind: ConnectionKind) -> Result<Self, CreatorInputError> {
let invalid = CreatorInputError::InvalidCursor;
if raw.len() > MAX_CURSOR_BYTES {
return Err(invalid);
}
let bytes = URL_SAFE_NO_PAD.decode(raw).map_err(|_| invalid)?;
let cursor: Self = serde_json::from_slice(&bytes).map_err(|_| invalid)?;
let micros: i64 = cursor.created_at_micros.parse().map_err(|_| invalid)?;
if cursor.version != 1
|| cursor.owner_id != owner
|| cursor.kind != kind
|| micros.to_string() != cursor.created_at_micros
|| cursor.relationship_id.is_empty()
|| cursor.relationship_id.len() > 2 * MAX_USER_ID_BYTES + 4
{
return Err(invalid);
}
let (length, pair) = cursor.relationship_id.split_once(':').ok_or(invalid)?;
let length: usize = length.parse().map_err(|_| invalid)?;
let follower = pair.get(..length).ok_or(invalid)?;
let followee = pair.get(length..).ok_or(invalid)?;
if normalize_user_id(follower).map_err(|_| invalid)? != follower
|| normalize_user_id(followee).map_err(|_| invalid)? != followee
|| follower == followee
|| relationship_id(follower, followee) != cursor.relationship_id
|| match kind {
ConnectionKind::Following => follower != owner,
ConnectionKind::Followers => followee != owner,
}
{
return Err(invalid);
}
Ok(cursor)
}
pub fn is_after(&self, micros: i64, key: &str) -> bool {
// 私有字段只可由构造函数或严格 decode 生成。
let at: i64 = self.created_at_micros.parse().expect("已校验的游标时间");
(micros, key) < (at, self.relationship_id.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creator_keys_do_not_collide_and_preserve_direction() {
assert_ne!(relationship_id("a", "bc"), relationship_id("ab", "c"));
assert_ne!(relationship_id("a:", "b"), relationship_id("a", ":b"));
assert_ne!(relationship_id("甲", "乙"), relationship_id("乙", "甲"));
assert_eq!(relationship_id("甲", "乙"), "3:甲乙");
}
#[test]
fn creator_removing_follower_only_targets_incoming_edge() {
let follow = prepare_mutation("a", "b", CreatorAction::Follow).unwrap();
let unfollow = prepare_mutation("a", "b", CreatorAction::Unfollow).unwrap();
let remove = prepare_mutation("a", "b", CreatorAction::RemoveFollower).unwrap();
assert_eq!(follow.relationship_id, unfollow.relationship_id);
assert_eq!(remove.relationship_id, relationship_id("b", "a"));
assert_eq!(remove.target_id, "b");
assert!(follow.insert);
assert!(!unfollow.insert);
assert!(!remove.insert);
for action in [
CreatorAction::Follow,
CreatorAction::Unfollow,
CreatorAction::RemoveFollower,
] {
assert_eq!(
prepare_mutation(" a ", "a", action),
Err(CreatorInputError::SelfRelationship)
);
}
}
#[test]
fn creator_input_limits_reject_invalid_and_unbounded_values() {
for id in ["", " ", "a\0b", &"甲".repeat(86)] {
assert!(normalize_user_id(id).is_err());
}
assert_eq!(normalize_user_id(" 甲 ").unwrap(), "甲");
assert_eq!(parse_list_limit(None).unwrap(), 20);
assert_eq!(parse_list_limit(Some("50")).unwrap(), 50);
for limit in ["", "0", "51", "-1", "+1", "1.5", " 2", "1000000000000"] {
assert!(parse_list_limit(Some(limit)).is_err());
}
}
#[test]
fn creator_cursor_binds_owner_kind_and_orders_equal_timestamps() {
let cursor = ConnectionCursor::new(
"a".into(),
ConnectionKind::Following,
9007199254740993,
"1:ac".into(),
);
let encoded = cursor.encode();
let restored = ConnectionCursor::decode(&encoded, "a", ConnectionKind::Following).unwrap();
assert_eq!(restored, cursor);
assert!(restored.is_after(9007199254740993, "1:ab"));
assert!(!restored.is_after(9007199254740993, "1:ac"));
assert!(!restored.is_after(9007199254740994, "1:ab"));
assert!(ConnectionCursor::decode(&encoded, "b", ConnectionKind::Following).is_err());
assert!(ConnectionCursor::decode(&encoded, "a", ConnectionKind::Followers).is_err());
}
#[test]
fn creator_cursor_rejects_malformed_unknown_and_oversized_payloads() {
let cursor = ConnectionCursor::new("a".into(), ConnectionKind::Followers, 3, "1:ba".into());
let mut json = serde_json::to_value(cursor).unwrap();
for bad in [
serde_json::json!(3),
serde_json::json!("+3"),
serde_json::json!("03"),
serde_json::json!("9223372036854775808"),
] {
json["createdAtMicros"] = bad;
assert!(
ConnectionCursor::decode(
&URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap()),
"a",
ConnectionKind::Followers
)
.is_err()
);
}
json["createdAtMicros"] = serde_json::json!("3");
json["extra"] = serde_json::json!(true);
assert!(
ConnectionCursor::decode(
&URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap()),
"a",
ConnectionKind::Followers
)
.is_err()
);
for raw in ["!", "", &"a".repeat(MAX_CURSOR_BYTES + 1)] {
assert!(ConnectionCursor::decode(raw, "a", ConnectionKind::Followers).is_err());
}
for key in ["broken", "999:ba", "01:ba", "1:aa", "1:bc", "1:甲a"] {
let cursor =
ConnectionCursor::new("a".into(), ConnectionKind::Followers, 3, key.into());
assert!(
ConnectionCursor::decode(&cursor.encode(), "a", ConnectionKind::Followers).is_err()
);
}
}
}