GitHub

14 — 平台配置下发(settings 读面)

返回 README

状态:已实现(配置中心 W2,2026-09-02;W3-c 起上传两开关可按站点覆盖并按站执法)。端点 GET /settings;实现在 internal/platform/settings/handler.go(Effective),路由注册在 cmd/oauth/main.go。 管理端是 OAuth 控制台的 /settings 页;内部模型(三层解析、审计、传播)见 infra docs/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

响应头:每次响应都带 ETagCache-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)

  1. 进程启动时拉一次;失败则用 §2 的默认值启动,不阻塞启动
  2. 之后每 30–60 秒带 If-None-Match 轮询一次。304 → 什么都不做;200 → 整份替换内存快照; 网络错误 / 5xx → 沿用上一份快照(fail-open to last snapshot),连续失败超过 5 分钟打告警日志。
  3. 读值只读内存快照,请求路径零网络。
  4. 不做长轮询,不需要 Redis。
  5. 生效延迟上限 ≈ 控制台写入 → 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