# @stago/sdk

云台模块开发 SDK，为非专业开发人员提供简化的模块配置和 API 开发工具。

## 安装

```bash
pnpm add @stago/sdk
```

## 功能模块

### 1. 模块配置 (`defineModule`)

简化模块配置，SDK 自动处理 Module Federation 内部细节：

```typescript
// stago.config.ts
import { defineModule } from '@stago/sdk'

export default defineModule({
  name: '@demo/task-management',
  version: '1.0.0',
  description: '任务管理模块',
  menu: {
    title: '任务管理',
    icon: 'UnorderedListOutlined',
    order: 10,
    children: [
      { key: 'dashboard', title: '仪表盘', path: '/' },
      { key: 'list', title: '任务列表', path: '/list' }
    ]
  },
  api: {
    prefix: '/tasks',
    handlers: './src/api'
  }
})
```

#### `client.logtoAppId`（api-only 必填）

`kind: 'api-only'` 的模块，其业务前端独立部署、用官方 logto SDK 登录（每个前端在 logto 是独立 Application，App ID 各异）。在 `stago.config.ts` 声明该前端对应的 logto Application ID，入库后由平台加入 access_token `client_id` 白名单：

```typescript
export default defineModule({
  kind: 'api-only',
  name: '@demo/task-api',
  version: '1.0.0',
  api: { prefix: '/tasks' },           // api-only 必填
  client: { logtoAppId: 'logto_app_id_xxx' }  // api-only 必填
})
```

