Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions cmd/service/mail_rules_reorder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package service

import (
"context"
"strings"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)

const mailRulesReorderSchemaPath = "mail.user_mailbox.rules.reorder"

func maybeCompleteMailRulesReorderIDs(ctx context.Context, ac *client.APIClient, opts *ServiceMethodOptions, request *client.RawApiRequest, checkErr func(interface{}, core.Identity) error) error {
if opts.SchemaPath != mailRulesReorderSchemaPath {
return nil
}

body, ok := toStringAnyMap(request.Data)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "mail rules reorder requires a JSON object body").WithParam("--data")
}

requestedIDs, err := mailRuleIDsFromBody(body)
if err != nil {
return err
}
if err := validateRequestedMailRuleIDs(requestedIDs); err != nil {
return err
}

existingIDs, err := listAllMailRuleIDs(ctx, ac, *request, checkErr)
if err != nil {
return err
}

completedIDs, err := completeMailRuleIDs(requestedIDs, existingIDs)
if err != nil {
return err
}
body["rule_ids"] = completedIDs
request.Data = body
return nil
}

func toStringAnyMap(v any) (map[string]any, bool) {
if m, ok := v.(map[string]any); ok {
return m, true
}
return nil, false
}

func mailRuleIDsFromBody(body map[string]any) ([]string, error) {
raw, ok := body["rule_ids"]
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "rule_ids is required").WithParam("rule_ids")
}
switch ids := raw.(type) {
case []string:
return append([]string(nil), ids...), nil
case []any:
out := make([]string, 0, len(ids))
for i, id := range ids {
s, ok := id.(string)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "rule_ids[%d] must be a string", i).WithParam("rule_ids")
}
out = append(out, s)
}
return out, nil
default:
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "rule_ids must be an array of strings").WithParam("rule_ids")
}
}

func validateRequestedMailRuleIDs(requestedIDs []string) error {
if len(requestedIDs) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one rule_id is required for reorder").WithParam("rule_ids")
}
seen := map[string]struct{}{}
for _, id := range requestedIDs {
if id == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "rule_ids cannot contain an empty string").WithParam("rule_ids")
}
if _, ok := seen[id]; ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rule_id: %s", id).WithParam("rule_ids")
}
seen[id] = struct{}{}
}
return nil
}

func completeMailRuleIDs(requestedIDs, existingIDs []string) ([]string, error) {
existingSet := make(map[string]struct{}, len(existingIDs))
for _, id := range existingIDs {
if id == "" {
continue
}
existingSet[id] = struct{}{}
}
for _, id := range requestedIDs {
if _, ok := existingSet[id]; !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown rule_id: %s", id).WithParam("rule_ids")
}
}

requestedSet := make(map[string]struct{}, len(requestedIDs))
completed := make([]string, 0, len(existingIDs))
for _, id := range requestedIDs {
requestedSet[id] = struct{}{}
completed = append(completed, id)
}
for _, id := range existingIDs {
if _, ok := requestedSet[id]; !ok {
completed = append(completed, id)
}
}
return completed, nil
}

func listAllMailRuleIDs(ctx context.Context, ac *client.APIClient, reorderRequest client.RawApiRequest, checkErr func(interface{}, core.Identity) error) ([]string, error) {
listRequest := client.RawApiRequest{
Method: "GET",
URL: mailRulesListURL(reorderRequest.URL),
Params: copyRequestParamsWithoutPageToken(reorderRequest.Params),
As: reorderRequest.As,
}
result, err := ac.PaginateAll(ctx, listRequest, client.PaginationOptions{
PageLimit: 0,
PageDelay: -1,
Identity: reorderRequest.As,
})
if err != nil {
return nil, err
}
if apiErr := checkErr(result, reorderRequest.As); apiErr != nil {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Review | [P1 稳定性] 分页中途失败会被静默吞掉,导致用不完整的规则列表去 reorder

PaginateAll 内部的 paginateLoopinternal/client/client.go:376-397)在第 2 页及以后失败时并不返回 error:网络错误走 break,业务错误(code != 0)也是 append 后 break,两种情况最终都返回 allResults, nil。而 mergePagedResults 的顶层字段(含 code)是从第一页复制的,所以第 139 行的 checkErr(result, ...) 只能看到第一页的 code: 0,中途失败完全感知不到。listAllMailRuleIDs 也没有检查合并结果里的 data.has_more

具体失败场景:某邮箱规则有 2 页,第 2 页请求超时。此时 PaginateAll 只返回第一页原始响应(len(results)==1 直接返回 results[0],其 has_more 仍为 true),existingIDs 只含第一页 ID。接着分两种走向——(a) 用户要移动的规则正好在第 2 页:completeMailRuleIDsunknown rule_id: xxx,误导用户以为该规则不存在;(b) 用户要移动的规则在第 1 页:补齐出的 rule_ids 缺少第 2 页规则,reorder 仍会触发后端“缺少规则 ID”的报错,本 PR 想解决的问题在分页场景下并未解决。

修复建议: PaginateAll 返回后先校验完整性再继续——取 data["has_more"],为 true 时直接返回明确错误(例如 failed to fetch the complete rule list, please retry)而不是继续调用 reorder;同时补一条“第 2 页返回 code != 0 / 网络失败”的 httpmock 单测,断言不发出 reorder 请求。

如有疑问或认为判断不准确,欢迎直接回复讨论。

return nil, apiErr
}
return extractMailRuleIDs(result)
}

