feat(gateway,admin-web): Skill 运行时安装 + backthink 打扰原因 + 建提醒入口(P0-19/P0-20/P0-22,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- P0-19:POST /admin/plugins/install 支持 `kind:"skill"`(此前一律 400)——yaml 或 path 二选一、
  name 一致性校验、显式错误码(bad_request/name_mismatch/skill_parse_failed/name_conflict)、
  装后以 kind:"skill" 出现在 GET /admin/plugins;**文件来源解析错误绝不回显文件内容**(变异验证实测确认
  该泄露向量真实存在:yaml 解码器会带出出错值前几字符 ⇒ 只回显路径 + 通用原因,细节进日志)
- P0-20:AdminBackthinkItem 补 route/detail/bypassed(DB 早已存,零迁移),面板 Backthink 展示
- P0-22:新增 POST /admin/automations/reminders(owner-only + 审计),at/message 必填、RFC3339 校验、
  409 already_exists;过去时间不作 400(下次轮询立即触发)
- 顺手修真缺陷:新建 id 原用 time.Now().UnixNano(),Windows 时钟粒度粗到毫秒 ⇒ 并行创建撞 id 得假 409;
  改用 crypto/rand 128 位 hex(仓库既有 skills/store.go 范式)
- 复核:gateway 包 build/vet/测试绿 + 前端 tsc 0 错;新增 12 用例(含 8 条非法输入显式码);
  **4 条变异反证**均已回滚
- 未做(登记):WS 帧侧的 route/detail 未在前端展示(`lib/wsParse.ts` 3 行,需放行);skill 未落
  skills.Store(重启后消失,注入点在 cmd/cyrene);提醒无删除入口(CancelReminder 已存在、无路由)
This commit is contained in:
AskaEth committed 2026-10-04 14:21:41 +08:00
1 parent 3638ed7708
commit decf59c1b4
12 files changed
+956 -24

No files matched your search

+22 -1
View File
@@ -10,6 +10,22 @@ import { adminBackthink } from "../../lib/api";
import { useAsyncData } from "../../lib/useAsyncData";
import { BACKTHINK_SESSION_ID } from "../../store/sessionCache";
/** 推送路由的中文标签(后端 PushResult.Route 的取值域)。未知取值原样显示,
* 绝不猜一个更好听的词。 */
const PUSH_ROUTE_LABEL: Record<string, string> = {
active_client: "活跃客户端",
im_bridge: "IM 桥",
rate_limited: "限频拦截",
none: "无投递目标",
};
/** 推送路由的色调:真的送出去了才用 ok,被限频拦住是 warn。 */
function pushRouteTone(route: string): "ok" | "warn" | "neutral" {
if (route === "active_client" || route === "im_bridge") return "ok";
if (route === "rate_limited") return "warn";
return "neutral";
}
function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
const markPushed = useAppStore((s) => s.markBackthinkPushed);
// 裁决 16.4.2:这条思考的链路图取自**后台思考会话**(`sess-backthink`,design/19 §1
@@ -26,6 +42,8 @@ function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
<span className="font-medium">{item.trigger}</span>
<Tag tone={item.shouldNotify ? "warn" : "neutral"}>{item.shouldNotify ? "需要打扰" : "静默"}</Tag>
<Tag tone={item.pushed ? "ok" : "neutral"}>{item.pushed ? "已推送" : "未推送"}</Tag>
{item.route ? <Tag tone={pushRouteTone(item.route)}>{PUSH_ROUTE_LABEL[item.route] ?? item.route}</Tag> : null}
{item.bypassed ? <Tag tone="accent">事件直发</Tag> : null}
<span className="ml-auto">
<Button size="sm" variant="ghost" disabled={item.pushed} onClick={() => markPushed(item.id)}>
{item.pushed ? "已推送" : "推送结果"}
@@ -37,6 +55,9 @@ function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
{item.conclusion}
</div>
) : null}
{item.detail ? (
<div className="text-xs text-[var(--mu-text-lo)]">推送说明:{item.detail}</div>
) : null}
<button
className="text-xs text-[var(--mu-accent-hi)]"
onClick={() => setOpen((v) => !v)}
@@ -117,7 +138,7 @@ export function BackthinkModule(): React.ReactNode {
</div>
)}
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。</Notice>
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。「推送结果」的 route / detail / bypassed 由这次历史拉取带回(后端 PushResult 的如实投影);实时帧里同一信息裹在原始 PushResult 内、前端解析器尚未读取,因此本次会话新到的行可能暂时只显示「已推送/未推送」。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。</Notice>
</div>
);
}
+7
View File
@@ -37,5 +37,12 @@ export interface BackthinkItem {
conclusion?: string;
shouldNotify?: boolean;
pushed?: boolean;
/** 投递路由(后端 PushResult.Route):none | rate_limited | active_client | im_bridge。
* 缺席 = 后端没报告该字段(例如实时帧未经解析),不等于「无路由」。 */
route?: string;
/** 打扰决策或投递的简短原因(后端 PushResult.Detail),例如 quiet hours、无活跃客户端。 */
detail?: string;
/** 事件驱动推送绕过了限频闸(后端 PushResult.Bypassed,"该发就发")。 */
bypassed?: boolean;
at: number;
}
+12 -1
View File
@@ -114,13 +114,24 @@ type AdminUserRecord struct {
// AdminBackthinkItem 是管理监控列表中一条后台思考记录的
// 线上结构。
//
// Pushed 只回答「发没发出」;Route/Detail/Bypassed(design/12 §4、
// design/10 §3 #12)回答「走了哪条路、为什么打扰/为什么不打扰」——
// 它们直接来自后端已存的 PushResult,因此面板不必靠猜来解释
// 一次静默。
type AdminBackthinkItem struct {
ID string `json:"id"`
Trigger string `json:"trigger"`
Conclusion string `json:"conclusion,omitempty"`
ShouldNotify bool `json:"shouldNotify"`
Pushed bool `json:"pushed"`
At int64 `json:"at"`
// Route 是投递路由:none | rate_limited | active_client | im_bridge。
Route string `json:"route,omitempty"`
// Detail 是打扰决策或投递的简短原因(如 quiet hours、无活跃客户端)。
Detail string `json:"detail,omitempty"`
// Bypassed 表示这是事件驱动的推送、绕过了限频闸("该发就发")。
Bypassed bool `json:"bypassed"`
At int64 `json:"at"`
}
// AdminRagDoc 是管理 RAG 列表中一个知识库文档(项目索引
@@ -0,0 +1,102 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
)
// listBackthinkItems 调用管理端点并把线上结构解码回来。
func listBackthinkItems(t *testing.T, ts *httptest.Server, tok string) (int, []byte, []AdminBackthinkItem) {
t.Helper()
resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/backthink", nil, tok)
var list struct {
Data []AdminBackthinkItem `json:"data"`
}
if resp.StatusCode == http.StatusOK {
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
}
return resp.StatusCode, raw, list.Data
}
// TestAdminBackthink_ExposesPushRouteDetailBypass 是 P0-20 的真断言:
// GET /api/v1/admin/backthink 必须把后端已存的 PushResult 里
// route/detail/bypassed 真的送到客户端 —— 面板才能解释「为什么打扰 /
// 为什么没打扰」。断言同时落在原始 JSON 与解码后的值上。
func TestAdminBackthink_ExposesPushRouteDetailBypass(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{
ID: 11, Trigger: "event", At: time.UnixMilli(1700), Conclusion: "服务器告警",
ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Bypassed: true, Detail: "2 active clients"},
},
{
ID: 10, Trigger: "heartbeat", At: time.UnixMilli(1600), Conclusion: "只是想了一下",
ShouldPush: false,
PushResult: backthink.PushResult{Pushed: false, Route: backthink.RouteRateLimited, Detail: "proactive push rate limited"},
},
}}
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
for _, want := range []string{
`"route":"active_client"`,
`"route":"rate_limited"`,
`"detail":"proactive push rate limited"`,
`"bypassed":true`,
} {
if !strings.Contains(string(raw), want) {
t.Fatalf("response is missing %s: %s", want, raw)
}
}
if len(items) != 2 {
t.Fatalf("items = %d, want 2", len(items))
}
got := items[0]
if got.ID != "11" || got.Route != string(backthink.RouteActiveClient) || got.Detail != "2 active clients" || !got.Bypassed || !got.Pushed {
t.Fatalf("item[0] = %+v", got)
}
silent := items[1]
if silent.Route != string(backthink.RouteRateLimited) || silent.Detail == "" || silent.Bypassed || silent.Pushed {
t.Fatalf("item[1] = %+v", silent)
}
}
// TestAdminBackthink_OmitsEmptyRouteAndDetail 钉住「没有值就不编」:未尝试
// 推送(零值 PushResult)的记录不带 route/detail 键,而 bypassed 作为
// 布尔始终显式出现 —— 面板因此能区分「这次没有路由」与「字段没接线」。
func TestAdminBackthink_OmitsEmptyRouteAndDetail(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{ID: 9, Trigger: "idle", At: time.UnixMilli(900), Conclusion: "什么都没有", ShouldPush: false},
}}
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
if len(items) != 1 {
t.Fatalf("items = %d, want 1", len(items))
}
it := items[0]
if it.Route != "" || it.Detail != "" || it.Bypassed || it.Pushed || it.ShouldNotify {
t.Fatalf("item = %+v, want zero push fields", it)
}
if strings.Contains(string(raw), `"route"`) || strings.Contains(string(raw), `"detail"`) {
t.Fatalf("empty route/detail must be omitted: %s", raw)
}
if !strings.Contains(string(raw), `"bypassed":false`) {
t.Fatalf("bypassed must always be present: %s", raw)
}
}
+41 -22
View File
@@ -1,13 +1,17 @@
package gateway
// 管理侧插件安装/卸载与已安装插件列表。v1 中的运行时
// 「插件」是一个外部 MCP server:安装即连接它、把它的
// 工具热注册进能力注册表(从而无需重启就注入下一次
// 模型请求),把其配置持久化到被 gitignore 的
// plugins.json,并持有活着的 server 以便之后刷新/卸载
// (design/09 插件生态 §1)。在单静态二进制中安装编译好的 Go 插件
// 不可能,因此不提供该方式。写操作仅 owner 且带审计;
// API key 会被持久化,但绝不返回,也绝不原样记日志。
// 管理侧插件安装/卸载与已安装插件列表。运行时「插件」按 design/09
// §2.1 有两类,都经同一个 install 端点、由 body 的 `kind` 分派:
//
// - MCP server(本文件):安装即连接它、把它的工具热注册进能力注册表
// (从而无需重启就注入下一次模型请求),把其配置持久化到被 gitignore
// 的 plugins.json,并持有活着的 server 以便之后刷新/卸载。
// - Skill(admin_plugins_skill.go):安装即解析一份工作流 YAML 并
// RegisterSkill 进注册表。
//
// 在单静态二进制中安装编译好的 Go 插件不可能,因此不提供该方式。
// 写操作仅 owner 且带审计;API key 会被持久化,但绝不返回,
// 也绝不原样记日志。
import (
"context"
@@ -36,11 +40,16 @@ type pluginActionRequest struct {
// pluginInstallRequest 是 POST /api/v1/admin/plugins/install 的请求体。Config
// 承载 MCP server 连接字段;顶层的 name/kind 被单独
// 保留,使该请求读起来与其他管理写一致。
// 保留,使该请求读起来与其他管理写一致。YAML/Path 是
// kind="skill" 的两种来源(内联内容或磁盘路径,二选一)。
type pluginInstallRequest struct {
Kind string `json:"kind"`
Name string `json:"name"`
Config pluginInstallConfig `json:"config"`
// YAML 是 Skill 定义的 YAML 文本(kind="skill")。
YAML string `json:"yaml"`
// Path 是 Skill 定义的 YAML 文件路径(kind="skill"),与 YAML 互斥。
Path string `json:"path"`
}
// pluginInstallConfig 是 MCP server 连接块。Endpoint 是 http/sse
@@ -75,10 +84,9 @@ type AdminInstalledPlugin struct {
ToolCount int `json:"toolCount"`
}
// adminPluginInstall 安装(或刷新)一个 MCP server 插件:它校验
// 请求、连接并发现该 server 的工具,然后持久化配置。
// connect/discover/register 失败会返回 502 且不持久化任何东西,
// 因此插件绝不会处于半安装状态。
// adminPluginInstall 安装(或刷新)一个运行时插件。它先按 body 的
// kind 分派(缺省为 mcp),两类的失败语义一致:校验或接入失败即返回
// 400/502/409 且不留下任何半安装状态。
func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
var req pluginInstallRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -86,19 +94,30 @@ func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
s.auditClaims(r, "plugin.install", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return
}
kind := strings.TrimSpace(req.Kind)
if kind == "" {
kind = runtimeconfig.PluginKindMCP
}
if kind != runtimeconfig.PluginKindMCP {
switch kind {
case runtimeconfig.PluginKindMCP:
s.adminInstallMCPPlugin(w, r, req)
case pluginKindSkill:
s.adminInstallSkillPlugin(w, r, req)
default:
s.auditClaims(r, "plugin.install", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "kind %q is not installable; only %s", kind, runtimeconfig.PluginKindMCP))
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "kind %q is not installable; only %s and %s", kind, runtimeconfig.PluginKindMCP, pluginKindSkill))
}
}
// adminInstallMCPPlugin 安装(或刷新)一个 MCP server 插件:它校验
// 请求、连接并发现该 server 的工具,然后持久化配置。
// connect/discover/register 失败会返回 502 且不持久化任何东西,
// 因此插件绝不会处于半安装状态。
func (s *Server) adminInstallMCPPlugin(w http.ResponseWriter, r *http.Request, req pluginInstallRequest) {
name := strings.TrimSpace(req.Name)
if name == "" {
s.auditClaims(r, "plugin.install", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
return
}
if s.plugins == nil {
@@ -108,7 +127,7 @@ func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
}
cfg := runtimeconfig.PluginConfig{
Name: name,
Kind: kind,
Kind: runtimeconfig.PluginKindMCP,
Transport: strings.TrimSpace(req.Config.Transport),
Endpoint: firstNonEmpty(req.Config.Endpoint, req.Config.URL),
Command: strings.TrimSpace(req.Config.Command),
@@ -0,0 +1,200 @@
package gateway
// Skill 运行时安装(design/09 §2.1):POST /api/v1/admin/plugins/install 的
// kind="skill" 分支。单二进制红线不允许热装编译好的 Go 程序,因此「安装
// Skill」= 解析一份 Skill 工作流 YAML(内联内容或磁盘路径)并
// RegisterSkill 进能力注册表。效果与 MCP 安装同范式:注册即刻可见
// (GET /api/v1/admin/plugins 按 kind="skill" 列出、下一轮模型请求即可枚举),
// 无需重启。执行侧仍按设计后置(Runner.Run 返回 ErrRunPending,M12 交付)。
// 仅 owner 且带审计;解析失败 400 显式作答,绝不产生半安装状态。
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
const (
// pluginKindSkill 是 install 端点接受的第二类插件(design/09 §0/§2.1)。
pluginKindSkill = "skill"
// maxSkillYAMLBytes 限制内联 YAML 与磁盘文件的大小,避免把任意巨型
// 文件读进内存。Skill 定义是提示词 + 步骤表,1 MiB 已足够宽裕。
maxSkillYAMLBytes = 1 << 20
)
// skillSource 是 Skill 定义的两种来源之一:内联 YAML 文本,或磁盘上的
// YAML 文件路径。fromFile 决定解析失败时能否回显 YAML 解码器的原始
// 错误(它可能带出被解析内容的前几个字符,见 adminInstallSkillPlugin)。
type skillSource struct {
text string
path string
fromFile bool
}
// describe 返回审计/日志用的来源标签,绝不包含 YAML 内容。
func (s skillSource) describe() string {
if s.fromFile {
return "file:" + s.path
}
return "inline"
}
// adminInstallSkillPlugin 安装(或刷新)一个 Skill:校验来源 →
// 解析 YAML → 注册进能力注册表。同名已注册的 *Skill* 视为刷新
// (与 MCP 安装「重复安装同名 = 刷新」同语义);同名已被其他 kind
// 的能力占用则 409 显式失败,绝不静默顶掉别人。
func (s *Server) adminInstallSkillPlugin(w http.ResponseWriter, r *http.Request, req pluginInstallRequest) {
src, err := skillSourceOf(req)
if err != nil {
s.auditClaims(r, "plugin.install", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err))
return
}
skill, err := s.parseSkill(src)
if err != nil {
s.auditClaims(r, "plugin.install", "error")
s.log.Error("skill install parse failed", "source", src.describe(), "error", err)
if src.fromFile {
// 内容由服务端从磁盘读出:YAML 解码错误会带出内容片段(实测
// `cannot unmarshal !!str \`LEAKMAR...\`` 会泄露前 7 个字符),
// 因此绝不回显给客户端(完整原因在后端日志里)。路径用 %s 而非
// %q:Windows 路径经 %q 会变成双反斜杠,回显给面板很难读。
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed", "skill 文件「%s」解析失败,详细原因见后端日志", src.path))
return
}
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed", "skill YAML 解析失败:%v", err))
return
}
if name := strings.TrimSpace(req.Name); name != "" && name != skill.Name() {
s.auditClaims(r, "plugin.install", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "name_mismatch", "name %q 与 YAML 中的 skill 名 %q 不一致", name, skill.Name()))
return
}
reg, ok := s.caps.(*capreg.Registry)
if !ok {
s.auditClaims(r, "plugin.install", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured"))
return
}
refreshed, err := registerSkillForInstall(reg, skill)
if err != nil {
s.auditClaims(r, "plugin.install", "error")
s.log.Error("skill install register failed", "skill", skill.Name(), "error", err)
var conflict *skillNameConflict
if errors.As(err, &conflict) {
writeError(w, apperr.Wrap(apperr.ErrConflict, "name_conflict", "%v", conflict))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_install_failed", "skill %q 注册失败", skill.Name()))
return
}
s.auditClaims(r, "plugin.install", "ok")
s.log.Info("skill installed", "skill", skill.Name(), "source", src.describe(), "refreshed", refreshed, "steps", len(skill.Steps()))
writeJSON(w, http.StatusOK, map[string]any{
"installed": true,
"name": skill.Name(),
"kind": pluginKindSkill,
"refreshed": refreshed,
"steps": len(skill.Steps()),
"guardrails": len(skill.Guardrails()),
})
}
// skillNameConflict 表示要安装的 skill 名已被另一类能力占用(409 显式
// 作答,绝不静默替换)。它是错误值,使 handler 能把「名字冲突」与
// 「注册表故障」分开映射到 409 与 500。
type skillNameConflict struct {
name string
occupiedBy string
occupiedK string
}
// Error 实现 error。
func (c *skillNameConflict) Error() string {
return fmt.Sprintf("能力名 %q 已被 kind=%s 的能力(%s)占用,不能用作 skill 名;请换名或先卸载该能力", c.name, c.occupiedK, c.occupiedBy)
}
// registerSkillForInstall 把 skill 注册进 reg 并报告是否发生了刷新:
// - 名字空闲 → 直接注册;
// - 名字已被**同类**(KindSkill)占用 → 先移除再注册(MCP 安装的
// 「重复安装同名 = 刷新」同语义;注册表已落盘的策略标记会被重新套上);
// - 名字被**其他 kind** 占用 → 返回 *skillNameConflict,绝不删除别人。
func registerSkillForInstall(reg *capreg.Registry, skill *skills.Skill) (bool, error) {
existing, err := reg.Get(skill.Name())
switch {
case err == nil && existing.Kind != capreg.KindSkill:
return false, &skillNameConflict{name: skill.Name(), occupiedBy: existing.Name, occupiedK: string(existing.Kind)}
case err == nil:
if rerr := reg.Remove(existing.Name); rerr != nil {
return false, fmt.Errorf("gateway: refresh skill %q: %w", skill.Name(), rerr)
}
if rerr := skills.Register(reg, skill); rerr != nil {
return false, fmt.Errorf("gateway: refresh skill %q: %w", skill.Name(), rerr)
}
return true, nil
case errors.Is(err, capreg.ErrNotFound):
if rerr := skills.Register(reg, skill); rerr != nil {
return false, fmt.Errorf("gateway: register skill %q: %w", skill.Name(), rerr)
}
return false, nil
default:
return false, fmt.Errorf("gateway: lookup skill %q: %w", skill.Name(), err)
}
}
// skillSourceOf 从安装请求体取出 Skill 来源,强制 YAML 与 Path 二选一
// (都空或都给都是显式 400 —— 后者会让人无法判断到底装的是哪一份)。
func skillSourceOf(req pluginInstallRequest) (skillSource, error) {
text := strings.TrimSpace(req.YAML)
path := strings.TrimSpace(req.Path)
switch {
case text != "" && path != "":
return skillSource{}, errors.New("yaml 与 path 互斥,只能给其中一个")
case path != "":
return skillSource{path: path, fromFile: true}, nil
case text != "":
return skillSource{text: text}, nil
default:
return skillSource{}, errors.New("kind=\"skill\" 需要 yaml(YAML 内容)或 path(YAML 文件路径)")
}
}
// parseSkill 用 skills.Loader 把来源解析为一个 Skill。文件来源有大小上限,
// 且只接受普通文件(目录/设备文件不是 Skill 定义)。
func (s *Server) parseSkill(src skillSource) (*skills.Skill, error) {
loader := skills.NewLoader(s.log)
if !src.fromFile {
skill, err := loader.Load(strings.NewReader(src.text))
if err != nil {
return nil, fmt.Errorf("skill yaml: %w", err)
}
return skill, nil
}
f, err := os.Open(src.path)
if err != nil {
return nil, fmt.Errorf("open %q: %w", src.path, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("stat %q: %w", src.path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%q 不是普通文件", src.path)
}
if info.Size() > maxSkillYAMLBytes {
return nil, fmt.Errorf("%q 超过 %d 字节上限", src.path, maxSkillYAMLBytes)
}
skill, err := loader.Load(io.LimitReader(f, maxSkillYAMLBytes))
if err != nil {
return nil, fmt.Errorf("skill file %q: %w", src.path, err)
}
return skill, nil
}
@@ -0,0 +1,296 @@
package gateway
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// dailyBriefYAML 是一份合法的 Skill 定义:两步工作流 + 一条引用 +
// 一条护栏,用来断言注册进注册表的确实是「工作流」而不是空壳。
const dailyBriefYAML = `name: daily-brief
description: 每日简报
prompt: 你是简报助手
steps:
- name: gather
prompt: 收集今日要点
- name: summarize
prompt: 归纳成 3 条
depends: [gather]
refs:
- name: calendar
type: tool
value: calendar
guardrails:
- id: no-pii
message: 不要泄露隐私
enforced: true
`
// skillInstallResult 是 install 响应中的 skill 分支字段。
type skillInstallResult struct {
Installed bool `json:"installed"`
Name string `json:"name"`
Kind string `json:"kind"`
Refreshed bool `json:"refreshed"`
Steps int `json:"steps"`
Guardrails int `json:"guardrails"`
}
// errorEnvelope 是 {error:{code,message}} 信封的最小投影。
type errorEnvelope struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// installSkill 发起一次 skill 安装并返回状态码与原始响应体。
func installSkill(t *testing.T, env *pluginTestEnv, body any) (int, []byte) {
t.Helper()
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", body, env.token)
return resp.StatusCode, raw
}
// TestAdminPluginInstallSkill_RegistersWorkflowCapability 是本条的真断言:
// POST kind="skill" 之后能力注册表里真的出现该 Skill(kind=skill),
// 且其步骤/引用/护栏按 YAML 落地 —— 而不是只回一个 200。
func TestAdminPluginInstallSkill_RegistersWorkflowCapability(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": dailyBriefYAML})
if status != http.StatusOK {
t.Fatalf("install status = %d, body = %s", status, raw)
}
var envelope struct {
Data skillInstallResult `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
if !envelope.Data.Installed || envelope.Data.Kind != "skill" || envelope.Data.Name != "daily-brief" || envelope.Data.Refreshed {
t.Fatalf("install data = %+v", envelope.Data)
}
if envelope.Data.Steps != 2 || envelope.Data.Guardrails != 1 {
t.Fatalf("install data counts = %+v, want 2 steps / 1 guardrail", envelope.Data)
}
cap, err := env.caps.Get("daily-brief")
if err != nil {
t.Fatalf("registry lookup: %v", err)
}
if cap.Kind != capreg.KindSkill {
t.Fatalf("capability kind = %q, want %q", cap.Kind, capreg.KindSkill)
}
skill, ok := cap.Value.(sdk.Skill)
if !ok {
t.Fatalf("capability value = %T, want sdk.Skill", cap.Value)
}
if got := skill.Steps(); len(got) != 2 || got[1].Name != "summarize" || len(got[1].Depends) != 1 {
t.Fatalf("steps = %+v", got)
}
if got := skill.Refs(); len(got) != 1 || got[0].Value != "calendar" {
t.Fatalf("refs = %+v", got)
}
if got := skill.Guardrails(); len(got) != 1 || !got[0].Enforced || got[0].Message != "不要泄露隐私" {
t.Fatalf("guardrails = %+v", got)
}
// 管理读面必须看得见它(面板靠 GET /admin/plugins 列出 kind=skill)。
resp, raw := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/plugins", nil, env.token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("plugins list status = %d, body = %s", resp.StatusCode, raw)
}
var list struct {
Data []PluginSummary `json:"data"`
}
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatalf("decode list: %v (%s)", err, raw)
}
found := false
for _, it := range list.Data {
if it.Name == "daily-brief" && it.Kind == "skill" && it.Description == "每日简报" {
found = true
}
}
if !found {
t.Fatalf("skill not visible in /admin/plugins: %+v", list.Data)
}
}
// TestAdminPluginInstallSkill_ReinstallRefreshesSameName 钉住「重复安装同名 =
// 刷新」语义:第二次带新描述的 YAML 必须换掉旧定义,而不是报冲突、
// 更不是留下旧定义(否则面板「保存成功」是假的)。
func TestAdminPluginInstallSkill_ReinstallRefreshesSameName(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
if status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": dailyBriefYAML}); status != http.StatusOK {
t.Fatalf("first install status = %d, body = %s", status, raw)
}
updated := strings.Replace(dailyBriefYAML, "description: 每日简报", "description: 每日简报 v2", 1)
status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": updated})
if status != http.StatusOK {
t.Fatalf("refresh status = %d, body = %s", status, raw)
}
var envelope struct {
Data skillInstallResult `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
if !envelope.Data.Refreshed {
t.Fatalf("refreshed = false, want true for same-name reinstall: %s", raw)
}
cap, err := env.caps.Get("daily-brief")
if err != nil {
t.Fatalf("registry lookup: %v", err)
}
if cap.Description != "每日简报 v2" {
t.Fatalf("description after refresh = %q, want 每日简报 v2", cap.Description)
}
if n := len(env.caps.List(capreg.KindSkill)); n != 1 {
t.Fatalf("skill count = %d, want 1 (refresh must not duplicate)", n)
}
}
// TestAdminPluginInstallSkill_FromFile 覆盖 design/09 §2.1 的第二种来源:
// 磁盘 YAML 路径。同时验证卸载沿用通用能力移除路径。
func TestAdminPluginInstallSkill_FromFile(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
path := filepath.Join(t.TempDir(), "daily-brief.yaml")
if err := os.WriteFile(path, []byte(dailyBriefYAML), 0o600); err != nil {
t.Fatalf("write skill file: %v", err)
}
status, raw := installSkill(t, env, map[string]any{"kind": "skill", "path": path})
if status != http.StatusOK {
t.Fatalf("install status = %d, body = %s", status, raw)
}
if _, err := env.caps.Get("daily-brief"); err != nil {
t.Fatalf("skill not registered from file: %v", err)
}
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", map[string]any{"name": "daily-brief"}, env.token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("uninstall status = %d, body = %s", resp.StatusCode, raw)
}
if _, err := env.caps.Get("daily-brief"); err == nil {
t.Fatal("skill still registered after uninstall")
}
}
// TestAdminPluginInstallSkill_RejectsNameConflictWithOtherKind 证明刷新语义
// 不会越界:同名已被一个 tool 占用时返回 409,且那个 tool 必须原样存活
// (绝不先删后装)。
func TestAdminPluginInstallSkill_RejectsNameConflictWithOtherKind(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
if err := env.caps.RegisterTool(stubTool{}); err != nil { // 名为 "echo" 的工具
t.Fatalf("register tool: %v", err)
}
conflicting := strings.Replace(dailyBriefYAML, "name: daily-brief", "name: echo", 1)
status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": conflicting})
if status != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", status, raw)
}
var envErr errorEnvelope
if err := json.Unmarshal(raw, &envErr); err != nil {
t.Fatalf("decode error envelope: %v (%s)", err, raw)
}
if envErr.Error.Code != "name_conflict" {
t.Fatalf("error code = %q, want name_conflict (%s)", envErr.Error.Code, raw)
}
cap, err := env.caps.Get("echo")
if err != nil {
t.Fatalf("tool echo disappeared after a rejected skill install: %v", err)
}
if cap.Kind != capreg.KindTool {
t.Fatalf("capability echo kind = %q, want %q", cap.Kind, capreg.KindTool)
}
}
// TestAdminPluginInstallSkill_InvalidRequestsReturn400 逐条钉住非法输入的
// 显式 400(不静默、不留半安装状态)。
func TestAdminPluginInstallSkill_InvalidRequestsReturn400(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
dir := t.TempDir()
leaky := filepath.Join(dir, "leaky.yaml")
// 类型不匹配会让 yaml 解码器把出错值的前几个字符写进错误信息,
// 因此「文件来源」这条必须只回显通用原因(见下一条用例)。
if err := os.WriteFile(leaky, []byte("name: leaky\nsteps: \"LEAKMARKER_SECRET\"\n"), 0o600); err != nil {
t.Fatalf("write leaky skill: %v", err)
}
cases := []struct {
name string
body map[string]any
wantCode string
}{
{"neither yaml nor path", map[string]any{"kind": "skill"}, "bad_request"},
{"yaml and path", map[string]any{"kind": "skill", "yaml": dailyBriefYAML, "path": leaky}, "bad_request"},
{"name mismatch", map[string]any{"kind": "skill", "name": "other", "yaml": dailyBriefYAML}, "name_mismatch"},
{"blank name in yaml", map[string]any{"kind": "skill", "yaml": "description: 无名字\n"}, "skill_parse_failed"},
{"not yaml at all", map[string]any{"kind": "skill", "yaml": "]{ not yaml"}, "skill_parse_failed"},
{"missing file", map[string]any{"kind": "skill", "path": filepath.Join(dir, "nope.yaml")}, "skill_parse_failed"},
{"path is a directory", map[string]any{"kind": "skill", "path": dir}, "skill_parse_failed"},
{"bad yaml file", map[string]any{"kind": "skill", "path": leaky}, "skill_parse_failed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
status, raw := installSkill(t, env, tc.body)
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", status, raw)
}
var envErr errorEnvelope
if err := json.Unmarshal(raw, &envErr); err != nil {
t.Fatalf("decode error envelope: %v (%s)", err, raw)
}
if envErr.Error.Code != tc.wantCode {
t.Fatalf("error code = %q, want %q (%s)", envErr.Error.Code, tc.wantCode, raw)
}
})
}
if n := len(env.caps.List(capreg.KindSkill)); n != 0 {
t.Fatalf("skills registered after rejected installs = %d, want 0", n)
}
}
// TestAdminPluginInstallSkill_FileParseErrorDoesNotEchoContent 是
// 上一条的安全面:文件由服务端读取,解析错误可能带出文件片段
// (yaml 解码器会打印出错值的前 7 个字符),因此响应体绝不回显
// 内容,只给通用原因 + 路径。
func TestAdminPluginInstallSkill_FileParseErrorDoesNotEchoContent(t *testing.T) {
t.Parallel()
env := newPluginTestEnv(t, nil)
path := filepath.Join(t.TempDir(), "leaky.yaml")
if err := os.WriteFile(path, []byte("name: leaky\nsteps: \"LEAKMARKER_SECRET\"\n"), 0o600); err != nil {
t.Fatalf("write leaky skill: %v", err)
}
status, raw := installSkill(t, env, map[string]any{"kind": "skill", "path": path})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", status, raw)
}
// "LEAKMAR" 正是解码器会带出的前 7 个字符;回显内联内容时它必现,
// 因此这一断言能真正抓住「把文件内容透给客户端」的回归。
if strings.Contains(string(raw), "LEAKMAR") {
t.Fatalf("file-sourced parse error echoed file content: %s", raw)
}
var envErr errorEnvelope
if err := json.Unmarshal(raw, &envErr); err != nil {
t.Fatalf("decode error envelope: %v (%s)", err, raw)
}
if strings.Contains(envErr.Error.Message, "LEAKMAR") {
t.Fatalf("decoded message leaked file content: %s", envErr.Error.Message)
}
if !strings.Contains(envErr.Error.Message, path) {
t.Fatalf("error should name the file path, got %q", envErr.Error.Message)
}
}
+3
View File
@@ -377,6 +377,9 @@ func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
Conclusion: rec.Conclusion,
ShouldNotify: rec.ShouldPush,
Pushed: rec.PushResult.Pushed,
Route: string(rec.PushResult.Route),
Detail: rec.PushResult.Detail,
Bypassed: rec.PushResult.Bypassed,
At: rec.At.UnixMilli(),
})
}
@@ -1,10 +1,14 @@
package gateway
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
@@ -185,3 +189,82 @@ func (s *Server) adminDeleteAutomation(w http.ResponseWriter, r *http.Request) {
s.auditClaims(r, "automation.delete", "ok")
writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true})
}
// reminderCreateRequest 是 POST /api/v1/admin/automations/reminders 的请求体。
// At 是 RFC3339(带小数秒亦可);ID 省略时由后端生成,使面板无需
// 自己保证唯一。
type reminderCreateRequest struct {
ID string `json:"id"`
At string `json:"at"`
Message string `json:"message"`
}
// adminCreateReminder 创建一条真实的定时提醒(design/10 §3 #12「提醒」)。
// 提醒进 ReminderStore 后由既有的轮询循环到点发布 reminder.trigger
// 到进程内 bus(P0-22 之前该 Store 只有测试调用,生产没有创建入口,
// 因此提醒功能实际不可达)。at 若已是过去时刻,按 Store 语义会在
// 下一次轮询立即触发 —— 这是真实行为,不是假成功。
func (s *Server) adminCreateReminder(w http.ResponseWriter, r *http.Request) {
var req reminderCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
message := strings.TrimSpace(req.Message)
rawAt := strings.TrimSpace(req.At)
if message == "" || rawAt == "" {
s.auditClaims(r, "automation.reminder.create", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "at 与 message 均为必填"))
return
}
at, err := time.Parse(time.RFC3339, rawAt)
if err != nil {
s.auditClaims(r, "automation.reminder.create", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_at", "at 必须是 RFC3339 时间戳(如 2026-10-05T09:30:00+08:00)"))
return
}
auto, ok := s.automations.(*automations.Service)
if !ok {
s.auditClaims(r, "automation.reminder.create", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
return
}
id := strings.TrimSpace(req.ID)
if id == "" {
id = newReminderID()
}
rem := automations.Reminder{ID: id, At: at, Message: message}
if err := auto.AddReminder(r.Context(), rem); err != nil {
s.auditClaims(r, "automation.reminder.create", "error")
if errors.Is(err, automations.ErrDuplicate) {
writeError(w, apperr.Wrap(apperr.ErrConflict, "already_exists", "提醒 %q 已存在", id))
return
}
if errors.Is(err, automations.ErrInvalidRule) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_rule", "提醒 id 或时间非法"))
return
}
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "提醒创建失败"))
return
}
s.auditClaims(r, "automation.reminder.create", "ok")
writeJSON(w, http.StatusCreated, map[string]any{
"id": rem.ID,
"at": rem.At.Format(time.RFC3339),
"message": rem.Message,
"kind": "reminder",
})
}
// newReminderID 生成面板省略 id 时使用的提醒 id。它用 crypto/rand 的
// 128 位十六进制随机值(与 skills.Store 的 newID 同范式),而不是
// UnixNano:Windows 的 time.Now 粒度可粗到毫秒,同一刻度内连创两条
// 会撞 id,被 ReminderStore 判为 ErrDuplicate(假失败)。
func newReminderID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand 实际不会失败;兜底也在前缀上与随机形式同形。
return "rem-" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
return "rem-" + hex.EncodeToString(b[:])
}
@@ -0,0 +1,185 @@
package gateway
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
)
// reminderTestEnv 构建一个注入了真实 automations.Service 的网关
// (P0-22 之前该服务在生产里没有任何创建入口,提醒因此不可达)。
func reminderTestEnv(t *testing.T) (*httptest.Server, *automations.Service, string) {
t.Helper()
auto, err := automations.NewService(automations.Config{}, automations.Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("new automations service: %v", err)
}
_, ts, jwt := buildEnv(t, nil, Deps{Automations: auto})
return ts, auto, signAny(t, jwt, "acct", auth.RoleOwner)
}
// postReminder 调用提醒创建端点并返回状态码与原始响应体。
func postReminder(t *testing.T, ts *httptest.Server, tok string, body any) (int, []byte) {
t.Helper()
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/automations/reminders", body, tok)
return resp.StatusCode, raw
}
// TestAdminCreateReminder_AppearsInServiceList 是 P0-22 的真断言:
// POST /api/v1/admin/automations/reminders 之后,该提醒真的出现在
// ReminderStore(Service.Reminders / List)里,且时间与文案一致 ——
// 而不是只回一个 201。
func TestAdminCreateReminder_AppearsInServiceList(t *testing.T) {
t.Parallel()
ts, auto, tok := reminderTestEnv(t)
at := time.Now().Add(2 * time.Hour).Truncate(time.Second)
status, raw := postReminder(t, ts, tok, map[string]any{"id": "rem-drink", "at": at.Format(time.RFC3339), "message": "提醒主人喝水"})
if status != http.StatusCreated {
t.Fatalf("status = %d, want 201, body = %s", status, raw)
}
var envelope struct {
Data struct {
ID string `json:"id"`
At string `json:"at"`
Message string `json:"message"`
Kind string `json:"kind"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
if envelope.Data.ID != "rem-drink" || envelope.Data.Kind != "reminder" || envelope.Data.Message != "提醒主人喝水" {
t.Fatalf("created reminder = %+v", envelope.Data)
}
if envelope.Data.At != at.Format(time.RFC3339) {
t.Fatalf("at = %q, want %q", envelope.Data.At, at.Format(time.RFC3339))
}
list := auto.Reminders()
if len(list) != 1 {
t.Fatalf("Reminders() = %+v, want exactly the created one", list)
}
if list[0].ID != "rem-drink" || list[0].Message != "提醒主人喝水" || !list[0].At.Equal(at) {
t.Fatalf("reminder in store = %+v", list[0])
}
// 管理读面(面板的自动化页)也必须看到它,且 kind 为 reminder。
resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/automations", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("automations list status = %d, body = %s", resp.StatusCode, raw)
}
var listEnv struct {
Data []AdminAutomationRecord `json:"data"`
}
if err := json.Unmarshal(raw, &listEnv); err != nil {
t.Fatalf("decode automations list: %v (%s)", err, raw)
}
found := false
for _, rec := range listEnv.Data {
if rec.ID == "rem-drink" && rec.Kind == "reminder" && rec.Name == "提醒主人喝水" {
found = true
}
}
if !found {
t.Fatalf("reminder not visible in /admin/automations: %+v", listEnv.Data)
}
}
// TestAdminCreateReminder_GeneratesIDWhenOmitted 证明面板不必自己保证
// id 唯一:省略 id 时后端生成,且两次创建得到两条不同提醒。
func TestAdminCreateReminder_GeneratesIDWhenOmitted(t *testing.T) {
t.Parallel()
ts, auto, tok := reminderTestEnv(t)
at := time.Now().Add(time.Hour).Truncate(time.Second).Format(time.RFC3339)
ids := make([]string, 0, 2)
for i := 0; i < 2; i++ {
status, raw := postReminder(t, ts, tok, map[string]any{"at": at, "message": fmt.Sprintf("第 %d 次", i)})
if status != http.StatusCreated {
t.Fatalf("status = %d, want 201, body = %s", status, raw)
}
var envelope struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
if !strings.HasPrefix(envelope.Data.ID, "rem-") {
t.Fatalf("generated id = %q, want rem- prefix", envelope.Data.ID)
}
ids = append(ids, envelope.Data.ID)
}
if ids[0] == ids[1] {
t.Fatalf("generated ids collide: %q", ids[0])
}
if n := len(auto.Reminders()); n != 2 {
t.Fatalf("Reminders() = %d, want 2", n)
}
}
// TestAdminCreateReminder_InvalidInputReturns400 逐条钉住非法输入的显式
// 400;重复 id 是 409(已有同名提醒),绝不静默覆盖。
func TestAdminCreateReminder_InvalidInputReturns400(t *testing.T) {
t.Parallel()
ts, auto, tok := reminderTestEnv(t)
at := time.Now().Add(time.Hour).Truncate(time.Second)
cases := []struct {
name string
body map[string]any
want int
code string
}{
{"missing message", map[string]any{"at": at.Format(time.RFC3339)}, http.StatusBadRequest, "bad_request"},
{"missing at", map[string]any{"message": "x"}, http.StatusBadRequest, "bad_request"},
{"at not rfc3339", map[string]any{"at": "明天早上", "message": "x"}, http.StatusBadRequest, "invalid_at"},
{"at unix millis", map[string]any{"at": "1760000000000", "message": "x"}, http.StatusBadRequest, "invalid_at"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
status, raw := postReminder(t, ts, tok, tc.body)
if status != tc.want {
t.Fatalf("status = %d, want %d, body = %s", status, tc.want, raw)
}
var envErr errorEnvelope
if err := json.Unmarshal(raw, &envErr); err != nil {
t.Fatalf("decode error envelope: %v (%s)", err, raw)
}
if envErr.Error.Code != tc.code {
t.Fatalf("error code = %q, want %q (%s)", envErr.Error.Code, tc.code, raw)
}
})
}
if n := len(auto.Reminders()); n != 0 {
t.Fatalf("reminders after rejected writes = %d, want 0", n)
}
body := map[string]any{"id": "dup", "at": at.Format(time.RFC3339), "message": "第一次"}
if status, raw := postReminder(t, ts, tok, body); status != http.StatusCreated {
t.Fatalf("first status = %d, body = %s", status, raw)
}
status, raw := postReminder(t, ts, tok, body)
if status != http.StatusConflict {
t.Fatalf("duplicate status = %d, want 409, body = %s", status, raw)
}
var envErr errorEnvelope
if err := json.Unmarshal(raw, &envErr); err != nil {
t.Fatalf("decode duplicate envelope: %v (%s)", err, raw)
}
if envErr.Error.Code != "already_exists" {
t.Fatalf("duplicate code = %q, want already_exists (%s)", envErr.Error.Code, raw)
}
if n := len(auto.Reminders()); n != 1 {
t.Fatalf("reminders = %d, want 1 (duplicate must not overwrite)", n)
}
}
@@ -80,6 +80,7 @@ var ownerOnlyRoutes = []rbacRoute{
{"iot/scenarios", http.MethodPost, "/api/v1/admin/iot/scenarios"},
{"iot/rules", http.MethodPost, "/api/v1/admin/iot/rules"},
{"automations POST", http.MethodPost, "/api/v1/admin/automations"},
{"automations/reminders POST", http.MethodPost, "/api/v1/admin/automations/reminders"},
{"automations/{id}/run", http.MethodPost, "/api/v1/admin/automations/a1/run"},
{"automations/{id} DELETE", http.MethodDelete, "/api/v1/admin/automations/a1"},
{"llm POST", http.MethodPost, "/api/v1/admin/llm"},
+4
View File
@@ -191,6 +191,10 @@ func (s *Server) registerAdminWriteRoutes(mux *http.ServeMux, ownerAuth func(htt
mux.Handle("POST /api/v1/admin/iot/scenarios", ownerAuth(http.HandlerFunc(s.adminIoTScenario)))
mux.Handle("POST /api/v1/admin/iot/rules", ownerAuth(http.HandlerFunc(s.adminIoTRule)))
mux.Handle("POST /api/v1/admin/automations", ownerAuth(http.HandlerFunc(s.adminCreateAutomation)))
// 定时提醒(P0-22):cron/event 之外的第三种自动化,到点把
// reminder.trigger 发布到进程内 bus。它是真实写入(进 ReminderStore
// 的轮询集合),因此与其他自动化写同门同审计。
mux.Handle("POST /api/v1/admin/automations/reminders", ownerAuth(http.HandlerFunc(s.adminCreateReminder)))
mux.Handle("POST /api/v1/admin/automations/{id}/run", ownerAuth(http.HandlerFunc(s.adminRunAutomation)))
mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation)))
mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider)))