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:
2026-03-16 17:01:58 +08:00
parent 04501fea22
commit b3767dd4ab
20 changed files with 1671 additions and 83 deletions

View File

@ -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
View 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

View File

@ -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`

View File

@ -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

View File

@ -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

View File

@ -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}"

View File

@ -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 ? (

View File

@ -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

View File

@ -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;

View File

@ -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) {

View File

@ -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 {

View File

@ -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) {

View File

@ -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`

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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` 并持续重启。

View File

@ -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
View 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
View 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 鉴权失败这类问题