From 9fb2a6857b9df2aed482801e20d3402927feb4d2 Mon Sep 17 00:00:00 2001 From: qzl Date: Mon, 30 Mar 2026 11:37:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=97=A5=E5=8E=86=E5=88=86=E4=BA=AB?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8C=89=E6=89=8B=E6=9C=BA=E5=8F=B7+?= =?UTF-8?q?=E5=A5=BD=E5=8F=8B=E5=85=B3=E7=B3=BB=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/lib/data/network/api_client.dart | 12 +- apps/lib/data/network/error_code_mapper.dart | 2 + apps/lib/data/network/i_api_client.dart | 6 +- .../widgets/home_chat_item_renderer.dart | 2 +- .../settings/data/apis/settings_api.dart | 8 +- apps/pubspec.yaml | 2 +- backend/Dockerfile | 1 - .../core/agentscope/tools/custom/calendar.py | 156 +++++++++++---- .../agentscope/tools/custom/user_lookup.py | 180 +++++++++--------- .../agentscope/tools/utils/calendar_domain.py | 26 +-- backend/src/v1/auth/gateway.py | 66 ++++--- backend/src/v1/schedule_items/service.py | 45 +++-- backend/src/v1/users/contact_resolver.py | 52 +++++ .../core/agentscope/test_calendar_tools.py | 41 ++-- .../core/agentscope/test_user_lookup_tool.py | 63 ++++++ .../unit/v1/schedule_items/test_share.py | 87 +++++++-- .../unit/v1/users/test_contact_resolver.py | 71 +++++++ deploy/static/releases/manifest.json | 11 ++ docs/protocols/calendar/schedule-items.md | 22 ++- docs/protocols/common/http-error-codes.md | 1 + 20 files changed, 624 insertions(+), 230 deletions(-) create mode 100644 backend/src/v1/users/contact_resolver.py create mode 100644 backend/tests/unit/core/agentscope/test_user_lookup_tool.py create mode 100644 backend/tests/unit/v1/users/test_contact_resolver.py diff --git a/apps/lib/data/network/api_client.dart b/apps/lib/data/network/api_client.dart index 01fc04b..30864d7 100644 --- a/apps/lib/data/network/api_client.dart +++ b/apps/lib/data/network/api_client.dart @@ -64,9 +64,17 @@ class ApiClient implements IApiClient { } @override - Future> get(String path, {Options? options}) async { + Future> get( + String path, { + Map? queryParameters, + Options? options, + }) async { try { - return await _dio.get(path, options: options); + return await _dio.get( + path, + queryParameters: queryParameters, + options: options, + ); } on DioException catch (e) { throw ApiException.fromDioError(e); } diff --git a/apps/lib/data/network/error_code_mapper.dart b/apps/lib/data/network/error_code_mapper.dart index d305553..bbaaa37 100644 --- a/apps/lib/data/network/error_code_mapper.dart +++ b/apps/lib/data/network/error_code_mapper.dart @@ -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': diff --git a/apps/lib/data/network/i_api_client.dart b/apps/lib/data/network/i_api_client.dart index 2488a2b..6ff5556 100644 --- a/apps/lib/data/network/i_api_client.dart +++ b/apps/lib/data/network/i_api_client.dart @@ -1,7 +1,11 @@ import 'package:dio/dio.dart'; abstract class IApiClient { - Future> get(String path, {Options? options}); + Future> get( + String path, { + Map? queryParameters, + Options? options, + }); Future> post(String path, {dynamic data, Options? options}); Future> put(String path, {dynamic data, Options? options}); Future> patch(String path, {dynamic data, Options? options}); diff --git a/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart b/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart index c3c5a68..7e3af6a 100644 --- a/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart +++ b/apps/lib/features/home/presentation/widgets/home_chat_item_renderer.dart @@ -78,7 +78,7 @@ class HomeChatItemRenderer { child: Text( item.content, style: TextStyle( - fontSize: AppSpacing.md, + fontSize: 14, height: 1.45, color: isUser ? colorScheme.onPrimaryContainer diff --git a/apps/lib/features/settings/data/apis/settings_api.dart b/apps/lib/features/settings/data/apis/settings_api.dart index 2ad4c13..1797452 100644 --- a/apps/lib/features/settings/data/apis/settings_api.dart +++ b/apps/lib/features/settings/data/apis/settings_api.dart @@ -53,14 +53,16 @@ class SettingsApi { String platform = 'android', String channel = 'release', }) async { - final params = { + final queryParameters = { '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); } } diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 3c20889..bf84cb9 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 437b82d..3a63387 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/src/core/agentscope/tools/custom/calendar.py b/backend/src/core/agentscope/tools/custom/calendar.py index eaf6fae..cf4102a 100644 --- a/backend/src/core/agentscope/tools/custom/calendar.py +++ b/backend/src/core/agentscope/tools/custom/calendar.py @@ -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: diff --git a/backend/src/core/agentscope/tools/custom/user_lookup.py b/backend/src/core/agentscope/tools/custom/user_lookup.py index deebd8c..cc68c98 100644 --- a/backend/src/core/agentscope/tools/custom/user_lookup.py +++ b/backend/src/core/agentscope/tools/custom/user_lookup.py @@ -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= + - 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, ) diff --git a/backend/src/core/agentscope/tools/utils/calendar_domain.py b/backend/src/core/agentscope/tools/utils/calendar_domain.py index be3261e..1abdbe5 100644 --- a/backend/src/core/agentscope/tools/utils/calendar_domain.py +++ b/backend/src/core/agentscope/tools/utils/calendar_domain.py @@ -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 diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index 29ef760..ec41f5e 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -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: diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index fbdf7c5..110f4cb 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -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: diff --git a/backend/src/v1/users/contact_resolver.py b/backend/src/v1/users/contact_resolver.py new file mode 100644 index 0000000..f30e041 --- /dev/null +++ b/backend/src/v1/users/contact_resolver.py @@ -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 diff --git a/backend/tests/unit/core/agentscope/test_calendar_tools.py b/backend/tests/unit/core/agentscope/test_calendar_tools.py index 64d9023..ed6169b 100644 --- a/backend/tests/unit/core/agentscope/test_calendar_tools.py +++ b/backend/tests/unit/core/agentscope/test_calendar_tools.py @@ -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" diff --git a/backend/tests/unit/core/agentscope/test_user_lookup_tool.py b/backend/tests/unit/core/agentscope/test_user_lookup_tool.py new file mode 100644 index 0000000..3842d49 --- /dev/null +++ b/backend/tests/unit/core/agentscope/test_user_lookup_tool.py @@ -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"] diff --git a/backend/tests/unit/v1/schedule_items/test_share.py b/backend/tests/unit/v1/schedule_items/test_share.py index c2dd026..8727001 100644 --- a/backend/tests/unit/v1/schedule_items/test_share.py +++ b/backend/tests/unit/v1/schedule_items/test_share.py @@ -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" diff --git a/backend/tests/unit/v1/users/test_contact_resolver.py b/backend/tests/unit/v1/users/test_contact_resolver.py new file mode 100644 index 0000000..023c6af --- /dev/null +++ b/backend/tests/unit/v1/users/test_contact_resolver.py @@ -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 diff --git a/deploy/static/releases/manifest.json b/deploy/static/releases/manifest.json index d806500..95fa120 100644 --- a/deploy/static/releases/manifest.json +++ b/deploy/static/releases/manifest.json @@ -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" } ] } diff --git a/docs/protocols/calendar/schedule-items.md b/docs/protocols/calendar/schedule-items.md index 459cf7a..4fc99bb 100644 --- a/docs/protocols/calendar/schedule-items.md +++ b/docs/protocols/calendar/schedule-items.md @@ -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` diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index f64dbb7..ccaffc0 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -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 |