feat: 新增招聘职位管理 to#85340676
This commit is contained in:
1 parent
5fed80fc0f
commit
19e19bcd2b
13 files changed
+632
-8
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
|
||||
@@ -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 <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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
})
|
||||
}
|
||||
+11
-5
@@ -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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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`。
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 会自动包装。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user