feat: 日历分享改为按手机号+好友关系校验

This commit is contained in:
qzl
2026-03-30 11:37:41 +08:00
parent 60318b7aaa
commit 9fb2a6857b
20 changed files with 624 additions and 230 deletions
+10 -2
View File
@@ -64,9 +64,17 @@ class ApiClient implements IApiClient {
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) async {
Future<Response<T>> get<T>(
String path, {
Map<String, String>? queryParameters,
Options? options,
}) async {
try {
return await _dio.get<T>(path, options: options);
return await _dio.get<T>(
path,
queryParameters: queryParameters,
options: options,
);
} on DioException catch (e) {
throw ApiException.fromDioError(e);
}
@@ -141,6 +141,8 @@ String? mapErrorCodeToL10nKey(
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_SHARE_FORBIDDEN':
return 'errorForbidden';
case 'SCHEDULE_ITEM_SHARE_TARGET_NOT_FRIEND':
return 'errorForbidden';
case 'SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED':
return 'errorGenericSafe';
case 'SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE':
+5 -1
View File
@@ -1,7 +1,11 @@
import 'package:dio/dio.dart';
abstract class IApiClient {
Future<Response<T>> get<T>(String path, {Options? options});
Future<Response<T>> get<T>(
String path, {
Map<String, String>? queryParameters,
Options? options,
});
Future<Response<T>> post<T>(String path, {dynamic data, Options? options});
Future<Response<T>> put<T>(String path, {dynamic data, Options? options});
Future<Response<T>> patch<T>(String path, {dynamic data, Options? options});
@@ -78,7 +78,7 @@ class HomeChatItemRenderer {
child: Text(
item.content,
style: TextStyle(
fontSize: AppSpacing.md,
fontSize: 14,
height: 1.45,
color: isUser
? colorScheme.onPrimaryContainer
@@ -53,14 +53,16 @@ class SettingsApi {
String platform = 'android',
String channel = 'release',
}) async {
final params = <String, String>{
final queryParameters = <String, String>{
'platform': platform,
'channel': channel,
'current_version_code': currentVersionCode.toString(),
'current_version_name': currentVersionName,
};
final queryString = Uri(queryParameters: params).query;
final response = await _client.get('$_prefix/check-updates?$queryString');
final response = await _client.get(
'$_prefix/check-updates',
queryParameters: queryParameters,
);
return AppVersionResponse.fromJson(response.data);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: social_app
description: "Social App - A Flutter mobile application"
publish_to: 'none'
version: 0.1.1+4
version: 0.1.1+5
environment:
sdk: ^3.10.7
-1
View File
@@ -9,7 +9,6 @@ RUN uv sync --frozen --no-dev
COPY backend/src ./backend/src
COPY backend/alembic ./backend/alembic
COPY backend/scripts ./backend/scripts
ENV PYTHONPATH=/app/backend/src
ENV PYTHONDONTWRITEBYTECODE=1
@@ -11,7 +11,6 @@ from core.agentscope.tools.utils.calendar_domain import (
map_calendar_exception,
merge_schedule_metadata_for_update,
parse_iso_datetime,
resolve_share_target_phone_map,
schedule_event_to_dict,
)
from core.agentscope.tools.utils.calendar_ui import (
@@ -31,9 +30,12 @@ from v1.schedule_items.schemas import (
class CalendarShareInvitee(BaseModel):
model_config = ConfigDict(extra="forbid")
user_id: str = Field(
alias="userId",
description="Target invitee user id as UUID string.",
phone: str = Field(
alias="phone",
description=(
"Target invitee phone. Accepts +8613xxxxxxxxx / 8613xxxxxxxxx "
"/ 13xxxxxxxxx and normalizes to E.164 (+86...)."
),
)
permission_view: bool = Field(
default=True,
@@ -497,8 +499,8 @@ async def calendar_share(
list[CalendarShareInvitee],
Field(
description=(
"Invitee list with userId and per-user permissions. "
"Prefer composing with user_lookup tool to get userId first."
"Invitee list with phone and per-user permissions. "
"Prefer composing with user_lookup tool to pick a friend phone first."
),
min_length=1,
),
@@ -506,11 +508,25 @@ async def calendar_share(
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Share a calendar event with invitee user ids.
"""Share a calendar event with invitee phones.
Input contract:
- invitees use `phone` (not `userId`)
- phone accepts local/86/E.164 forms and is normalized before share
Orchestration contract:
- prefer `user_lookup` first to get friend candidates
- choose matched friend phone(s)
- call `calendar_share`
Output contract:
- status can be success / partial / failure
- result includes per-item outcomes in `items=[{phone,status,code}]`
- first failure is exposed in `error` when any item fails
Args:
event_id: Target event id as UUID string.
invitees: Invitee list with user id and per-user permissions.
invitees: Invitee list with phone and per-user permissions.
Returns:
ToolResponse with serialized ToolAgentOutput payload.
@@ -537,56 +553,116 @@ async def calendar_share(
)
target_uuid = UUID(event_id)
phone_map = resolve_share_target_phone_map(
[invitee.user_id for invitee in invitees]
)
if not phone_map:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="NOT_FOUND",
message="未找到任何有效的邀请目标",
retryable=False,
)
invited: list[str] = []
result_items: list[dict[str, str]] = []
for invitee in invitees:
try:
normalized_user_id = str(UUID(invitee.user_id.strip()))
except ValueError:
continue
phone = phone_map.get(normalized_user_id)
if phone is None:
raw_phone = invitee.phone.strip()
normalized_phone = raw_phone
for separator in (" ", "-", "(", ")"):
normalized_phone = normalized_phone.replace(separator, "")
if normalized_phone.startswith("0086"):
normalized_phone = f"+86{normalized_phone[4:]}"
elif normalized_phone.startswith("86") and normalized_phone[2:].isdigit():
normalized_phone = f"+{normalized_phone}"
elif normalized_phone.startswith("1") and normalized_phone.isdigit():
normalized_phone = f"+86{normalized_phone}"
if (
len(normalized_phone) != 14
or not normalized_phone.startswith("+861")
or not normalized_phone[1:].isdigit()
or normalized_phone[4] not in {"3", "4", "5", "6", "7", "8", "9"}
):
result_items.append(
{
"phone": raw_phone,
"status": "failure",
"code": "INVALID_ARGUMENT",
"message": "无效手机号格式",
}
)
continue
permission = {
"permission_view": invitee.permission_view,
"permission_edit": invitee.permission_edit,
"permission_invite": invitee.permission_invite,
}
await service.share(
target_uuid, ScheduleItemShareRequest(phone=phone, **permission)
try:
await service.share(
target_uuid,
ScheduleItemShareRequest(phone=normalized_phone, **permission),
)
invited.append(normalized_phone)
result_items.append(
{
"phone": normalized_phone,
"status": "success",
}
)
except Exception as exc:
code, message, _ = map_calendar_exception(exc)
result_items.append(
{
"phone": normalized_phone,
"status": "failure",
"code": code,
"message": message,
}
)
failure_count = len(
[item for item in result_items if item["status"] == "failure"]
)
success_count = len(invited)
if success_count and failure_count:
final_status = ToolStatus.PARTIAL
elif success_count:
final_status = ToolStatus.SUCCESS
else:
final_status = ToolStatus.FAILURE
compact_items = ",".join(
[
"{"
f"phone={item.get('phone')},status={item.get('status')},"
f"code={item.get('code', '')}"
"}"
for item in result_items
]
)
summary = (
f"status={final_status.value} success={success_count} "
f"failed={failure_count}"
)
if compact_items:
summary = f"{summary} items=[{compact_items}]"
error_info: ErrorInfo | None = None
if failure_count:
first_failure = next(
(item for item in result_items if item.get("status") == "failure"),
None,
)
invited.append(phone)
if not invited:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="NOT_FOUND",
message="邀请目标均无有效手机号",
error_info = ErrorInfo(
code=str(
first_failure.get("code") if first_failure else "INTERNAL_ERROR"
),
message=str(
first_failure.get("message")
if first_failure and first_failure.get("message")
else "日历分享失败"
),
retryable=False,
details={"results": result_items},
)
summary = (
f"status=success invited_count={len(invited)} invited=[{','.join(invited)}]"
)
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
status=final_status,
result=summary,
error=error_info,
)
)
except Exception as exc:
@@ -1,24 +1,21 @@
from typing import Annotated, Any, cast
from typing import Any, cast
from uuid import UUID
from fastapi import HTTPException
from pydantic import Field
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from agentscope.tool import ToolResponse
from core.agentscope.tools.tool_call_context import get_current_tool_call_id
from core.agentscope.tools.utils import (
find_auth_phone_by_user_id,
list_auth_users,
)
from core.agentscope.tools.utils.tool_response_builder import (
build_error_output,
build_tool_response,
)
from models.friendships import Friendship
from models.profile import Profile
from schemas.enums import FriendshipStatus
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
from v1.auth.gateway import SupabaseAuthGateway
from v1.users.contact_resolver import resolve_contacts_by_user_ids
def _dump_tool_output(output: ToolAgentOutput) -> ToolResponse:
@@ -43,85 +40,99 @@ def _lookup_error_output(
return _dump_tool_output(output)
async def _resolve_identity(
async def _list_friend_contacts(
*,
session: AsyncSession,
user_phone: str | None,
user_name: str | None,
) -> dict[str, Any]:
"""Resolve user identity by phone or username."""
phone = user_phone.strip() if isinstance(user_phone, str) else ""
name = user_name.strip() if isinstance(user_name, str) else ""
owner_id: UUID,
) -> list[dict[str, str]]:
"""Load accepted friends and return contact tuples.
if bool(phone) == bool(name):
raise HTTPException(
status_code=400,
detail="请提供 phone 或 username 其中之一",
Returns items shaped as:
- userId: friend user UUID string
- username: friend username
- phone: friend phone in E.164 format
"""
friendships_stmt = (
select(Friendship)
.where(
or_(
Friendship.user_low_id == owner_id,
Friendship.user_high_id == owner_id,
)
)
if phone:
auth_gateway = SupabaseAuthGateway()
user = await auth_gateway.get_user_by_phone(phone)
user_id = UUID(user.id)
stmt = (
select(Profile.username)
.where(Profile.id == user_id)
.where(Profile.deleted_at.is_(None))
.where(Friendship.status == FriendshipStatus.ACCEPTED)
.where(Friendship.deleted_at.is_(None))
)
friendships = (await session.execute(friendships_stmt)).scalars().all()
friend_ids: list[UUID] = []
for friendship in friendships:
friend_id = (
friendship.user_high_id
if friendship.user_low_id == owner_id
else friendship.user_low_id
)
username = (await session.execute(stmt)).scalar_one_or_none()
friend_ids.append(friend_id)
return {
"userId": str(user_id),
"phone": user.phone,
"username": username,
"matchedBy": "phone",
}
if not friend_ids:
return []
stmt = (
profiles_stmt = (
select(Profile)
.where(Profile.username == name)
.where(Profile.id.in_(friend_ids))
.where(Profile.deleted_at.is_(None))
)
profile = await session.execute(stmt)
profile = profile.scalar_one_or_none()
profiles = (await session.execute(profiles_stmt)).scalars().all()
profiles_by_id = {profile.id: profile for profile in profiles}
auth_gateway = SupabaseAuthGateway()
resolved_contacts = await resolve_contacts_by_user_ids(
user_ids=friend_ids,
profiles_by_id=profiles_by_id,
auth_gateway=auth_gateway,
)
if profile is None:
raise HTTPException(status_code=404, detail="用户不存在")
contacts: list[dict[str, str]] = []
for friend_id in friend_ids:
contact = resolved_contacts.get(friend_id)
if contact is None:
continue
phone = contact.phone
if not phone:
continue
contacts.append(
{
"userId": str(friend_id),
"username": str(contact.username or ""),
"phone": phone,
}
)
users = list_auth_users()
phone_value = find_auth_phone_by_user_id(users=users, user_id=profile.id)
return {
"userId": str(profile.id),
"phone": phone_value,
"username": profile.username,
"matchedBy": "username",
}
contacts.sort(key=lambda item: (item["username"], item["phone"]))
return contacts
async def user_lookup(
user_phone: Annotated[
str | None,
Field(description="User phone to look up."),
] = None,
user_name: Annotated[
str | None,
Field(description="Username to look up."),
] = None,
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Look up user identity by phone or username.
"""List current user's accepted friend contacts.
Args:
user_phone: User phone for lookup.
user_name: Username for lookup.
This tool is intentionally argument-free for business inputs. Runtime
context (`session`, `owner_id`) is injected by toolkit preset kwargs.
Intended composition:
1) call `user_lookup` to obtain friend username/phone candidates
2) resolve target friend from user utterance
3) call `calendar_share` with selected phone(s)
Result format (in ToolAgentOutput.result):
- status=success
- friends_count=<n>
- friends=[{userId=...,username=...,phone=...}, ...]
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_call_args = {"user_phone": user_phone, "user_name": user_name}
tool_call_args: dict[str, Any] = {}
if session is None or owner_id is None:
return _lookup_error_output(
@@ -132,20 +143,23 @@ async def user_lookup(
)
try:
resolved = await _resolve_identity(
contacts = await _list_friend_contacts(
session=cast(AsyncSession, session),
user_phone=user_phone,
user_name=user_name,
owner_id=cast(UUID, owner_id),
)
username = str(resolved.get("username") or "")
phone = str(resolved.get("phone") or "")
user_id = str(resolved.get("userId") or "")
matched_by = str(resolved.get("matchedBy") or "")
summary = (
f"status=success matched_by={matched_by} user_id={user_id} "
f"username={username} has_phone={str(bool(phone)).lower()}"
compact_items = ",".join(
[
"{"
f"userId={item.get('userId')},"
f"username={item.get('username')},"
f"phone={item.get('phone')}"
"}"
for item in contacts
]
)
summary = f"status=success friends_count={len(contacts)}"
if compact_items:
summary = f"{summary} friends=[{compact_items}]"
return _dump_tool_output(
ToolAgentOutput(
tool_name="user_lookup",
@@ -155,24 +169,10 @@ async def user_lookup(
result=summary,
)
)
except HTTPException as exc:
if exc.status_code == 404:
return _lookup_error_output(
tool_call_args=tool_call_args,
code="NOT_FOUND",
message=exc.detail or "用户不存在",
retryable=False,
)
return _lookup_error_output(
tool_call_args=tool_call_args,
code="LOOKUP_FAILED",
message=exc.detail or "用户查找失败",
retryable=True,
)
except Exception as exc:
return _lookup_error_output(
tool_call_args=tool_call_args,
code="INTERNAL_ERROR",
message=f"用户查找失败: {str(exc)}",
message=f"好友查找失败: {str(exc)}",
retryable=True,
)
@@ -8,11 +8,8 @@ from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.utils.auth_helpers import (
find_auth_phone_by_user_id,
list_auth_users,
)
from core.auth.models import CurrentUser
from core.http.errors import ApiProblemError
from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
from v1.schedule_items.schemas import ScheduleItemMetadata
@@ -22,6 +19,8 @@ _HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
def map_calendar_exception(exc: Exception) -> tuple[str, str, bool]:
if isinstance(exc, ApiProblemError):
return exc.code, exc.detail, False
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, str) and detail.strip():
@@ -127,22 +126,3 @@ def parse_iso_datetime(value: str | None) -> datetime | None:
if parsed.tzinfo is None:
raise ValueError("时间必须包含时区信息")
return parsed.astimezone(timezone.utc)
def resolve_share_target_phone_map(invitee_user_ids: list[str]) -> dict[str, str]:
users = list_auth_users()
resolved: dict[str, str] = {}
for raw_user_id in invitee_user_ids:
if not isinstance(raw_user_id, str):
continue
normalized_user_id = raw_user_id.strip()
if not normalized_user_id:
continue
try:
user_uuid = UUID(normalized_user_id)
except ValueError:
continue
phone = find_auth_phone_by_user_id(users=users, user_id=user_uuid)
if phone:
resolved[str(user_uuid)] = phone
return resolved
+38 -28
View File
@@ -41,6 +41,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
self._user_lookup_cache_ttl_seconds: int = 60
self._user_lookup_cache_expires_at: float = 0.0
self._users_by_phone: dict[str, Any] = {}
self._users_by_id: dict[str, Any] = {}
def _get_client(self) -> Any:
return supabase_service.get_client()
@@ -207,17 +208,30 @@ class SupabaseAuthGateway(AuthServiceGateway):
)
async def get_user_by_id(self, user_id: str) -> UserByIdResponse:
try:
admin_client = self._get_admin_client()
user = await asyncio.to_thread(admin_client.auth.get_user_by_id, user_id)
users = await self.get_users_by_ids([user_id])
resolved = users.get(user_id)
if resolved is None:
raise _auth_error(
status_code=404,
code="AUTH_USER_NOT_FOUND",
detail="User not found",
)
return resolved
async def get_users_by_ids(
self, user_ids: list[str]
) -> dict[str, UserByIdResponse]:
await self._refresh_user_lookup_cache_if_needed()
resolved: dict[str, UserByIdResponse] = {}
for raw_user_id in user_ids:
normalized_user_id = raw_user_id.strip()
if not normalized_user_id:
continue
user = self._users_by_id.get(normalized_user_id)
if user is None:
raise _auth_error(
status_code=404,
code="AUTH_USER_NOT_FOUND",
detail="User not found",
)
continue
user_attrs = getattr(user, "user", user)
return UserByIdResponse(
resolved[normalized_user_id] = UserByIdResponse(
id=str(getattr(user_attrs, "id", "")),
phone=getattr(user_attrs, "phone", None),
created_at=str(getattr(user_attrs, "created_at", "")),
@@ -227,19 +241,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
else None
),
)
except AuthError as exc:
logger.warning("Get user by id failed", error_type=type(exc).__name__)
if _is_auth_upstream_unavailable(exc):
raise _auth_error(
status_code=503,
code="AUTH_SERVICE_UNAVAILABLE",
detail=AUTH_UNAVAILABLE_DETAIL,
) from exc
raise _auth_error(
status_code=404,
code="AUTH_USER_NOT_FOUND",
detail="User not found",
) from exc
return resolved
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
normalized_query = _normalize_phone_search_query(query)
@@ -287,10 +289,15 @@ class SupabaseAuthGateway(AuthServiceGateway):
admin_client = self._get_admin_client()
users = await asyncio.to_thread(_list_auth_users, admin_client)
users_by_phone: dict[str, Any] = {}
users_by_id: dict[str, Any] = {}
for candidate in users:
candidate_id = str(getattr(candidate, "id", "")).strip()
if candidate_id:
users_by_id[candidate_id] = candidate
candidate_phone = _normalize_phone(getattr(candidate, "phone", ""))
if candidate_phone:
users_by_phone[candidate_phone] = candidate
self._users_by_id = users_by_id
self._users_by_phone = users_by_phone
self._user_lookup_cache_expires_at = now + self._user_lookup_cache_ttl_seconds
@@ -386,10 +393,15 @@ def _list_auth_users(client: Any) -> list[Any]:
return users
def _normalize_phone(raw_phone: object) -> str | None:
phone = str(raw_phone).strip()
def _sanitize_phone_token(raw: object) -> str:
token = str(raw).strip()
for separator in (" ", "-", "(", ")"):
phone = phone.replace(separator, "")
token = token.replace(separator, "")
return token
def _normalize_phone(raw_phone: object) -> str | None:
phone = _sanitize_phone_token(raw_phone)
if not phone:
return None
if phone.startswith("00") and len(phone) > 2:
@@ -402,9 +414,7 @@ def _normalize_phone(raw_phone: object) -> str | None:
def _normalize_phone_search_query(raw_query: str) -> str | None:
query = raw_query.strip()
for separator in (" ", "-", "(", ")"):
query = query.replace(separator, "")
query = _sanitize_phone_token(raw_query)
if not query:
return None
if query.startswith("00") and len(query) > 2:
+30 -15
View File
@@ -14,12 +14,14 @@ from models.inbox_messages import InboxMessage
from models.profile import Profile
from models.schedule_items import ScheduleItem
from schemas.enums import (
FriendshipStatus,
InboxMessageStatus,
InboxMessageType,
SubscriptionPermission,
SubscriptionStatus,
)
from v1.auth.gateway import SupabaseAuthGateway
from v1.friendships.repository import SQLAlchemyFriendshipRepository
from v1.inbox_messages.repository import InboxMessageRepository
from v1.schedule_items.repository import ScheduleItemRepository
from v1.schedule_items.schemas import (
@@ -34,11 +36,13 @@ from v1.schedule_items.schemas import (
ScheduleItemStatus,
SubscriberInfo,
)
from v1.users.contact_resolver import resolve_contacts_by_user_ids
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from v1.auth.schemas import UserByIdResponse, UserByPhoneResponse
from v1.friendships.repository import FriendshipRepository
from v1.users.repository import UserRepository
logger = get_logger("v1.schedule_items.service")
@@ -56,6 +60,7 @@ class ScheduleItemService(BaseService):
_session: AsyncSession
_auth_gateway: AuthByPhoneGateway
_inbox_repository: InboxMessageRepository
_friendship_repository: FriendshipRepository
_user_repository: UserRepository | None
def __init__(
@@ -65,6 +70,7 @@ class ScheduleItemService(BaseService):
current_user: CurrentUser | None,
auth_gateway: AuthByPhoneGateway | None = None,
inbox_repository: InboxMessageRepository | None = None,
friendship_repository: FriendshipRepository | None = None,
user_repository: UserRepository | None = None,
) -> None:
super().__init__(current_user=current_user)
@@ -74,6 +80,9 @@ class ScheduleItemService(BaseService):
if inbox_repository is None:
raise ValueError("inbox_repository is required")
self._inbox_repository = inbox_repository
self._friendship_repository = friendship_repository or (
SQLAlchemyFriendshipRepository(session)
)
self._user_repository = user_repository
async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse:
@@ -188,26 +197,20 @@ class ScheduleItemService(BaseService):
)
except SQLAlchemyError:
logger.exception("Failed to get subscriber profiles")
resolved_contacts = await resolve_contacts_by_user_ids(
user_ids=subscriber_ids,
profiles_by_id=profiles,
auth_gateway=self._auth_gateway,
)
for sub in subscriptions:
if sub.status == SubscriptionStatus.ACTIVE:
profile = profiles.get(sub.subscriber_id)
phone = None
try:
user_info = await self._auth_gateway.get_user_by_id(
str(sub.subscriber_id)
)
phone = user_info.phone
except Exception:
logger.warning(
"Failed to get phone for subscriber",
subscriber_id=str(sub.subscriber_id),
)
contact = resolved_contacts.get(sub.subscriber_id)
subscribers.append(
SubscriberInfo(
user_id=sub.subscriber_id,
username=profile.username if profile else None,
avatar_url=profile.avatar_url if profile else None,
phone=phone,
username=contact.username if contact else None,
avatar_url=contact.avatar_url if contact else None,
phone=contact.phone if contact else None,
permission=sub.permission,
status=sub.status.value
if hasattr(sub.status, "value")
@@ -518,6 +521,18 @@ class ScheduleItemService(BaseService):
target_user = await self._auth_gateway.get_user_by_phone(request.phone)
recipient_id = UUID(target_user.id)
friendship = await self._friendship_repository.get_friendship_between_users(
user_id, recipient_id
)
if friendship is None or friendship.status != FriendshipStatus.ACCEPTED:
raise ApiProblemError(
status_code=403,
detail=problem_payload(
code="SCHEDULE_ITEM_SHARE_TARGET_NOT_FRIEND",
detail="You can only share calendar with accepted friends",
),
)
existing = await self._repository.get_subscription(item_id, recipient_id)
if existing:
if existing.status == SubscriptionStatus.PENDING:
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from uuid import UUID
from core.logging import get_logger
from models.profile import Profile
from v1.auth.schemas import UserByIdResponse
logger = get_logger("v1.users.contact_resolver")
class AuthContactGateway(Protocol):
async def get_user_by_id(self, user_id: str) -> UserByIdResponse:
raise NotImplementedError
@dataclass(frozen=True)
class ContactInfo:
user_id: UUID
username: str | None
avatar_url: str | None
phone: str | None
async def resolve_contacts_by_user_ids(
*,
user_ids: list[UUID],
profiles_by_id: dict[UUID, Profile],
auth_gateway: AuthContactGateway,
) -> dict[UUID, ContactInfo]:
resolved: dict[UUID, ContactInfo] = {}
for user_id in user_ids:
profile = profiles_by_id.get(user_id)
phone: str | None = None
try:
user_info = await auth_gateway.get_user_by_id(str(user_id))
phone = user_info.phone
except Exception:
logger.warning(
"Failed to resolve auth phone",
user_id=str(user_id),
)
resolved[user_id] = ContactInfo(
user_id=user_id,
username=profile.username if profile else None,
avatar_url=profile.avatar_url if profile else None,
phone=phone,
)
return resolved
@@ -261,21 +261,12 @@ async def test_calendar_share_executes_with_valid_invitee(
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
target_user_id = str(uuid4())
monkeypatch.setattr(
calendar_module,
"resolve_share_target_phone_map",
lambda user_ids: {target_user_id: "+8613900001234"}
if target_user_id in user_ids
else {},
)
event_id = str(uuid4())
result = await calendar_module.calendar_share(
event_id=event_id,
invitees=[
calendar_module.CalendarShareInvitee(
userId=target_user_id,
phone="13900001234",
permissionView=True,
permissionEdit=False,
permissionInvite=False,
@@ -287,9 +278,37 @@ async def test_calendar_share_executes_with_valid_invitee(
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success invited_count=1")
assert payload["result"].startswith("status=success success=1 failed=0")
assert "+8613900001234" in payload["result"]
assert len(fake_service.share_calls) == 1
share_call = fake_service.share_calls[0]
assert share_call["item_id"] == event_id
assert share_call["request"].phone == "+8613900001234"
@pytest.mark.asyncio
async def test_calendar_share_rejects_invalid_phone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_share(
event_id=str(uuid4()),
invitees=[
calendar_module.CalendarShareInvitee(
phone="12345",
permissionView=True,
permissionEdit=False,
permissionInvite=False,
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
@@ -0,0 +1,63 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
from uuid import uuid4
import pytest
from agentscope.tool import ToolResponse
from core.agentscope.tools.custom.user_lookup import user_lookup
import core.agentscope.tools.custom.user_lookup as user_lookup_module
def _decode_tool_response(response: ToolResponse) -> dict[str, Any]:
assert response.content
first = response.content[0]
if isinstance(first, dict):
text = str(first.get("text", ""))
else:
text = str(getattr(first, "text", ""))
return json.loads(text)
@pytest.mark.asyncio
async def test_user_lookup_requires_runtime_context() -> None:
result = await user_lookup()
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS"
@pytest.mark.asyncio
async def test_user_lookup_returns_friend_contacts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_list_friend_contacts(**_: Any) -> list[dict[str, str]]:
return [
{
"userId": "00000000-0000-0000-0000-000000000101",
"username": "alice",
"phone": "+8613900000001",
},
{
"userId": "00000000-0000-0000-0000-000000000102",
"username": "bob",
"phone": "+8613900000002",
},
]
monkeypatch.setattr(
user_lookup_module,
"_list_friend_contacts",
_fake_list_friend_contacts,
)
result = await user_lookup(session=SimpleNamespace(), owner_id=uuid4())
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert "friends_count=2" in payload["result"]
assert "username=alice" in payload["result"]
assert "+8613900000001" in payload["result"]
@@ -1,17 +1,18 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import cast
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.http.errors import ApiProblemError
from core.auth.models import CurrentUser
from models.inbox_messages import InboxMessage, InboxMessageType
from models.schedule_items import ScheduleItem
from schemas.enums import FriendshipStatus
from v1.auth.schemas import UserByPhoneResponse
from v1.schedule_items.repository import ScheduleItemRepository
from v1.schedule_items.schemas import ScheduleItemShareRequest
@@ -79,6 +80,14 @@ class AuthGatewayStub:
phone_confirmed_at=None,
)
async def get_user_by_id(self, user_id: str) -> UserByPhoneResponse:
return UserByPhoneResponse(
id=user_id,
phone="+8613810000000",
created_at="2026-02-28T10:00:00Z",
phone_confirmed_at=None,
)
class InboxRepoStub:
async def create(self, data: dict[str, object]) -> InboxMessage:
@@ -127,6 +136,26 @@ class AuthGatewayInvalidIdStub:
phone_confirmed_at=None,
)
async def get_user_by_id(self, user_id: str) -> UserByPhoneResponse:
return UserByPhoneResponse(
id=user_id,
phone="+8613810000000",
created_at="2026-02-28T10:00:00Z",
phone_confirmed_at=None,
)
class FriendshipRepoStub:
def __init__(self, accepted: bool = True) -> None:
self._accepted = accepted
async def get_friendship_between_users(self, user_id_1: UUID, user_id_2: UUID):
if not self._accepted:
return None
friendship = MagicMock()
friendship.status = FriendshipStatus.ACCEPTED
return friendship
@pytest.mark.asyncio
async def test_share_forbidden_when_not_owner() -> None:
@@ -140,11 +169,12 @@ async def test_share_forbidden_when_not_owner() -> None:
),
session=AsyncMock(),
current_user=CurrentUser(id=requester_id),
auth_gateway=AuthGatewayStub(),
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
@@ -171,8 +201,9 @@ async def test_share_success_creates_calendar_invitation_message() -> None:
),
session=session,
current_user=CurrentUser(id=owner_id),
auth_gateway=AuthGatewayStub(),
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
)
result = await service.share(
@@ -203,11 +234,12 @@ async def test_share_returns_not_found_when_item_missing() -> None:
repository=cast(ScheduleItemRepository, ShareRepo(None)),
session=AsyncMock(),
current_user=CurrentUser(id=requester_id),
auth_gateway=AuthGatewayStub(),
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.share(
uuid4(),
ScheduleItemShareRequest(
@@ -233,11 +265,12 @@ async def test_share_invalid_auth_user_id_returns_503() -> None:
),
session=session,
current_user=CurrentUser(id=owner_id),
auth_gateway=AuthGatewayInvalidIdStub(),
auth_gateway=cast(Any, AuthGatewayInvalidIdStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
@@ -266,11 +299,12 @@ async def test_share_sqlalchemy_error_rolls_back() -> None:
),
session=session,
current_user=CurrentUser(id=owner_id),
auth_gateway=AuthGatewayStub(),
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub()),
)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(ApiProblemError) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
@@ -284,3 +318,34 @@ async def test_share_sqlalchemy_error_rolls_back() -> None:
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Schedule item store unavailable"
session.rollback.assert_awaited_once()
@pytest.mark.asyncio
async def test_share_returns_forbidden_when_target_is_not_friend() -> None:
owner_id = UUID("00000000-0000-0000-0000-000000000001")
item_id = uuid4()
service = ScheduleItemService(
repository=cast(
ScheduleItemRepository,
ShareRepo(_build_item(item_id=item_id, owner_id=owner_id)),
),
session=AsyncMock(),
current_user=CurrentUser(id=owner_id),
auth_gateway=cast(Any, AuthGatewayStub()),
inbox_repository=InboxRepoStub(),
friendship_repository=cast(Any, FriendshipRepoStub(accepted=False)),
)
with pytest.raises(ApiProblemError) as exc_info:
await service.share(
item_id,
ScheduleItemShareRequest(
phone="+8613810000000",
permission_view=True,
permission_edit=False,
permission_invite=False,
),
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == "You can only share calendar with accepted friends"
@@ -0,0 +1,71 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
from uuid import uuid4
import pytest
from v1.auth.schemas import UserByIdResponse
from v1.users.contact_resolver import resolve_contacts_by_user_ids
class _AuthGatewayStub:
def __init__(self, responses: dict[str, UserByIdResponse]) -> None:
self._responses = responses
async def get_user_by_id(self, user_id: str) -> UserByIdResponse:
response = self._responses.get(user_id)
if response is None:
raise RuntimeError("missing")
return response
@pytest.mark.asyncio
async def test_resolve_contacts_by_user_ids_builds_contact_map() -> None:
user_id = uuid4()
profile = SimpleNamespace(
id=user_id,
username="alice",
avatar_url="https://img.example/a.png",
)
gateway = _AuthGatewayStub(
{
str(user_id): UserByIdResponse(
id=str(user_id),
phone="+8613900001001",
created_at="2026-01-01T00:00:00Z",
phone_confirmed_at=None,
)
}
)
contacts = await resolve_contacts_by_user_ids(
user_ids=[user_id],
profiles_by_id=cast(dict[Any, Any], {user_id: profile}),
auth_gateway=gateway,
)
assert str(contacts[user_id].user_id) == str(user_id)
assert contacts[user_id].username == "alice"
assert contacts[user_id].avatar_url == "https://img.example/a.png"
assert contacts[user_id].phone == "+8613900001001"
@pytest.mark.asyncio
async def test_resolve_contacts_by_user_ids_keeps_profile_on_auth_failure() -> None:
user_id = uuid4()
profile = SimpleNamespace(
id=user_id,
username="bob",
avatar_url=None,
)
gateway = _AuthGatewayStub({})
contacts = await resolve_contacts_by_user_ids(
user_ids=[user_id],
profiles_by_id=cast(dict[Any, Any], {user_id: profile}),
auth_gateway=gateway,
)
assert contacts[user_id].username == "bob"
assert contacts[user_id].phone is None
+11
View File
@@ -21,6 +21,17 @@
"release_notes": null,
"file_size": 21572828,
"sha256": "2b59596044d473c8aa477a12d01958b9dc08b2aee528226039c37bdaa1372da8"
},
{
"platform": "android",
"channel": "release",
"version_name": "0.1.1",
"version_code": 5,
"min_supported_version_code": 5,
"file_name": "social-app-android-v0.1.1+5-release.apk",
"release_notes": null,
"file_size": 22909435,
"sha256": "6982e21662d9a49b0bd91da6d04d1b51b83b084bd326004c1049822a8563771f"
}
]
}
+19 -3
View File
@@ -139,7 +139,7 @@ Base URL: `/api/v1/schedule-items`
说明:
- `subscribers` 列表仅包含状态为 `active` 的订阅者
- `phone` 字段来自 Supabase Auth,用于显示订阅者手机号
- 前端显示优先级:`phone ?? username ?? userId`
- 前端显示优先级:`phone ?? username ?? user_id`
### ScheduleItemShareRequest
@@ -152,7 +152,9 @@ Base URL: `/api/v1/schedule-items`
}
```
说明:`permission_view`、`permission_edit`、`permission_invite` 为布尔值,内部会转换为位掩码整数:
说明:
- 分享目标仅支持 `phone`E.164 中国手机号,如 `+8613812345678`),不接受 `user_id` 作为分享入参。
- `permission_view`、`permission_edit`、`permission_invite` 为布尔值,内部会转换为位掩码整数:
- `permission_view = 1`
- `permission_invite = 2`
- `permission_edit = 4`
@@ -270,7 +272,7 @@ Base URL: `/api/v1/schedule-items`
## 6) POST `/{item_id}/share`
分享日程给其他用户
按手机号分享日程给已接受好友
### Path Parameters
@@ -280,10 +282,24 @@ Base URL: `/api/v1/schedule-items`
`ScheduleItemShareRequest` 对象。
- `phone`: 分享目标手机号(E.164,中国号段,如 `+8613xxxxxxxxx`
- `permission_view`: 是否授予查看权限
- `permission_edit`: 是否授予编辑权限
- `permission_invite`: 是否授予继续邀请权限
### Response
`ScheduleItemShareResponse` 对象。
### Error Responses
| Status | Code | 说明 |
|--------|------|------|
| 403 | `SCHEDULE_ITEM_SHARE_FORBIDDEN` | 当前用户无分享权限 |
| 403 | `SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED` | 授权位超过当前用户可授予上限 |
| 403 | `SCHEDULE_ITEM_SHARE_TARGET_NOT_FRIEND` | 仅允许分享给已接受好友 |
| 404 | `SCHEDULE_ITEM_NOT_FOUND` | 日程不存在 |
---
## 7) POST `/{item_id}/accept`
@@ -115,6 +115,7 @@ When creating/modifying/deprecating any code, this table must be updated in the
| `SCHEDULE_ITEM_PAGE_INVALID` | schedule_items | 400 | Pagination `page` must be greater than or equal to 1 |
| `SCHEDULE_ITEM_PAGE_SIZE_INVALID` | schedule_items | 400 | Pagination `page_size` out of allowed range |
| `SCHEDULE_ITEM_SHARE_FORBIDDEN` | schedule_items | 403 | Current user cannot share this schedule item |
| `SCHEDULE_ITEM_SHARE_TARGET_NOT_FRIEND` | schedule_items | 403 | Recipient must be an accepted friend of current user |
| `SCHEDULE_ITEM_FORBIDDEN` | schedule_items | 403 | Current user does not have permission to edit this schedule item |
| `SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED` | schedule_items | 403 | Requested share permission exceeds inviter permission |
| `SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE` | schedule_items | 400 | Recipient already has active subscription |