func mailRulesListURL(reorderURL string) string {
return strings.TrimSuffix(reorderURL, "/reorder")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Review | [P3 可维护性] 对远端 meta 形状的三处硬编码假设没有任何断言或告警保护

补齐能否生效同时依赖三个硬编码假设:schema path 常量 mail.user_mailbox.rules.reorder(第 15 行)、list URL = reorder URL 去掉 /reorder 后缀(第 146 行)、响应数组字段名是 itemsrules(第 165-167 行)。但仓内 internal/registry/meta_data_default.json 并不包含 mail rules 的定义,meta 是运行时从远端注册表拉取的,这三个假设在编译期和单测里都无法被验证——单测用的是自己造的 mailSpec() / mailRulesReorderMethod()

具体失败场景:远端 meta 把 schema path 改名(例如 rulesrule),或把 reorder 端点从 /rules/reorder 换成 /rules/order。前者会让 maybeCompleteMailRulesReorderIDs 直接 return nil,用户悄无声息地退回到「后端报缺少规则 ID」的旧行为;后者会让 TrimSuffix 原样返回,list 请求打到 reorder 端点上拿到 404/405。两种情况都没有任何日志或告警提示补齐已失效。

修复建议: 在 mailRulesListURL 里判断,当 reorder URL 既不以 /reorder 结尾、派生 URL 又与原 URL 相同时,往 stderr 打一条 warning 说明正按同路径 GET 取列表;数组字段名也可以复用 internal/client/pagination.go 已有的 output.FindArrayField,而不是再写一份 items / rules 白名单。

如有疑问或认为判断不准确,欢迎直接回复讨论。

}

func copyRequestParamsWithoutPageToken(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
if k == "page_token" {
continue
}
out[k] = v
}
return out
}

func extractMailRuleIDs(result any) ([]string, error) {
data, ok := nestedMap(result, "data")
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rules list response missing data")
}
rules, ok := data["items"].([]any)
if !ok {
rules, ok = data["rules"].([]any)
}
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rules list response missing items")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 AI Review | [P2 正确性] 规则列表为空时报的是 internal error,而不是可操作的校验错误

data 里既没有 items 也没有 rules 时,这里直接返回 InternalError(invalid_response)。但 Lark OAPI 在列表为空时通常会省略数组字段(或返回 null),这属于正常响应,被当成了响应格式非法。

具体失败场景:新邮箱一条收信规则都没有,用户执行 reorder --data '{"rule_ids":["r1"]}',拿到的是 mail rules list response missing items 这种内部错误,既看不出问题在哪、也不知道该怎么办;而按设计本应落到 completeMailRuleIDsunknown rule_id: r1,那是一条明确可操作的提示。另外 PaginateAlllen(results)==0 时返回空 map,同样会先在第 162 行撞上 missing data

修复建议: items / rules 缺失或为 null 时按空列表处理(ids := []string{} 后正常返回),只有字段存在但类型不是数组时才报 invalid_response;补一条空列表响应的单测,断言最终报的是 unknown rule_id

如有疑问或认为判断不准确,欢迎直接回复讨论。

}

ids := make([]string, 0, len(rules))
for i, item := range rules {
rule, ok := item.(map[string]any)
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rules list item %d is not an object", i)
}
id, ok := rule["rule_id"].(string)
if !ok || id == "" {
id, ok = rule["id"].(string)
}
if !ok || id == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rules list item %d missing rule_id", i)
}
ids = append(ids, id)
}
return ids, nil
}

func nestedMap(result any, key string) (map[string]any, bool) {
m, ok := toStringAnyMap(result)
if !ok {
return nil, false
}
return toStringAnyMap(m[key])
}
Loading
Loading