mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 00:48:12 +00:00
fix(vikunja): body 里的 ID 必须是 JSON 数字(建任务/指派在 v2 下必定 422)
线上真调用暴露:vikunja_task_create 把 project_id 当字符串发出 →
422 validation failed: expected integer at body.project_id。同类的还有指派,
而且 v2 的 assignees **根本不接受 username 字段**(422 unexpected property),
两个分支都是坏的。单测没盖到,因为从未发过真实请求体。
实测(vikunja v2.6.0,2026-09-12):
{"project_id":"1"} → 422 expected integer
{"user_id":"1"} → 422 expected integer
{"username":"jianf"} → 422 unexpected property
{"user_id":1} → 201 ✓
{"label_id":1} → 201 ✓(插件本来就 Atoi,无需改)
修法:
- taskBody:project_id 走 parseID(数字)
- 新增 resolveUserID:用户名 → 数字 id,查 GET /users?q=(v1 用 ?s=);
**只认精确匹配**,不做「只有一条就用它」的模糊兜底 —— 指派是写别人任务的动作,猜错人更贵
- assigneeBody:v2 只发 {"user_id":N},不再带 username
- task_assignees remove:路径也用解析后的数字 id
新增 5 项回归测试钉住请求体形状(数字 project_id / user_id、无 username 字段、
数字 ID 不查用户表、移除走数字路径、未知用户给可读错误)。
版本 1.0.0 → 1.0.1。线上复验:create(project_id=1, assignees=jianf) 不再报错、
add→list 显示 jianf、标签 add/remove 正常,测试数据已清理(任务/标签残留 0)。
This commit is contained in:
@ -8,4 +8,8 @@ require gitcode.com/JianFeeeee/homeagent-sdk v1.2.0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => /root/.homeagent/hmapdev/sdk/v1.2.0
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "vikunja",
|
||||
"name_zh": "Vikunja 待办",
|
||||
"name_en": "Vikunja",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "Vikunja 待办/任务管理:任务增删改查、项目与看板桶、标签、指派、评论、关联、附件、保存筛选器、团队与分享、通知、订阅、Webhook、时间跟踪、数据导入、实例管理;并附通用 API 直通工具兜底",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.bin",
|
||||
|
||||
@ -1152,7 +1152,9 @@ func taskBody(args map[string]interface{}, includeTitle bool) map[string]interfa
|
||||
}
|
||||
}
|
||||
if v := argID(args, "project_id"); v != "" {
|
||||
body["project_id"] = v
|
||||
// 必须是 JSON 数字:发字符串 "1" 会被 Vikunja 判 422 expected integer
|
||||
// (v2 body.project_id 校验 integer;线上实测)
|
||||
body["project_id"] = parseID(v)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@ -1214,7 +1216,12 @@ func (p *Plugin) handleTaskCreate(args map[string]interface{}) (interface{}, err
|
||||
}
|
||||
if asg := argStr(args, "assignees"); asg != "" {
|
||||
for _, u := range splitCSV(asg) {
|
||||
if _, err := p.call(p.addAssigneeMethod(), "/tasks/"+taskID+"/assignees", map[string]interface{}{"user_id": u, "username": u}); err != nil {
|
||||
body, berr := p.assigneeBody(u)
|
||||
if berr != nil {
|
||||
notes = append(notes, "指派 "+u+" 失败: "+berr.Error())
|
||||
continue
|
||||
}
|
||||
if _, err := p.call(p.addAssigneeMethod(), "/tasks/"+taskID+"/assignees", body); err != nil {
|
||||
notes = append(notes, "指派 "+u+" 失败: "+err.Error())
|
||||
}
|
||||
}
|
||||
@ -1403,17 +1410,21 @@ func (p *Plugin) handleTaskAssignees(args map[string]interface{}) (interface{},
|
||||
if user == "" {
|
||||
return nil, errors.New("add 需要参数 user(用户名或用户 ID)")
|
||||
}
|
||||
body := map[string]interface{}{"username": user}
|
||||
if n, err := strconv.Atoi(user); err == nil {
|
||||
body["user_id"] = n
|
||||
body, err := p.assigneeBody(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.call(p.addAssigneeMethod(), "/tasks/"+id+"/assignees", body)
|
||||
case "remove":
|
||||
user := argStr(args, "user")
|
||||
if user == "" {
|
||||
return nil, errors.New("remove 需要参数 user(用户 ID)")
|
||||
return nil, errors.New("remove 需要参数 user(用户名或用户 ID)")
|
||||
}
|
||||
return p.call(http.MethodDelete, "/tasks/"+id+"/assignees/"+user, nil)
|
||||
uid, err := p.resolveUserID(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.call(http.MethodDelete, "/tasks/"+id+"/assignees/"+strconv.Itoa(uid), nil)
|
||||
}
|
||||
return nil, fmt.Errorf("未知 action: %s(可用 list|add|remove)", action)
|
||||
}
|
||||
@ -1531,6 +1542,78 @@ func parseID(s string) interface{} {
|
||||
return s
|
||||
}
|
||||
|
||||
// numOf 把 JSON 解出的值取成整数(SDK 放进 map 的数字是 float64)
|
||||
func numOf(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case json.Number:
|
||||
if i, err := n.Int64(); err == nil {
|
||||
return int(i)
|
||||
}
|
||||
case string:
|
||||
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveUserID 把「用户名或 ID」解析成**数字** user_id。
|
||||
//
|
||||
// 为什么必须解析:v2 的 assignees 只接受 {"user_id":N}
|
||||
// - 发 {"username":"x"} → 422 unexpected property
|
||||
// - 发 {"user_id":"1"} → 422 expected integer
|
||||
//
|
||||
// 两种都已在线上实测确认(2026-09-12)。
|
||||
func (p *Plugin) resolveUserID(user string) (int, error) {
|
||||
u := strings.TrimSpace(user)
|
||||
if u == "" {
|
||||
return 0, errors.New("需要参数 user(用户名或用户 ID)")
|
||||
}
|
||||
if n, err := strconv.Atoi(u); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
res, err := p.call(http.MethodGet, qv("/users", p.searchParam(), u), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("按用户名 %q 查用户失败: %w", u, err)
|
||||
}
|
||||
items := asSlice(res)
|
||||
var names []string
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprint(m["username"])
|
||||
names = append(names, name)
|
||||
if strings.EqualFold(name, u) {
|
||||
if id := numOf(m["id"]); id > 0 {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 只认精确匹配(不区分大小写)。不做「只有一条就用它」的模糊兜底:
|
||||
// 指派会写到别人的任务上,猜错人比让模型改用数字 ID 更贵。
|
||||
if len(names) > 0 {
|
||||
return 0, fmt.Errorf("找不到用户 %q(同名/相近的:%s);也可直接传数字用户 ID", u, strings.Join(names, ", "))
|
||||
}
|
||||
return 0, fmt.Errorf("找不到用户 %q", u)
|
||||
}
|
||||
|
||||
// assigneeBody 拼出指派请求体:只含数字 user_id(见 resolveUserID 的说明)
|
||||
func (p *Plugin) assigneeBody(user string) (map[string]interface{}, error) {
|
||||
uid, err := p.resolveUserID(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"user_id": uid}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleTaskAttachments(args map[string]interface{}) (interface{}, error) {
|
||||
p.ensure()
|
||||
id := argID(args, "id")
|
||||
|
||||
@ -401,3 +401,123 @@ func TestCompactToggle(t *testing.T) {
|
||||
t.Errorf("关闭精简后应保留 description")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 回归:JSON body 里的 ID 必须是数字(线上实测的 422 缺口)────────────
|
||||
//
|
||||
// vikunja v2.6.0 实测(2026-09-12):
|
||||
// {"project_id":"1"} → 422 expected integer at body.project_id
|
||||
// {"user_id":"1"} → 422 expected integer at body.user_id
|
||||
// {"username":"jianf"} → 422 unexpected property at body.username
|
||||
// 旧实现把 argID() 的字符串直接塞进 body,assignee 还额外带 username,
|
||||
// 于是「建任务」「指派」在 v2 下必定失败 —— 只有真调用才暴露,单测没盖到。
|
||||
|
||||
func TestTaskCreateSendsNumericProjectID(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
var raw []byte
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ = io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{"id":42,"title":"买菜"}`))
|
||||
})
|
||||
// project_id 传 float64 —— 这正是 SDK 从 JSON 解出来的真实类型
|
||||
if _, err := p.handleTaskCreate(map[string]interface{}{"project_id": float64(3), "title": "买菜"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if _, ok := body["project_id"].(float64); !ok {
|
||||
t.Errorf("project_id 必须是 JSON 数字,实际 %T=%v", body["project_id"], body["project_id"])
|
||||
}
|
||||
if strings.Contains(string(raw), `"project_id":"`) {
|
||||
t.Errorf("出现字符串型 project_id(v2 会 422 expected integer): %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeAddResolvesUsernameToNumericUserID(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
var raw []byte
|
||||
var calls []string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users":
|
||||
if r.URL.Query().Get("q") != "alice" {
|
||||
t.Errorf("v2 用户搜索应用 q=,实际 query=%q", r.URL.RawQuery)
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"id":7,"username":"alice"},{"id":9,"username":"alice2"}]`))
|
||||
case "/api/v2/tasks/1/assignees":
|
||||
raw, _ = io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"user_id":7}`))
|
||||
default:
|
||||
t.Errorf("意外请求: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "alice"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("应先查用户再指派,实际调用: %v", calls)
|
||||
}
|
||||
if n, ok := body["user_id"].(float64); !ok || int(n) != 7 {
|
||||
t.Errorf("user_id 必须是数字 7,实际 %T=%v", body["user_id"], body["user_id"])
|
||||
}
|
||||
if _, ok := body["username"]; ok {
|
||||
t.Errorf("v2 不接受 username 字段(422 unexpected property): %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeAddNumericUserSkipsLookup(t *testing.T) {
|
||||
var calls []string
|
||||
var body map[string]interface{}
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
if r.URL.Path == "/api/v2/users" {
|
||||
t.Errorf("传数字 ID 时不该再查用户表")
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"user_id":7}`))
|
||||
})
|
||||
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "7"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Errorf("应只有一次请求,实际: %v", calls)
|
||||
}
|
||||
if n, ok := body["user_id"].(float64); !ok || int(n) != 7 {
|
||||
t.Errorf("user_id 应为数字 7,实际 %T=%v", body["user_id"], body["user_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeRemoveUsesResolvedNumericPath(t *testing.T) {
|
||||
var gotPath string
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/users":
|
||||
_, _ = w.Write([]byte(`[{"id":7,"username":"alice"}]`))
|
||||
default:
|
||||
gotPath = r.Method + " " + r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
})
|
||||
if _, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "remove", "user": "alice"}); err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if gotPath != "DELETE /api/v2/tasks/1/assignees/7" {
|
||||
t.Errorf("移除应用解析出的数字 ID,实际 %q", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssigneeAddUnknownUserGivesReadableError(t *testing.T) {
|
||||
p, _ := newTestPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`[{"id":7,"username":"bob"}]`))
|
||||
})
|
||||
_, err := p.handleTaskAssignees(map[string]interface{}{"id": "1", "action": "add", "user": "alice"})
|
||||
if err == nil {
|
||||
t.Fatal("找不到用户时必须报错,而不是发出一个注定 422 的请求")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "找不到用户") || !strings.Contains(err.Error(), "bob") {
|
||||
t.Errorf("错误信息应说明找不到并给出相近候选: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user