refactor: 重构 Tool Result 契约,移除 ui_hints 统一使用 result 字段

- ToolAgentOutput 移除 result_summary 和 ui_hints,统一使用 result 字段
- 日历/用户查找工具移除 ui_hints 输出,改为机器可读的结构化结果
- Agent History 移除 tool 消息的 ui_hints 处理逻辑
- App 版本检查改为 manifest.json 方式,支持多渠道发布
- 更新 settings 配置和测试用例适配新结构
This commit is contained in:
qzl
2026-03-17 12:18:09 +08:00
parent c26cdbbc27
commit aa30fe0ce6
44 changed files with 984 additions and 655 deletions
@@ -54,10 +54,7 @@ def _is_agui_event(event: dict[str, Any]) -> bool:
def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]:
payload = dict(event)
event_type = str(payload.get("type", "")).strip().upper()
if event_type in {
EventType.TEXT_MESSAGE_END.value,
EventType.TOOL_CALL_RESULT.value,
}:
if event_type == EventType.TEXT_MESSAGE_END.value:
ui_hints = payload.get("ui_hints")
if ui_hints is not None:
try:
@@ -67,7 +64,6 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]:
except Exception:
pass
payload.pop("ui_hints", None)
if event_type == EventType.TEXT_MESSAGE_END.value:
for key in (
"inputTokens",
"outputTokens",
@@ -76,6 +72,9 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]:
"model",
):
payload.pop(key, None)
if event_type == EventType.TOOL_CALL_RESULT.value:
payload.pop("ui_hints", None)
payload.pop("ui_schema", None)
return payload
@@ -130,10 +129,13 @@ def _build_text_end(event: dict[str, Any]) -> TextMessageEndEvent:
def _build_tool_result(event: dict[str, Any]) -> ToolCallResultEvent:
data = event.get("data", {})
content = data.get("result")
if not isinstance(content, str):
content = data.get("toolAgentOutput", "")
return ToolCallResultEvent(
message_id=data.get("messageId", ""),
tool_call_id=data.get("toolCallId", ""),
content=data.get("toolAgentOutput", ""),
content=content,
role="tool",
)
@@ -191,7 +193,7 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]:
tool_result_payload["threadId"] = thread_id
if isinstance(run_id, str) and run_id:
tool_result_payload["runId"] = run_id
reserved = {"type", "threadId", "runId"}
reserved = {"type", "threadId", "runId", "ui_hints", "ui_schema"}
tool_result_payload.update({k: v for k, v in data.items() if k not in reserved})
return tool_result_payload
+2 -3
View File
@@ -213,9 +213,8 @@ class SqlAlchemyEventStore:
"tool_call_id": self._event_value(event, "tool_call_id"),
"tool_call_args": self._event_value(event, "tool_call_args"),
"status": self._event_value(event, "status"),
"result_summary": self._event_value(event, "result_summary"),
"result": self._event_value(event, "result"),
"error": self._event_value(event, "error"),
"ui_hints": self._event_value(event, "ui_hints"),
}
try:
@@ -231,7 +230,7 @@ class SqlAlchemyEventStore:
)
return
content = tool_output.result_summary
content = tool_output.result
locked_session = await session_repo.lock_session_for_update(
session_id=session_id
@@ -112,13 +112,8 @@ class PipelineStageEmitter:
"tool_call_id": tool_output.tool_call_id,
"tool_call_args": tool_output.tool_call_args,
"status": tool_output.status.value,
"result_summary": tool_output.result_summary,
"result": tool_output.result,
}
ui_hints = tool_output.model_dump(mode="json", exclude_none=True).get(
"ui_hints"
)
if ui_hints is not None:
payload["ui_hints"] = ui_hints
if tool_output.error:
payload["error"] = tool_output.error.model_dump(mode="json")
@@ -16,13 +16,9 @@ from core.agentscope.tools.utils.calendar_domain import (
)
from core.agentscope.tools.utils.calendar_ui import (
calendar_error_output,
calendar_read_hints,
calendar_share_hints,
calendar_write_hints,
dump_tool_output,
)
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
from schemas.agent.ui_hints import UiHintListItem, UiHintStatus
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemShareRequest,
@@ -73,6 +69,18 @@ def _validate_runtime_context(
return None
def _format_event_brief(event_items: list[dict[str, Any]], limit: int = 3) -> str:
briefs: list[str] = []
for item in event_items[:limit]:
event_id = str(item.get("id") or "")
title = str(item.get("title") or "")
start_at = str(item.get("startAt") or "")
if not event_id:
continue
briefs.append(f"{{id={event_id},title={title},startAt={start_at}}}")
return ",".join(briefs)
async def calendar_read(
query: Annotated[
str | None,
@@ -114,24 +122,28 @@ async def calendar_read(
service = create_schedule_service(
cast(AsyncSession, session), cast(UUID, owner_id)
)
items, total = await service.list_paginated(page=page, page_size=page_size)
total_pages = max(1, (total + page_size - 1) // page_size) if total else 0
items, total = await service.list_paginated(
page=page,
page_size=page_size,
query=query,
)
total_pages = (total + page_size - 1) // page_size if total else 0
event_items = [schedule_event_to_dict(item) for item in items]
query_value = (query or "").strip() or "*"
event_brief = _format_event_brief(event_items)
summary = (
f"status=success query={query_value} total={total} page={page}/"
f"{total_pages or 1} returned={len(event_items)}"
)
if event_brief:
summary = f"{summary} items=[{event_brief}]"
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=f"已获取日程列表,共 {total}",
ui_hints=calendar_read_hints(
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
events=event_items,
),
result=summary,
)
)
except Exception as exc:
@@ -297,6 +309,7 @@ async def calendar_write(
success_count = 0
failed_count = 0
success_event_ids: list[str] = []
result_items: list[dict[str, Any]] = []
for idx, operation in enumerate(operations):
@@ -353,6 +366,7 @@ async def calendar_write(
"message": f"日程「{created.title}」已创建",
}
)
success_event_ids.append(str(created.id))
continue
if operation == "update":
@@ -397,6 +411,7 @@ async def calendar_write(
"message": f"日程「{updated.title}」已更新",
}
)
success_event_ids.append(str(updated.id))
continue
if operation == "delete":
@@ -413,6 +428,7 @@ async def calendar_write(
"message": f"日程 {event_id} 已删除",
}
)
success_event_ids.append(event_id)
continue
except Exception as exc:
code, message, _ = map_calendar_exception(exc)
@@ -430,16 +446,22 @@ async def calendar_write(
if failed_count == 0:
final_status = ToolStatus.SUCCESS
ui_status = UiHintStatus.SUCCESS
summary = f"日程批量操作完成,共 {batch_size} 条,成功 {success_count} "
summary = (
f"status=success batch={batch_size} success={success_count} "
f"failed={failed_count} ids=[{','.join(success_event_ids)}]"
)
elif success_count == 0:
final_status = ToolStatus.FAILURE
ui_status = UiHintStatus.ERROR
summary = f"日程批量操作失败,共 {batch_size} 条,失败 {failed_count} "
summary = (
f"status=failure batch={batch_size} success={success_count} "
f"failed={failed_count}"
)
else:
final_status = ToolStatus.PARTIAL
ui_status = UiHintStatus.WARNING
summary = f"日程批量操作部分成功,共 {batch_size} 条,成功 {success_count} 条,失败 {failed_count}"
summary = (
f"status=partial batch={batch_size} success={success_count} "
f"failed={failed_count} ids=[{','.join(success_event_ids)}]"
)
error_info: ErrorInfo | None = None
if final_status == ToolStatus.FAILURE:
@@ -459,30 +481,10 @@ async def calendar_write(
retryable=False,
details={"results": result_items},
)
result_list_items = [
UiHintListItem(
id=(
str(item.get("eventId"))
if isinstance(item, dict) and item.get("eventId") is not None
else None
),
title=(
f"#{int(item.get('index', 0)) + 1} {str(item.get('operation', 'unknown'))}"
if isinstance(item, dict)
else "unknown"
),
subtitle=(
"成功"
if isinstance(item, dict) and item.get("status") == "success"
else "失败"
),
description=(
str(item.get("message") or "") if isinstance(item, dict) else ""
),
summary = (
f"{summary} first_error_code={error_info.code} "
f"first_error_message={error_info.message}"
)
for item in result_items
]
return dump_tool_output(
ToolAgentOutput(
@@ -490,24 +492,8 @@ async def calendar_write(
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=final_status,
result_summary=summary,
result=summary,
error=error_info,
ui_hints=calendar_write_hints(
operation="batch",
message=summary,
event=None,
event_id=None,
status=ui_status,
).model_copy(
update={
"list_items": result_list_items,
"meta": {
"total": batch_size,
"success": success_count,
"failed": failed_count,
},
}
),
)
)
@@ -611,19 +597,14 @@ async def calendar_share(
retryable=False,
)
summary = f"日程已分享,已邀请 {len(invited)}"
summary = f"status=success event_id={event_id} invited_count={len(invited)}"
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=summary,
ui_hints=calendar_share_hints(
event_id=event_id,
invited=invited,
permission={"per_user": True},
),
result=summary,
)
)
except Exception as exc:
@@ -17,15 +17,6 @@ from core.agentscope.tools.utils.tool_response_builder import (
)
from models.profile import Profile
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
from schemas.agent.ui_hints import (
UiHintAction,
UiHintActionCopy,
UiHintActionStyle,
UiHintIntent,
UiHintKvItem,
UiHintStatus,
UiHintsPayload,
)
from v1.auth.gateway import SupabaseAuthGateway
@@ -47,51 +38,10 @@ def _lookup_error_output(
message=message,
retryable=retryable,
)
output = output.model_copy(
update={
"tool_call_args": tool_call_args,
"ui_hints": UiHintsPayload(
intent=UiHintIntent.STATUS,
status=UiHintStatus.ERROR,
title="用户查找失败",
body=message,
),
}
)
output = output.model_copy(update={"tool_call_args": tool_call_args})
return _dump_tool_output(output)
def _lookup_success_hints(resolved: dict[str, Any]) -> UiHintsPayload:
user_id = str(resolved.get("userId") or "")
email = str(resolved.get("email") or "")
username = str(resolved.get("username") or "")
matched_by = str(resolved.get("matchedBy") or "")
return UiHintsPayload(
intent=UiHintIntent.DATA,
status=UiHintStatus.SUCCESS,
title="用户信息",
description=f"匹配方式: {matched_by}",
items=[
UiHintKvItem(key="user_id", label="用户ID", value=user_id, copyable=True),
UiHintKvItem(key="email", label="邮箱", value=email, copyable=True),
UiHintKvItem(key="username", label="用户名", value=username or "-"),
UiHintKvItem(key="matched_by", label="匹配方式", value=matched_by),
],
actions=[
UiHintAction(
label="复制用户ID",
style=UiHintActionStyle.SECONDARY,
action=UiHintActionCopy(
type="copy",
content=user_id,
successMessage="用户ID已复制",
),
disabled=not bool(user_id),
)
],
)
async def _resolve_identity(
*,
session: AsyncSession,
@@ -189,15 +139,19 @@ async def user_lookup(
username = str(resolved.get("username") or "")
email = str(resolved.get("email") or "")
summary = f"已找到用户: {username or email}"
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_email={str(bool(email)).lower()}"
)
return _dump_tool_output(
ToolAgentOutput(
tool_name="user_lookup",
tool_call_id="user_lookup-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=summary,
ui_hints=_lookup_success_hints(resolved),
result=summary,
)
)
except HTTPException as exc:
@@ -8,16 +8,6 @@ from core.agentscope.tools.utils.tool_response_builder import (
build_tool_response,
)
from schemas.agent.runtime_models import ToolAgentOutput
from schemas.agent.ui_hints import (
UiHintAction,
UiHintActionNavigation,
UiHintActionStyle,
UiHintIntent,
UiHintKvItem,
UiHintListItem,
UiHintStatus,
UiHintsPayload,
)
def dump_tool_output(output: ToolAgentOutput) -> ToolResponse:
@@ -32,12 +22,6 @@ def calendar_error_output(
message: str,
retryable: bool,
) -> ToolResponse:
ui_hints = UiHintsPayload(
intent=UiHintIntent.STATUS,
status=UiHintStatus.ERROR,
title="日历操作失败",
body=message,
)
output = build_error_output(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
@@ -45,120 +29,5 @@ def calendar_error_output(
message=message,
retryable=retryable,
)
output = output.model_copy(
update={"tool_call_args": tool_call_args, "ui_hints": ui_hints}
)
output = output.model_copy(update={"tool_call_args": tool_call_args})
return dump_tool_output(output)
def calendar_read_hints(
*,
total: int,
page: int,
page_size: int,
total_pages: int,
events: list[dict[str, Any]],
) -> UiHintsPayload:
event_items = [
UiHintListItem(
id=event.get("id"),
title=str(event.get("title") or "未命名日程"),
subtitle=str(event.get("startAt") or ""),
description=str(event.get("location") or "") or None,
)
for event in events
]
return UiHintsPayload(
intent=UiHintIntent.LIST,
status=UiHintStatus.SUCCESS,
title="日程列表",
description=f"{total} 个日程",
items=[
UiHintKvItem(key="total", label="总数", value=total),
UiHintKvItem(key="page", label="当前页", value=page),
UiHintKvItem(key="page_size", label="每页", value=page_size),
UiHintKvItem(key="total_pages", label="总页数", value=total_pages),
],
listItems=event_items,
actions=[
UiHintAction(
label="打开日历",
style=UiHintActionStyle.PRIMARY,
action=UiHintActionNavigation(type="navigation", path="/calendar"),
)
],
meta={"total": total, "page": page, "page_size": page_size},
)
def calendar_write_hints(
*,
operation: str,
message: str,
event: dict[str, Any] | None,
event_id: str | None,
status: UiHintStatus = UiHintStatus.SUCCESS,
) -> UiHintsPayload:
kv_items: list[UiHintKvItem] = []
if event:
kv_items = [
UiHintKvItem(
key="event_id",
label="日程ID",
value=str(event.get("id") or ""),
copyable=True,
),
UiHintKvItem(
key="title",
label="标题",
value=str(event.get("title") or ""),
copyable=True,
),
UiHintKvItem(
key="start_at",
label="开始时间",
value=str(event.get("startAt") or ""),
copyable=True,
),
]
elif event_id:
message = f"目标日程 ID: {event_id}\n{message}"
return UiHintsPayload(
intent=UiHintIntent.STATUS,
status=status,
title="日历操作完成",
body=message,
items=kv_items,
actions=[
UiHintAction(
label="查看日历",
style=UiHintActionStyle.PRIMARY,
action=UiHintActionNavigation(type="navigation", path="/calendar"),
)
],
)
def calendar_share_hints(
*,
event_id: str,
invited: list[str],
permission: dict[str, Any],
) -> UiHintsPayload:
permission_text = (
", ".join([k for k, v in permission.items() if v is True]) or "按邀请人单独设置"
)
return UiHintsPayload(
intent=UiHintIntent.STATUS,
status=UiHintStatus.SUCCESS,
title="日程已分享",
description=f"已邀请 {len(invited)}",
items=[
UiHintKvItem(key="event_id", label="日程ID", value=event_id, copyable=True),
UiHintKvItem(key="permission", label="权限", value=permission_text),
],
listItems=[UiHintListItem(title=email) for email in invited] if invited else [],
)
@@ -34,7 +34,7 @@ def build_error_output(
tool_name=tool_name,
tool_call_id=tool_call_id,
status=ToolStatus.FAILURE,
result_summary=message,
result=f"status=failure code={code} message={message}",
error=ErrorInfo(
code=code,
message=message,
+29 -12
View File
@@ -199,23 +199,39 @@ class DatabaseSettings(BaseModel):
class AppVersionSettings(BaseModel):
releases_dir: str = Field(
manifest_path: str = Field(
default="deploy/static/releases/manifest.json",
description="发布清单文件路径,相对于项目根目录",
)
release_path_prefix: str = Field(
default="releases",
description="安装包目录,相对于项目根目录",
description="下载 URL 中文件目录前缀",
)
current_version: str = Field(
default="0.1.0",
description="当前版本号",
)
current_build: int = Field(
default=1,
description="当前构建号",
)
download_base_url: str = Field(
default="",
download_base_url: AnyHttpUrl | None = Field(
default=None,
description="下载链接基础域名,如 https://your-domain.com",
)
@field_validator("download_base_url", mode="before")
@classmethod
def empty_download_base_url_to_none(cls, value: object) -> object:
if value == "":
return None
return value
@field_validator("manifest_path")
@classmethod
def validate_manifest_path(cls, value: str) -> str:
normalized = Path(value)
if normalized.is_absolute() or ".." in normalized.parts:
raise ValueError("manifest_path must be a safe relative path")
return value
class TestSettings(BaseModel):
email: str = ""
password: str = ""
def _resolve_env_file() -> str:
current = Path(__file__).resolve()
@@ -241,6 +257,7 @@ class Settings(BaseSettings):
taskiq: TaskiqSettings = TaskiqSettings()
database: DatabaseSettings = DatabaseSettings()
app_version: AppVersionSettings = AppVersionSettings()
test: TestSettings = Field(default_factory=TestSettings)
@computed_field
@property
+5 -6
View File
@@ -312,13 +312,12 @@ class ToolAgentOutput(BaseModel):
description="Snapshot of tool call arguments for traceability and debugging.",
)
status: ToolStatus = Field(..., description="Tool execution status.")
result_summary: str = Field(
result: str = Field(
...,
description="Concise tool result summary with key facts and without verbose logs.",
)
ui_hints: UiHintsPayload | None = Field(
default=None,
description="Optional UI semantic hints translated into ui_schema by ui_compiler.",
description=(
"Compact machine-oriented tool result. Keep it short but include "
"action-critical facts (ids/status/counts) for downstream agent steps."
),
)
error: ErrorInfo | None = Field(
default=None, description="Tool execution error details."
+6 -2
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from schemas.agent.ui_schema import UiSchemaRenderer
@@ -51,7 +53,9 @@ class HistoryMessage(BaseModel):
id: str = Field(description="Message UUID")
seq: int = Field(description="Message sequence number")
role: str = Field(description="Message role: user | assistant | tool")
role: Literal["user", "assistant"] = Field(
description="Message role: user | assistant"
)
content: str = Field(description="Message text content")
attachments: list[HistoryMessageAttachment] = Field(
default_factory=list,
@@ -59,7 +63,7 @@ class HistoryMessage(BaseModel):
)
ui_schema: UiSchemaRenderer | None = Field(
default=None,
description="Compiled UI schema from worker/tool ui_hints for frontend rendering",
description="Compiled UI schema from worker ui_hints for frontend rendering",
)
timestamp: str = Field(description="Message creation timestamp in ISO-8601 format")
+2
View File
@@ -449,6 +449,8 @@ class AgentService:
)
for msg_dict in raw_messages:
msg = AgentChatMessage.model_validate(msg_dict)
if msg.role == "tool":
continue
signed_urls: dict[str, str] = {}
attachments = extract_user_message_attachments(msg.metadata)
-45
View File
@@ -24,7 +24,6 @@ def convert_message_to_history(
转换规则:
- role=user: 读取 metadata.user_message_attachments,转换为 attachments[]
- role=tool: 读取 content 和 metadata.tool_agent_output.ui_hints,编译成 ui_schema
- role=assistant: 读取 metadata.worker_agent_output.ui_hints,编译成 ui_schema
"""
role = message.role
@@ -37,9 +36,6 @@ def convert_message_to_history(
if role == "user":
attachments = _convert_user_attachments(metadata, get_signed_url_fn)
elif role == "tool":
ui_schema = _compile_tool_ui_hints(metadata)
elif role == "assistant":
ui_schema = _compile_worker_ui_hints(metadata)
@@ -92,47 +88,6 @@ def _convert_user_attachments(
return signed_attachments
def _compile_tool_ui_hints(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
) -> dict[str, Any] | None:
"""编译 tool 消息的 ui_hints"""
if not metadata:
return None
if isinstance(metadata, AgentChatMessageMetadata):
tool_output = metadata.tool_agent_output
else:
tool_output_data = metadata.get("tool_agent_output")
if not tool_output_data:
return None
if isinstance(tool_output_data, dict):
raw_ui_schema = tool_output_data.get("ui_schema")
if isinstance(raw_ui_schema, dict):
return raw_ui_schema
legacy_ui_schema = tool_output_data.get("uiSchema")
if isinstance(legacy_ui_schema, dict):
return legacy_ui_schema
from schemas.agent.runtime_models import ToolAgentOutput
try:
tool_output = ToolAgentOutput.model_validate(tool_output_data)
except Exception:
return None
if not tool_output:
return None
ui_hints = tool_output.ui_hints
if not ui_hints:
return None
try:
compiled = compile_ui_hints(ui_hints)
return compiled
except Exception:
return None
def _compile_worker_ui_hints(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
) -> dict[str, Any] | None:
+109 -77
View File
@@ -1,128 +1,160 @@
from __future__ import annotations
import re
import json
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ValidationError
from core.config.settings import config
from core.config.settings import PROJECT_ROOT, config
from core.logging import get_logger
class AppVersionInfo(BaseModel):
has_update: bool = Field(description="是否有新版本可用")
latest_version: str = Field(description="最新版本号,如 0.1.0")
latest_build: int = Field(description="最新构建号")
min_required_version: str = Field(description="强制更新版本号")
update_type: Literal["none", "optional", "required"] = Field(
description="更新类型: none=无更新, optional=可选更新, required=必须更新"
)
latest_version_name: str = Field(description="最新展示版本号,如 0.1.1")
latest_version_code: int = Field(description="最新构建号(versionCode/buildNumber)")
min_supported_version_code: int = Field(description="最低支持版本构建号")
download_url: str | None = Field(default=None, description="安装包下载链接")
release_notes: str | None = Field(default=None, description="版本更新说明")
file_name: str | None = Field(default=None, description="安装包文件名")
file_size: int | None = Field(default=None, description="安装包大小(字节)")
sha256: str | None = Field(default=None, description="安装包哈希")
class ReleaseRecord(BaseModel):
platform: Literal["android", "ios"]
channel: str = Field(default="release", min_length=1, max_length=32)
version_name: str = Field(min_length=1, max_length=32)
version_code: int = Field(ge=1)
min_supported_version_code: int = Field(ge=1)
file_name: str = Field(min_length=1)
release_notes: str | None = None
file_size: int | None = Field(default=None, ge=1)
sha256: str | None = Field(default=None, min_length=32, max_length=128)
class ReleaseManifest(BaseModel):
releases: list[ReleaseRecord] = Field(default_factory=list)
router = APIRouter(prefix="/app", tags=["app"])
logger = get_logger("api.app.version")
def _parse_version(filename: str) -> tuple[str, int, tuple[int, ...]] | None:
pattern = r"app[-_]v?(\d+\.\d+\.\d+)\+(\d+)\.(?:apk|ipa)"
match = re.search(pattern, filename, re.IGNORECASE)
if match:
version = match.group(1)
build = int(match.group(2))
version_tuple = tuple(int(x) for x in version.split("."))
return (version, build, version_tuple)
return None
def _manifest_file_path() -> Path:
return (PROJECT_ROOT / config.app_version.manifest_path).resolve()
def _get_latest_release(
platform: Literal["ios", "android"],
) -> tuple[str, int, str] | None:
releases_dir = config.app_version.releases_dir
base_path = Path.cwd().parent / "deploy" / "static" / releases_dir
def _load_manifest() -> ReleaseManifest:
manifest_file = _manifest_file_path()
if not manifest_file.is_file():
return ReleaseManifest()
if not base_path.exists():
return None
try:
raw = json.loads(manifest_file.read_text(encoding="utf-8"))
return ReleaseManifest.model_validate(raw)
except (json.JSONDecodeError, OSError, ValidationError):
logger.warning("Invalid release manifest, fallback to empty manifest")
return ReleaseManifest()
target_ext = "ipa" if platform == "ios" else "apk"
candidates = []
MIN_APK_SIZE = 1024 * 1024 # 1MB
MIN_IPA_SIZE = 1024 * 1024 # 1MB
for f in base_path.iterdir():
if not f.is_file():
continue
ext = f.suffix.lstrip(".").lower()
if ext != target_ext:
continue
# 简单校验文件大小,排除伪装文件
if f.stat().st_size < (MIN_APK_SIZE if ext == "apk" else MIN_IPA_SIZE):
continue
parsed = _parse_version(f.name)
if parsed:
version, build, version_tuple = parsed
candidates.append((version_tuple, build, f.name))
def _select_latest_release(
manifest: ReleaseManifest,
platform: Literal["android", "ios"],
channel: str,
) -> ReleaseRecord | None:
candidates = [
release
for release in manifest.releases
if release.platform == platform and release.channel == channel
]
if not candidates:
return None
candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
result = candidates[0]
return result[2].replace("+", "."), result[1], result[2]
return max(candidates, key=lambda release: release.version_code)
def _compare_versions(
current_version: str, current_build: int, latest_version: str, latest_build: int
) -> tuple[bool, Literal["none", "optional", "required"]]:
if current_build >= latest_build:
return False, "none"
def _build_download_url(file_name: str) -> str | None:
base_url = config.app_version.download_base_url
if base_url is None:
return None
if current_build < latest_build - 2:
return True, "required"
return True, "optional"
base_url_text = str(base_url).rstrip("/")
path_prefix = config.app_version.release_path_prefix.strip().strip("/")
if path_prefix:
return f"{base_url_text}/{path_prefix}/{file_name}"
return f"{base_url_text}/{file_name}"
def _resolve_update_type(
current_version_code: int,
latest_version_code: int,
min_supported_version_code: int,
) -> Literal["none", "optional", "required"]:
if current_version_code >= latest_version_code:
return "none"
if current_version_code < min_supported_version_code:
return "required"
return "optional"
@router.get("/check-updates", response_model=AppVersionInfo)
async def check_updates(
current_version: str | None = Query(None, description="前端当前版本,如 0.1.0"),
current_build: int | None = Query(None, description="前端当前构建号,如 1"),
current_version_code: int = Query(
..., ge=1, description="前端当前构建号(versionCode/buildNumber),如 1"
),
current_version_name: str = Query(
"0.0.0",
min_length=1,
max_length=32,
description="前端当前展示版本号,如 0.1.0",
),
platform: Literal["ios", "android"] = Query("ios", description="平台类型"),
channel: str = Query(
"release",
min_length=1,
max_length=32,
pattern=r"^[a-z0-9_-]+$",
description="发布渠道,如 release/beta",
),
) -> AppVersionInfo:
current_build = current_build or 0
manifest = _load_manifest()
latest = _select_latest_release(manifest, platform=platform, channel=channel)
latest = _get_latest_release(platform)
if not latest:
if latest is None:
return AppVersionInfo(
has_update=False,
latest_version=config.app_version.current_version,
latest_build=config.app_version.current_build,
min_required_version=config.app_version.current_version,
update_type="none",
latest_version_name=current_version_name,
latest_version_code=current_version_code,
min_supported_version_code=current_version_code,
download_url=None,
release_notes=None,
file_name=None,
file_size=None,
sha256=None,
)
latest_version, latest_build, filename = latest
has_update, update_type = _compare_versions(
current_version or "0.0.0",
current_build,
latest_version,
latest_build,
update_type = _resolve_update_type(
current_version_code=current_version_code,
latest_version_code=latest.version_code,
min_supported_version_code=latest.min_supported_version_code,
)
download_url: str | None = None
if has_update and config.app_version.download_base_url:
download_url = f"{config.app_version.download_base_url.rstrip('/')}/{config.app_version.releases_dir}/{filename}"
has_update = update_type != "none"
return AppVersionInfo(
has_update=has_update,
latest_version=latest_version,
latest_build=latest_build,
min_required_version=latest_version,
update_type=update_type,
download_url=download_url,
release_notes="问题修复和体验优化",
latest_version_name=latest.version_name,
latest_version_code=latest.version_code,
min_supported_version_code=latest.min_supported_version_code,
download_url=_build_download_url(latest.file_name) if has_update else None,
release_notes=latest.release_notes,
file_name=latest.file_name,
file_size=latest.file_size,
sha256=latest.sha256,
)
+20 -1
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING, Protocol, Sequence
from uuid import UUID
from sqlalchemy import func, select, update, delete
from sqlalchemy import func, or_, select, update, delete
from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository
@@ -39,6 +39,7 @@ class ScheduleItemRepository(Protocol):
*,
page: int,
page_size: int,
query: str | None = None,
) -> tuple[list[ScheduleItem], int]: ...
async def create_subscription(self, data: dict) -> ScheduleSubscription: ...
async def get_subscriptions_by_item_id(
@@ -164,8 +165,12 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
*,
page: int,
page_size: int,
query: str | None = None,
) -> tuple[list[ScheduleItem], int]:
offset = (page - 1) * page_size
normalized_query = (query or "").strip()
has_query = bool(normalized_query)
query_like = f"%{normalized_query}%"
try:
count_stmt = (
select(func.count())
@@ -173,6 +178,13 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
.where(ScheduleItem.owner_id == owner_id)
.where(ScheduleItem.deleted_at.is_(None))
)
if has_query:
count_stmt = count_stmt.where(
or_(
ScheduleItem.title.ilike(query_like),
ScheduleItem.description.ilike(query_like),
)
)
count_result = await self._session.execute(count_stmt)
total = int(count_result.scalar_one() or 0)
@@ -184,6 +196,13 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
.offset(offset)
.limit(page_size)
)
if has_query:
items_stmt = items_stmt.where(
or_(
ScheduleItem.title.ilike(query_like),
ScheduleItem.description.ilike(query_like),
)
)
items_result = await self._session.execute(items_stmt)
items = list(items_result.scalars().all())
return items, total
+2
View File
@@ -269,6 +269,7 @@ class ScheduleItemService(BaseService):
*,
page: int,
page_size: int,
query: str | None = None,
) -> tuple[list[ScheduleItemResponse], int]:
user_id = self.require_user_id()
if page < 1:
@@ -280,6 +281,7 @@ class ScheduleItemService(BaseService):
user_id,
page=page,
page_size=page_size,
query=query,
)
except SQLAlchemyError:
logger.exception(