diff --git a/example/vikunja/go.mod b/example/vikunja/go.mod index e18d757..2ba8ae8 100644 --- a/example/vikunja/go.mod +++ b/example/vikunja/go.mod @@ -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 diff --git a/example/vikunja/plg.json b/example/vikunja/plg.json index beebe1e..c3ac6dc 100644 --- a/example/vikunja/plg.json +++ b/example/vikunja/plg.json @@ -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", diff --git a/example/vikunja/plugin.go b/example/vikunja/plugin.go index 56859e0..7f20901 100644 --- a/example/vikunja/plugin.go +++ b/example/vikunja/plugin.go @@ -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") diff --git a/example/vikunja/plugin_test.go b/example/vikunja/plugin_test.go index 0707f22..f50e533 100644 --- a/example/vikunja/plugin_test.go +++ b/example/vikunja/plugin_test.go @@ -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) + } +}