feat(outlook): 添加 Outlook MCP 集成支持并优化分页功能
- 新增 NANO_OUTLOOK_MCP_URL 和 NANO_OUTLOOK_MCP_SERVER_ID 环境变量配置 - 实现 Outlook 邮件和日历的分页查询功能,添加安全参数验证 - 为 app-instance 创建脚本添加 Outlook MCP 服务器 ID 参数 - 更新前端 Outlook 页面实现邮件列表和日历事件的分页浏览 - 添加 Git 忽略文件配置和 Docker 挂载路径修复 BREAKING CHANGE: Outlook 集成现在需要配置 MCP URL 和服务器 ID 环境变量
This commit is contained in:
@ -16,6 +16,8 @@ NANO_API_BASE=
|
||||
|
||||
# Must be reachable from app-instance containers.
|
||||
NANO_AUTHZ_URL=http://nano-authz-service:19090
|
||||
NANO_OUTLOOK_MCP_URL=
|
||||
NANO_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||
|
||||
# Must be reachable from auth-portal and authz-service containers.
|
||||
NANO_DEPLOY_URL=http://nano-deploy-control:8090
|
||||
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
# Runtime data generated by local Docker deployment
|
||||
authz-service/runtime/data/
|
||||
app-instance/runtime/instances/
|
||||
app-instance/runtime/registry/
|
||||
router-proxy/runtime/conf.d/
|
||||
|
||||
# Local build / cache artifacts
|
||||
**/__pycache__/
|
||||
**/.pytest_cache/
|
||||
**/node_modules/
|
||||
**/.next/
|
||||
*.log
|
||||
24
README.md
24
README.md
@ -93,6 +93,12 @@ Browser
|
||||
- 可空,自定义 provider base URL
|
||||
- `NANO_AUTHZ_URL`
|
||||
- 这个值必须是 `app-instance` 容器能访问到的 AuthZ 地址
|
||||
- `NANO_OUTLOOK_MCP_URL`
|
||||
- 可空。
|
||||
- 如果配置了,所有新创建的 `app-instance` 都会默认带一个 Outlook MCP HTTP 工具配置。
|
||||
- `NANO_OUTLOOK_MCP_SERVER_ID`
|
||||
- Outlook MCP 默认 server id。
|
||||
- 推荐固定成 `outlook_mcp`。
|
||||
- `NANO_DEPLOY_URL`
|
||||
- `auth-portal` 和 `authz-service` 在容器网络里访问 deploy-control 的地址
|
||||
|
||||
@ -114,6 +120,8 @@ export NANO_API_KEY='sk-xxxxxxxx'
|
||||
export NANO_API_BASE=''
|
||||
|
||||
export NANO_AUTHZ_URL='http://nano-authz-service:19090'
|
||||
export NANO_OUTLOOK_MCP_URL=''
|
||||
export NANO_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
|
||||
export NANO_DEPLOY_URL='http://nano-deploy-control:8090'
|
||||
```
|
||||
|
||||
@ -236,8 +244,10 @@ docker run -d \
|
||||
--network "$NANO_NET" \
|
||||
-p 8090:8090 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "$PROJECT_ROOT/app-instance:/app-instance" \
|
||||
-v "$PROJECT_ROOT/router-proxy:/router-proxy" \
|
||||
-v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \
|
||||
-v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \
|
||||
-e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \
|
||||
-e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \
|
||||
-e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
||||
-e APP_INSTANCE_IMAGE="nano/app-instance:latest" \
|
||||
-e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \
|
||||
@ -246,6 +256,8 @@ docker run -d \
|
||||
-e APP_INSTANCE_API_KEY="$NANO_API_KEY" \
|
||||
-e APP_INSTANCE_API_BASE="$NANO_API_BASE" \
|
||||
-e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \
|
||||
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$NANO_OUTLOOK_MCP_URL" \
|
||||
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$NANO_OUTLOOK_MCP_SERVER_ID" \
|
||||
-e DEPLOY_PUBLIC_SCHEME="http" \
|
||||
-e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \
|
||||
-e DEPLOY_PUBLIC_PORT="8088" \
|
||||
@ -253,6 +265,14 @@ docker run -d \
|
||||
nano/deploy-control:latest
|
||||
```
|
||||
|
||||
这里不要把宿主机目录挂到容器内的另一个短路径,比如 `/app-instance`。
|
||||
|
||||
原因是 `deploy-control` 会通过挂载进来的 Docker socket 再去创建 `app-instance` 容器;这时传给 Docker 的 bind mount 源路径必须是宿主机真实路径。如果你把宿主机目录映射成容器内短路径,`create-instance.sh` 生成的挂载源就会变成错误路径,最终表现为:
|
||||
|
||||
- 注册接口超时
|
||||
- `app-instance` 容器反复重启
|
||||
- 日志里出现 `Missing nanobot config: /root/.nanobot/config.json`
|
||||
|
||||
当前版本里,新实例的默认大模型配置就是从这里分发的:
|
||||
|
||||
- `APP_INSTANCE_PROVIDER`
|
||||
|
||||
@ -21,7 +21,10 @@ from loguru import logger
|
||||
from nanobot.authz.client import AuthzClient
|
||||
from nanobot.config.schema import Config, MCPServerConfig
|
||||
|
||||
OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook")
|
||||
OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp")
|
||||
OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8
|
||||
OUTLOOK_OVERVIEW_EVENT_LIMIT = 20
|
||||
OUTLOOK_MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class OutlookIntegrationError(RuntimeError):
|
||||
@ -125,6 +128,38 @@ def _default_outlook_permissions() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]:
|
||||
safe_top = max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE))
|
||||
safe_skip = max(0, int(skip))
|
||||
return safe_top, safe_skip
|
||||
|
||||
|
||||
def _normalize_page_payload(payload: dict[str, Any], *, top: int, skip: int) -> dict[str, Any]:
|
||||
items = payload.get("value", []) if isinstance(payload, dict) else []
|
||||
returned = len(items) if isinstance(items, list) else 0
|
||||
page = payload.get("page") if isinstance(payload, dict) else None
|
||||
if isinstance(page, dict):
|
||||
normalized = dict(payload)
|
||||
normalized["page"] = {
|
||||
"top": int(page.get("top", top)),
|
||||
"skip": int(page.get("skip", skip)),
|
||||
"returned": int(page.get("returned", returned)),
|
||||
"has_more": bool(page.get("has_more", False)),
|
||||
"next_skip": page.get("next_skip"),
|
||||
}
|
||||
return normalized
|
||||
return {
|
||||
**payload,
|
||||
"page": {
|
||||
"top": top,
|
||||
"skip": skip,
|
||||
"returned": returned,
|
||||
"has_more": returned >= top,
|
||||
"next_skip": skip + returned if returned >= top else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def ensure_outlook_authz_permissions(config: Config) -> None:
|
||||
backend_id = _require_backend_identity(config)
|
||||
client = _authz_client(config)
|
||||
@ -708,7 +743,7 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
inbox = await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_list_messages",
|
||||
{"folder": "inbox", "top": 8},
|
||||
{"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||
scopes=["list_tools", "tool:mail_list_messages"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@ -718,7 +753,7 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
sent = await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_list_messages",
|
||||
{"folder": "sentitems", "top": 8},
|
||||
{"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||
scopes=["list_tools", "tool:mail_list_messages"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@ -731,7 +766,8 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
{
|
||||
"start_time": start_of_day.isoformat(),
|
||||
"end_time": end_of_day.isoformat(),
|
||||
"top": 20,
|
||||
"top": OUTLOOK_OVERVIEW_EVENT_LIMIT,
|
||||
"skip": 0,
|
||||
},
|
||||
scopes=["list_tools", "tool:calendar_list_events"],
|
||||
)
|
||||
@ -764,13 +800,21 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
inbox = await provider.list_messages(folder="inbox", top=8)
|
||||
inbox = await provider.list_messages(
|
||||
folder="inbox",
|
||||
top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT,
|
||||
skip=0,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
inbox = {"value": []}
|
||||
warnings.append(f"inbox unavailable: {exc}")
|
||||
|
||||
try:
|
||||
sent = await provider.list_messages(folder="sentitems", top=8)
|
||||
sent = await provider.list_messages(
|
||||
folder="sentitems",
|
||||
top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT,
|
||||
skip=0,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
sent = {"value": []}
|
||||
warnings.append(f"sent items unavailable: {exc}")
|
||||
@ -779,7 +823,8 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
||||
calendar = await provider.list_events(
|
||||
start_time=start_of_day.isoformat(),
|
||||
end_time=end_of_day.isoformat(),
|
||||
top=20,
|
||||
top=OUTLOOK_OVERVIEW_EVENT_LIMIT,
|
||||
skip=0,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
calendar = {"value": []}
|
||||
@ -825,6 +870,92 @@ async def get_message_detail(
|
||||
return await provider.get_message(message_id=message_id, changekey=changekey)
|
||||
|
||||
|
||||
async def list_messages(
|
||||
config: Config,
|
||||
*,
|
||||
folder: str,
|
||||
top: int,
|
||||
skip: int = 0,
|
||||
unread_only: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
|
||||
|
||||
if _use_authz_mode(config):
|
||||
payload = await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"mail_list_messages",
|
||||
{
|
||||
"folder": folder,
|
||||
"top": safe_top,
|
||||
"skip": safe_skip,
|
||||
"unread_only": unread_only,
|
||||
},
|
||||
scopes=["list_tools", "tool:mail_list_messages"],
|
||||
)
|
||||
return {
|
||||
"folder": folder,
|
||||
"unread_only": unread_only,
|
||||
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
|
||||
}
|
||||
|
||||
input_data = _saved_connection_input(config.workspace_path)
|
||||
provider, _normalized, _mods = _build_provider(input_data)
|
||||
payload = await provider.list_messages(
|
||||
folder=folder,
|
||||
top=safe_top,
|
||||
skip=safe_skip,
|
||||
unread_only=unread_only,
|
||||
)
|
||||
return {
|
||||
"folder": folder,
|
||||
"unread_only": unread_only,
|
||||
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
|
||||
}
|
||||
|
||||
|
||||
async def list_events(
|
||||
config: Config,
|
||||
*,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
top: int,
|
||||
skip: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
|
||||
|
||||
if _use_authz_mode(config):
|
||||
payload = await _call_outlook_mcp_tool(
|
||||
config,
|
||||
"calendar_list_events",
|
||||
{
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"top": safe_top,
|
||||
"skip": safe_skip,
|
||||
},
|
||||
scopes=["list_tools", "tool:calendar_list_events"],
|
||||
)
|
||||
return {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
|
||||
}
|
||||
|
||||
input_data = _saved_connection_input(config.workspace_path)
|
||||
provider, _normalized, _mods = _build_provider(input_data)
|
||||
payload = await provider.list_events(
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
top=safe_top,
|
||||
skip=safe_skip,
|
||||
)
|
||||
return {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
|
||||
}
|
||||
|
||||
|
||||
def is_outlook_mcp_registered(config: Config) -> bool:
|
||||
return OUTLOOK_SERVER_ID in config.tools.mcp_servers
|
||||
|
||||
|
||||
@ -2312,6 +2312,11 @@ def _register_routes(app: FastAPI) -> None:
|
||||
server_id = req.id.strip()
|
||||
if not server_id:
|
||||
raise HTTPException(status_code=400, detail="Server id is required")
|
||||
auth_mode = (req.auth_mode or "none").strip().lower() or "none"
|
||||
auth_audience = (req.auth_audience or "").strip()
|
||||
auth_scopes = [str(item).strip() for item in list(req.auth_scopes or []) if str(item).strip()]
|
||||
if auth_mode == "oauth_backend_token" and not auth_audience:
|
||||
auth_audience = f"mcp:{server_id}"
|
||||
|
||||
config.tools.mcp_servers[server_id] = MCPServerConfig(
|
||||
command=req.command,
|
||||
@ -2319,9 +2324,9 @@ def _register_routes(app: FastAPI) -> None:
|
||||
env=req.env,
|
||||
url=req.url,
|
||||
headers=req.headers,
|
||||
auth_mode=req.auth_mode,
|
||||
auth_audience=req.auth_audience,
|
||||
auth_scopes=req.auth_scopes,
|
||||
auth_mode=auth_mode,
|
||||
auth_audience=auth_audience,
|
||||
auth_scopes=auth_scopes,
|
||||
tool_timeout=req.tool_timeout,
|
||||
sensitive=req.sensitive,
|
||||
)
|
||||
@ -2460,6 +2465,56 @@ def _register_routes(app: FastAPI) -> None:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/integrations/outlook/messages")
|
||||
async def get_outlook_messages(
|
||||
folder: str = "inbox",
|
||||
top: int = 20,
|
||||
skip: int = 0,
|
||||
unread_only: bool = False,
|
||||
):
|
||||
from nanobot.web.outlook import OutlookIntegrationError, list_messages
|
||||
|
||||
config: Config = app.state.config
|
||||
if not folder.strip():
|
||||
raise HTTPException(status_code=400, detail="folder is required")
|
||||
try:
|
||||
return await list_messages(
|
||||
config,
|
||||
folder=folder.strip(),
|
||||
top=top,
|
||||
skip=skip,
|
||||
unread_only=unread_only,
|
||||
)
|
||||
except OutlookIntegrationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/integrations/outlook/events")
|
||||
async def get_outlook_events(
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
top: int = 20,
|
||||
skip: int = 0,
|
||||
):
|
||||
from nanobot.web.outlook import OutlookIntegrationError, list_events
|
||||
|
||||
config: Config = app.state.config
|
||||
if not start_time.strip() or not end_time.strip():
|
||||
raise HTTPException(status_code=400, detail="start_time and end_time are required")
|
||||
try:
|
||||
return await list_events(
|
||||
config,
|
||||
start_time=start_time.strip(),
|
||||
end_time=end_time.strip(),
|
||||
top=top,
|
||||
skip=skip,
|
||||
)
|
||||
except OutlookIntegrationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/integrations/outlook/message-detail")
|
||||
async def get_outlook_message_detail(message_id: str, changekey: str | None = None):
|
||||
from nanobot.web.outlook import OutlookIntegrationError, get_message_detail
|
||||
|
||||
@ -16,6 +16,7 @@ HOST_PORT=""
|
||||
PUBLIC_URL=""
|
||||
AUTHZ_BASE_URL=""
|
||||
AUTHZ_OUTLOOK_MCP_URL=""
|
||||
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
|
||||
BACKEND_ID=""
|
||||
CLIENT_ID=""
|
||||
CLIENT_SECRET=""
|
||||
@ -60,6 +61,8 @@ Optional:
|
||||
--authz-base-url <url> AuthZ service base URL.
|
||||
--authz-outlook-mcp-url <url>
|
||||
Managed Outlook MCP URL for AuthZ mode.
|
||||
--outlook-mcp-server-id <id>
|
||||
Default Outlook MCP server id. Default: outlook_mcp
|
||||
--backend-id <id> Pre-assigned backend id.
|
||||
--client-id <id> Pre-assigned AuthZ client id.
|
||||
--client-secret <secret> Pre-assigned AuthZ client secret.
|
||||
@ -133,6 +136,7 @@ render_config_json() {
|
||||
API_BASE="$API_BASE" \
|
||||
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
||||
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
||||
OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \
|
||||
BACKEND_ID="$BACKEND_ID" \
|
||||
CLIENT_ID="$CLIENT_ID" \
|
||||
CLIENT_SECRET="$CLIENT_SECRET" \
|
||||
@ -145,12 +149,48 @@ from pathlib import Path
|
||||
|
||||
target = Path(os.environ["TARGET_PATH"])
|
||||
provider = os.environ["PROVIDER"]
|
||||
outlook_mcp_url = os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip()
|
||||
outlook_server_id = os.environ["OUTLOOK_MCP_SERVER_ID"].strip() or "outlook_mcp"
|
||||
|
||||
provider_cfg = {"apiKey": os.environ["API_KEY"]}
|
||||
api_base = os.environ["API_BASE"].strip()
|
||||
if api_base:
|
||||
provider_cfg["apiBase"] = api_base
|
||||
|
||||
outlook_tool_names = [
|
||||
"auth_status",
|
||||
"mail_list_folders",
|
||||
"mail_list_messages",
|
||||
"mail_search_messages",
|
||||
"mail_get_message",
|
||||
"mail_send_email",
|
||||
"mail_reply_to_message",
|
||||
"mail_forward_message",
|
||||
"mail_move_message",
|
||||
"mail_delta_sync",
|
||||
"calendar_list_events",
|
||||
"calendar_create_event",
|
||||
"calendar_update_event",
|
||||
"calendar_get_schedule",
|
||||
"calendar_find_meeting_times",
|
||||
"calendar_delta_sync",
|
||||
]
|
||||
|
||||
default_mcp_servers = {}
|
||||
if outlook_mcp_url:
|
||||
default_mcp_servers[outlook_server_id] = {
|
||||
"command": "",
|
||||
"args": [],
|
||||
"env": {},
|
||||
"url": outlook_mcp_url,
|
||||
"headers": {},
|
||||
"authMode": "oauth_backend_token",
|
||||
"authAudience": f"mcp:{outlook_server_id}",
|
||||
"authScopes": ["list_tools", *[f"tool:{name}" for name in outlook_tool_names]],
|
||||
"toolTimeout": 60,
|
||||
"sensitive": True,
|
||||
}
|
||||
|
||||
data = {
|
||||
"agents": {
|
||||
"defaults": {
|
||||
@ -163,12 +203,13 @@ data = {
|
||||
},
|
||||
"tools": {
|
||||
"restrictToWorkspace": True,
|
||||
"mcpServers": default_mcp_servers,
|
||||
},
|
||||
"authz": {
|
||||
"enabled": bool(os.environ["AUTHZ_BASE_URL"].strip()),
|
||||
"baseUrl": os.environ["AUTHZ_BASE_URL"].strip() or "http://127.0.0.1:19090",
|
||||
"requestTimeoutSeconds": 10,
|
||||
"outlookMcpUrl": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(),
|
||||
"outlookMcpUrl": outlook_mcp_url,
|
||||
},
|
||||
"backend_identity": {
|
||||
"backendId": os.environ["BACKEND_ID"].strip(),
|
||||
@ -281,6 +322,10 @@ while [[ $# -gt 0 ]]; do
|
||||
AUTHZ_OUTLOOK_MCP_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--outlook-mcp-server-id)
|
||||
OUTLOOK_MCP_SERVER_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--backend-id)
|
||||
BACKEND_ID="${2:-}"
|
||||
shift 2
|
||||
@ -448,6 +493,7 @@ RUN_ARGS=(
|
||||
-e "APP_PUBLIC_PORT=8080"
|
||||
-e "APP_FRONTEND_PORT=3000"
|
||||
-e "APP_BACKEND_PORT=18080"
|
||||
-e "NANOBOT_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
||||
--label "nano.instance.id=${INSTANCE_ID}"
|
||||
--label "nano.instance.slug=${INSTANCE_SLUG}"
|
||||
--label "nano.instance.public_url=${PUBLIC_URL}"
|
||||
|
||||
@ -19,7 +19,9 @@ import {
|
||||
import {
|
||||
connectOutlook,
|
||||
disconnectOutlook,
|
||||
getOutlookEvents,
|
||||
getOutlookMessageDetail,
|
||||
getOutlookMessages,
|
||||
getOutlookOverview,
|
||||
getOutlookStatus,
|
||||
testOutlookConnection,
|
||||
@ -27,9 +29,12 @@ import {
|
||||
import type {
|
||||
OutlookConnectionPayload,
|
||||
OutlookConnectionTestResult,
|
||||
OutlookEventListResponse,
|
||||
OutlookEventSummary,
|
||||
OutlookMessageDetail,
|
||||
OutlookMessageListResponse,
|
||||
OutlookMessageSummary,
|
||||
OutlookPageInfo,
|
||||
OutlookStatus,
|
||||
} from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -52,6 +57,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
type OutlookFormState = OutlookConnectionPayload;
|
||||
type OutlookView = 'inbox' | 'sent' | 'calendar' | 'settings';
|
||||
type OutlookMailboxView = 'inbox' | 'sent';
|
||||
|
||||
const MAILBOX_PAGE_SIZE = 20;
|
||||
const CALENDAR_PAGE_SIZE = 100;
|
||||
|
||||
const EMPTY_FORM: OutlookFormState = {
|
||||
email: '',
|
||||
@ -124,6 +133,18 @@ function formatTime(value?: string | null): string {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function buildCalendarRange(anchorKey: string): { startTime: string; endTime: string } {
|
||||
const anchor = new Date(`${anchorKey}T00:00:00`);
|
||||
const start = Number.isNaN(anchor.getTime()) ? new Date() : anchor;
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 7);
|
||||
return {
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mailboxLabel(
|
||||
mailbox?:
|
||||
| {
|
||||
@ -333,6 +354,15 @@ export default function OutlookPage() {
|
||||
const [messageLoading, setMessageLoading] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<OutlookEventSummary | null>(null);
|
||||
const [activeView, setActiveView] = useState<OutlookView>('settings');
|
||||
const [inboxPage, setInboxPage] = useState<OutlookMessageListResponse | null>(null);
|
||||
const [sentPage, setSentPage] = useState<OutlookMessageListResponse | null>(null);
|
||||
const [calendarPage, setCalendarPage] = useState<OutlookEventListResponse | null>(null);
|
||||
const [calendarAnchorKey, setCalendarAnchorKey] = useState<string>(toLocalDateKey(new Date()));
|
||||
const [mailboxLoading, setMailboxLoading] = useState<Record<OutlookMailboxView, boolean>>({
|
||||
inbox: false,
|
||||
sent: false,
|
||||
});
|
||||
const [calendarLoading, setCalendarLoading] = useState(false);
|
||||
|
||||
const applyStatus = useCallback((nextStatus: OutlookStatus, forceFormSync = false) => {
|
||||
setStatus(nextStatus);
|
||||
@ -358,6 +388,44 @@ export default function OutlookPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMailboxPage = useCallback(async (view: OutlookMailboxView, skip = 0) => {
|
||||
setMailboxLoading((current) => ({ ...current, [view]: true }));
|
||||
try {
|
||||
const nextPage = await getOutlookMessages(view === 'inbox' ? 'inbox' : 'sentitems', {
|
||||
top: MAILBOX_PAGE_SIZE,
|
||||
skip,
|
||||
});
|
||||
if (view === 'inbox') {
|
||||
setInboxPage(nextPage);
|
||||
} else {
|
||||
setSentPage(nextPage);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || `加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`);
|
||||
} finally {
|
||||
setMailboxLoading((current) => ({ ...current, [view]: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCalendarPage = useCallback(async (anchorKey: string) => {
|
||||
setCalendarLoading(true);
|
||||
try {
|
||||
const range = buildCalendarRange(anchorKey);
|
||||
const nextPage = await getOutlookEvents({
|
||||
...range,
|
||||
top: CALENDAR_PAGE_SIZE,
|
||||
skip: 0,
|
||||
});
|
||||
setCalendarPage(nextPage);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载日程失败');
|
||||
} finally {
|
||||
setCalendarLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadStatus = useCallback(async (
|
||||
background = false,
|
||||
options?: {
|
||||
@ -465,14 +533,14 @@ export default function OutlookPage() {
|
||||
label: '收件箱',
|
||||
hint: '最近接收邮件',
|
||||
icon: Inbox,
|
||||
count: overviewPending ? null : inboxCount,
|
||||
count: null,
|
||||
},
|
||||
{
|
||||
id: 'sent' as const,
|
||||
label: '发件箱',
|
||||
hint: '最近发送记录',
|
||||
icon: Send,
|
||||
count: overviewPending ? null : sentCount,
|
||||
count: null,
|
||||
},
|
||||
{
|
||||
id: 'calendar' as const,
|
||||
@ -497,6 +565,33 @@ export default function OutlookPage() {
|
||||
}
|
||||
}, [activeView, availableViews]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConfigured) {
|
||||
return;
|
||||
}
|
||||
if (activeView === 'inbox' && !inboxPage && !mailboxLoading.inbox) {
|
||||
void loadMailboxPage('inbox', 0);
|
||||
}
|
||||
if (activeView === 'sent' && !sentPage && !mailboxLoading.sent) {
|
||||
void loadMailboxPage('sent', 0);
|
||||
}
|
||||
if (activeView === 'calendar' && !calendarPage && !calendarLoading) {
|
||||
void loadCalendarPage(calendarAnchorKey);
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
calendarAnchorKey,
|
||||
calendarLoading,
|
||||
calendarPage,
|
||||
inboxPage,
|
||||
isConfigured,
|
||||
loadCalendarPage,
|
||||
loadMailboxPage,
|
||||
mailboxLoading.inbox,
|
||||
mailboxLoading.sent,
|
||||
sentPage,
|
||||
]);
|
||||
|
||||
const updateField = <K extends keyof OutlookFormState>(key: K, value: OutlookFormState[K]) => {
|
||||
setFormDirty(true);
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
@ -524,6 +619,10 @@ export default function OutlookPage() {
|
||||
setForm((current) => ({ ...current, password: '' }));
|
||||
setFormDirty(false);
|
||||
setTestResult(null);
|
||||
setInboxPage(null);
|
||||
setSentPage(null);
|
||||
setCalendarPage(null);
|
||||
setCalendarAnchorKey(toLocalDateKey(new Date()));
|
||||
await loadStatus(true, { forceFormSync: true });
|
||||
setActiveView('inbox');
|
||||
} catch (err: any) {
|
||||
@ -542,6 +641,10 @@ export default function OutlookPage() {
|
||||
setTestResult(null);
|
||||
setSelectedMessageRef(null);
|
||||
setSelectedEvent(null);
|
||||
setInboxPage(null);
|
||||
setSentPage(null);
|
||||
setCalendarPage(null);
|
||||
setCalendarAnchorKey(toLocalDateKey(new Date()));
|
||||
setActiveView('settings');
|
||||
setFormDirty(false);
|
||||
await loadStatus(true, { forceFormSync: true });
|
||||
@ -554,6 +657,13 @@ export default function OutlookPage() {
|
||||
|
||||
const refreshOverview = async () => {
|
||||
await loadStatus(true, { preserveOverview: true });
|
||||
if (activeView === 'inbox') {
|
||||
await loadMailboxPage('inbox', inboxPage?.page.skip ?? 0);
|
||||
} else if (activeView === 'sent') {
|
||||
await loadMailboxPage('sent', sentPage?.page.skip ?? 0);
|
||||
} else if (activeView === 'calendar') {
|
||||
await loadCalendarPage(calendarAnchorKey);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -601,7 +711,7 @@ export default function OutlookPage() {
|
||||
<TopStat label="日程" value={String(eventCount)} loading={overviewPending} />
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={() => void loadStatus(true)}>
|
||||
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
@ -669,10 +779,20 @@ export default function OutlookPage() {
|
||||
<MessageCard
|
||||
title="收件箱"
|
||||
icon={<MailOpen className="h-4 w-4" />}
|
||||
items={overview?.recentInbox || []}
|
||||
loading={overviewPending}
|
||||
items={inboxPage?.value || []}
|
||||
page={inboxPage?.page || null}
|
||||
loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)}
|
||||
emptyLabel="还没有读取到收件箱邮件"
|
||||
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
|
||||
onRefresh={() => void loadMailboxPage('inbox', inboxPage?.page.skip ?? 0)}
|
||||
refreshing={mailboxLoading.inbox}
|
||||
onPreviousPage={() => void loadMailboxPage('inbox', Math.max(0, (inboxPage?.page.skip ?? 0) - MAILBOX_PAGE_SIZE))}
|
||||
onNextPage={() => {
|
||||
const nextSkip = inboxPage?.page.next_skip;
|
||||
if (typeof nextSkip === 'number') {
|
||||
void loadMailboxPage('inbox', nextSkip);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@ -680,21 +800,48 @@ export default function OutlookPage() {
|
||||
<MessageCard
|
||||
title="发件箱"
|
||||
icon={<Send className="h-4 w-4" />}
|
||||
items={overview?.recentSent || []}
|
||||
loading={overviewPending}
|
||||
items={sentPage?.value || []}
|
||||
page={sentPage?.page || null}
|
||||
loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)}
|
||||
emptyLabel="还没有读取到已发送邮件"
|
||||
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
|
||||
onRefresh={() => void loadMailboxPage('sent', sentPage?.page.skip ?? 0)}
|
||||
refreshing={mailboxLoading.sent}
|
||||
onPreviousPage={() => void loadMailboxPage('sent', Math.max(0, (sentPage?.page.skip ?? 0) - MAILBOX_PAGE_SIZE))}
|
||||
onNextPage={() => {
|
||||
const nextSkip = sentPage?.page.next_skip;
|
||||
if (typeof nextSkip === 'number') {
|
||||
void loadMailboxPage('sent', nextSkip);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="calendar" className="mt-0">
|
||||
<EventCard
|
||||
items={overview?.todayEvents || []}
|
||||
startDate={overview?.today}
|
||||
loading={overviewPending}
|
||||
items={calendarPage?.value || []}
|
||||
startDate={calendarAnchorKey}
|
||||
loading={calendarLoading || (activeView === 'calendar' && !calendarPage)}
|
||||
onOpen={(item) => setSelectedEvent(item)}
|
||||
onRefresh={refreshOverview}
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => void loadCalendarPage(calendarAnchorKey)}
|
||||
refreshing={calendarLoading}
|
||||
onPreviousWeek={() => {
|
||||
const next = new Date(`${calendarAnchorKey}T00:00:00`);
|
||||
next.setDate(next.getDate() - 7);
|
||||
setCalendarAnchorKey(toLocalDateKey(next));
|
||||
setCalendarPage(null);
|
||||
}}
|
||||
onNextWeek={() => {
|
||||
const next = new Date(`${calendarAnchorKey}T00:00:00`);
|
||||
next.setDate(next.getDate() + 7);
|
||||
setCalendarAnchorKey(toLocalDateKey(next));
|
||||
setCalendarPage(null);
|
||||
}}
|
||||
onCurrentWeek={() => {
|
||||
const nextKey = toLocalDateKey(new Date());
|
||||
setCalendarAnchorKey(nextKey);
|
||||
setCalendarPage(null);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@ -1037,27 +1184,29 @@ function MessageCard({
|
||||
title,
|
||||
icon,
|
||||
items,
|
||||
page,
|
||||
loading = false,
|
||||
emptyLabel,
|
||||
onOpen,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
items: OutlookMessageSummary[];
|
||||
page: OutlookPageInfo | null;
|
||||
loading?: boolean;
|
||||
emptyLabel: string;
|
||||
onOpen: (item: OutlookMessageSummary) => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
onPreviousPage: () => void;
|
||||
onNextPage: () => void;
|
||||
}) {
|
||||
const pageSize = 8;
|
||||
const [page, setPage] = useState(1);
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||
const visibleItems = items.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) {
|
||||
setPage(totalPages);
|
||||
}
|
||||
}, [page, totalPages]);
|
||||
const currentPage = page ? Math.floor(page.skip / Math.max(page.top, 1)) + 1 : 1;
|
||||
const pageLabel = page ? `第 ${currentPage} 页 · 本页 ${page.returned} 封` : '正在读取邮件…';
|
||||
|
||||
return (
|
||||
<Card className="rounded-[28px] shadow-sm">
|
||||
@ -1067,26 +1216,24 @@ function MessageCard({
|
||||
{icon}
|
||||
{title}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{loading ? '正在读取邮件…' : `共 ${items.length} 封`}</p>
|
||||
<p className="text-sm text-muted-foreground">{loading ? '正在读取邮件…' : pageLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onNextPage}
|
||||
disabled={!page || !page.has_more || refreshing}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
{!loading && totalPages > 1 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page === 1}>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{loading ? (
|
||||
@ -1106,7 +1253,7 @@ function MessageCard({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleItems.map((item) => (
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id || `${item.subject}-${item.receivedDateTime}`}
|
||||
type="button"
|
||||
@ -1144,6 +1291,9 @@ function EventCard({
|
||||
onOpen,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
onPreviousWeek,
|
||||
onNextWeek,
|
||||
onCurrentWeek,
|
||||
}: {
|
||||
items: OutlookEventSummary[];
|
||||
startDate?: string | null;
|
||||
@ -1151,6 +1301,9 @@ function EventCard({
|
||||
onOpen: (item: OutlookEventSummary) => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
onPreviousWeek: () => void;
|
||||
onNextWeek: () => void;
|
||||
onCurrentWeek: () => void;
|
||||
}) {
|
||||
const initialAnchor = startDate ? new Date(startDate) : new Date();
|
||||
const anchor = Number.isNaN(initialAnchor.getTime()) ? new Date() : initialAnchor;
|
||||
@ -1186,9 +1339,20 @@ function EventCard({
|
||||
{formatDayLabel(weekDays[0])} - {formatDayLabel(weekDays[weekDays.length - 1])}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}>
|
||||
上一周
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
|
||||
本周
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
|
||||
下一周
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{loading ? (
|
||||
|
||||
@ -21,7 +21,9 @@ import type {
|
||||
OutlookConnectionPayload,
|
||||
OutlookConnectionTestResult,
|
||||
OutlookConnectResult,
|
||||
OutlookEventListResponse,
|
||||
OutlookMessageDetail,
|
||||
OutlookMessageListResponse,
|
||||
OutlookOverview,
|
||||
OutlookStatus,
|
||||
UiAgentDescriptor,
|
||||
@ -838,6 +840,44 @@ export async function getOutlookOverview(): Promise<OutlookOverview> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOutlookMessages(
|
||||
folder: string,
|
||||
options?: {
|
||||
top?: number;
|
||||
skip?: number;
|
||||
unreadOnly?: boolean;
|
||||
}
|
||||
): Promise<OutlookMessageListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
folder,
|
||||
top: String(options?.top ?? 20),
|
||||
skip: String(options?.skip ?? 0),
|
||||
});
|
||||
if (options?.unreadOnly) {
|
||||
params.set('unread_only', 'true');
|
||||
}
|
||||
return fetchJSON(`/api/integrations/outlook/messages?${params.toString()}`, {
|
||||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOutlookEvents(options: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
top?: number;
|
||||
skip?: number;
|
||||
}): Promise<OutlookEventListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
start_time: options.startTime,
|
||||
end_time: options.endTime,
|
||||
top: String(options.top ?? 20),
|
||||
skip: String(options.skip ?? 0),
|
||||
});
|
||||
return fetchJSON(`/api/integrations/outlook/events?${params.toString()}`, {
|
||||
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOutlookMessageDetail(
|
||||
messageId: string,
|
||||
changekey?: string | null
|
||||
|
||||
@ -331,6 +331,14 @@ export interface OutlookEventSummary {
|
||||
organizer?: OutlookMailboxAddress | null;
|
||||
}
|
||||
|
||||
export interface OutlookPageInfo {
|
||||
top: number;
|
||||
skip: number;
|
||||
returned: number;
|
||||
has_more: boolean;
|
||||
next_skip?: number | null;
|
||||
}
|
||||
|
||||
export interface OutlookDefaultsFields {
|
||||
domain: string;
|
||||
service_endpoint: string;
|
||||
@ -445,6 +453,20 @@ export interface OutlookOverview {
|
||||
meta: OutlookMeta;
|
||||
}
|
||||
|
||||
export interface OutlookMessageListResponse {
|
||||
folder: string;
|
||||
unread_only: boolean;
|
||||
value: OutlookMessageSummary[];
|
||||
page: OutlookPageInfo;
|
||||
}
|
||||
|
||||
export interface OutlookEventListResponse {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
value: OutlookEventSummary[];
|
||||
page: OutlookPageInfo;
|
||||
}
|
||||
|
||||
export interface ProcessRun {
|
||||
run_id: string;
|
||||
parent_run_id?: string | null;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
import { HttpError, callAuthzService } from '@/lib/runtime-control';
|
||||
import { HttpError, REGISTER_REQUEST_TIMEOUT_MS, callAuthzService } from '@/lib/runtime-control';
|
||||
|
||||
function errorStatus(error: unknown): number {
|
||||
if (error instanceof HttpError) {
|
||||
@ -36,7 +36,7 @@ export async function POST(request: NextRequest) {
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
}, REGISTER_REQUEST_TIMEOUT_MS);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
const REGISTER_REQUEST_TIMEOUT_MS = 90000;
|
||||
|
||||
function normalizeBaseUrl(value?: string | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
@ -26,9 +27,9 @@ function buildApiUrl(path: string): string {
|
||||
return path;
|
||||
}
|
||||
|
||||
async function fetchJSON<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
async function fetchJSON<T>(path: string, options?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(buildApiUrl(path), {
|
||||
@ -76,7 +77,7 @@ export async function register(username: string, email: string, password: string
|
||||
return fetchJSON('/api/runtime/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
});
|
||||
}, REGISTER_REQUEST_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string {
|
||||
|
||||
@ -4,6 +4,7 @@ const AUTHZ_API_BASE_URL = (process.env.AUTHZ_API_BASE_URL || 'http://127.0.0.1:
|
||||
const DEPLOY_API_BASE_URL = (process.env.DEPLOY_API_BASE_URL || 'http://127.0.0.1:8090').trim().replace(/\/+$/, '');
|
||||
const DEPLOY_API_TOKEN = (process.env.DEPLOY_API_TOKEN || '').trim();
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
const REGISTER_REQUEST_TIMEOUT_MS = 90000;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
@ -24,9 +25,9 @@ function asString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
async function fetchJson<T>(url: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
@ -80,13 +81,15 @@ export async function callDeployControl<T>(path: string, payload: JsonObject): P
|
||||
});
|
||||
}
|
||||
|
||||
export async function callAuthzService<T>(path: string, payload: JsonObject): Promise<T> {
|
||||
export async function callAuthzService<T>(path: string, payload: JsonObject, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||
return fetchJson<T>(`${AUTHZ_API_BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
export { REGISTER_REQUEST_TIMEOUT_MS };
|
||||
|
||||
export async function callInstanceApi<T>(apiBaseUrl: string, path: string, payload: JsonObject): Promise<T> {
|
||||
const baseUrl = apiBaseUrl.trim().replace(/\/+$/, '');
|
||||
if (!baseUrl) {
|
||||
|
||||
@ -354,6 +354,9 @@ a2a:planner
|
||||
- 如果不传 `scope/scopes`,服务会返回该 audience 下允许的全部 scope
|
||||
- 如果传了 `scope/scopes`,必须是允许 scope 的子集,否则返回 `403 Requested scopes exceed backend permissions`
|
||||
- 如果 audience 未启用,返回 `403 Audience is not enabled for this backend`
|
||||
- 当前默认开启 `AUTHZ_MCP_PERMISSIVE_DEFAULT=1`
|
||||
- 对 `mcp:*` audience,会优先放行本次请求里声明的 scopes,并自动补 `list_tools`
|
||||
- 如果你后面要改回严格模式,把这个环境变量设成 `0`
|
||||
|
||||
### Token 内省:`POST /oauth/introspect`
|
||||
|
||||
|
||||
@ -39,7 +39,8 @@ ACCESS_TOKEN_TTL_SECONDS = int(os.getenv("AUTHZ_ACCESS_TOKEN_TTL_SECONDS", "3600
|
||||
PRIVATE_KEY_PATH = Path(os.getenv("AUTHZ_PRIVATE_KEY_PATH", DATA_DIR / "signing_key.pem"))
|
||||
DEPLOY_API_BASE_URL = os.getenv("DEPLOY_API_BASE_URL", "http://127.0.0.1:8090").rstrip("/")
|
||||
DEPLOY_API_TOKEN = os.getenv("DEPLOY_API_TOKEN", "").strip()
|
||||
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("AUTHZ_UPSTREAM_TIMEOUT_SECONDS", "15"))
|
||||
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("AUTHZ_UPSTREAM_TIMEOUT_SECONDS", "90"))
|
||||
MCP_PERMISSIVE_DEFAULT = os.getenv("AUTHZ_MCP_PERMISSIVE_DEFAULT", "1").strip() not in {"0", "false", "False"}
|
||||
|
||||
store = JsonStore(DATA_DIR)
|
||||
signer = JwtSigner(PRIVATE_KEY_PATH, ISSUER, ACCESS_TOKEN_TTL_SECONDS)
|
||||
@ -235,10 +236,14 @@ def _issue_token(payload: OAuthTokenRequest) -> OAuthTokenResponse:
|
||||
raise HTTPException(status_code=403, detail="Backend is disabled")
|
||||
|
||||
allowed = _allowed_scopes_for_audience(credential.backend_id, payload.aud)
|
||||
requested = {item.strip() for item in (payload.scopes or []) if isinstance(item, str) and item.strip()}
|
||||
if payload.aud.startswith("mcp:") and MCP_PERMISSIVE_DEFAULT:
|
||||
allowed = set(allowed)
|
||||
allowed.add("list_tools")
|
||||
allowed.update(requested)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="Audience is not enabled for this backend")
|
||||
|
||||
requested = set(payload.scopes or [])
|
||||
if requested:
|
||||
if not requested.issubset(allowed):
|
||||
raise HTTPException(status_code=403, detail="Requested scopes exceed backend permissions")
|
||||
|
||||
@ -14,6 +14,8 @@ APP_INSTANCE_API_BASE=
|
||||
|
||||
# Used as a fallback when authz-service does not explicitly pass authz_base_url.
|
||||
DEFAULT_AUTHZ_BASE_URL=http://nano-authz-service:19090
|
||||
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=
|
||||
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||
|
||||
DEPLOY_PUBLIC_SCHEME=http
|
||||
DEPLOY_PUBLIC_BASE_DOMAIN=203.0.113.10.nip.io
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker.io \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg \
|
||||
&& install -m 0755 -d /etc/apt/keyrings \
|
||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||
&& . /etc/os-release \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
- `DEPLOY_CONTROL_API_TOKEN`
|
||||
- `APP_INSTANCE_API_KEY`
|
||||
- `DEFAULT_AUTHZ_BASE_URL`
|
||||
- `DEFAULT_AUTHZ_OUTLOOK_MCP_URL`
|
||||
- `DEFAULT_OUTLOOK_MCP_SERVER_ID`
|
||||
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
||||
- `DEPLOY_PUBLIC_PORT`
|
||||
- `DEPLOY_PUBLIC_SCHEME`
|
||||
@ -35,6 +37,15 @@ http://<instance-slug>.127.0.0.1.nip.io:8088
|
||||
|
||||
实例容器本身的 `20000-29999` 端口默认只绑定到部署机 `127.0.0.1`,外部入口应走 `router-proxy`。
|
||||
|
||||
如果你希望所有新实例默认带 Outlook MCP HTTP 工具,可以设置:
|
||||
|
||||
```bash
|
||||
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=http://10.6.80.29:8000/mcp
|
||||
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||
```
|
||||
|
||||
这样 `deploy-control` 创建的新实例会自动写入一条默认 MCP server 配置,并默认使用 `oauth_backend_token` + `mcp:<server_id>` 的 audience。
|
||||
|
||||
## 本机启动
|
||||
|
||||
```bash
|
||||
@ -51,3 +62,30 @@ uv run server.py
|
||||
- `/home/ivan/xuan/nano_project/router-proxy`
|
||||
|
||||
并传入对应环境变量,让容器内脚本路径仍能访问这两个目录。
|
||||
|
||||
关键点:
|
||||
|
||||
- 宿主机路径要原样挂进容器,不要改挂载目标路径
|
||||
- 同时显式传 `APP_INSTANCE_DIR` 和 `ROUTER_PROXY_DIR`
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name nano-deploy-control \
|
||||
--restart unless-stopped \
|
||||
--network nano-instance-edge \
|
||||
-p 8090:8090 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /home/ivan/xuan/nano_project/app-instance:/home/ivan/xuan/nano_project/app-instance \
|
||||
-v /home/ivan/xuan/nano_project/router-proxy:/home/ivan/xuan/nano_project/router-proxy \
|
||||
-e APP_INSTANCE_DIR=/home/ivan/xuan/nano_project/app-instance \
|
||||
-e ROUTER_PROXY_DIR=/home/ivan/xuan/nano_project/router-proxy \
|
||||
-e DEPLOY_CONTROL_API_TOKEN=change-me \
|
||||
-e APP_INSTANCE_IMAGE=nano/app-instance:latest \
|
||||
-e APP_INSTANCE_NETWORK_NAME=nano-instance-edge \
|
||||
-e APP_INSTANCE_API_KEY=sk-xxxxxxxx \
|
||||
nano/deploy-control:latest
|
||||
```
|
||||
|
||||
如果这里错把宿主机目录映射成容器内的另一个短路径,例如 `/app-instance`,那么 `deploy-control` 通过 Docker socket 创建实例时会把错误路径传给 Docker,最终导致实例容器拿不到 `config.json` 并持续重启。
|
||||
|
||||
@ -41,6 +41,8 @@ DEFAULT_MODEL = os.environ.get("APP_INSTANCE_MODEL", "openai/gpt-5").strip()
|
||||
DEFAULT_API_KEY = os.environ.get("APP_INSTANCE_API_KEY", "").strip()
|
||||
DEFAULT_API_BASE = os.environ.get("APP_INSTANCE_API_BASE", "").strip()
|
||||
DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip()
|
||||
DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip()
|
||||
DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp"
|
||||
PUBLIC_SCHEME = os.environ.get("DEPLOY_PUBLIC_SCHEME", "http").strip() or "http"
|
||||
PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "127.0.0.1.nip.io").strip()
|
||||
PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip()
|
||||
@ -48,6 +50,7 @@ PUBLIC_PORT = int(os.environ.get("DEPLOY_PUBLIC_PORT", "8088").strip() or "8088"
|
||||
AUTO_START_PROXY = os.environ.get("DEPLOY_AUTO_START_PROXY", "1").strip() not in {"0", "false", "False"}
|
||||
HEALTH_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_HEALTH_TIMEOUT_SECONDS", "60").strip() or "60")
|
||||
HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS", "1").strip() or "1")
|
||||
INSTANCE_INTERNAL_PORT = int(os.environ.get("APP_INSTANCE_INTERNAL_PORT", "8080").strip() or "8080")
|
||||
SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0"
|
||||
SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090")
|
||||
|
||||
@ -154,22 +157,37 @@ def build_public_url(host: str) -> str:
|
||||
return f"{PUBLIC_SCHEME}://{netloc}"
|
||||
|
||||
|
||||
def build_internal_api_base_url(record: dict[str, Any]) -> str:
|
||||
container_name = str(record.get("container_name", "") or "").strip()
|
||||
if container_name:
|
||||
return f"http://{container_name}:{INSTANCE_INTERNAL_PORT}"
|
||||
fallback = str(record.get("api_base_url", "") or record.get("public_url", "") or "").strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def wait_for_backend(record: dict[str, Any]) -> None:
|
||||
host_port = int(record.get("host_port", 0) or 0)
|
||||
if host_port <= 0:
|
||||
raise ApiError(HTTPStatus.BAD_GATEWAY, "instance host port missing from registry")
|
||||
container_name = str(record.get("container_name", "") or "").strip()
|
||||
targets: list[str] = []
|
||||
if container_name:
|
||||
targets.append(f"http://{container_name}:{INSTANCE_INTERNAL_PORT}/api/ping")
|
||||
if host_port > 0:
|
||||
targets.append(f"http://127.0.0.1:{host_port}/api/ping")
|
||||
if not targets:
|
||||
raise ApiError(HTTPStatus.BAD_GATEWAY, "instance health target missing from registry")
|
||||
|
||||
deadline = time.time() + HEALTH_TIMEOUT_SECONDS
|
||||
target = f"http://127.0.0.1:{host_port}/api/ping"
|
||||
last_error = "backend not ready"
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib_request.urlopen(target, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
if payload.get("message") == "pong":
|
||||
return
|
||||
last_error = f"unexpected ping response from {target}"
|
||||
except (urllib_error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
last_error = str(exc)
|
||||
for target in targets:
|
||||
try:
|
||||
with urllib_request.urlopen(target, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
if payload.get("message") == "pong":
|
||||
return
|
||||
last_error = f"unexpected ping response from {target}"
|
||||
except (urllib_error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
last_error = f"{target}: {exc}"
|
||||
time.sleep(HEALTH_INTERVAL_SECONDS)
|
||||
raise ApiError(HTTPStatus.BAD_GATEWAY, f"instance health check failed: {last_error}")
|
||||
|
||||
@ -197,6 +215,9 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
api_key = str(payload.get("api_key", "") or DEFAULT_API_KEY).strip()
|
||||
api_base = str(payload.get("api_base", "") or DEFAULT_API_BASE).strip()
|
||||
authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip()
|
||||
authz_outlook_mcp_url = str(
|
||||
payload.get("authz_outlook_mcp_url", "") or DEFAULT_AUTHZ_OUTLOOK_MCP_URL
|
||||
).strip()
|
||||
backend_name = str(payload.get("backend_name", "") or username).strip() or username
|
||||
image_name = str(payload.get("image_name", "") or INSTANCE_IMAGE).strip() or INSTANCE_IMAGE
|
||||
|
||||
@ -236,6 +257,9 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
command.extend(["--api-base", api_base])
|
||||
if authz_base_url:
|
||||
command.extend(["--authz-base-url", authz_base_url])
|
||||
if authz_outlook_mcp_url:
|
||||
command.extend(["--authz-outlook-mcp-url", authz_outlook_mcp_url])
|
||||
command.extend(["--outlook-mcp-server-id", DEFAULT_OUTLOOK_MCP_SERVER_ID])
|
||||
if payload.get("replace") is True:
|
||||
command.append("--replace")
|
||||
|
||||
@ -254,7 +278,7 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"instance": existing,
|
||||
"public_url": str(existing.get("public_url", "") or ""),
|
||||
"frontend_base_url": str(existing.get("frontend_base_url", "") or existing.get("public_url", "") or ""),
|
||||
"api_base_url": str(existing.get("api_base_url", "") or existing.get("public_url", "") or ""),
|
||||
"api_base_url": build_internal_api_base_url(existing),
|
||||
}
|
||||
|
||||
|
||||
@ -269,7 +293,7 @@ def resolve_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"instance": record,
|
||||
"public_url": str(record.get("public_url", "") or ""),
|
||||
"frontend_base_url": str(record.get("frontend_base_url", "") or record.get("public_url", "") or ""),
|
||||
"api_base_url": str(record.get("api_base_url", "") or record.get("public_url", "") or ""),
|
||||
"api_base_url": build_internal_api_base_url(record),
|
||||
}
|
||||
|
||||
|
||||
|
||||
395
域名配置指引.md
Normal file
395
域名配置指引.md
Normal file
@ -0,0 +1,395 @@
|
||||
# nano_project 域名配置指引
|
||||
|
||||
这份文档专门解释一件事:
|
||||
|
||||
- 如果你不用 `127.0.0.1.nip.io`
|
||||
- 想换成自己的正式域名
|
||||
- 应该怎么理解、怎么配、该改哪些地方
|
||||
|
||||
先说最重要的结论:
|
||||
|
||||
- `DNS` 只管把域名解析到 `IP`
|
||||
- `端口` 不归 DNS 管
|
||||
- 所以“域名配到哪个端口”本质上是反向代理或公网入口层在处理
|
||||
|
||||
也就是说:
|
||||
|
||||
- 域名解析本身,是项目外部的事情
|
||||
- 但项目里生成出来的实例地址、门户地址,又会依赖你填的域名
|
||||
|
||||
所以这件事是:
|
||||
|
||||
- 一半在系统外
|
||||
- 一半和系统配置有关
|
||||
|
||||
---
|
||||
|
||||
## 1. 先理解这套系统里每个端口是干什么的
|
||||
|
||||
当前默认端口职责:
|
||||
|
||||
- `3081`
|
||||
- `auth-portal`
|
||||
- 用户注册、登录入口
|
||||
- `8088`
|
||||
- `router-proxy`
|
||||
- 所有用户实例统一入口
|
||||
- `8090`
|
||||
- `deploy-control`
|
||||
- 内部控制面
|
||||
- `19090`
|
||||
- `authz-service`
|
||||
- 内部鉴权服务
|
||||
|
||||
正常公网暴露建议:
|
||||
|
||||
- 暴露 `3081`
|
||||
- 暴露 `8088`
|
||||
- 不要直接暴露 `8090`
|
||||
- 不要直接暴露 `19090`
|
||||
|
||||
---
|
||||
|
||||
## 2. 推荐的域名规划
|
||||
|
||||
最推荐这样分:
|
||||
|
||||
- `portal.example.com`
|
||||
- 给登录/注册页
|
||||
- `*.apps.example.com`
|
||||
- 给用户实例
|
||||
|
||||
这样用户最终访问会像:
|
||||
|
||||
```text
|
||||
https://portal.example.com
|
||||
https://alice.apps.example.com
|
||||
https://bob.apps.example.com
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `portal.example.com` 走 `auth-portal`
|
||||
- `alice.apps.example.com` 走 `router-proxy`
|
||||
|
||||
---
|
||||
|
||||
## 3. 只配 DNS 还不够
|
||||
|
||||
很多人最容易误解的是:
|
||||
|
||||
“我把域名解析到服务器 IP,就等于已经配好了”
|
||||
|
||||
这不对。
|
||||
|
||||
你还要解决:
|
||||
|
||||
- 用户访问 `80/443` 时,流量先进谁
|
||||
- 谁把流量转到 `3081`
|
||||
- 谁把流量转到 `8088`
|
||||
|
||||
所以正式域名一般至少要有两层:
|
||||
|
||||
### 第一层:DNS
|
||||
|
||||
例如:
|
||||
|
||||
- `portal.example.com` -> 服务器公网 IP
|
||||
- `apps.example.com` -> 服务器公网 IP
|
||||
- `*.apps.example.com` -> 服务器公网 IP
|
||||
|
||||
### 第二层:公网反向代理
|
||||
|
||||
例如用:
|
||||
|
||||
- Nginx
|
||||
- Caddy
|
||||
- Traefik
|
||||
- 云负载均衡
|
||||
|
||||
它负责:
|
||||
|
||||
- 监听公网 `80/443`
|
||||
- 根据域名把请求转发到本机不同端口
|
||||
|
||||
---
|
||||
|
||||
## 4. 最直接的映射关系
|
||||
|
||||
如果你先不做 HTTPS,只做最基础的 HTTP:
|
||||
|
||||
- `portal.example.com` -> 转发到 `127.0.0.1:3081`
|
||||
- `*.apps.example.com` -> 转发到 `127.0.0.1:8088`
|
||||
|
||||
也就是:
|
||||
|
||||
```text
|
||||
portal.example.com -> auth-portal -> 3081
|
||||
*.apps.example.com -> router-proxy -> 8088
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `router-proxy` 是靠 `Host` 头识别具体实例的
|
||||
- 所以必须把原始 Host 透传过去
|
||||
|
||||
---
|
||||
|
||||
## 5. 这个项目内部哪些值要改
|
||||
|
||||
如果你要从 `127.0.0.1.nip.io` 换成正式域名,至少要改这些:
|
||||
|
||||
### 本机部署变量里
|
||||
|
||||
把:
|
||||
|
||||
```bash
|
||||
export NANO_BASE_DOMAIN=127.0.0.1.nip.io
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```bash
|
||||
export NANO_BASE_DOMAIN=apps.example.com
|
||||
```
|
||||
|
||||
这样以后新创建的实例 URL 才会变成:
|
||||
|
||||
```text
|
||||
http://alice.apps.example.com:8088
|
||||
```
|
||||
|
||||
如果你后面还有外层 `80/443` 代理,不想让用户看到 `:8088`,那还需要额外调整入口层做无端口访问转发。
|
||||
|
||||
### `deploy-control` 里实际影响实例地址的变量
|
||||
|
||||
它们是:
|
||||
|
||||
- `DEPLOY_PUBLIC_SCHEME`
|
||||
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
||||
- `DEPLOY_PUBLIC_PORT`
|
||||
|
||||
例如:
|
||||
|
||||
```bash
|
||||
-e DEPLOY_PUBLIC_SCHEME="https" \
|
||||
-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \
|
||||
-e DEPLOY_PUBLIC_PORT="443" \
|
||||
```
|
||||
|
||||
或者如果你暂时还是明文 HTTP:
|
||||
|
||||
```bash
|
||||
-e DEPLOY_PUBLIC_SCHEME="http" \
|
||||
-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \
|
||||
-e DEPLOY_PUBLIC_PORT="8088" \
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 什么时候可以把端口从 URL 里去掉
|
||||
|
||||
如果你希望用户访问:
|
||||
|
||||
```text
|
||||
https://alice.apps.example.com
|
||||
```
|
||||
|
||||
而不是:
|
||||
|
||||
```text
|
||||
http://alice.apps.example.com:8088
|
||||
```
|
||||
|
||||
那你需要满足这两个条件:
|
||||
|
||||
1. 外层已经有监听 `80/443` 的反向代理
|
||||
2. 它已经把 `*.apps.example.com` 转发到本机 `8088`
|
||||
|
||||
这时项目内部就应该写:
|
||||
|
||||
```bash
|
||||
DEPLOY_PUBLIC_SCHEME=https
|
||||
DEPLOY_PUBLIC_BASE_DOMAIN=apps.example.com
|
||||
DEPLOY_PUBLIC_PORT=443
|
||||
```
|
||||
|
||||
或者很多时候你也可以直接在显示层隐藏默认端口概念,让用户只看标准 `https` 地址。
|
||||
|
||||
---
|
||||
|
||||
## 7. 一套推荐的正式域名方案
|
||||
|
||||
假设你有:
|
||||
|
||||
- 门户域名:`portal.example.com`
|
||||
- 实例根域名:`apps.example.com`
|
||||
|
||||
推荐这样做:
|
||||
|
||||
### 项目内部
|
||||
|
||||
`deploy-control`:
|
||||
|
||||
```bash
|
||||
-e DEPLOY_PUBLIC_SCHEME="https" \
|
||||
-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \
|
||||
-e DEPLOY_PUBLIC_PORT="443" \
|
||||
```
|
||||
|
||||
本机部署变量:
|
||||
|
||||
```bash
|
||||
export NANO_BASE_DOMAIN=apps.example.com
|
||||
```
|
||||
|
||||
### 项目外部
|
||||
|
||||
DNS:
|
||||
|
||||
- `portal.example.com` -> 服务器 IP
|
||||
- `apps.example.com` -> 服务器 IP
|
||||
- `*.apps.example.com` -> 服务器 IP
|
||||
|
||||
公网代理:
|
||||
|
||||
- `portal.example.com` -> `127.0.0.1:3081`
|
||||
- `*.apps.example.com` -> `127.0.0.1:8088`
|
||||
|
||||
---
|
||||
|
||||
## 8. 一个常见误区
|
||||
|
||||
### 误区 1
|
||||
|
||||
“我把 `portal.example.com` 配给 `8088` 可以吗?”
|
||||
|
||||
技术上能转,但不推荐。
|
||||
|
||||
因为:
|
||||
|
||||
- `8088` 是实例入口
|
||||
- `3081` 才是门户入口
|
||||
|
||||
更清晰的职责划分应该是:
|
||||
|
||||
- 门户 -> `3081`
|
||||
- 实例 -> `8088`
|
||||
|
||||
### 误区 2
|
||||
|
||||
“我能不能把 `8090` 和 `19090` 也直接开放给公网?”
|
||||
|
||||
不建议。
|
||||
|
||||
因为:
|
||||
|
||||
- `8090` 是内部部署控制面
|
||||
- `19090` 是内部鉴权服务
|
||||
|
||||
这两个应该尽量只允许容器网络或内网访问。
|
||||
|
||||
### 误区 3
|
||||
|
||||
“DNS 能不能直接决定端口?”
|
||||
|
||||
不能。
|
||||
|
||||
DNS 只能决定:
|
||||
|
||||
- 域名 -> IP
|
||||
|
||||
端口是:
|
||||
|
||||
- 浏览器默认端口规则
|
||||
- URL 里显式写端口
|
||||
- 反向代理转发规则
|
||||
|
||||
共同决定的。
|
||||
|
||||
---
|
||||
|
||||
## 9. 最简单的理解方式
|
||||
|
||||
把它拆成两件事就不容易乱:
|
||||
|
||||
### 系统内的事
|
||||
|
||||
这个项目要知道:
|
||||
|
||||
- 实例公网地址长什么样
|
||||
- 新实例生成什么域名
|
||||
- 对外协议是 `http` 还是 `https`
|
||||
|
||||
所以它关心:
|
||||
|
||||
- `DEPLOY_PUBLIC_SCHEME`
|
||||
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
||||
- `DEPLOY_PUBLIC_PORT`
|
||||
|
||||
### 系统外的事
|
||||
|
||||
你的服务器或云环境要负责:
|
||||
|
||||
- 域名解析
|
||||
- TLS 证书
|
||||
- 80/443 入口
|
||||
- 把请求转给 `3081` 或 `8088`
|
||||
|
||||
---
|
||||
|
||||
## 10. 如果你现在只是本机测试
|
||||
|
||||
那你可以完全先不管正式域名。
|
||||
|
||||
继续用:
|
||||
|
||||
```bash
|
||||
export NANO_BASE_DOMAIN=127.0.0.1.nip.io
|
||||
```
|
||||
|
||||
这已经足够验证整个系统:
|
||||
|
||||
- 注册
|
||||
- 登录
|
||||
- 创建实例
|
||||
- 跳转个人实例
|
||||
|
||||
等你准备真正对外给别人访问时,再处理正式域名和 HTTPS。
|
||||
|
||||
---
|
||||
|
||||
## 11. 一句话结论
|
||||
|
||||
如果你问:
|
||||
|
||||
“域名应该配到什么端口上?”
|
||||
|
||||
最实用的答案是:
|
||||
|
||||
- 门户域名 -> `3081`
|
||||
- 实例泛域名 -> `8088`
|
||||
- `8090` 和 `19090` 不建议直接公开
|
||||
|
||||
但更准确地说:
|
||||
|
||||
- 域名解析本身不带端口
|
||||
- 真正的端口转发,是由外层反向代理做的
|
||||
|
||||
---
|
||||
|
||||
## 12. 你后面最可能要补的东西
|
||||
|
||||
如果你准备上正式域名,下一步通常是补下面其中一个:
|
||||
|
||||
- `Nginx` 反向代理配置
|
||||
- `Caddy` 配置
|
||||
- 云负载均衡转发规则
|
||||
- HTTPS 证书配置
|
||||
|
||||
如果你要,我下一步可以继续给你补:
|
||||
|
||||
- `Nginx 域名反代示例.md`
|
||||
- 或者 `Caddy 域名反代示例.md`
|
||||
|
||||
都可以直接按这个项目的端口结构来写。
|
||||
618
部署指南.md
Normal file
618
部署指南.md
Normal file
@ -0,0 +1,618 @@
|
||||
# nano_project 本机一步步部署指南
|
||||
|
||||
这份文档适合第一次在本机把整个项目跑起来的人,目标是:
|
||||
|
||||
- 在一台 `Linux` 或 `WSL2 Ubuntu` 机器上
|
||||
- 用 `Docker` 跑完整链路
|
||||
- 最后能在浏览器里注册账号,并自动创建你的专属实例
|
||||
|
||||
这套项目当前的推荐本机测试方式是:
|
||||
|
||||
- `auth-portal`
|
||||
- `authz-service`
|
||||
- `deploy-control`
|
||||
- `router-proxy`
|
||||
- `app-instance`
|
||||
|
||||
全部一起跑。
|
||||
|
||||
如果你只单独跑某个前端页面,页面能打开,但注册、登录、创建实例这些核心能力不一定会通。
|
||||
|
||||
---
|
||||
|
||||
## 0. 先说前提
|
||||
|
||||
### 适合的环境
|
||||
|
||||
推荐:
|
||||
|
||||
- Linux
|
||||
- WSL2 Ubuntu
|
||||
|
||||
不推荐直接按这份文档在纯 Windows 命令行里照抄,因为这里依赖:
|
||||
|
||||
- Docker
|
||||
- Bash 脚本
|
||||
- Docker Socket 挂载
|
||||
- 宿主机目录挂载
|
||||
|
||||
### 你需要先装好的工具
|
||||
|
||||
- `docker`
|
||||
- `git`
|
||||
- `curl`
|
||||
- `openssl`
|
||||
- `python3`
|
||||
|
||||
先检查:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker ps
|
||||
python3 --version
|
||||
openssl version
|
||||
curl --version
|
||||
```
|
||||
|
||||
如果 `docker ps` 报错,先把 Docker 启动起来。
|
||||
|
||||
---
|
||||
|
||||
## 1. 进入项目根目录
|
||||
|
||||
```bash
|
||||
cd /home/ivan/xuan/nano_project
|
||||
```
|
||||
|
||||
你执行完以后,建议顺手确认一下当前目录:
|
||||
|
||||
```bash
|
||||
pwd
|
||||
```
|
||||
|
||||
你应该看到:
|
||||
|
||||
```text
|
||||
/home/ivan/xuan/nano_project
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 准备一套本机测试变量
|
||||
|
||||
### 为什么这里用 `127.0.0.1.nip.io`
|
||||
|
||||
因为这是最省事的本机测试域名方案。
|
||||
|
||||
它的作用是:
|
||||
|
||||
- `alice.127.0.0.1.nip.io`
|
||||
- 自动解析到 `127.0.0.1`
|
||||
|
||||
这样 `router-proxy` 就能按子域名区分不同实例。
|
||||
|
||||
### 直接复制执行
|
||||
|
||||
```bash
|
||||
export PROJECT_ROOT=/home/ivan/xuan/nano_project
|
||||
export NANO_NET=nano-instance-edge
|
||||
|
||||
export NANO_DEPLOY_TOKEN="$(openssl rand -hex 32)"
|
||||
export NANO_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
|
||||
|
||||
export NANO_SERVER_IP=127.0.0.1
|
||||
export NANO_BASE_DOMAIN=127.0.0.1.nip.io
|
||||
|
||||
export NANO_PROVIDER=openai
|
||||
export NANO_MODEL=openai/gpt-5
|
||||
export NANO_API_KEY='把这里换成你自己的模型 API Key'
|
||||
export NANO_API_BASE=''
|
||||
|
||||
export NANO_AUTHZ_URL='http://nano-authz-service:19090'
|
||||
export NANO_OUTLOOK_MCP_URL=''
|
||||
export NANO_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
|
||||
export NANO_DEPLOY_URL='http://nano-deploy-control:8090'
|
||||
```
|
||||
|
||||
### 这里每个变量大概是干什么的
|
||||
|
||||
- `PROJECT_ROOT`
|
||||
- 仓库根目录
|
||||
- `NANO_NET`
|
||||
- 所有容器共用的 Docker 网络
|
||||
- `NANO_DEPLOY_TOKEN`
|
||||
- `auth-portal` / `authz-service` 调 `deploy-control` 时的鉴权 token
|
||||
- `NANO_AUTHZ_INTERNAL_TOKEN`
|
||||
- AuthZ 内部接口 token
|
||||
- `NANO_BASE_DOMAIN`
|
||||
- 实例基础域名
|
||||
- `NANO_PROVIDER`
|
||||
- 新实例默认模型提供商
|
||||
- `NANO_MODEL`
|
||||
- 新实例默认模型
|
||||
- `NANO_API_KEY`
|
||||
- 新实例默认模型 API Key
|
||||
- `NANO_API_BASE`
|
||||
- 自定义模型网关地址,没有就留空
|
||||
- `NANO_AUTHZ_URL`
|
||||
- 容器网络内访问 AuthZ 的地址
|
||||
- `NANO_DEPLOY_URL`
|
||||
- 容器网络内访问 deploy-control 的地址
|
||||
- `NANO_OUTLOOK_MCP_URL`
|
||||
- 可选;如果你有独立 Outlook MCP 服务,可以在这里填
|
||||
- `NANO_OUTLOOK_MCP_SERVER_ID`
|
||||
- Outlook MCP 默认 server id,当前推荐固定 `outlook_mcp`
|
||||
|
||||
### 一个特别重要的提醒
|
||||
|
||||
`NANO_API_KEY` 不能空着。
|
||||
|
||||
如果这里不填,新用户注册时虽然页面可能能走到一半,但自动创建 `app-instance` 时大概率失败,因为实例配置里需要 `APP_INSTANCE_API_KEY`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 创建运行目录
|
||||
|
||||
```bash
|
||||
mkdir -p \
|
||||
"$PROJECT_ROOT/authz-service/runtime/data" \
|
||||
"$PROJECT_ROOT/app-instance/runtime/instances" \
|
||||
"$PROJECT_ROOT/app-instance/runtime/registry" \
|
||||
"$PROJECT_ROOT/router-proxy/runtime/conf.d"
|
||||
```
|
||||
|
||||
这一步的作用是给下面几个东西留持久化空间:
|
||||
|
||||
- AuthZ 数据
|
||||
- 实例注册表
|
||||
- 每个用户实例的配置目录
|
||||
- router-proxy 生成出来的路由文件
|
||||
|
||||
---
|
||||
|
||||
## 4. 构建镜像
|
||||
|
||||
第一次构建会比较久,正常情况要等几分钟。
|
||||
|
||||
```bash
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
docker build -t nano/app-instance:latest app-instance
|
||||
docker build -t nano/authz-service:latest authz-service
|
||||
docker build -t nano/deploy-control:latest deploy-control
|
||||
docker build -t nano/auth-portal:latest auth-portal/src
|
||||
```
|
||||
|
||||
如果中间有某个镜像失败,不要继续往下跑,先把失败那一步修掉。
|
||||
|
||||
常见失败原因:
|
||||
|
||||
- Docker 没启动
|
||||
- 网络拉镜像失败
|
||||
- 你的本机磁盘空间不够
|
||||
|
||||
---
|
||||
|
||||
## 5. 创建共享 Docker 网络
|
||||
|
||||
```bash
|
||||
docker network inspect "$NANO_NET" >/dev/null 2>&1 || docker network create "$NANO_NET"
|
||||
```
|
||||
|
||||
执行完后可确认:
|
||||
|
||||
```bash
|
||||
docker network ls | grep "$NANO_NET"
|
||||
```
|
||||
|
||||
应该能看到:
|
||||
|
||||
```text
|
||||
nano-instance-edge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 启动统一入口代理 `router-proxy`
|
||||
|
||||
```bash
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
PROXY_NETWORK_NAME="$NANO_NET" \
|
||||
PROXY_HTTP_PORT=8088 \
|
||||
./router-proxy/start-proxy.sh --replace
|
||||
```
|
||||
|
||||
启动后,统一入口走:
|
||||
|
||||
```text
|
||||
http://<你的实例slug>.127.0.0.1.nip.io:8088
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
http://alice.127.0.0.1.nip.io:8088
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 启动 `authz-service`
|
||||
|
||||
```bash
|
||||
docker rm -f nano-authz-service >/dev/null 2>&1 || true
|
||||
|
||||
docker run -d \
|
||||
--name nano-authz-service \
|
||||
--restart unless-stopped \
|
||||
--network "$NANO_NET" \
|
||||
-p 19090:19090 \
|
||||
-v "$PROJECT_ROOT/authz-service/runtime/data:/var/lib/authz-service/data" \
|
||||
-e AUTHZ_ISSUER="$NANO_AUTHZ_URL" \
|
||||
-e AUTHZ_INTERNAL_TOKEN="$NANO_AUTHZ_INTERNAL_TOKEN" \
|
||||
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
|
||||
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
||||
nano/authz-service:latest
|
||||
```
|
||||
|
||||
### 这里有个很关键的坑
|
||||
|
||||
`AUTHZ_ISSUER` 这里不能写成:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:19090
|
||||
```
|
||||
|
||||
因为后面新创建的 `app-instance` 也是通过 Docker 网络去访问 AuthZ 的。
|
||||
|
||||
所以这里要写容器网络里可访问的地址:
|
||||
|
||||
```text
|
||||
http://nano-authz-service:19090
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 启动 `deploy-control`
|
||||
|
||||
这一步最容易配错的点是挂载目录。
|
||||
|
||||
一定要注意:
|
||||
|
||||
- `app-instance` 和 `router-proxy` 的宿主机路径,要按原路径挂进容器
|
||||
- 不能偷懒挂到容器里的另一个短路径比如 `/app-instance`
|
||||
- 同时要把 `APP_INSTANCE_DIR` 和 `ROUTER_PROXY_DIR` 也明确传进去
|
||||
|
||||
直接执行:
|
||||
|
||||
```bash
|
||||
docker rm -f nano-deploy-control >/dev/null 2>&1 || true
|
||||
|
||||
docker run -d \
|
||||
--name nano-deploy-control \
|
||||
--restart unless-stopped \
|
||||
--network "$NANO_NET" \
|
||||
-p 8090:8090 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \
|
||||
-v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \
|
||||
-e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \
|
||||
-e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \
|
||||
-e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
||||
-e APP_INSTANCE_IMAGE="nano/app-instance:latest" \
|
||||
-e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \
|
||||
-e APP_INSTANCE_PROVIDER="$NANO_PROVIDER" \
|
||||
-e APP_INSTANCE_MODEL="$NANO_MODEL" \
|
||||
-e APP_INSTANCE_API_KEY="$NANO_API_KEY" \
|
||||
-e APP_INSTANCE_API_BASE="$NANO_API_BASE" \
|
||||
-e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \
|
||||
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$NANO_OUTLOOK_MCP_URL" \
|
||||
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$NANO_OUTLOOK_MCP_SERVER_ID" \
|
||||
-e DEPLOY_PUBLIC_SCHEME="http" \
|
||||
-e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \
|
||||
-e DEPLOY_PUBLIC_PORT="8088" \
|
||||
-e DEPLOY_AUTO_START_PROXY="1" \
|
||||
nano/deploy-control:latest
|
||||
```
|
||||
|
||||
### 这一步在做什么
|
||||
|
||||
`deploy-control` 会负责:
|
||||
|
||||
- 收到“创建实例”的请求
|
||||
- 调用 `app-instance/create-instance.sh`
|
||||
- 通过 Docker 创建对应用户实例
|
||||
- 刷新 `router-proxy`
|
||||
|
||||
---
|
||||
|
||||
## 9. 启动 `auth-portal`
|
||||
|
||||
```bash
|
||||
docker rm -f nano-auth-portal >/dev/null 2>&1 || true
|
||||
|
||||
docker run -d \
|
||||
--name nano-auth-portal \
|
||||
--restart unless-stopped \
|
||||
--network "$NANO_NET" \
|
||||
-p 3081:3081 \
|
||||
-e AUTHZ_API_BASE_URL="$NANO_AUTHZ_URL" \
|
||||
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
|
||||
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
||||
nano/auth-portal:latest
|
||||
```
|
||||
|
||||
这个页面就是用户看到的登录/注册入口。
|
||||
|
||||
---
|
||||
|
||||
## 10. 做健康检查
|
||||
|
||||
### 先检查接口
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:19090/healthz
|
||||
curl http://127.0.0.1:8090/healthz
|
||||
curl -I http://127.0.0.1:3081
|
||||
```
|
||||
|
||||
你应该大致看到:
|
||||
|
||||
- `authz-service` 返回健康 JSON
|
||||
- `deploy-control` 返回健康 JSON
|
||||
- `auth-portal` 返回 `HTTP/1.1 200 OK`
|
||||
|
||||
### 再看容器状态
|
||||
|
||||
```bash
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
||||
```
|
||||
|
||||
你至少应该能看到这些容器:
|
||||
|
||||
- `nano-authz-service`
|
||||
- `nano-deploy-control`
|
||||
- `nano-auth-portal`
|
||||
- `nano-router-proxy`
|
||||
|
||||
### 再看一下代理日志
|
||||
|
||||
```bash
|
||||
docker logs --tail=50 nano-router-proxy
|
||||
```
|
||||
|
||||
如果这一步没有明显报错,就可以开始浏览器测试了。
|
||||
|
||||
---
|
||||
|
||||
## 11. 浏览器首次测试
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3081/register
|
||||
```
|
||||
|
||||
然后按顺序操作:
|
||||
|
||||
1. 注册一个新账号
|
||||
2. 注册成功后,系统会自动创建一个你的专属实例
|
||||
3. 浏览器应该跳到你的实例地址
|
||||
|
||||
跳转目标一般长这样:
|
||||
|
||||
```text
|
||||
http://你的slug.127.0.0.1.nip.io:8088
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
http://alice.127.0.0.1.nip.io:8088
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 确认实例真的被创建出来了
|
||||
|
||||
```bash
|
||||
cd "$PROJECT_ROOT/app-instance"
|
||||
./list-instances.sh
|
||||
./list-instances.sh --json
|
||||
```
|
||||
|
||||
你应该能看到类似:
|
||||
|
||||
- `instance_id`
|
||||
- `instance_slug`
|
||||
- `container_name`
|
||||
- `public_url`
|
||||
|
||||
以及对应的 `app-instance-<slug>` 容器。
|
||||
|
||||
你还可以继续查:
|
||||
|
||||
```bash
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. 如果你只是想单独看前端页面
|
||||
|
||||
如果你只是想看 `auth-portal` 页面样子,不跑全链路,也可以单独启动它的前端开发模式:
|
||||
|
||||
```bash
|
||||
cd /home/ivan/xuan/nano_project/auth-portal/src
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
然后打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3081
|
||||
```
|
||||
|
||||
但是要注意:
|
||||
|
||||
- 这只能看页面
|
||||
- 注册、登录、创建实例这些动作是否成功,仍然取决于 `authz-service` 和 `deploy-control` 有没有另外启动
|
||||
|
||||
---
|
||||
|
||||
## 14. 一键排错命令
|
||||
|
||||
如果你感觉“不对劲”,先跑这几条:
|
||||
|
||||
```bash
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
||||
|
||||
docker logs --tail=100 nano-authz-service
|
||||
docker logs --tail=100 nano-deploy-control
|
||||
docker logs --tail=100 nano-auth-portal
|
||||
docker logs --tail=100 nano-router-proxy
|
||||
|
||||
curl http://127.0.0.1:19090/healthz
|
||||
curl http://127.0.0.1:8090/healthz
|
||||
curl -I http://127.0.0.1:3081
|
||||
```
|
||||
|
||||
如果是实例创建失败,再加两条:
|
||||
|
||||
```bash
|
||||
cd "$PROJECT_ROOT/app-instance"
|
||||
./list-instances.sh --json
|
||||
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. 最常见的坑
|
||||
|
||||
### 1. API Key 没填
|
||||
|
||||
现象:
|
||||
|
||||
- 注册页面提交后创建实例失败
|
||||
|
||||
原因:
|
||||
|
||||
- `APP_INSTANCE_API_KEY` 没有有效值
|
||||
|
||||
### 2. Docker 没启动
|
||||
|
||||
现象:
|
||||
|
||||
- `deploy-control` 无法创建实例
|
||||
- 或 `docker ps` 本身就报错
|
||||
|
||||
### 3. `AUTHZ_ISSUER` 写成了 `127.0.0.1`
|
||||
|
||||
错误写法:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:19090
|
||||
```
|
||||
|
||||
正确写法:
|
||||
|
||||
```text
|
||||
http://nano-authz-service:19090
|
||||
```
|
||||
|
||||
原因:
|
||||
|
||||
- 新实例容器里访问不到宿主机自己的 `127.0.0.1:19090`
|
||||
|
||||
### 4. `deploy-control` 的路径挂载写错
|
||||
|
||||
错误思路:
|
||||
|
||||
- 把宿主机的 `app-instance` 挂到容器里的 `/app-instance`
|
||||
|
||||
正确思路:
|
||||
|
||||
- 宿主机原路径挂进去
|
||||
- 并设置:
|
||||
- `APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance"`
|
||||
- `ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy"`
|
||||
|
||||
### 5. `nip.io` 解析失败
|
||||
|
||||
如果实例跳转地址打不开,先试:
|
||||
|
||||
```bash
|
||||
ping 127.0.0.1.nip.io
|
||||
```
|
||||
|
||||
如果你本地网络把 `nip.io` 拦了,这套子域名测试方式就会失效。
|
||||
|
||||
### 6. 端口被占用
|
||||
|
||||
默认会用到这些端口:
|
||||
|
||||
- `3081`
|
||||
- `8090`
|
||||
- `19090`
|
||||
- `8088`
|
||||
|
||||
你可以先查:
|
||||
|
||||
```bash
|
||||
ss -ltnp | grep -E '3081|8090|19090|8088'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 如果你要重新来一遍
|
||||
|
||||
如果你只是想“重新部署这四个基础容器”,可以先停掉它们:
|
||||
|
||||
```bash
|
||||
docker rm -f \
|
||||
nano-auth-portal \
|
||||
nano-authz-service \
|
||||
nano-deploy-control \
|
||||
nano-router-proxy 2>/dev/null || true
|
||||
```
|
||||
|
||||
如果你还想把旧实例容器也一起清掉,再额外处理 `app-instance-*`。
|
||||
|
||||
注意:
|
||||
|
||||
- 不要在你还需要旧数据的时候乱删 `runtime/`
|
||||
- `authz-service/runtime/data` 和 `app-instance/runtime/instances` 里都有持久化数据
|
||||
|
||||
---
|
||||
|
||||
## 17. 本机部署成功后的结果应该是什么
|
||||
|
||||
如果整个流程正常,最后你会得到:
|
||||
|
||||
- 一个可以打开的注册页:
|
||||
- `http://127.0.0.1:3081/register`
|
||||
- 一个统一实例入口代理:
|
||||
- `http://<slug>.127.0.0.1.nip.io:8088`
|
||||
- 一个能自动创建用户专属容器的部署控制面
|
||||
- 一份实例注册表:
|
||||
- `app-instance/runtime/registry/instances.json`
|
||||
|
||||
---
|
||||
|
||||
## 18. 你下一步最建议做什么
|
||||
|
||||
第一次建议这样测:
|
||||
|
||||
1. 用全新用户名注册一个测试账号
|
||||
2. 确认浏览器跳到了你的实例 URL
|
||||
3. 再执行 `./app-instance/list-instances.sh --json`
|
||||
4. 确认注册表里真的有这条实例记录
|
||||
|
||||
如果你后面想要,我还可以继续补两份文档:
|
||||
|
||||
- `服务器部署指南.md`
|
||||
- 面向公网服务器、固定 IP、长期运行
|
||||
- `常见报错排查.md`
|
||||
- 专门收集 502、超时、实例起不来、MCP 鉴权失败这类问题
|
||||
Reference in New Issue
Block a user