平台后端换 Stago JWT 时校验 `client_id ∈ {平台 LOGTO_APP_ID} ∪ {approved 模块当前版本的 client.logtoAppId}`，命中才签发。详见下方 [api-only 业务前端鉴权](#7-api-only-业务前端鉴权stagosdkclient)。

### 2. 模块上下文 Hooks

在 React 组件中使用平台提供的上下文。模块请求统一走 `useModuleRequest`（不要直接调 `platform.http` / 原生 `fetch`）：

```typescript
import { useModule, useUser, useRouter, useModuleRequest } from '@stago/sdk'

function MyComponent() {
  const user = useUser()
  const router = useRouter()
  const { request } = useModuleRequest()

  // JSON API：自动解包 ModuleApiResult，直接得到 data
  const load = async () => {
    const tasks = await request<Task[]>('/tasks')
    // 上传
    await request('/upload', { method: 'POST', body: formData })
    // 导出（透出 Response / Blob）
    const res = await request<Response>('/export', {
      method: 'POST',
      body: { id: 1 },
      responseKind: 'response',
      timeout: 120_000,
    })
  }

  router.navigate('/detail/123')
  return <div>{user.name}</div>
}
```

### 3. API SDK (`defineApiRouter`)

为模块定义后端 API 路由（使用 Hono 框架）：

```typescript
// src/api/index.ts
import { defineApiRouter, apiSuccess, apiError } from '@stago/sdk'

export default defineApiRouter({
  prefix: '/employee',
  routes: {
    '/list': {
      GET: async (c, ctx) => {
        const result = await ctx.db.query('SELECT * FROM employees')
        return apiSuccess(c, { employees: result.rows })
      }
    },

    '/create': {
      middleware: [
        async (c, next) => {
          const body = await c.req.json()
          if (!body.name) {
            return apiError(c, 'VALIDATION_ERROR', 'name is required', 400)
          }
          await next()
        }
      ],
      POST: async (c, ctx) => {
        const { name, phone } = await c.req.json()
        await ctx.db.query(
          'INSERT INTO employees (name, phone) VALUES ($1, $2)',
          [name, phone]
        )
        return apiSuccess(c, { created: true }, 'Employee created')
      }
    },

    '/:id': {
      GET: async (c, ctx) => {
        const result = await ctx.db.query(
          'SELECT * FROM employees WHERE id = $1',
          [c.req.param('id')]
        )
        if (result.rows.length === 0) {
          return apiError(c, 'NOT_FOUND', 'Employee not found', 404)
        }
        return apiSuccess(c, result.rows[0])
      }
    }
  }
})
```

### 4. API 响应工具

标准化 API 响应格式（Hono Context）：

```typescript
// 成功响应
return apiSuccess(c, { items: [...] }, 'Items fetched')
// => { success: true, data: { items: [...] }, message: 'Items fetched' }

// 错误响应
return apiError(c, 'NOT_FOUND', 'Item not found', 404)
// => { success: false, error: { code: 'NOT_FOUND', message: 'Item not found' } }

// 验证错误
return apiValidationError(c, ['name', 'email'])
// => { success: false, error: { code: 'VALIDATION_ERROR', message: '缺少必要参数: name, email' } }
```

### 5. 生命周期钩子

模块加载/卸载时执行：

```typescript
import { defineApiWithSetup } from '@stago/sdk'

let cache: Map<string, unknown>

export default defineApiWithSetup({
  config: {
    routes: {
      '/list': {
        GET: async (c, ctx) => {
          const cached = cache.get('employees')
          if (cached) return apiSuccess(c, cached)

          const result = await ctx.db.query('SELECT * FROM employees')
          cache.set('employees', result.rows)
          return apiSuccess(c, { employees: result.rows })
        }
      }
    }
  },

  setup: async (ctx) => {
    cache = new Map()
    ctx.log('info', 'Module initialized with cache')
  },

  teardown: () => {
    cache.clear()
  }
})
```

### ModuleDeps 与环境变量 (`deps.env`)

模块 Elysia handler 通过 `ModuleDeps` 接收平台依赖；业务密钥只应从 `deps.env` 读取：

```typescript
function routes(app: ModuleApp, deps: ModuleDeps) {
  const apiKey = deps.env.OPENAI_KEY // 未配置时为 undefined
  // ...
}
```

### 业务审计

模块后端可在请求处理成功后通过 `createModuleApp()` 注入的 `audit` 主动记录业务审计。操作人 ID、模块名称、模块版本和服务端时间由平台补齐，模块不得自行提交这些字段：

```typescript
app.post('/snapshot/lock', async ({ audit }) => {
  const result = await lockSnapshot()
  await audit.record({
    action: 'snapshot.locked',
    summary: '锁定历史月快照',
    target: { type: 'snapshot', id: '2026-07' },
    metadata: { source: 'manual' },
  })
  return result
})
```

`action` 可省略或传空字符串；SDK 会统一存为 `NULL`。审计写入会被等待，校验或持久化失败将抛错，不会静默忽略。

- **来源**：生产环境由平台从 `stago.module_env` 表加载并注入；本地开发用 `createDevModuleDeps({ env: { KEY: 'value' } })` 模拟
- **热更新**：模块负责人或管理员 `PUT /api/modules/:name/env` 保存后会触发 `reloadModuleApis()`，新请求即使用新值
- **禁止**：模块内不要 `dotenv.config()`、不要读写 `process.env` 中的平台机密（如 `JWT_SECRET`、`DB_PASSWORD`）；模块上下文中对 `process.env` 的写入会被拦截

## API 上下文 (`ApiContext`) 与 `ModuleDb`

Elysia 模块 handler 通过 `ModuleDeps.db`（类型 `ModuleDb`）访问数据库；`defineApiRouter` 的 `ctx.db` 与之相同：

```typescript
interface ModuleDb {
  query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
  transaction: <T>(
    fn: (tx: ModuleDb) => Promise<T>,
    options?: { timeoutMs?: number },
  ) => Promise<T>
}
```

事务示例：

```typescript
await ctx.db.transaction(async (tx) => {
  await tx.query('INSERT INTO orders (id) VALUES ($1)', [orderId])
  await tx.query('UPDATE inventory SET qty = qty - 1 WHERE sku = $1', [sku])
})
```

约束：

- 回调内只用 `tx.query`（不要用外层 `db.query`）
- 禁止在事务回调内发起外部 HTTP（会长时间占用连接池）
- 默认 `statement_timeout` 30s；`options.timeoutMs` 可覆盖但钳制在 1s–120s（非法/超限自动收敛，无法关闭）；事务空转超过上限由 `idle_in_transaction_session_timeout` 断开
- 禁止 `db.query('BEGIN')` / 手动事务 SQL
- 禁止一切 `SET` / `SET LOCAL`（含 `gp_max_slices`、`statement_mem` 等数据库参数）；超时用 `options.timeoutMs`。Greenplum 重查询若撞 slice 上限，用事务级临时表分步物化（见下条），而不是调参
- DDL 一律禁止，唯一例外是**事务级临时表**：`CREATE TEMP TABLE tmp_xxx ... ON COMMIT DROP`（表名必须 `tmp_` 前缀、必须声明 `ON COMMIT DROP`，连接池共享，会话级临时表会泄漏），以及建在 `tmp_` 表上的 `CREATE INDEX`；`ANALYZE tmp_xxx` 本就不在拦截范围。须在 `db.transaction` 回调内使用，事务结束自动清理
- 以上规则 dev（`stago dev`）与生产共用同一份实现（`assertModuleSqlAllowed`），本地开发阶段即报与线上一致的错误

`ApiContext`（Hono 路由）还包含：

```typescript
interface ApiContext {
  db: ModuleDb
  module: {
    name: string    // 模块名 @scope/name
    version: string // 版本号
  }
  log: (level: 'info' | 'warn' | 'error', message: string, data?: unknown) => void
}
```

## 模块上下文 (`ModuleContext`)

React 组件可用完整上下文：

```typescript
interface ModuleContext {
  user: ModuleUser           // 用户信息（id / phone / name，与 live.employees 一一对应）
  theme: ModuleTheme         // 主题控制
  router: ModuleRouter       // 路由导航
  notifications: ModuleNotifications  // 通知服务
  globalState: ModuleGlobalState      // 跨模块状态
  platform: PlatformServices          // 平台服务
}
```

```typescript
interface ModuleUser {
  id: number      // live.employees.id
  phone: string   // live.employees.phone
  name: string    // live.employees.name
  role: 'admin' | 'user'
  permissions: string[]
}
```

## 类型导出

SDK 导出完整的 TypeScript 类型定义：

- `ModuleConfig` - 模块配置
- `ApiHandler` - API 处理函数
- `ApiContext` - API 上下文
- `ModuleDb` - 模块数据库客户端（`query` / `transaction`）
- `ModuleDeps` - 平台注入依赖（含 `db`、`env`、`log`）
- `RouteDefinition` - 路由定义
- `ModuleContext` - 模块上下文
- `ModuleUser` - 用户信息
- 等 20+ 类型

### 6. 开发环境数据库访问 (`@stago/sdk/dev`)

模块开发时，可在本地 Dev Server 直接访问真实数据库：

```typescript
// .env 配置
DB_HOST=localhost
DB_PORT=5432
DB_NAME=your_database
DB_USER=postgres
DB_PASSWORD=your_password
```

```typescript
// vite.config.ts
import { stagoConfigPlugin, stagoApiPlugin, stagoDevApiPlugin } from '@stago/sdk/vite'

export default defineConfig(({ command }) => ({
  plugins: [
    react(),
    stagoConfigPlugin(),
    // API 挂在同一 Vite 端口，handler 修改后下次请求自动热更新
    stagoDevApiPlugin({ apiEntry: '/src/handlers/index.ts' }),
    stagoApiPlugin(), // Bundle handlers to dist/api.js
    command !== 'serve' && federation({ /* ... */ }),
  ].filter(Boolean),
}))
```

启动 `pnpm dev` 后，前端与 API 共用 Vite Dev Server（如 `http://localhost:5173`），无需单独起 API 进程。

测试 API：
```bash
curl http://localhost:5173/api/dashboard
```

开发时 `ctx.db.query()` 直接访问 `.env` 配置的真实数据库，无需 mock。修改 `src/handlers` 或 `src/lib` 后保存，刷新或重新请求即可看到变更。

**SQL 日志（开发 + 生产）**：`ctx.db.query` 由平台/开发插件统一包装，通过环境变量控制：

| 环境 | 默认 | 开启 | 关闭 |
|------|------|------|------|
| 开发 (`npm run dev`) | 开启 | — | `DB_LOG_SQL=false` |
| 生产 (`@stago/api`) | 关闭 | `DB_LOG_SQL=true` | — |

```bash
DB_LOG_SQL=true   # 生产排查时可在平台 .env 打开
```

日志示例：

```txt
[SQL] @ops/catindex@0.3.14 42ms rows=12 params=[2026,4,2]
SELECT ... FROM dws.category_negative_month_stats ...
```

业务代码也可主动打日志：`ctx.log('info', 'message', { data })`。

**生产环境（`@stago/api`）** 使用 rslog 门面，输出 **JSON 行** 便于采集，例如：

```json
{"time":"...","level":"info","kind":"sql","module":"@ops/catindex","version":"0.3.14","durationMs":42,"rowCount":12,"sql":"SELECT ..."}
```

环境变量：`LOG_LEVEL`（error/warn/info/debug）。

**注意：** `@stago/sdk/dev` 和 `@stago/sdk/dev-react` 仅用于开发，不会打包到生产。

### 7. api-only 业务前端鉴权 (`@stago/sdk/client`)

`kind: 'api-only'` 的模块业务前端独立部署在自有 CDN/nginx，用官方 logto SDK 登录（`resources` / `getAccessToken` 使用 SDK 常量 `STAGO_LOGTO_API_RESOURCE`，与平台一致），再用 `createStagoClient` 收口后续流程：logto access_token → 调 `{apiBaseUrl}/auth/logto-callback` 换 Stago JWT → 存储 → `stago.fetch()` 自动注入 `Authorization: Bearer <jwt>`、拼 `apiBaseUrl + prefix`、401 时单飞刷新重试一次。

```bash
bun add @stago/sdk
```

```typescript
import { createStagoClient, STAGO_LOGTO_API_RESOURCE } from '@stago/sdk/client'

// LogtoConfig.resources: [STAGO_LOGTO_API_RESOURCE]

const stago = createStagoClient({
  // 可含部署前缀，例如 https://api.zhangchuwang.cn/stago
  apiBaseUrl: 'https://api.zhangchuwang.cn/stago',
  prefix: '/tasks', // 与 stago.config.ts 的 api.prefix 一致
  getAccessToken: () => logtoClient.getAccessToken(STAGO_LOGTO_API_RESOURCE),
  // loginPath?: 默认 '/auth/logto-callback'；旧网关可设 '/api/auth/logto-callback'
  // storage? / fetch? / timeout?
})

// login → https://api.zhangchuwang.cn/stago/auth/logto-callback
const user = await stago.login()

// fetch → https://api.zhangchuwang.cn/stago/tasks/list
const res = await stago.fetch('/list', { query: { page: 1 } })
const data = await res.json()

stago.getToken()
stago.getUser()
await stago.logout()
```

`fetch` 选项：`query`（自动序列化为 querystring）、`json`（自动 stringify + 设 content-type）、`body`、其余 `RequestInit`。

错误：`StagoClientError` 为基类，`AuthError`（登录/换 token 失败）、`ApiError`（HTTP 非 2xx，含 `status`）。超时（默认 30s）通过 `AbortController` 实现。

后端鉴权链路：平台 `exchangeLogtoToken` 用 `LOGTO_ENDPOINT` JWKS 验签 access_token（`aud === LOGTO_API_RESOURCE`（未设则默认 `STAGO_LOGTO_API_RESOURCE`）、`client_id ∈ 允许集合`：平台 `LOGTO_APP_ID` ∪ approved 模块当前版本的 `client.logtoAppId`，60s TTL + 模块/版本写操作即时失效），再从 AT JWT payload 取身份声明（须经 Logto Custom JWT 写入），反查员工 + `hasPlatformAccess` 门禁后签发 Stago JWT（7d）。网关转发前 `resolveAuthUser` 校验登录态、`canAccessModule` 校验模块访问权、`attachModuleUserHeader` 透传用户。公开路径若为 `/stago/auth/logto-callback`，需由网关映射到平台内部的换票接口（当前平台路由多为 `/api/auth/logto-callback`）。详见仓库 `docs/auth-token-exchange-proposal.md`。

### SDK 入口速查

| 入口 | 用途 | 使用位置 |
|------|------|---------|
| `@stago/sdk` | 业务 hooks（`useModule`、`useModuleRequest` 等） | 模块业务代码 |
| `@stago/sdk/http` | Host 请求管道（`executeHttpRequest`）；**模块前端勿用** | 云台 Host / 平台实现 |
| `@stago/sdk/client` | api-only 业务前端运行时（`createStagoClient`：logto 登录换 Stago JWT + Bearer 注入 + 401 懒刷新） | 业务前端（独立部署，非底座页面） |
| `@stago/sdk/vite` | Vite 插件（`stagoConfigPlugin`、`stagoApiPlugin`、`stagoDevApiPlugin`） | `vite.config.ts` |
| `@stago/sdk/dev-react` | 本地开发用 `DevModuleProvider`（浏览器，无 Node 依赖） | `src/main.tsx` |
| `@stago/sdk/dev` | Node dev 工具（db pool、api-server），仅 Vite 插件内部使用 | 通常不需手动引用 |

## 许可证

MIT
