From 19e19bcd2bd4a3926bf80708a50ef0720eb3d570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=81=8B=E5=B7=9D?= Date: Wed, 12 Aug 2026 17:44:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8B=9B=E8=81=98?= =?UTF-8?q?=E8=81=8C=E4=BD=8D=E7=AE=A1=E7=90=86=20to#85340676?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changes/976-recruit-job.md | 5 + internal/helpers/recruit.go | 284 ++++++++++++++++++ internal/helpers/recruit_test.go | 207 +++++++++++++ internal/helpers/register_products_test.go | 2 +- internal/helpers/register_recruit.go | 11 + pkg/edition/default.go | 16 +- pkg/edition/edition_test.go | 8 + skills/content-qa/mono-multi-coverage.yaml | 4 + skills/mono/SKILL.md | 2 + skills/mono/references/products/recruit.md | 29 ++ skills/multi/dingtalk-misc/SKILL.md | 3 +- .../multi/dingtalk-misc/references/recruit.md | 64 ++++ test/cli/unregistered_modules_command_test.go | 5 +- 13 files changed, 632 insertions(+), 8 deletions(-) create mode 100644 .changes/976-recruit-job.md create mode 100644 internal/helpers/recruit.go create mode 100644 internal/helpers/recruit_test.go create mode 100644 internal/helpers/register_recruit.go create mode 100644 skills/mono/references/products/recruit.md create mode 100644 skills/multi/dingtalk-misc/references/recruit.md diff --git a/.changes/976-recruit-job.md b/.changes/976-recruit-job.md new file mode 100644 index 00000000..a1be13c3 --- /dev/null +++ b/.changes/976-recruit-job.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。 diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go new file mode 100644 index 00000000..315fbc39 --- /dev/null +++ b/internal/helpers/recruit.go @@ -0,0 +1,284 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +const ( + recruitServerID = "recruit" + recruitCreateJobTool = "create_job" + recruitGetJobTool = "get_job_detail" + recruitListJobsTool = "list_jobs" +) + +var recruitDryRun = &contract.DryRunSpec{ + PreviewKind: contract.DryRunPreviewInvocation, + RemoteReads: false, +} + +func newRecruitCommand() *cobra.Command { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: "recruit", + Selection: contract.ProductSelectionDecl{ + AgentSummary: "查询和创建钉钉招聘职位", + UseWhen: []string{"需要查询招聘职位列表、查看职位详情或创建职位时"}, + AvoidWhen: []string{"查询企业员工与组织架构使用 contact;查询人才池、职业历程或绩效使用 hrbrain"}, + }, + }) + + root := &cobra.Command{ + Use: "recruit", + Short: "钉钉招聘", + Long: "查询和创建钉钉招聘中的职位信息。", + RunE: groupRunE, + } + job := &cobra.Command{ + Use: "job", + Short: "招聘职位管理", + RunE: groupRunE, + } + job.AddCommand( + newRecruitJobListCommand(), + newRecruitJobGetCommand(), + newRecruitJobCreateCommand(), + ) + root.AddCommand(job) + return root +} + +func newRecruitJobListCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "list", + Short: "查询招聘职位列表", + Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。", + Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json", + Server: recruitServerID, + Tool: recruitListJobsTool, + Safety: recruitSafetyRead(), + Flags: []LeafFlag{ + {Name: "job-ids", Usage: "职位 ID,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "jobIds"}, + {Name: "required-edu", Usage: "学历要求枚举值", Kind: LeafInt, Bind: "requiredEdu"}, + {Name: "status", Usage: "职位状态:draft/open/invalid/closed,多个值用逗号分隔", Bind: "statusList", Trim: true, OmitEmpty: true, Transform: transformRecruitStatuses}, + {Name: "job-nature", Usage: "职位性质", Bind: "jobNature", Trim: true, OmitEmpty: true}, + {Name: "campus", Usage: "是否为校园招聘", Kind: LeafBool, Bind: "campus"}, + {Name: "start-modified-time", Usage: "修改时间范围起点", Bind: "startModifiedTime", Trim: true, OmitEmpty: true}, + {Name: "end-modified-time", Usage: "修改时间范围终点", Bind: "endModifiedTime", Trim: true, OmitEmpty: true}, + {Name: "creator-user-ids", Usage: "创建人 userId,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "creatorUserIds"}, + {Name: "keyword", Usage: "职位搜索关键词", Bind: "keyword", Trim: true, OmitEmpty: true}, + {Name: "category", Usage: "职位分类", Bind: "category", Trim: true, OmitEmpty: true}, + {Name: "cursor", Usage: "分页游标;首次查询不传,翻页时传返回的 nextCursor", Kind: LeafInt, Bind: "cursor"}, + {Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", ArgDefault: "20"}, + }, + Validate: validateRecruitList, + Call: runRecruitJobList, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"}, + Description: "按条件分页查询招聘职位", + DryRun: recruitDryRun, + Interface: recruitMCPInterface(recruitListJobsTool), + Selection: contract.SelectionSpec{ + AgentSummary: "按关键词、状态、创建人等条件分页查询招聘职位", + UseWhen: []string{"需要查找职位、筛选在招职位或取得 jobId 时"}, + AvoidWhen: []string{"已经持有明确 jobId 且需要完整详情时使用 recruit job get"}, + Examples: []string{`dws recruit job list --keyword "Java" --status open --size 20 --format json`}, + }, + Parameters: recruitListParamDecls(), + }, + }) +} + +func runRecruitJobList(_ *cobra.Command, tool string, args map[string]any) error { + params := map[string]any{"param": map[string]any{}, "size": args["size"]} + query := params["param"].(map[string]any) + for key, value := range args { + switch key { + case "cursor": + params[key] = value + case "size": + default: + query[key] = value + } + } + return callMCPToolOnServer(recruitServerID, tool, params) +} + +func newRecruitJobGetCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "get", + Short: "查询招聘职位详情", + Long: "根据职位 ID 查询招聘职位详情。", + Example: " dws recruit job get --job-id JOB_ID --format json", + Server: recruitServerID, + Tool: recruitGetJobTool, + Safety: recruitSafetyRead(), + Flags: []LeafFlag{{ + Name: "job-id", Usage: "职位 ID(必填)", Bind: "jobId", Trim: true, + Required: true, RequiredHint: "--job-id 为必填", + }}, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitGetJobTool, CanonicalPath: "recruit.get_job_detail", CLIPath: "recruit job get", PrimaryCLIPath: "recruit job get"}, + Description: "根据职位 ID 查询招聘职位详情", + DryRun: recruitDryRun, + Interface: recruitMCPInterface(recruitGetJobTool), + Selection: contract.SelectionSpec{ + AgentSummary: "获取指定招聘职位的完整信息", + UseWhen: []string{"已经持有明确 jobId,需要查看职位详情时"}, + AvoidWhen: []string{"不知道 jobId 时先使用 recruit job list 查询"}, + Examples: []string{"dws recruit job get --job-id --format json"}, + }, + Parameters: []contract.ParamDecl{{Name: "job-id", Property: "jobId", Required: boolPtr(true), InterfaceType: "string"}}, + }, + }) +} + +func newRecruitJobCreateCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "create", + Short: "创建招聘职位", + Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。", + Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json", + Server: recruitServerID, + Tool: recruitCreateJobTool, + Safety: recruitSafetyCreate(), + Flags: []LeafFlag{{ + Name: "from", Usage: "职位 JSON 文件路径(必填)", Bind: "atsAddJobParam", Trim: true, + Required: true, RequiredHint: "--from 为必填", Transform: loadRecruitJobFile, + }}, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitCreateJobTool, CanonicalPath: "recruit.create_job", CLIPath: "recruit job create", PrimaryCLIPath: "recruit job create"}, + Description: "从 JSON 文件创建招聘职位", + DryRun: recruitDryRun, + Interface: recruitMCPInterface(recruitCreateJobTool), + Selection: contract.SelectionSpec{ + AgentSummary: "使用结构化 JSON 创建招聘职位", + UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、minSalary、maxSalary"}, + AvoidWhen: []string{"仅查询职位时使用 recruit job list 或 recruit job get"}, + Examples: []string{"dws recruit job create --from ./job.json --dry-run --format json"}, + }, + Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 读取后作为 atsAddJobParam 对象发送"}}, + }, + }) +} + +func recruitListParamDecls() []contract.ParamDecl { + return []contract.ParamDecl{ + {Name: "job-ids", Property: "param.jobIds", InterfaceType: "array"}, + {Name: "required-edu", Property: "param.requiredEdu", InterfaceType: "number"}, + {Name: "status", Property: "param.statusList", InterfaceType: "array", Enum: []string{"draft", "open", "invalid", "closed"}}, + {Name: "job-nature", Property: "param.jobNature", InterfaceType: "string"}, + {Name: "campus", Property: "param.campus", InterfaceType: "boolean"}, + {Name: "start-modified-time", Property: "param.startModifiedTime", InterfaceType: "string"}, + {Name: "end-modified-time", Property: "param.endModifiedTime", InterfaceType: "string"}, + {Name: "creator-user-ids", Property: "param.creatorUserIds", InterfaceType: "array"}, + {Name: "keyword", Property: "param.keyword", InterfaceType: "string"}, + {Name: "category", Property: "param.category", InterfaceType: "string"}, + {Name: "cursor", Property: "cursor", InterfaceType: "number"}, + {Name: "size", Property: "size", Required: boolPtr(true), InterfaceType: "number"}, + } +} + +func recruitMCPInterface(tool string) *contract.InterfaceSpec { + return &contract.InterfaceSpec{ + Mode: contract.InterfaceModeMCP, + Availability: contract.InterfaceAvailable, + Ref: &contract.InterfaceRefSpec{ProductID: recruitServerID, RPCName: tool}, + } +} + +func recruitSafetyRead() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func recruitSafetyCreate() contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent"} +} + +func transformRecruitStatuses(raw string) (any, error) { + values := strings.Split(raw, ",") + statuses := make([]int, 0, len(values)) + seen := make(map[int]bool, len(values)) + lookup := map[string]int{"draft": 0, "open": 1, "invalid": 2, "closed": 3} + for _, value := range values { + name := strings.ToLower(strings.TrimSpace(value)) + if name == "" { + continue + } + status, ok := lookup[name] + if !ok { + return nil, apperrors.NewValidation(fmt.Sprintf("--status 不支持 %q,可选 draft/open/invalid/closed", value)) + } + if !seen[status] { + statuses = append(statuses, status) + seen[status] = true + } + } + return statuses, nil +} + +func validateRecruitList(cmd *cobra.Command, _ []string) error { + if cursor, _ := cmd.Flags().GetInt("cursor"); cursor < 0 { + return apperrors.NewValidation("--cursor 必须大于或等于 0") + } + size, _ := cmd.Flags().GetInt("size") + if cmd.Flags().Changed("size") && (size < 1 || size > 100) { + return apperrors.NewValidation("--size 必须在 1 到 100 之间") + } + return nil +} + +func loadRecruitJobFile(path string) (any, error) { + data, err := os.ReadFile(strings.TrimSpace(path)) + if err != nil { + return nil, fmt.Errorf("读取职位 JSON 失败: %w", err) + } + var job map[string]any + if err := json.Unmarshal(data, &job); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("职位文件不是有效的 JSON 对象: %v", err)) + } + if job == nil { + return nil, apperrors.NewValidation("职位 JSON 顶层必须是对象") + } + if err := validateRecruitJob(job); err != nil { + return nil, err + } + return job, nil +} + +func validateRecruitJob(job map[string]any) error { + for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "minSalary", "maxSalary"} { + value, ok := job[name] + if !ok || value == nil || (isString(value) && strings.TrimSpace(value.(string)) == "") { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 缺少必填字段 %s", name)) + } + } + for _, name := range []string{"name", "description", "jobNature"} { + if !isString(job[name]) { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name)) + } + } + for _, name := range []string{"requiredEdu", "minSalary", "maxSalary"} { + if _, ok := job[name].(float64); !ok { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是数字", name)) + } + } + if job["minSalary"].(float64) > job["maxSalary"].(float64) { + return apperrors.NewValidation("职位 JSON 中 minSalary 不能大于 maxSalary") + } + return nil +} + +func isString(value any) bool { + _, ok := value.(string) + return ok +} diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go new file mode 100644 index 00000000..274ab409 --- /dev/null +++ b/internal/helpers/recruit_test.go @@ -0,0 +1,207 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "context" + "encoding/json" + stderrors "errors" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type recruitCapturedCall struct { + productID string + tool string + args map[string]any +} + +type recruitCaptureCaller struct { + productID string + tool string + args map[string]any + calls []recruitCapturedCall +} + +func (c *recruitCaptureCaller) CallTool(_ context.Context, productID, tool string, args map[string]any) (*edition.ToolResult, error) { + c.productID = productID + c.tool = tool + c.args = args + c.calls = append(c.calls, recruitCapturedCall{productID: productID, tool: tool, args: args}) + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil +} + +func (c *recruitCaptureCaller) Format() string { return "json" } +func (c *recruitCaptureCaller) DryRun() bool { return false } +func (c *recruitCaptureCaller) Fields() string { return "" } +func (c *recruitCaptureCaller) JQ() string { return "" } + +func withRecruitCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func TestRecruitJobListWrapsQueryParameters(t *testing.T) { + caller := withRecruitCaller(t) + cmd := newRecruitJobListCommand() + cmd.SetArgs([]string{ + "--keyword", "Java", "--status", "open,draft", "--creator-user-ids", "u1,u2", + "--campus=false", "--cursor", "10", "--size", "30", + }) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != recruitServerID || caller.tool != recruitListJobsTool { + t.Fatalf("dispatch = %s/%s, want %s/%s", caller.productID, caller.tool, recruitServerID, recruitListJobsTool) + } + if caller.args["cursor"] != 10 || caller.args["size"] != 30 { + t.Fatalf("pagination args = %#v", caller.args) + } + param, ok := caller.args["param"].(map[string]any) + if !ok { + t.Fatalf("param = %#v, want object", caller.args["param"]) + } + if param["keyword"] != "Java" || param["campus"] != false { + t.Fatalf("param = %#v", param) + } + statuses, ok := param["statusList"].([]int) + if !ok || len(statuses) != 2 || statuses[0] != 1 || statuses[1] != 0 { + t.Fatalf("statusList = %#v", param["statusList"]) + } + creators, ok := param["creatorUserIds"].([]string) + if !ok || len(creators) != 2 || creators[0] != "u1" || creators[1] != "u2" { + t.Fatalf("creatorUserIds = %#v", param["creatorUserIds"]) + } +} + +func TestRecruitJobListDefaultsAndValidation(t *testing.T) { + caller := withRecruitCaller(t) + cmd := newRecruitJobListCommand() + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.args["size"] != 20 { + t.Fatalf("size = %#v, want 20", caller.args["size"]) + } + if _, ok := caller.args["cursor"]; ok { + t.Fatalf("first page unexpectedly sent cursor: %#v", caller.args) + } + + badStatus := newRecruitJobListCommand() + badStatus.SetArgs([]string{"--status", "unknown"}) + if err := badStatus.Execute(); err == nil || !strings.Contains(err.Error(), "draft/open/invalid/closed") { + t.Fatalf("invalid status error = %v", err) + } + + badSize := newRecruitJobListCommand() + badSize.SetArgs([]string{"--size", "101"}) + if err := badSize.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 100") { + t.Fatalf("invalid size error = %v", err) + } +} + +func TestRecruitJobGetDispatchesJobID(t *testing.T) { + caller := withRecruitCaller(t) + cmd := newRecruitJobGetCommand() + cmd.SetArgs([]string{"--job-id", "job-1"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != recruitServerID || caller.tool != recruitGetJobTool || caller.args["jobId"] != "job-1" { + t.Fatalf("captured call = %s/%s %#v", caller.productID, caller.tool, caller.args) + } +} + +func writeRecruitJobFixture(t *testing.T) (string, map[string]any) { + t.Helper() + path := filepath.Join(t.TempDir(), "job.json") + job := map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", + "requiredEdu": 1, "minSalary": 20000, "maxSalary": 35000, + } + data, err := json.Marshal(job) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path, job +} + +func TestRecruitJobCreateRequiresConfirmationBeforeRemoteCall(t *testing.T) { + caller := withRecruitCaller(t) + path, _ := writeRecruitJobFixture(t) + cmd := newRecruitJobCreateCommand() + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs([]string{"--from", path}) + err := cmd.Execute() + var appErr *apperrors.Error + if !stderrors.As(err, &appErr) || appErr.Reason != "confirmation_required" { + t.Fatalf("create without confirmation error = %T %v, want confirmation_required", err, err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP calls before confirmation = %d: %#v, want none", len(caller.calls), caller.calls) + } +} + +func TestRecruitJobCreateCallsRemoteOnceWhenConfirmed(t *testing.T) { + caller := withRecruitCaller(t) + path, _ := writeRecruitJobFixture(t) + cmd := newRecruitJobCreateCommand() + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetArgs([]string{"--from", path, "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("MCP calls after confirmation = %d: %#v, want exactly one", len(caller.calls), caller.calls) + } + call := caller.calls[0] + if call.productID != recruitServerID || call.tool != recruitCreateJobTool { + t.Fatalf("dispatch = %s/%s, want %s/%s", call.productID, call.tool, recruitServerID, recruitCreateJobTool) + } + wantArgs := map[string]any{"atsAddJobParam": map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL_TIME", + "requiredEdu": float64(1), "minSalary": float64(20000), "maxSalary": float64(35000), + }} + if !reflect.DeepEqual(call.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", call.args, wantArgs) + } +} + +func TestRecruitCreateValidatesJobFile(t *testing.T) { + withRecruitCaller(t) + badPath := filepath.Join(t.TempDir(), "bad-job.json") + if err := os.WriteFile(badPath, []byte(`{"name":"缺字段"}`), 0o600); err != nil { + t.Fatal(err) + } + bad := newRecruitJobCreateCommand() + bad.Flags().Bool("yes", false, "确认创建") + bad.SetArgs([]string{"--from", badPath, "--yes"}) + if err := bad.Execute(); err == nil || !strings.Contains(err.Error(), "description") { + t.Fatalf("missing field error = %v", err) + } +} + +func TestRecruitCommandTree(t *testing.T) { + root := newRecruitCommand() + for _, path := range [][]string{{"job", "list"}, {"job", "get"}, {"job", "create"}} { + cmd, _, err := root.Find(path) + if err != nil || cmd == root || cmd.Name() != path[len(path)-1] { + t.Fatalf("Find(%v) = %v, %v", path, cmd, err) + } + } +} diff --git a/internal/helpers/register_products_test.go b/internal/helpers/register_products_test.go index 7e89a565..16933d43 100644 --- a/internal/helpers/register_products_test.go +++ b/internal/helpers/register_products_test.go @@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t for _, want := range []string{ "agoal", "aisearch", "aitable", "attendance", "calendar", "chat", "contact", "devdoc", "ding", "doc", "drive", "live", "mail", - "markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard", + "markdown", "minutes", "oa", "recruit", "report", "sheet", "todo", "wiki", "whiteboard", } { if !seenProducts[want] { t.Errorf("public product %q was not registered", want) diff --git a/internal/helpers/register_recruit.go b/internal/helpers/register_recruit.go new file mode 100644 index 00000000..3ba9ceb2 --- /dev/null +++ b/internal/helpers/register_recruit.go @@ -0,0 +1,11 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +// 招聘是开源库显式维护的公开命令,不依赖生成式产品注册表。 +func init() { + RegisterPublic(func() Handler { + return wukongHandler{name: "recruit", buildFn: newRecruitCommand} + }) +} diff --git a/pkg/edition/default.go b/pkg/edition/default.go index 5e0310b6..04e92642 100644 --- a/pkg/edition/default.go +++ b/pkg/edition/default.go @@ -43,11 +43,11 @@ func defaultHooks() *Hooks { } } -// openSupplementServers returns helper-only MCP endpoints owned by the open -// CLI. They are callable by explicit server ID but are deliberately excluded -// from VisibleProducts, so no top-level product command is generated. They -// stay separate from syncdata.StaticServers because only explicitly wired CLI -// helpers may call them; they are not public MCP product surfaces. +// openSupplementServers returns explicitly wired MCP endpoints owned by the +// open CLI. They are callable by explicit server ID but are deliberately +// excluded from VisibleProducts, so this hook never generates a top-level +// product command. A public command using one of these endpoints must register +// its Cobra tree explicitly via helpers.RegisterPublic. func openSupplementServers() []ServerInfo { return []ServerInfo{ { @@ -60,6 +60,12 @@ func openSupplementServers() []ServerInfo { Name: "钉钉白板", Endpoint: "https://mcp-gw.dingtalk.com/server/whiteboard", }, + { + ID: "recruit", + Name: "钉钉招聘", + Endpoint: "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff", + Prefixes: []string{"recruit", "job"}, + }, } } diff --git a/pkg/edition/edition_test.go b/pkg/edition/edition_test.go index c581c714..7418836e 100644 --- a/pkg/edition/edition_test.go +++ b/pkg/edition/edition_test.go @@ -107,7 +107,12 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { servers := openSupplementServers() foundMCPMeta := false foundWhiteboard := false + foundRecruit := false for _, server := range servers { + if server.ID == "recruit" { + foundRecruit = server.Endpoint == "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff" && + len(server.Prefixes) == 2 && server.Prefixes[0] == "recruit" && server.Prefixes[1] == "job" + } if server.ID == "whiteboard" { foundWhiteboard = server.Endpoint == "https://mcp-gw.dingtalk.com/server/whiteboard" } @@ -128,4 +133,7 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { if !foundWhiteboard { t.Fatal("openSupplementServers() missing helper-only whiteboard endpoint") } + if !foundRecruit { + t.Fatal("openSupplementServers() missing explicitly wired recruit endpoint") + } } diff --git a/skills/content-qa/mono-multi-coverage.yaml b/skills/content-qa/mono-multi-coverage.yaml index 440c149e..8dc8895a 100644 --- a/skills/content-qa/mono-multi-coverage.yaml +++ b/skills/content-qa/mono-multi-coverage.yaml @@ -108,6 +108,10 @@ coverage: multi_skill: dingtalk-misc multi_refs: - references/whiteboard.md + - mono: recruit + multi_skill: dingtalk-misc + multi_refs: + - references/recruit.md omit_coverage: - mono: simple diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 8fbd5975..3f491809 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -94,6 +94,7 @@ cli_version: ">=1.0.15" | `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) | | `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) | | `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) | +| `recruit` | 钉钉招聘:查询职位列表、获取职位详情、创建职位 | [recruit.md](./references/products/recruit.md) | | `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) | ## 意图判断决策树 @@ -121,6 +122,7 @@ cli_version: ">=1.0.15" 用户提到"待办/TODO/任务提醒/循环待办" → `todo` 用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki` 用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert` +用户提到"招聘/职位/JD/在招职位/创建职位/职位详情" → `recruit` 用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume` 用户提到"监听待我审批的任务/监听审批任务创建、完成或转交/监听审批单发起或终止/监听我发起的审批完成/监听审批实例完成/订阅 OA 事件/event consume user_oa_approval_*" → `event consume` diff --git a/skills/mono/references/products/recruit.md b/skills/mono/references/products/recruit.md new file mode 100644 index 00000000..6c2b48f8 --- /dev/null +++ b/skills/mono/references/products/recruit.md @@ -0,0 +1,29 @@ +# 钉钉招聘 + +`dws recruit` 提供招聘职位列表、职位详情和职位创建能力。组织、业务标识和当前 +操作人由登录身份及 MCP Connector 注入,不要要求用户提供或猜测身份字段。 + +## 查询 + +```bash +dws recruit job list --keyword "Java" --status open --size 20 --format json +dws recruit job get --job-id JOB_ID --format json +``` + +列表状态支持 `draft`、`open`、`invalid`、`closed`。首页不传 `--cursor`; +`hasMore=true` 时把响应 `nextCursor` 回填。查询详情前必须从真实列表结果取得 +`jobId`。 + +## 创建 + +创建是非幂等写操作。先用 `--dry-run` 检查;实际执行必须经过用户明确确认,并 +交由 CLI 的确认流程处理: + +```bash +dws recruit job create --from ./job.json --dry-run --format json +dws recruit job create --from ./job.json --format json +``` + +JSON 必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、 +`minSalary`、`maxSalary`。后三者为数字,且最低薪资不得大于最高薪资。文件只写 +职位对象,不要添加 MCP 信封字段 `atsAddJobParam`。 diff --git a/skills/multi/dingtalk-misc/SKILL.md b/skills/multi/dingtalk-misc/SKILL.md index 487f8c7f..d4eed650 100644 --- a/skills/multi/dingtalk-misc/SKILL.md +++ b/skills/multi/dingtalk-misc/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-misc -description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 +description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 metadata: cli_version: ">=0.2.14" category: product @@ -31,6 +31,7 @@ metadata: | 电子表格 / 工作表 / 单元格读写 / 公式 / 超链接 / 浮动图片 | 电子表格创建/读写/公式/超链接/浮动图片/导出 | `dws sheet` | [sheet.md](references/sheet.md) | | 开放平台文档 / API文档 / 接口文档 / 接口报错 | 开放平台开发文档搜索 | `dws devdoc` | [devdoc.md](references/devdoc.md) | | 白板 / 画布 / OpenNodes / 白板节点 | 读取和更新钉钉文档中的内嵌白板 | `dws whiteboard` | [whiteboard.md](references/whiteboard.md) | +| 招聘 / 职位 / JD / 在招职位 / 创建职位 / 职位详情 | 钉钉招聘职位的查询、详情与创建 | `dws recruit` | [recruit.md](references/recruit.md) | | 搜索技能 / 找技能 / 安装技能 / 技能市场 / 安装 DWS mono 或 multi skill | DWS 技能市场搜索、下载、安装与内置技能部署 | `dws skill` | [skill.md](references/skill.md) | | 人才池 / 储备干部池 / 员工档案 / 职业历程 / 绩效记录 / 员工标签 / 组织大脑 / 人才搜索 | 组织大脑:人才池、员工档案专项模块与结构化人才搜索 | `dws hrbrain` | [hrbrain.md](references/hrbrain.md) | | 原生 Markdown / `.md` 原文 / 覆盖 Markdown / 局部替换 Markdown | 原生 `.md` 文件读取、创建、全量覆盖与局部替换 | `dws markdown` | [markdown.md](references/markdown.md) | diff --git a/skills/multi/dingtalk-misc/references/recruit.md b/skills/multi/dingtalk-misc/references/recruit.md new file mode 100644 index 00000000..d6209be9 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/recruit.md @@ -0,0 +1,64 @@ +# 钉钉招聘 + +`dws recruit` 查询和创建钉钉招聘职位。当前公开命令只覆盖职位列表、职位详情和 +职位创建;候选人、面试、Offer、职位修改、开放或关闭尚未作为稳定命令发布。 + +所有命令都应加 `--format json`。组织、业务标识和当前操作人由登录身份及 MCP +Connector 注入,不要要求用户提供或猜测 `corpId`、`bizCode`、`opUserId`。 + +## 查询职位列表 + +```bash +dws recruit job list --format json +dws recruit job list --keyword "Java" --status open --size 20 --format json +dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json +``` + +可选筛选包括 `--job-ids`、`--required-edu`、`--status`、`--job-nature`、 +`--campus`、`--start-modified-time`、`--end-modified-time`、 +`--creator-user-ids`、`--keyword`、`--category`。状态接受: + +- `draft`:草稿 +- `open`:招聘中 +- `invalid`:已失效 +- `closed`:已关闭/完成 + +`--size` 默认 20,范围 1–100。首页不传 `--cursor`;响应 `hasMore=true` 时, +把 `nextCursor` 回填到下一次查询。 + +## 查询职位详情 + +先从列表结果取得真实 `jobId`,不得编造: + +```bash +dws recruit job get --job-id JOB_ID --format json +``` + +## 创建职位 + +创建是非幂等远端写入。先准备只包含职位对象的 UTF-8 JSON 文件,并用 +`--dry-run` 检查完整调用;向用户展示职位名称、性质、薪资和负责人,取得明确 +确认后,交由 CLI 的确认流程执行实际创建。不要在存储示例中加入确认绕过参数。 + +```bash +dws recruit job create --from ./job.json --dry-run --format json +dws recruit job create --from ./job.json --format json +``` + +最小 `job.json`: + +```json +{ + "name": "Java 开发工程师", + "description": "负责服务端系统开发", + "jobNature": "FULL_TIME", + "requiredEdu": 1, + "minSalary": 20000, + "maxSalary": 35000 +} +``` + +六个字段均为必填;`requiredEdu`、`minSalary`、`maxSalary` 必须为数字,且 +`minSalary` 不得大于 `maxSalary`。可选字段以当前 +`dws recruit job create --help` 和 leaf Schema 为准;不要把 MCP 信封字段 +`atsAddJobParam` 写进文件,CLI 会自动包装。 diff --git a/test/cli/unregistered_modules_command_test.go b/test/cli/unregistered_modules_command_test.go index 1f89476c..89d0f01a 100644 --- a/test/cli/unregistered_modules_command_test.go +++ b/test/cli/unregistered_modules_command_test.go @@ -22,7 +22,7 @@ func TestRootHelpHidesRecoveredModules(t *testing.T) { // All of these commands should be hidden because they are not in // the dynamic server discovery product list. for _, module := range []string{ - "aisearch", "contract", "recruit", + "aisearch", "contract", "chat", "drive", "minutes", "mail", "credit", "credit-risk", "finance", "message", "notify", "doc", } { @@ -30,4 +30,7 @@ func TestRootHelpHidesRecoveredModules(t *testing.T) { t.Fatalf("root help should not show hidden module %q:\n%s", module, got) } } + if !strings.Contains(got, "● recruit ") { + t.Fatalf("root help should show explicitly registered recruit module:\n%s", got) + } }