feat: 日历分享改为按手机号+好友关系校验
This commit is contained in:
@@ -64,9 +64,17 @@ class ApiClient implements IApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 {
|
try {
|
||||||
return await _dio.get<T>(path, options: options);
|
return await _dio.get<T>(
|
||||||
|
path,
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
throw ApiException.fromDioError(e);
|
throw ApiException.fromDioError(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ String? mapErrorCodeToL10nKey(
|
|||||||
return 'errorGenericSafe';
|
return 'errorGenericSafe';
|
||||||
case 'SCHEDULE_ITEM_SHARE_FORBIDDEN':
|
case 'SCHEDULE_ITEM_SHARE_FORBIDDEN':
|
||||||
return 'errorForbidden';
|
return 'errorForbidden';
|
||||||
|
case 'SCHEDULE_ITEM_SHARE_TARGET_NOT_FRIEND':
|
||||||
|
return 'errorForbidden';
|
||||||
case 'SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED':
|
case 'SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED':
|
||||||
return 'errorGenericSafe';
|
return 'errorGenericSafe';
|
||||||
case 'SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE':
|
case 'SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE':
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
abstract class IApiClient {
|
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>> post<T>(String path, {dynamic data, Options? options});
|
||||||
Future<Response<T>> put<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});
|
Future<Response<T>> patch<T>(String path, {dynamic data, Options? options});
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class HomeChatItemRenderer {
|
|||||||
child: Text(
|
child: Text(
|
||||||
item.content,
|
item.content,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: AppSpacing.md,
|
fontSize: 14,
|
||||||
height: 1.45,
|
height: 1.45,
|
||||||
color: isUser
|
color: isUser
|
||||||
? colorScheme.onPrimaryContainer
|
? colorScheme.onPrimaryContainer
|
||||||
|
|||||||
@@ -53,14 +53,16 @@ class SettingsApi {
|
|||||||
String platform = 'android',
|
String platform = 'android',
|
||||||
String channel = 'release',
|
String channel = 'release',
|
||||||
}) async {
|
}) async {
|
||||||
final params = <String, String>{
|
final queryParameters = <String, String>{
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'channel': channel,
|
'channel': channel,
|
||||||
'current_version_code': currentVersionCode.toString(),
|
'current_version_code': currentVersionCode.toString(),
|
||||||
'current_version_name': currentVersionName,
|
'current_version_name': currentVersionName,
|
||||||
};
|
};
|
||||||
final queryString = Uri(queryParameters: params).query;
|
final response = await _client.get(
|
||||||
final response = await _client.get('$_prefix/check-updates?$queryString');
|
'$_prefix/check-updates',
|
||||||
|
queryParameters: queryParameters,
|
||||||
|
);
|
||||||
return AppVersionResponse.fromJson(response.data);
|
return AppVersionResponse.fromJson(response.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: social_app
|
name: social_app
|
||||||
description: "Social App - A Flutter mobile application"
|
description: "Social App - A Flutter mobile application"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.1.1+4
|
version: 0.1.1+5
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.7
|
sdk: ^3.10.7
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ RUN uv sync --frozen --no-dev
|
|||||||
|
|
||||||
COPY backend/src ./backend/src
|
COPY backend/src ./backend/src
|
||||||
COPY backend/alembic ./backend/alembic
|
COPY backend/alembic ./backend/alembic
|
||||||
COPY backend/scripts ./backend/scripts
|
|
||||||
|
|
||||||
ENV PYTHONPATH=/app/backend/src
|
ENV PYTHONPATH=/app/backend/src
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from core.agentscope.tools.utils.calendar_domain import (
|
|||||||
map_calendar_exception,
|
map_calendar_exception,
|
||||||
merge_schedule_metadata_for_update,
|
merge_schedule_metadata_for_update,
|
||||||
parse_iso_datetime,
|
parse_iso_datetime,
|
||||||
resolve_share_target_phone_map,
|
|
||||||
schedule_event_to_dict,
|
schedule_event_to_dict,
|
||||||
)
|
)
|
||||||
from core.agentscope.tools.utils.calendar_ui import (
|
from core.agentscope.tools.utils.calendar_ui import (
|
||||||
@@ -31,9 +30,12 @@ from v1.schedule_items.schemas import (
|
|||||||
class CalendarShareInvitee(BaseModel):
|
class CalendarShareInvitee(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
user_id: str = Field(
|
phone: str = Field(
|
||||||
alias="userId",
|
alias="phone",
|
||||||
description="Target invitee user id as UUID string.",
|
description=(
|
||||||
|
"Target invitee phone. Accepts +8613xxxxxxxxx / 8613xxxxxxxxx "
|
||||||
|
"/ 13xxxxxxxxx and normalizes to E.164 (+86...)."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
permission_view: bool = Field(
|
permission_view: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
@@ -497,8 +499,8 @@ async def calendar_share(
|
|||||||
list[CalendarShareInvitee],
|
list[CalendarShareInvitee],
|
||||||
Field(
|
Field(
|
||||||
description=(
|
description=(
|
||||||
"Invitee list with userId and per-user permissions. "
|
"Invitee list with phone and per-user permissions. "
|
||||||
"Prefer composing with user_lookup tool to get userId first."
|
"Prefer composing with user_lookup tool to pick a friend phone first."
|
||||||
),
|
),
|
||||||
min_length=1,
|
min_length=1,
|
||||||
),
|
),
|
||||||
@@ -506,11 +508,25 @@ async def calendar_share(
|
|||||||
session: Any = None,
|
session: Any = None,
|
||||||
owner_id: Any = None,
|
owner_id: Any = None,
|
||||||
) -> ToolResponse:
|
) -> 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:
|
Args:
|
||||||
event_id: Target event id as UUID string.
|
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:
|
Returns:
|
||||||
ToolResponse with serialized ToolAgentOutput payload.
|
ToolResponse with serialized ToolAgentOutput payload.
|
||||||
@@ -537,56 +553,116 @@ async def calendar_share(
|
|||||||
)
|
)
|
||||||
target_uuid = UUID(event_id)
|
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] = []
|
invited: list[str] = []
|
||||||
|
result_items: list[dict[str, str]] = []
|
||||||
for invitee in invitees:
|
for invitee in invitees:
|
||||||
try:
|
raw_phone = invitee.phone.strip()
|
||||||
normalized_user_id = str(UUID(invitee.user_id.strip()))
|
normalized_phone = raw_phone
|
||||||
except ValueError:
|
for separator in (" ", "-", "(", ")"):
|
||||||
continue
|
normalized_phone = normalized_phone.replace(separator, "")
|
||||||
phone = phone_map.get(normalized_user_id)
|
if normalized_phone.startswith("0086"):
|
||||||
if phone is None:
|
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
|
continue
|
||||||
permission = {
|
permission = {
|
||||||
"permission_view": invitee.permission_view,
|
"permission_view": invitee.permission_view,
|
||||||
"permission_edit": invitee.permission_edit,
|
"permission_edit": invitee.permission_edit,
|
||||||
"permission_invite": invitee.permission_invite,
|
"permission_invite": invitee.permission_invite,
|
||||||
}
|
}
|
||||||
await service.share(
|
try:
|
||||||
target_uuid, ScheduleItemShareRequest(phone=phone, **permission)
|
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)
|
error_info = ErrorInfo(
|
||||||
if not invited:
|
code=str(
|
||||||
return calendar_error_output(
|
first_failure.get("code") if first_failure else "INTERNAL_ERROR"
|
||||||
tool_name=tool_name,
|
),
|
||||||
tool_call_args=tool_call_args,
|
message=str(
|
||||||
code="NOT_FOUND",
|
first_failure.get("message")
|
||||||
message="邀请目标均无有效手机号",
|
if first_failure and first_failure.get("message")
|
||||||
|
else "日历分享失败"
|
||||||
|
),
|
||||||
retryable=False,
|
retryable=False,
|
||||||
|
details={"results": result_items},
|
||||||
)
|
)
|
||||||
|
|
||||||
summary = (
|
|
||||||
f"status=success invited_count={len(invited)} invited=[{','.join(invited)}]"
|
|
||||||
)
|
|
||||||
return dump_tool_output(
|
return dump_tool_output(
|
||||||
ToolAgentOutput(
|
ToolAgentOutput(
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
|
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
|
||||||
tool_call_args=tool_call_args,
|
tool_call_args=tool_call_args,
|
||||||
status=ToolStatus.SUCCESS,
|
status=final_status,
|
||||||
result=summary,
|
result=summary,
|
||||||
|
error=error_info,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
from typing import Annotated, Any, cast
|
from typing import Any, cast
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from sqlalchemy import or_, select
|
||||||
from pydantic import Field
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from agentscope.tool import ToolResponse
|
from agentscope.tool import ToolResponse
|
||||||
from core.agentscope.tools.tool_call_context import get_current_tool_call_id
|
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 (
|
from core.agentscope.tools.utils.tool_response_builder import (
|
||||||
build_error_output,
|
build_error_output,
|
||||||
build_tool_response,
|
build_tool_response,
|
||||||
)
|
)
|
||||||
|
from models.friendships import Friendship
|
||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
|
from schemas.enums import FriendshipStatus
|
||||||
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
|
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
|
||||||
from v1.auth.gateway import SupabaseAuthGateway
|
from v1.auth.gateway import SupabaseAuthGateway
|
||||||
|
from v1.users.contact_resolver import resolve_contacts_by_user_ids
|
||||||
|
|
||||||
|
|
||||||
def _dump_tool_output(output: ToolAgentOutput) -> ToolResponse:
|
def _dump_tool_output(output: ToolAgentOutput) -> ToolResponse:
|
||||||
@@ -43,85 +40,99 @@ def _lookup_error_output(
|
|||||||
return _dump_tool_output(output)
|
return _dump_tool_output(output)
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_identity(
|
async def _list_friend_contacts(
|
||||||
*,
|
*,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_phone: str | None,
|
owner_id: UUID,
|
||||||
user_name: str | None,
|
) -> list[dict[str, str]]:
|
||||||
) -> dict[str, Any]:
|
"""Load accepted friends and return contact tuples.
|
||||||
"""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 ""
|
|
||||||
|
|
||||||
if bool(phone) == bool(name):
|
Returns items shaped as:
|
||||||
raise HTTPException(
|
- userId: friend user UUID string
|
||||||
status_code=400,
|
- username: friend username
|
||||||
detail="请提供 phone 或 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,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
.where(Friendship.status == FriendshipStatus.ACCEPTED)
|
||||||
if phone:
|
.where(Friendship.deleted_at.is_(None))
|
||||||
auth_gateway = SupabaseAuthGateway()
|
)
|
||||||
user = await auth_gateway.get_user_by_phone(phone)
|
friendships = (await session.execute(friendships_stmt)).scalars().all()
|
||||||
user_id = UUID(user.id)
|
friend_ids: list[UUID] = []
|
||||||
|
for friendship in friendships:
|
||||||
stmt = (
|
friend_id = (
|
||||||
select(Profile.username)
|
friendship.user_high_id
|
||||||
.where(Profile.id == user_id)
|
if friendship.user_low_id == owner_id
|
||||||
.where(Profile.deleted_at.is_(None))
|
else friendship.user_low_id
|
||||||
)
|
)
|
||||||
username = (await session.execute(stmt)).scalar_one_or_none()
|
friend_ids.append(friend_id)
|
||||||
|
|
||||||
return {
|
if not friend_ids:
|
||||||
"userId": str(user_id),
|
return []
|
||||||
"phone": user.phone,
|
|
||||||
"username": username,
|
|
||||||
"matchedBy": "phone",
|
|
||||||
}
|
|
||||||
|
|
||||||
stmt = (
|
profiles_stmt = (
|
||||||
select(Profile)
|
select(Profile)
|
||||||
.where(Profile.username == name)
|
.where(Profile.id.in_(friend_ids))
|
||||||
.where(Profile.deleted_at.is_(None))
|
.where(Profile.deleted_at.is_(None))
|
||||||
)
|
)
|
||||||
profile = await session.execute(stmt)
|
profiles = (await session.execute(profiles_stmt)).scalars().all()
|
||||||
profile = profile.scalar_one_or_none()
|
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:
|
contacts: list[dict[str, str]] = []
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
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()
|
contacts.sort(key=lambda item: (item["username"], item["phone"]))
|
||||||
phone_value = find_auth_phone_by_user_id(users=users, user_id=profile.id)
|
return contacts
|
||||||
|
|
||||||
return {
|
|
||||||
"userId": str(profile.id),
|
|
||||||
"phone": phone_value,
|
|
||||||
"username": profile.username,
|
|
||||||
"matchedBy": "username",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def user_lookup(
|
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,
|
session: Any = None,
|
||||||
owner_id: Any = None,
|
owner_id: Any = None,
|
||||||
) -> ToolResponse:
|
) -> ToolResponse:
|
||||||
"""Look up user identity by phone or username.
|
"""List current user's accepted friend contacts.
|
||||||
|
|
||||||
Args:
|
This tool is intentionally argument-free for business inputs. Runtime
|
||||||
user_phone: User phone for lookup.
|
context (`session`, `owner_id`) is injected by toolkit preset kwargs.
|
||||||
user_name: Username for lookup.
|
|
||||||
|
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:
|
Returns:
|
||||||
ToolResponse with serialized ToolAgentOutput payload.
|
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:
|
if session is None or owner_id is None:
|
||||||
return _lookup_error_output(
|
return _lookup_error_output(
|
||||||
@@ -132,20 +143,23 @@ async def user_lookup(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = await _resolve_identity(
|
contacts = await _list_friend_contacts(
|
||||||
session=cast(AsyncSession, session),
|
session=cast(AsyncSession, session),
|
||||||
user_phone=user_phone,
|
owner_id=cast(UUID, owner_id),
|
||||||
user_name=user_name,
|
|
||||||
)
|
)
|
||||||
|
compact_items = ",".join(
|
||||||
username = str(resolved.get("username") or "")
|
[
|
||||||
phone = str(resolved.get("phone") or "")
|
"{"
|
||||||
user_id = str(resolved.get("userId") or "")
|
f"userId={item.get('userId')},"
|
||||||
matched_by = str(resolved.get("matchedBy") or "")
|
f"username={item.get('username')},"
|
||||||
summary = (
|
f"phone={item.get('phone')}"
|
||||||
f"status=success matched_by={matched_by} user_id={user_id} "
|
"}"
|
||||||
f"username={username} has_phone={str(bool(phone)).lower()}"
|
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(
|
return _dump_tool_output(
|
||||||
ToolAgentOutput(
|
ToolAgentOutput(
|
||||||
tool_name="user_lookup",
|
tool_name="user_lookup",
|
||||||
@@ -155,24 +169,10 @@ async def user_lookup(
|
|||||||
result=summary,
|
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:
|
except Exception as exc:
|
||||||
return _lookup_error_output(
|
return _lookup_error_output(
|
||||||
tool_call_args=tool_call_args,
|
tool_call_args=tool_call_args,
|
||||||
code="INTERNAL_ERROR",
|
code="INTERNAL_ERROR",
|
||||||
message=f"用户查找失败: {str(exc)}",
|
message=f"好友查找失败: {str(exc)}",
|
||||||
retryable=True,
|
retryable=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ from uuid import UUID
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.auth.models import CurrentUser
|
||||||
|
from core.http.errors import ApiProblemError
|
||||||
from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository
|
from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository
|
||||||
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
|
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
|
||||||
from v1.schedule_items.schemas import ScheduleItemMetadata
|
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]:
|
def map_calendar_exception(exc: Exception) -> tuple[str, str, bool]:
|
||||||
|
if isinstance(exc, ApiProblemError):
|
||||||
|
return exc.code, exc.detail, False
|
||||||
if isinstance(exc, HTTPException):
|
if isinstance(exc, HTTPException):
|
||||||
detail = exc.detail
|
detail = exc.detail
|
||||||
if isinstance(detail, str) and detail.strip():
|
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:
|
if parsed.tzinfo is None:
|
||||||
raise ValueError("时间必须包含时区信息")
|
raise ValueError("时间必须包含时区信息")
|
||||||
return parsed.astimezone(timezone.utc)
|
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
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
|||||||
self._user_lookup_cache_ttl_seconds: int = 60
|
self._user_lookup_cache_ttl_seconds: int = 60
|
||||||
self._user_lookup_cache_expires_at: float = 0.0
|
self._user_lookup_cache_expires_at: float = 0.0
|
||||||
self._users_by_phone: dict[str, Any] = {}
|
self._users_by_phone: dict[str, Any] = {}
|
||||||
|
self._users_by_id: dict[str, Any] = {}
|
||||||
|
|
||||||
def _get_client(self) -> Any:
|
def _get_client(self) -> Any:
|
||||||
return supabase_service.get_client()
|
return supabase_service.get_client()
|
||||||
@@ -207,17 +208,30 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def get_user_by_id(self, user_id: str) -> UserByIdResponse:
|
async def get_user_by_id(self, user_id: str) -> UserByIdResponse:
|
||||||
try:
|
users = await self.get_users_by_ids([user_id])
|
||||||
admin_client = self._get_admin_client()
|
resolved = users.get(user_id)
|
||||||
user = await asyncio.to_thread(admin_client.auth.get_user_by_id, 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:
|
if user is None:
|
||||||
raise _auth_error(
|
continue
|
||||||
status_code=404,
|
|
||||||
code="AUTH_USER_NOT_FOUND",
|
|
||||||
detail="User not found",
|
|
||||||
)
|
|
||||||
user_attrs = getattr(user, "user", user)
|
user_attrs = getattr(user, "user", user)
|
||||||
return UserByIdResponse(
|
resolved[normalized_user_id] = UserByIdResponse(
|
||||||
id=str(getattr(user_attrs, "id", "")),
|
id=str(getattr(user_attrs, "id", "")),
|
||||||
phone=getattr(user_attrs, "phone", None),
|
phone=getattr(user_attrs, "phone", None),
|
||||||
created_at=str(getattr(user_attrs, "created_at", "")),
|
created_at=str(getattr(user_attrs, "created_at", "")),
|
||||||
@@ -227,19 +241,7 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except AuthError as exc:
|
return resolved
|
||||||
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
|
|
||||||
|
|
||||||
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
|
async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]:
|
||||||
normalized_query = _normalize_phone_search_query(query)
|
normalized_query = _normalize_phone_search_query(query)
|
||||||
@@ -287,10 +289,15 @@ class SupabaseAuthGateway(AuthServiceGateway):
|
|||||||
admin_client = self._get_admin_client()
|
admin_client = self._get_admin_client()
|
||||||
users = await asyncio.to_thread(_list_auth_users, admin_client)
|
users = await asyncio.to_thread(_list_auth_users, admin_client)
|
||||||
users_by_phone: dict[str, Any] = {}
|
users_by_phone: dict[str, Any] = {}
|
||||||
|
users_by_id: dict[str, Any] = {}
|
||||||
for candidate in users:
|
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", ""))
|
candidate_phone = _normalize_phone(getattr(candidate, "phone", ""))
|
||||||
if candidate_phone:
|
if candidate_phone:
|
||||||
users_by_phone[candidate_phone] = candidate
|
users_by_phone[candidate_phone] = candidate
|
||||||
|
self._users_by_id = users_by_id
|
||||||
self._users_by_phone = users_by_phone
|
self._users_by_phone = users_by_phone
|
||||||
self._user_lookup_cache_expires_at = now + self._user_lookup_cache_ttl_seconds
|
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
|
return users
|
||||||
|
|
||||||
|
|
||||||
def _normalize_phone(raw_phone: object) -> str | None:
|
def _sanitize_phone_token(raw: object) -> str:
|
||||||
phone = str(raw_phone).strip()
|
token = str(raw).strip()
|
||||||
for separator in (" ", "-", "(", ")"):
|
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:
|
if not phone:
|
||||||
return None
|
return None
|
||||||
if phone.startswith("00") and len(phone) > 2:
|
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:
|
def _normalize_phone_search_query(raw_query: str) -> str | None:
|
||||||
query = raw_query.strip()
|
query = _sanitize_phone_token(raw_query)
|
||||||
for separator in (" ", "-", "(", ")"):
|
|
||||||
query = query.replace(separator, "")
|
|
||||||
if not query:
|
if not query:
|
||||||
return None
|
return None
|
||||||
if query.startswith("00") and len(query) > 2:
|
if query.startswith("00") and len(query) > 2:
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ from models.inbox_messages import InboxMessage
|
|||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
from models.schedule_items import ScheduleItem
|
from models.schedule_items import ScheduleItem
|
||||||
from schemas.enums import (
|
from schemas.enums import (
|
||||||
|
FriendshipStatus,
|
||||||
InboxMessageStatus,
|
InboxMessageStatus,
|
||||||
InboxMessageType,
|
InboxMessageType,
|
||||||
SubscriptionPermission,
|
SubscriptionPermission,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
)
|
)
|
||||||
from v1.auth.gateway import SupabaseAuthGateway
|
from v1.auth.gateway import SupabaseAuthGateway
|
||||||
|
from v1.friendships.repository import SQLAlchemyFriendshipRepository
|
||||||
from v1.inbox_messages.repository import InboxMessageRepository
|
from v1.inbox_messages.repository import InboxMessageRepository
|
||||||
from v1.schedule_items.repository import ScheduleItemRepository
|
from v1.schedule_items.repository import ScheduleItemRepository
|
||||||
from v1.schedule_items.schemas import (
|
from v1.schedule_items.schemas import (
|
||||||
@@ -34,11 +36,13 @@ from v1.schedule_items.schemas import (
|
|||||||
ScheduleItemStatus,
|
ScheduleItemStatus,
|
||||||
SubscriberInfo,
|
SubscriberInfo,
|
||||||
)
|
)
|
||||||
|
from v1.users.contact_resolver import resolve_contacts_by_user_ids
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from v1.auth.schemas import UserByIdResponse, UserByPhoneResponse
|
from v1.auth.schemas import UserByIdResponse, UserByPhoneResponse
|
||||||
|
from v1.friendships.repository import FriendshipRepository
|
||||||
from v1.users.repository import UserRepository
|
from v1.users.repository import UserRepository
|
||||||
|
|
||||||
logger = get_logger("v1.schedule_items.service")
|
logger = get_logger("v1.schedule_items.service")
|
||||||
@@ -56,6 +60,7 @@ class ScheduleItemService(BaseService):
|
|||||||
_session: AsyncSession
|
_session: AsyncSession
|
||||||
_auth_gateway: AuthByPhoneGateway
|
_auth_gateway: AuthByPhoneGateway
|
||||||
_inbox_repository: InboxMessageRepository
|
_inbox_repository: InboxMessageRepository
|
||||||
|
_friendship_repository: FriendshipRepository
|
||||||
_user_repository: UserRepository | None
|
_user_repository: UserRepository | None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -65,6 +70,7 @@ class ScheduleItemService(BaseService):
|
|||||||
current_user: CurrentUser | None,
|
current_user: CurrentUser | None,
|
||||||
auth_gateway: AuthByPhoneGateway | None = None,
|
auth_gateway: AuthByPhoneGateway | None = None,
|
||||||
inbox_repository: InboxMessageRepository | None = None,
|
inbox_repository: InboxMessageRepository | None = None,
|
||||||
|
friendship_repository: FriendshipRepository | None = None,
|
||||||
user_repository: UserRepository | None = None,
|
user_repository: UserRepository | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(current_user=current_user)
|
super().__init__(current_user=current_user)
|
||||||
@@ -74,6 +80,9 @@ class ScheduleItemService(BaseService):
|
|||||||
if inbox_repository is None:
|
if inbox_repository is None:
|
||||||
raise ValueError("inbox_repository is required")
|
raise ValueError("inbox_repository is required")
|
||||||
self._inbox_repository = inbox_repository
|
self._inbox_repository = inbox_repository
|
||||||
|
self._friendship_repository = friendship_repository or (
|
||||||
|
SQLAlchemyFriendshipRepository(session)
|
||||||
|
)
|
||||||
self._user_repository = user_repository
|
self._user_repository = user_repository
|
||||||
|
|
||||||
async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse:
|
async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse:
|
||||||
@@ -188,26 +197,20 @@ class ScheduleItemService(BaseService):
|
|||||||
)
|
)
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
logger.exception("Failed to get subscriber profiles")
|
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:
|
for sub in subscriptions:
|
||||||
if sub.status == SubscriptionStatus.ACTIVE:
|
if sub.status == SubscriptionStatus.ACTIVE:
|
||||||
profile = profiles.get(sub.subscriber_id)
|
contact = resolved_contacts.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),
|
|
||||||
)
|
|
||||||
subscribers.append(
|
subscribers.append(
|
||||||
SubscriberInfo(
|
SubscriberInfo(
|
||||||
user_id=sub.subscriber_id,
|
user_id=sub.subscriber_id,
|
||||||
username=profile.username if profile else None,
|
username=contact.username if contact else None,
|
||||||
avatar_url=profile.avatar_url if profile else None,
|
avatar_url=contact.avatar_url if contact else None,
|
||||||
phone=phone,
|
phone=contact.phone if contact else None,
|
||||||
permission=sub.permission,
|
permission=sub.permission,
|
||||||
status=sub.status.value
|
status=sub.status.value
|
||||||
if hasattr(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)
|
target_user = await self._auth_gateway.get_user_by_phone(request.phone)
|
||||||
recipient_id = UUID(target_user.id)
|
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)
|
existing = await self._repository.get_subscription(item_id, recipient_id)
|
||||||
if existing:
|
if existing:
|
||||||
if existing.status == SubscriptionStatus.PENDING:
|
if existing.status == SubscriptionStatus.PENDING:
|
||||||
|
|||||||
@@ -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(
|
monkeypatch.setattr(
|
||||||
calendar_module, "create_schedule_service", lambda *_: fake_service
|
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())
|
event_id = str(uuid4())
|
||||||
result = await calendar_module.calendar_share(
|
result = await calendar_module.calendar_share(
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
invitees=[
|
invitees=[
|
||||||
calendar_module.CalendarShareInvitee(
|
calendar_module.CalendarShareInvitee(
|
||||||
userId=target_user_id,
|
phone="13900001234",
|
||||||
permissionView=True,
|
permissionView=True,
|
||||||
permissionEdit=False,
|
permissionEdit=False,
|
||||||
permissionInvite=False,
|
permissionInvite=False,
|
||||||
@@ -287,9 +278,37 @@ async def test_calendar_share_executes_with_valid_invitee(
|
|||||||
payload = _decode_tool_response(result)
|
payload = _decode_tool_response(result)
|
||||||
|
|
||||||
assert payload["status"] == "success"
|
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 "+8613900001234" in payload["result"]
|
||||||
assert len(fake_service.share_calls) == 1
|
assert len(fake_service.share_calls) == 1
|
||||||
share_call = fake_service.share_calls[0]
|
share_call = fake_service.share_calls[0]
|
||||||
assert share_call["item_id"] == event_id
|
assert share_call["item_id"] == event_id
|
||||||
assert share_call["request"].phone == "+8613900001234"
|
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 __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import cast
|
from typing import Any, cast
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from core.http.errors import ApiProblemError
|
||||||
from core.auth.models import CurrentUser
|
from core.auth.models import CurrentUser
|
||||||
from models.inbox_messages import InboxMessage, InboxMessageType
|
from models.inbox_messages import InboxMessage, InboxMessageType
|
||||||
from models.schedule_items import ScheduleItem
|
from models.schedule_items import ScheduleItem
|
||||||
|
from schemas.enums import FriendshipStatus
|
||||||
from v1.auth.schemas import UserByPhoneResponse
|
from v1.auth.schemas import UserByPhoneResponse
|
||||||
from v1.schedule_items.repository import ScheduleItemRepository
|
from v1.schedule_items.repository import ScheduleItemRepository
|
||||||
from v1.schedule_items.schemas import ScheduleItemShareRequest
|
from v1.schedule_items.schemas import ScheduleItemShareRequest
|
||||||
@@ -79,6 +80,14 @@ class AuthGatewayStub:
|
|||||||
phone_confirmed_at=None,
|
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:
|
class InboxRepoStub:
|
||||||
async def create(self, data: dict[str, object]) -> InboxMessage:
|
async def create(self, data: dict[str, object]) -> InboxMessage:
|
||||||
@@ -127,6 +136,26 @@ class AuthGatewayInvalidIdStub:
|
|||||||
phone_confirmed_at=None,
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_share_forbidden_when_not_owner() -> None:
|
async def test_share_forbidden_when_not_owner() -> None:
|
||||||
@@ -140,11 +169,12 @@ async def test_share_forbidden_when_not_owner() -> None:
|
|||||||
),
|
),
|
||||||
session=AsyncMock(),
|
session=AsyncMock(),
|
||||||
current_user=CurrentUser(id=requester_id),
|
current_user=CurrentUser(id=requester_id),
|
||||||
auth_gateway=AuthGatewayStub(),
|
auth_gateway=cast(Any, AuthGatewayStub()),
|
||||||
inbox_repository=InboxRepoStub(),
|
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(
|
await service.share(
|
||||||
item_id,
|
item_id,
|
||||||
ScheduleItemShareRequest(
|
ScheduleItemShareRequest(
|
||||||
@@ -171,8 +201,9 @@ async def test_share_success_creates_calendar_invitation_message() -> None:
|
|||||||
),
|
),
|
||||||
session=session,
|
session=session,
|
||||||
current_user=CurrentUser(id=owner_id),
|
current_user=CurrentUser(id=owner_id),
|
||||||
auth_gateway=AuthGatewayStub(),
|
auth_gateway=cast(Any, AuthGatewayStub()),
|
||||||
inbox_repository=InboxRepoStub(),
|
inbox_repository=InboxRepoStub(),
|
||||||
|
friendship_repository=cast(Any, FriendshipRepoStub()),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await service.share(
|
result = await service.share(
|
||||||
@@ -203,11 +234,12 @@ async def test_share_returns_not_found_when_item_missing() -> None:
|
|||||||
repository=cast(ScheduleItemRepository, ShareRepo(None)),
|
repository=cast(ScheduleItemRepository, ShareRepo(None)),
|
||||||
session=AsyncMock(),
|
session=AsyncMock(),
|
||||||
current_user=CurrentUser(id=requester_id),
|
current_user=CurrentUser(id=requester_id),
|
||||||
auth_gateway=AuthGatewayStub(),
|
auth_gateway=cast(Any, AuthGatewayStub()),
|
||||||
inbox_repository=InboxRepoStub(),
|
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(
|
await service.share(
|
||||||
uuid4(),
|
uuid4(),
|
||||||
ScheduleItemShareRequest(
|
ScheduleItemShareRequest(
|
||||||
@@ -233,11 +265,12 @@ async def test_share_invalid_auth_user_id_returns_503() -> None:
|
|||||||
),
|
),
|
||||||
session=session,
|
session=session,
|
||||||
current_user=CurrentUser(id=owner_id),
|
current_user=CurrentUser(id=owner_id),
|
||||||
auth_gateway=AuthGatewayInvalidIdStub(),
|
auth_gateway=cast(Any, AuthGatewayInvalidIdStub()),
|
||||||
inbox_repository=InboxRepoStub(),
|
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(
|
await service.share(
|
||||||
item_id,
|
item_id,
|
||||||
ScheduleItemShareRequest(
|
ScheduleItemShareRequest(
|
||||||
@@ -266,11 +299,12 @@ async def test_share_sqlalchemy_error_rolls_back() -> None:
|
|||||||
),
|
),
|
||||||
session=session,
|
session=session,
|
||||||
current_user=CurrentUser(id=owner_id),
|
current_user=CurrentUser(id=owner_id),
|
||||||
auth_gateway=AuthGatewayStub(),
|
auth_gateway=cast(Any, AuthGatewayStub()),
|
||||||
inbox_repository=InboxRepoStub(),
|
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(
|
await service.share(
|
||||||
item_id,
|
item_id,
|
||||||
ScheduleItemShareRequest(
|
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.status_code == 503
|
||||||
assert exc_info.value.detail == "Schedule item store unavailable"
|
assert exc_info.value.detail == "Schedule item store unavailable"
|
||||||
session.rollback.assert_awaited_once()
|
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
|
||||||
@@ -21,6 +21,17 @@
|
|||||||
"release_notes": null,
|
"release_notes": null,
|
||||||
"file_size": 21572828,
|
"file_size": 21572828,
|
||||||
"sha256": "2b59596044d473c8aa477a12d01958b9dc08b2aee528226039c37bdaa1372da8"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ Base URL: `/api/v1/schedule-items`
|
|||||||
说明:
|
说明:
|
||||||
- `subscribers` 列表仅包含状态为 `active` 的订阅者
|
- `subscribers` 列表仅包含状态为 `active` 的订阅者
|
||||||
- `phone` 字段来自 Supabase Auth,用于显示订阅者手机号
|
- `phone` 字段来自 Supabase Auth,用于显示订阅者手机号
|
||||||
- 前端显示优先级:`phone ?? username ?? userId`
|
- 前端显示优先级:`phone ?? username ?? user_id`
|
||||||
|
|
||||||
### ScheduleItemShareRequest
|
### 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_view = 1`
|
||||||
- `permission_invite = 2`
|
- `permission_invite = 2`
|
||||||
- `permission_edit = 4`
|
- `permission_edit = 4`
|
||||||
@@ -270,7 +272,7 @@ Base URL: `/api/v1/schedule-items`
|
|||||||
|
|
||||||
## 6) POST `/{item_id}/share`
|
## 6) POST `/{item_id}/share`
|
||||||
|
|
||||||
分享日程给其他用户。
|
按手机号分享日程给已接受好友。
|
||||||
|
|
||||||
### Path Parameters
|
### Path Parameters
|
||||||
|
|
||||||
@@ -280,10 +282,24 @@ Base URL: `/api/v1/schedule-items`
|
|||||||
|
|
||||||
`ScheduleItemShareRequest` 对象。
|
`ScheduleItemShareRequest` 对象。
|
||||||
|
|
||||||
|
- `phone`: 分享目标手机号(E.164,中国号段,如 `+8613xxxxxxxxx`)
|
||||||
|
- `permission_view`: 是否授予查看权限
|
||||||
|
- `permission_edit`: 是否授予编辑权限
|
||||||
|
- `permission_invite`: 是否授予继续邀请权限
|
||||||
|
|
||||||
### Response
|
### Response
|
||||||
|
|
||||||
`ScheduleItemShareResponse` 对象。
|
`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`
|
## 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_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_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_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_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_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 |
|
| `SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE` | schedule_items | 400 | Recipient already has active subscription |
|
||||||
|
|||||||
Reference in New Issue
Block a user