chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。 - internal/feishu: 飞书通讯录客户端、快照与重试 - internal/ldaplib: LDAP 读写与批量操作 - internal/mapping: 飞书部门/用户到 LDAP 条目的映射 - internal/syncer: 同步计划生成、执行与调度 - internal/store: SQLite 台账 - internal/server + internal/webui: 管理后台与内嵌前端产物 - internal/password: SSHA 口令处理 - config.example.yml / Dockerfile / Makefile / web 前端源码 真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// Package feishu 封装飞书通讯录的读取,产出与 LDAP 无关的领域模型。
|
||||
//
|
||||
// 这里刻意只做“读”与规范化,不做任何映射或写入决策:把飞书的原始事实
|
||||
// 转成干净的结构,diff 引擎再拿它与 LDAP 现状比较。
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3"
|
||||
)
|
||||
|
||||
// 部门 ID 类型与用户 ID 类型固定使用 open_department_id / union_id:
|
||||
// 前者在租户内全局稳定且不随自定义 ID 变化,后者跨应用一致,适合做去重键。
|
||||
const (
|
||||
deptIDType = "open_department_id"
|
||||
userIDType = "union_id"
|
||||
)
|
||||
|
||||
// Client 是对官方 SDK 的薄封装,负责限速、超时与重试。
|
||||
type Client struct {
|
||||
lark *lark.Client
|
||||
log *slog.Logger
|
||||
timeout time.Duration
|
||||
|
||||
// ticker 实现一个简单的令牌节奏限速,避免触发飞书侧频控。
|
||||
mu sync.Mutex
|
||||
lastAt time.Time
|
||||
period time.Duration
|
||||
}
|
||||
|
||||
// Options 是构造 Client 所需的参数。
|
||||
type Options struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
BaseURL string
|
||||
QPS int
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// New 构造飞书客户端。
|
||||
func New(opt Options, log *slog.Logger) (*Client, error) {
|
||||
if opt.AppID == "" || opt.AppSecret == "" {
|
||||
return nil, fmt.Errorf("飞书 app-id / app-secret 未配置")
|
||||
}
|
||||
if opt.QPS <= 0 {
|
||||
opt.QPS = 5
|
||||
}
|
||||
if opt.Timeout <= 0 {
|
||||
opt.Timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
opts := []lark.ClientOptionFunc{
|
||||
lark.WithReqTimeout(opt.Timeout),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
}
|
||||
if opt.BaseURL != "" {
|
||||
opts = append(opts, lark.WithOpenBaseUrl(opt.BaseURL))
|
||||
}
|
||||
|
||||
return &Client{
|
||||
lark: lark.NewClient(opt.AppID, opt.AppSecret, opts...),
|
||||
log: log,
|
||||
timeout: opt.Timeout,
|
||||
period: time.Second / time.Duration(opt.QPS),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// wait 在两次外部调用之间维持最小间隔,实现全局限速。
|
||||
func (c *Client) wait(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
gap := c.period - now.Sub(c.lastAt)
|
||||
if gap > 0 {
|
||||
c.lastAt = now.Add(gap)
|
||||
} else {
|
||||
c.lastAt = now
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if gap > 0 {
|
||||
select {
|
||||
case <-time.After(gap):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// call 统一处理限速、超时与有限次数的指数退避重试。
|
||||
//
|
||||
// 只重试看起来是暂时性的错误(超时、连接问题、5xx);权限或参数错误立即返回,
|
||||
// 重试它们只会放大问题并拖慢整轮同步。
|
||||
func (c *Client) call(ctx context.Context, what string, fn func(ctx context.Context) error) error {
|
||||
const maxAttempts = 3
|
||||
|
||||
var err error
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
c.wait(ctx)
|
||||
|
||||
callCtx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
err = fn(callCtx)
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isRetryable(err) || attempt == maxAttempts {
|
||||
return fmt.Errorf("%s 失败: %w", what, err)
|
||||
}
|
||||
|
||||
backoff := time.Duration(attempt) * 500 * time.Millisecond
|
||||
c.log.Warn("飞书调用失败,准备重试",
|
||||
"op", what, "attempt", attempt, "backoff", backoff, "err", err)
|
||||
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s 失败: %w", what, err)
|
||||
}
|
||||
|
||||
// Test 做一次轻量的连通性检查:拉取根部门下的一页部门。
|
||||
func (c *Client) Test(ctx context.Context) error {
|
||||
return c.call(ctx, "连通性测试", func(ctx context.Context) error {
|
||||
resp, err := c.lark.Contact.V3.Department.List(ctx,
|
||||
larkcontact.NewListDepartmentReqBuilder().
|
||||
ParentDepartmentId("0").
|
||||
DepartmentIdType(deptIDType).
|
||||
PageSize(1).
|
||||
Build())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("飞书返回错误: code=%d msg=%s", resp.Code, resp.Msg)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isRetryable 判断错误是否值得重试。
|
||||
//
|
||||
// 只重试可能自愈的传输层问题与飞书的频控/服务端错误;权限、参数错误
|
||||
// 立即失败——重试它们既无意义,又会拖慢整轮同步并掩盖真正的问题。
|
||||
func isRetryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 上下文取消是调用方的决定,不要重试。
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
// 单次调用的超时值得再试一次;整体超时由外层 ctx 决定。
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, s := range []string{
|
||||
"timeout", "timed out", "connection reset", "connection refused",
|
||||
"broken pipe", "eof", "temporary failure", "too many request",
|
||||
"rate limit", "internal server error", "bad gateway",
|
||||
"service unavailable", "gateway timeout",
|
||||
} {
|
||||
if strings.Contains(msg, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3"
|
||||
)
|
||||
|
||||
// maxItems 是单次读取允许的最大条目数,用于防御分页异常。
|
||||
// 一个租户的部门或成员数量远超此值时必然意味着接口行为异常
|
||||
// (例如服务端在 has_more=false 时仍返回非空游标导致原地打转),
|
||||
// 宁可直接失败也不要无限翻页拖垮同步。
|
||||
const maxItems = 200000
|
||||
|
||||
// Department 是从飞书读取的部门。
|
||||
type Department struct {
|
||||
ID string // open_department_id
|
||||
ParentID string
|
||||
Name string
|
||||
// Order 是同级排序,用于稳定输出
|
||||
Order int
|
||||
}
|
||||
|
||||
// User 是从飞书读取的用户,字段已规范化为可空字符串。
|
||||
type User struct {
|
||||
UnionID string
|
||||
UserID string
|
||||
OpenID string
|
||||
Name string
|
||||
EnName string
|
||||
EnterpriseEmail string
|
||||
Email string
|
||||
Mobile string
|
||||
EmployeeNo string
|
||||
JobTitle string
|
||||
WorkStation string
|
||||
DepartmentIDs []string // open_department_id 列表
|
||||
}
|
||||
|
||||
// Resigned 表示飞书确认已离职的用户。
|
||||
type Resigned struct {
|
||||
UnionID string
|
||||
UserID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Snapshot 是一次完整读取的结果。
|
||||
type Snapshot struct {
|
||||
Departments []*Department
|
||||
Users []*User
|
||||
// ResignedUsers 仅在显式检查时填充(见 CheckResigned)。
|
||||
ResignedUsers []*Resigned
|
||||
// SkippedNoEmail 记录因缺少企业邮箱而无法生成登录名的用户。
|
||||
// 这些用户不会被同步,但必须在界面上显式暴露,避免“静默丢失”。
|
||||
SkippedNoEmail []*User
|
||||
// SkippedDuplicate 记录企业邮箱前缀与他人冲突的用户(uid, 姓名)。
|
||||
SkippedDuplicate []DuplicateConflict
|
||||
}
|
||||
|
||||
// DuplicateConflict 描述一次登录名冲突。
|
||||
type DuplicateConflict struct {
|
||||
UID string
|
||||
Names []string
|
||||
}
|
||||
|
||||
// Fetch 拉取部门与用户,构建期望状态快照。
|
||||
//
|
||||
// 如果 onlyDepts 非空,则只同步列出的部门;前缀 ^ 表示排除该部门。
|
||||
func (c *Client) Fetch(ctx context.Context, onlyDepts []string) (*Snapshot, error) {
|
||||
depts, err := c.fetchDepartments(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
depts = filterDepartments(depts, onlyDepts)
|
||||
|
||||
users, skippedNoEmail, dups, err := c.fetchUsers(ctx, depts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Snapshot{
|
||||
Departments: depts,
|
||||
Users: users,
|
||||
SkippedNoEmail: skippedNoEmail,
|
||||
SkippedDuplicate: dups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchDepartments 拉取全量部门(含子部门)。
|
||||
//
|
||||
// 使用 ParentDepartmentId("0") + FetchChild(true) 一次性取回整棵树,
|
||||
// 避免像旧实现那样逐部门递归调用造成 O(部门数) 次请求。
|
||||
func (c *Client) fetchDepartments(ctx context.Context) ([]*Department, error) {
|
||||
var out []*Department
|
||||
|
||||
err := c.call(ctx, "获取部门列表", func(ctx context.Context) error {
|
||||
iter, err := c.lark.Contact.V3.Department.ListByIterator(ctx,
|
||||
larkcontact.NewListDepartmentReqBuilder().
|
||||
ParentDepartmentId("0").
|
||||
FetchChild(true).
|
||||
DepartmentIdType(deptIDType).
|
||||
PageSize(50).
|
||||
Build())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out = out[:0]
|
||||
for {
|
||||
ok, dept, err := iter.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if dept == nil || dept.OpenDepartmentId == nil {
|
||||
continue
|
||||
}
|
||||
if len(out) >= maxItems {
|
||||
return fmt.Errorf("部门数量超过上限 %d,疑似分页异常", maxItems)
|
||||
}
|
||||
d := &Department{
|
||||
ID: deref(dept.OpenDepartmentId),
|
||||
ParentID: deref(dept.ParentDepartmentId),
|
||||
Name: deref(dept.Name),
|
||||
}
|
||||
if dept.Order != nil {
|
||||
if n, err := parseInt(dept.Order); err == nil {
|
||||
d.Order = n
|
||||
}
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Order != out[j].Order {
|
||||
return out[i].Order < out[j].Order
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fetchUsers 遍历部门拉取用户并按 union_id 去重。
|
||||
//
|
||||
// 为什么按部门遍历而不是用 Email 批量查询:官方 SDK 明确说明
|
||||
// BatchGetId 的 emails 参数不支持企业邮箱,而我们的登录名正是企业邮箱前缀,
|
||||
// 因此只能以部门为入口枚举,再按 union_id 去重(同一用户可属于多个部门)。
|
||||
func (c *Client) fetchUsers(ctx context.Context, depts []*Department) (
|
||||
users []*User, skippedNoEmail []*User, dups []DuplicateConflict, err error,
|
||||
) {
|
||||
byUnionID := make(map[string]*User)
|
||||
seenUID := make(map[string][]string) // uid -> 姓名列表,用于检测冲突
|
||||
|
||||
for _, d := range depts {
|
||||
err := c.call(ctx, "获取部门 "+d.ID+" 的用户", func(ctx context.Context) error {
|
||||
iter, err := c.lark.Contact.V3.User.FindByDepartmentByIterator(ctx,
|
||||
larkcontact.NewFindByDepartmentUserReqBuilder().
|
||||
DepartmentId(d.ID).
|
||||
DepartmentIdType(deptIDType).
|
||||
UserIdType(userIDType).
|
||||
PageSize(50).
|
||||
Build())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
ok, u, err := iter.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if u == nil || u.UnionId == nil {
|
||||
continue
|
||||
}
|
||||
if len(byUnionID) >= maxItems {
|
||||
return fmt.Errorf("用户数量超过上限 %d,疑似分页异常", maxItems)
|
||||
}
|
||||
// FindByDepartment 只返回在职人员,但仍做一次防御性过滤。
|
||||
if u.Status != nil && u.Status.IsResigned != nil && *u.Status.IsResigned {
|
||||
continue
|
||||
}
|
||||
|
||||
uid := deref(u.UnionId)
|
||||
if existing, ok := byUnionID[uid]; ok {
|
||||
// 同一用户在多部门出现:合并部门列表,而不是覆盖。
|
||||
existing.DepartmentIDs = mergeUnique(existing.DepartmentIDs, derefDepts(u.DepartmentIds))
|
||||
continue
|
||||
}
|
||||
|
||||
nu := &User{
|
||||
UnionID: uid,
|
||||
UserID: deref(u.UserId),
|
||||
OpenID: deref(u.OpenId),
|
||||
Name: deref(u.Name),
|
||||
EnName: deref(u.EnName),
|
||||
EnterpriseEmail: deref(u.EnterpriseEmail),
|
||||
Email: deref(u.Email),
|
||||
Mobile: deref(u.Mobile),
|
||||
EmployeeNo: deref(u.EmployeeNo),
|
||||
JobTitle: deref(u.JobTitle),
|
||||
WorkStation: deref(u.WorkStation),
|
||||
DepartmentIDs: derefDepts(u.DepartmentIds),
|
||||
}
|
||||
byUnionID[uid] = nu
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 校验登录名可用性:必须有企业邮箱,且前缀不与他人冲突。
|
||||
for _, u := range byUnionID {
|
||||
uid, ok := loginName(u)
|
||||
if !ok {
|
||||
skippedNoEmail = append(skippedNoEmail, u)
|
||||
continue
|
||||
}
|
||||
seenUID[uid] = append(seenUID[uid], displayName(u))
|
||||
}
|
||||
|
||||
conflicted := make(map[string]bool)
|
||||
for uid, names := range seenUID {
|
||||
if len(names) > 1 {
|
||||
conflicted[uid] = true
|
||||
dups = append(dups, DuplicateConflict{UID: uid, Names: names})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(dups, func(i, j int) bool { return dups[i].UID < dups[j].UID })
|
||||
|
||||
for _, u := range byUnionID {
|
||||
uid, ok := loginName(u)
|
||||
if !ok || conflicted[uid] {
|
||||
continue // 已在 skippedNoEmail / dups 中报告
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
|
||||
sort.SliceStable(users, func(i, j int) bool {
|
||||
return loginName2(users[i]) < loginName2(users[j])
|
||||
})
|
||||
return users, skippedNoEmail, dups, nil
|
||||
}
|
||||
|
||||
// CheckResigned 对给定的 union_id 列表确认其是否确实离职。
|
||||
//
|
||||
// 背景:FindByDepartment 不返回离职人员,所以“离职”只能通过缺席推断。
|
||||
// 但缺席也可能是权限范围变更或部门调整导致的假阳性,因此对候选中的人
|
||||
// 逐个调用 Get 读取真实的 status.is_resigned,确认后才停用。
|
||||
func (c *Client) CheckResigned(ctx context.Context, unionIDs []string) ([]*Resigned, error) {
|
||||
var out []*Resigned
|
||||
|
||||
for _, uid := range unionIDs {
|
||||
var got *Resigned
|
||||
err := c.call(ctx, "确认用户状态 "+uid, func(ctx context.Context) error {
|
||||
resp, err := c.lark.Contact.V3.User.Get(ctx,
|
||||
larkcontact.NewGetUserReqBuilder().
|
||||
UserId(uid).
|
||||
UserIdType(userIDType).
|
||||
Build())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("飞书返回错误: code=%d msg=%s", resp.Code, resp.Msg)
|
||||
}
|
||||
if resp.Data == nil || resp.Data.User == nil {
|
||||
return nil
|
||||
}
|
||||
u := resp.Data.User
|
||||
if u.Status != nil && u.Status.IsResigned != nil && *u.Status.IsResigned {
|
||||
got = &Resigned{UnionID: uid, UserID: deref(u.UserId), Name: deref(u.Name)}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if got != nil {
|
||||
out = append(out, got)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LoginName 导出登录名(企业邮箱 @ 前缀)。
|
||||
func LoginName(u *User) (string, bool) { return loginName(u) }
|
||||
|
||||
// DisplayName 导出用于展示的名字,找不到时回落到登录名。
|
||||
func DisplayName(u *User) string { return displayName(u) }
|
||||
|
||||
// loginName 从企业邮箱推导登录名。
|
||||
//
|
||||
// 刻意不使用个人邮箱兜底:企业邮箱是组织内唯一且受控的标识,
|
||||
// 回落到个人邮箱会产生不可预期、且在离职后仍可用的登录名。
|
||||
func loginName(u *User) (string, bool) {
|
||||
email := strings.TrimSpace(u.EnterpriseEmail)
|
||||
if email == "" {
|
||||
return "", false
|
||||
}
|
||||
at := strings.Index(email, "@")
|
||||
if at <= 0 {
|
||||
return "", false
|
||||
}
|
||||
name := strings.TrimSpace(email[:at])
|
||||
if name == "" {
|
||||
return "", false
|
||||
}
|
||||
return strings.ToLower(name), true
|
||||
}
|
||||
|
||||
func displayName(u *User) string {
|
||||
if strings.TrimSpace(u.Name) != "" {
|
||||
return u.Name
|
||||
}
|
||||
return u.EnName
|
||||
}
|
||||
|
||||
func loginName2(u *User) string {
|
||||
n, _ := loginName(u)
|
||||
return n
|
||||
}
|
||||
|
||||
// filterDepartments 按配置裁剪部门集合。
|
||||
//
|
||||
// 规则与旧实现保持一致:留空表示全量;列出的部门及其子部门被包含;
|
||||
// 前缀 ^ 表示排除该部门及其子部门。
|
||||
func filterDepartments(all []*Department, want []string) []*Department {
|
||||
if len(want) == 0 {
|
||||
return all
|
||||
}
|
||||
|
||||
byID := make(map[string]*Department, len(all))
|
||||
children := make(map[string][]*Department)
|
||||
for _, d := range all {
|
||||
byID[d.ID] = d
|
||||
children[d.ParentID] = append(children[d.ParentID], d)
|
||||
}
|
||||
|
||||
var include, exclude []string
|
||||
for _, w := range want {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(w, "^") {
|
||||
exclude = append(exclude, strings.TrimPrefix(w, "^"))
|
||||
} else {
|
||||
include = append(include, w)
|
||||
}
|
||||
}
|
||||
|
||||
keep := make(map[string]bool)
|
||||
|
||||
var mark func(id string)
|
||||
mark = func(id string) {
|
||||
if keep[id] {
|
||||
return
|
||||
}
|
||||
if d, ok := byID[id]; ok {
|
||||
keep[d.ID] = true
|
||||
}
|
||||
for _, ch := range children[id] {
|
||||
mark(ch.ID)
|
||||
}
|
||||
}
|
||||
|
||||
var unmark func(id string)
|
||||
unmark = func(id string) {
|
||||
delete(keep, id)
|
||||
for _, ch := range children[id] {
|
||||
unmark(ch.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(include) > 0 {
|
||||
for _, id := range include {
|
||||
mark(id)
|
||||
}
|
||||
} else {
|
||||
for _, d := range all {
|
||||
keep[d.ID] = true
|
||||
}
|
||||
}
|
||||
for _, id := range exclude {
|
||||
unmark(id)
|
||||
}
|
||||
|
||||
out := make([]*Department, 0, len(all))
|
||||
for _, d := range all {
|
||||
if keep[d.ID] {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func derefDepts(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeUnique 合并两个字符串切片并去重,保持原有顺序。
|
||||
func mergeUnique(a, b []string) []string {
|
||||
seen := make(map[string]bool, len(a)+len(b))
|
||||
out := make([]string, 0, len(a)+len(b))
|
||||
for _, list := range [][]string{a, b} {
|
||||
for _, v := range list {
|
||||
if v == "" || seen[v] {
|
||||
continue
|
||||
}
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseInt(s *string) (int, error) {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(deref(s), "%d", &n)
|
||||
return n, err
|
||||
}
|
||||
Reference in New Issue
Block a user