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
本 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>
285 lines
10 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|
|
}
|