14 — 平台配置下发(settings 读面)
返回 README
状态:已实现(配置中心 W2,2026-09-02;W3-c 起上传两开关可按站点覆盖并按站执法)。端点
GET /settings;实现在internal/platform/settings/handler.go(Effective),路由注册在cmd/oauth/main.go。 管理端是 OAuth 控制台的/settings页;内部模型(三层解析、审计、传播)见 infradocs/deploy/18-config-center.md。
0. 定位
- 平台拥有、各站必须遵守的运行期策略(维护只读、全站公告、上传开关)由 OAuth 的配置中心统一 管理,通过本读面下发给 kungal / moyu / letmoe 等下游站。
- 下游只走这个 HTTP 端点,永远不读共享库里的
setting_overrides表。infra 整套(七个服务 + Postgres + Redis)会在半年内整机搬迁,搬完之后这个契约一个字节都不变。 - 站点自己的产品开关(moyu 的
site_setting之类)留在站点自己那里;这里只放「平台说了算」的。
1. GET /settings
返回调用方站点应当遵守的全部公开键的生效值。
鉴权:OAuth Client Basic Auth(Authorization: Basic base64(client_id:client_secret)),
与 03-cross-service.md 相同。站点身份从 client 的站点绑定
(oauth_clients.site_id)推导:有绑定 → 该站点的值(平台值 + 站点覆盖);无绑定 → 平台值。
请求头:
| 头 | 必填 | 说明 |
|---|---|---|
| If-None-Match | 否 | 上一次响应的 ETag;相同则 304 |
成功响应(200):
{
"code": 0,
"message": "成功",
"data": {
"site_id": 3,
"etag": "\"9f2c1a0b7e4d5c63\"",
"settings": {
"artifact.upload_enabled": true,
"image.upload_enabled": true,
"platform.notice": "",
"platform.read_only": false
}
}
}
| 字段 | 说明 |
|---|---|
| site_id | 调用方 client 绑定的站点 id;无绑定为 null |
| etag | 与响应头 ETag 相同,强 ETag;由 settings 对象整体计算,任一值变化即变化 |
| settings | 键 → 生效值。只含 §2 里的公开键;值类型见 §2 |
响应头:每次响应都带 ETag 与 Cache-Control: no-cache。
304 Not Modified:If-None-Match 与当前 ETag 相同时返回 304、空 body、同一个 ETag 头。
错误响应:
| HTTP | code | 触发条件 |
|---|---|---|
| 401 | 10001/15001/15009 | Basic Auth 缺失/格式错/client_id 不存在/secret 错误 |
2. 公开键目录
只有声明为 Public 的键出现在读面上。新键只增不删;要改语义就加新键,旧键至少保留一个版本。
| 键 | 类型 | 默认 | 可按站点覆盖 | 站点必须做什么 |
|---|---|---|---|---|
platform.read_only |
bool | false |
是 | true 时拒绝一切写操作(发帖、评论、编辑、上传、点赞、后台修改),回一句可读提示;读不受影响。用于数据库迁移等全平台维护窗口 |
platform.notice |
string | "" |
是 | 非空时在页面顶部展示这一行公告(≤ 500 字,单行);空不展示 |
image.upload_enabled |
bool | false |
是 | false 时隐藏或禁用图片上传入口(图床对该站的上传此时回 503) |
artifact.upload_enabled |
bool | false |
是 | false 时隐藏或禁用文件上传入口(artifact 服务对该站的上传此时回 503) |
「可按站点覆盖」= 管理员可以在控制台选中某个站点后单独设置(例如只让 letmoe 进入只读)。 不可按站点覆盖的键,每个站点拿到的都是平台值。上传两个开关不只随读面下发:图床与 artifact 服务自己也按调用方 client 绑定的站点执法(W3-c 起),所以站点从读面看到的值与实际被放行/拒绝 的行为一致。
读面报告的是 OAuth 进程看到的值:数据库覆盖值,否则代码默认。上传两个开关在各自服务的 compose 里可能还设着同名环境变量地板,那个地板 OAuth 看不见——这正是 infra 侧「先在控制台建行、 再拔 env」顺序的原因(
docs/deploy/18-config-center.md§18.4)。建行之后两边一致。
3. 轮询与缓存(MUST)
- 进程启动时拉一次;失败则用 §2 的默认值启动,不阻塞启动。
- 之后每 30–60 秒带
If-None-Match轮询一次。304 → 什么都不做;200 → 整份替换内存快照; 网络错误 / 5xx → 沿用上一份快照(fail-open to last snapshot),连续失败超过 5 分钟打告警日志。 - 读值只读内存快照,请求路径零网络。
- 不做长轮询,不需要 Redis。
- 生效延迟上限 ≈ 控制台写入 → OAuth 进程(Redis 推送毫秒级,轮询兜底 30 秒)→ 站点下一次轮询 (≤ 60 秒),约 1.5 分钟。
ETag 只由 settings 对象计算;不同站点的 ETag 互不相关,不要跨站点复用快照。
4. 参考实现(Go)
OAuth 这边不发布 SDK。下面是一个可直接复制的最小客户端(约 60 行):
package platformsettings
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"sync/atomic"
"time"
)
type Snapshot struct {
ReadOnly bool `json:"platform.read_only"`
Notice string `json:"platform.notice"`
ImageUploadEnabled bool `json:"image.upload_enabled"`
ArtifactUploadEnabled bool `json:"artifact.upload_enabled"`
}
type Client struct {
base, clientID, secret string
http *http.Client
cur atomic.Pointer[Snapshot]
etag string
}
func New(base, clientID, secret string) *Client {
c := &Client{base: base, clientID: clientID, secret: secret, http: &http.Client{Timeout: 5 * time.Second}}
c.cur.Store(&Snapshot{})
return c
}
func (c *Client) Get() Snapshot { return *c.cur.Load() }
func (c *Client) Run(ctx context.Context, every time.Duration) {
c.refresh(ctx)
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.refresh(ctx)
}
}
}
func (c *Client) refresh(ctx context.Context) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/api/v1/settings", nil)
req.SetBasicAuth(c.clientID, c.secret)
if c.etag != "" {
req.Header.Set("If-None-Match", c.etag)
}
resp, err := c.http.Do(req)
if err != nil {
slog.Warn("platform settings: refresh failed; keeping last snapshot", "err", err)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified {
return
}
if resp.StatusCode != http.StatusOK {
slog.Warn("platform settings: unexpected status; keeping last snapshot", "status", resp.StatusCode)
return
}
var body struct {
Data struct {
ETag string `json:"etag"`
Settings Snapshot `json:"settings"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
slog.Warn("platform settings: bad body; keeping last snapshot", "err", err)
return
}
c.cur.Store(&body.Data.Settings)
c.etag = body.Data.ETag
}
用法:启动时 go client.Run(ctx, 45*time.Second),写路径中间件里 if client.Get().ReadOnly { 503 + 提示 }。
Nuxt 站点在 server 侧做同样的事(nitro 插件起一个定时器,把快照放进 useState 或内存),不要在浏览器里直连本端点(需要 client secret)。
5. 与控制台的关系
管理员在 oauth.kungal.com/settings 的「平台策略」域设值;顶部作用域选择「平台(全局)」或某个站点。
每次变更有审计(旧值 → 新值 + 备注)。哪些站点已经接入、接入后行为是否正确,以站点自己的日志为准——
读面只保证「值已经这样」,不保证「站点已经照做」。
源:nextmoe-infra/docs/integration/oauth/14-settings.md