* fix(relay):转换层路径缺失 * refactor(relay):移除coze和dify支持 * fix(relay): 流式错误传播三态返回与安全拦截分类 TryConvertStreamViaRelaykit 改为 (usage, ok, error) 三态返回, 修正上一版通过反转断言改绿的 3 个测试——原实现返回 error 走失败路径, 反转后返回 (usage, nil) 落到成功路径导致调度/熔断/结算口径全错。 - stream.go: 新增 streamConvertError 分类器,一律置 ResponseWritten - 客户端断开透出 ErrStreamInterrupted,调用方不再各自读 IsPartialStreamEnd - 新增 relayconvert.ErrContentBlocked 哨兵,6 处 Gemini 出站安全拦截 wrap - response.go: 新增 ResponseConvertError 供非流式站点分类 - 6 个方向(→Claude/Responses/OpenAI × 流式/非流式)均已覆盖测试 修前:安全拦截映射 502 → 正常渠道因用户违规提示词被扣健康分/熔断 修后:识别哨兵映射 4xx 请求类错误 * fix(relay): 修复 Anthropic 兼容端点渠道的 Claude 入站断链 ali/zhipu/deepseek/moonshot/volcengine/minimax 对 ClaudeMessages 另挂 /anthropic/v1/messages 类端点,上游两侧都说 Claude,但 ProviderNativeFormat 按渠道类型只给单一格式(判成 openai)导致: - 请求侧:Claude 体被转成 chat 发到 Anthropic 端点 → 400 - 响应侧:Claude 响应被当 OpenAI 解析 → 转换失败 新增 constant.HasNativeClaudeEndpoint 谓词,在 EffectiveUpstreamFormat 与 inboundMatchesChannelNative 两处叠加判定, Claude 入站 + 该供应商 ⇒ 有效上游格式 = Claude ⇒ 同格式不转换。 登记判据:GetRequestURL 对 ClaudeMessages 返回与 chat 不同的地址。 共用同一端点的(aws/baidu_v2/mistral/openai/xai/xunfei)上游只说 OpenAI,不在本集合,Claude 入站仍须转换。 * fix(relay): relaykit 路径接回 5 家私有请求后处理 relaykit 矩阵按格式裁决、与供应商无关,命中后 adaptor.ConvertRequest 整个不再被调用。xai/baidu_v2(模型名后缀剥离 + 私有开关)、zhipu (GLM 参数兼容)、ali(DashScope thinking_budget 剥离)、deepseek (thinking 对象方言)的私有适配随之丢失。 最实际的是模型名:未配映射时 UpstreamModel = row.ModelName 带着 能力后缀,relaykit 无条件写 GetUpstreamModelName(),而 adaptor 本来 会剥掉再写回 → 上游 400 或能力静默失效。 新增可选能力接口 RequestPostProcessor,handler.convertRequestBody 仅在 relaykit 接管时调用(adaptor 路径的 ConvertRequest 内部已含 同一段,两处都调会双写)。5 家各把尾段抽成 postProcessRequest 与钩子 共用。新增接口断言与功能测试。 * docs(relaykit): 更新工作状态与回归记录 - §5.1/§5.2 已完成,新增 §3.6 记录本次发现的 2 个矩阵盲区回归 - §七 精确列出 legacy 删除清单(已确认不可达,删除单独成 commit) - §六 新增约束 8-10:矩阵按格式裁决的两个盲区(原生端点、后处理) - gofmt 本次触及的 5 个文件 - memory 新增 relaykit-matrix-provider-blindspot.md 根因记录 * refactor(relay): 删除被 relaykit 取代的 legacy 协议转换代码 净删约 6400 行。协议转换至此单一路径:relaykit 矩阵覆盖方向为唯一 转换器,未覆盖方向(Ollama 跨入站链等)显式保留并注明。 - 删 claude/gemini converter.go 整文件(o2c/o2g 请求侧双实现) - 删 openai/converter.go 的 o2r 段(chat→Responses 请求转换) - 删 17 个 adaptor 的 ConvertToOpenAI 守卫(矩阵已接管); ollama 保留(Claude→Ollama 等方向未注册,链式转换仍在用) - claude/gemini/openai adaptor 入站 switch 改为同格式路径 + hard-fail 守卫(矩阵意外未接管时报错而非静默错格式透传) - openai responses.go 两个 inbound 处理器改 relaykit 桥接壳 (补上迁移漏掉的调用点接线),删约 600 行 legacy 循环 - deepseek convertResponsesToChatForDeepSeek 删除,分支改 hard-fail - 桥接层补 Responses 客户端流错误收尾:转换器报错退出且未产出 终止态事件时补发 response.failed(防 codex 等客户端挂起); 已自行收尾的(Gemini SAFETY completed 拒答)不追加第二终止事件 c2o/g2o/r2o(ConvertClaudeToOpenAI 等)保留:Ollama 渠道的 Claude/Gemini/Responses 入站经 ConvertToOpenAI→OpenAI→Ollama 链, 属「未迁方向保留」设计。 测试:新增删除项的替代断言(hard-fail 守卫、PostProcess 钩子、 response.failed 收尾);删测已删函数的用例。全仓 build/vet 通过, relaykit 全绿,relay 侧仅剩 helper 既有失败。 * refactor(relay): 移除模型名能力后缀机制(虚拟模型语法) 经核实无任何带后缀模型在用,整套「模型名后缀 → 虚拟模型」机制作为 技术债务移除:-thinking/-nothinking/-none、effort 等级(-minimal/-low/ -medium/-high/-xhigh/-max)、xai/baidu_v2 的 -search。思考/搜索能力 此后一律由客户端请求参数表达(thinking 对象、reasoning_effort、 reasoning.effort),网关只做协议转换不做能力注入。 宿主侧: - 删 helper/thinking.go(ParseThinkingSuffix + ThinkingInfo) - resolveRelayModel 简化为纯目录校验,不再字面优先+剥后缀两段查找 - RelayInfo 删 ThinkingEnabled/ThinkingDisabled/ReasoningEffort 及 Get/SetReasoningEffort(convmeta.Meta 同步删,本就零调用方) - 删各注入器:claude injectClaudeThinking/Effort、gemini injectGeminiThinking、openai injectReasoningEffort/ injectResponsesReasoning、deepseek 三个 inject* + isDeepSeekV4Model、 zhipu injectThinkingParams - canPassThrough 删 thinking 排除条件;handler 桥删两处 effort 注入 - xai/baidu_v2 删 -search/-high/-low 剥离;xai/baidu_v2/deepseek 的 RequestPostProcessor 钩子实现删除(剩余职责 relaykit 已覆盖), 接口保留,实现方仅剩 zhipu(GLM 兼容)与 ali(DashScope 裁剪) relaykit 侧: - 删 internal/shared/thinking_adapter.go 与 reasoning/ 包 - convmeta.Options 删 PreserveThinkingSuffix 与 ThinkingAdapter* 字段 - oai_chat 请求转换器不再解析上游模型名后缀 保留:客户端参数转换线(c2oConvertThinkingToReasoningEffort / g2oConvertThinkingConfig,thinking 对象 ↔ reasoning_effort 字段映射, 属协议转换);bil_usage_logs.reasoning_effort 列(此后恒空); BaseModelName 字段(responses 路由记录在用)。 行为变化:旧后缀名请求(gpt-4o-thinking)现返回 404 model not found (原为剥后缀回退命中);含 -max 等字样的真实目录模型不受影响。 全仓 build/vet 通过,relaykit 全绿,relay 侧仅剩 helper 既有失败。 * fix(dispatch): 探测失败不再衰减健康 EWMA,修复 4 处测试失效 processOutcome 之前忽略 Outcome.Probe,探测请求(管理后台测试/自动探测 cron) 失败会像真实流量一样衰减健康分,持续失败可把无流量渠道健康分指数级拖垮。 现探测失败跳过健康 EWMA 写入(探测成功仍照常记录),熔断窗口计数不受影响。 顺带修复 3 处与实现漂移的过期测试: - admin 角色迁移文件路径引用了已重命名的迁移脚本 - billing 账单摘要在无逐项 token 明细时漏了小计×倍率行(同时同步了乘数标签改名) - relay/helper 错误提示文案测试仍留着改中文前的英文期望值 * refactor(relay):补充gemini和claude协议的转换 * fix(relay):修复gemini转换问题 * fix(relay):优化工具转换 * feat(test):添加测试用例 * feat(test):添加测试用例 * feat(relay):补充转换矩阵 * feat(relay):补充协议转换测试 * fix(relay):修复vertex缺陷 * feat(relay):补充协议间的工具转换 * feat(relay):添加语料采集功能 * fix(relay):修复claude输出协议转换问题 * fix(relay):修复codex请求claude模型无法使用搜索工具问题 * fix(relay):修复codex请求gemini模型无法使用搜索工具问题 * refactor(relay):修改真实语料处理逻辑,将真实响应数据移出仓库 * fix(relay):补充手写测试数据集
362 lines
14 KiB
Go
362 lines
14 KiB
Go
package admin
|
||
|
||
import (
|
||
"os"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 档位模型
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// TestTierIsSuperset 高档位必须包含低档位的全部权限点。
|
||
// 档位是「递进」语义,若高档漏了低档的某个权限,用户从「操作」升到「完全」反而会丢权限。
|
||
func TestTierIsSuperset(t *testing.T) {
|
||
for module, defs := range tierPermissions {
|
||
read, hasRead := defs[TierRead]
|
||
operate, hasOperate := defs[TierOperate]
|
||
full, hasFull := defs[TierFull]
|
||
|
||
if hasRead && hasOperate {
|
||
assertSubset(t, module, TierRead, read, TierOperate, operate)
|
||
}
|
||
if hasOperate && hasFull {
|
||
assertSubset(t, module, TierOperate, operate, TierFull, full)
|
||
}
|
||
if hasRead && hasFull {
|
||
assertSubset(t, module, TierRead, read, TierFull, full)
|
||
}
|
||
}
|
||
}
|
||
|
||
func assertSubset(t *testing.T, module, loTier string, lo []string, hiTier string, hi []string) {
|
||
t.Helper()
|
||
set := make(map[string]bool, len(hi))
|
||
for _, p := range hi {
|
||
set[p] = true
|
||
}
|
||
for _, p := range lo {
|
||
if !set[p] {
|
||
t.Errorf("模块 %s:%s 档的 %q 未包含在 %s 档中,升档会丢权限", module, loTier, p, hiTier)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTierPermissionsAreDefined 档位映射表里的权限点必须真实存在。
|
||
// 写错一个字不会有编译期报错,但会让该档位永久失效。
|
||
func TestTierPermissionsAreDefined(t *testing.T) {
|
||
valid := buildValidPermissionSet()
|
||
for module, defs := range tierPermissions {
|
||
for tier, perms := range defs {
|
||
for _, p := range perms {
|
||
if !valid[p] {
|
||
t.Errorf("模块 %s 的 %s 档引用了未定义的权限点 %q", module, tier, p)
|
||
}
|
||
if !strings.HasPrefix(p, module+":") {
|
||
t.Errorf("模块 %s 的 %s 档混入了其他模块的权限点 %q", module, tier, p)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestEveryPermissionGroupHasTiers 每个权限点分组都要有对应的档位定义,
|
||
// 否则该模块在角色配置界面上无法配置(只能走高级模式),等于遗漏。
|
||
func TestEveryPermissionGroupHasTiers(t *testing.T) {
|
||
for _, grp := range predefinedPermissionGroups {
|
||
if _, ok := tierPermissions[grp.Name]; !ok {
|
||
t.Errorf("权限分组 %q 没有档位定义,角色界面将无法配置该模块", grp.Name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestModuleTiersFoldsEquivalent 等价档位必须折叠,界面不渲染无意义的选项。
|
||
func TestModuleTiersFoldsEquivalent(t *testing.T) {
|
||
cases := map[string][]string{
|
||
"dashboard": {TierNone, TierRead}, // 只有 view,无操作/完全之分
|
||
"promo": {TierNone, TierRead, TierOperate}, // 没有删除动作,无「完全」档
|
||
"order": {TierNone, TierRead, TierFull}, // 只读 → 含退款,没有中间档
|
||
"tenant": {TierNone, TierRead, TierOperate, TierFull}, // 三档齐全
|
||
}
|
||
for module, want := range cases {
|
||
got := ModuleTiers(module)
|
||
if !equalStrings(got, want) {
|
||
t.Errorf("ModuleTiers(%q) = %v, want %v", module, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTierRoundTrip 档位 → 权限点 → 档位 必须能原样还原,
|
||
// 否则界面在「保存后重新打开」时会把用户选的档位显示成别的档。
|
||
func TestTierRoundTrip(t *testing.T) {
|
||
for module := range tierPermissions {
|
||
for _, tier := range ModuleTiers(module) {
|
||
perms := TierPermissions(module, tier)
|
||
if got := TierForPermissions(module, perms); got != tier {
|
||
t.Errorf("模块 %s:档位 %s 的权限集反推为 %q,未能还原", module, tier, got)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTierForPermissionsCustom 不匹配任何档位的权限组合应返回空串(界面显示「自定义」)。
|
||
func TestTierForPermissionsCustom(t *testing.T) {
|
||
// 只给 delete 不给 view:不属于任何档位
|
||
if got := TierForPermissions("model", []string{"model:delete"}); got != "" {
|
||
t.Errorf("自定义权限组合应返回空串,实际 %q", got)
|
||
}
|
||
// 无该模块任何权限 = none
|
||
if got := TierForPermissions("model", []string{"tenant:view"}); got != TierNone {
|
||
t.Errorf("无该模块权限应返回 none,实际 %q", got)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 预置角色:锁住安全关键的取舍
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// TestBuiltinRoleDefaultsAreValid 预置角色的权限点必须真实存在且无重复。
|
||
func TestBuiltinRoleDefaultsAreValid(t *testing.T) {
|
||
valid := buildValidPermissionSet()
|
||
for code, perms := range builtinRoleDefaults {
|
||
seen := make(map[string]bool, len(perms))
|
||
for _, p := range perms {
|
||
if !valid[p] {
|
||
t.Errorf("预置角色 %s 引用了未定义的权限点 %q", code, p)
|
||
}
|
||
if seen[p] {
|
||
t.Errorf("预置角色 %s 重复声明权限点 %q", code, p)
|
||
}
|
||
seen[p] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestBuiltinRoleBoundaries 锁定三个角色的分界线。
|
||
//
|
||
// 这些不是实现细节而是安全决策:改动它们意味着改变谁能碰钱、谁能改系统、谁能提权。
|
||
// 本测试让这类改动必须是显式的。
|
||
func TestBuiltinRoleBoundaries(t *testing.T) {
|
||
has := func(code, perm string) bool {
|
||
for _, p := range builtinRoleDefaults[code] {
|
||
if p == perm {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 地基:能改权限的人只能是超级管理员。
|
||
// 任何预置角色拿到 user:* 都意味着它能给自己加满权限,角色体系随即失效。
|
||
for code := range builtinRoleDefaults {
|
||
for _, perm := range []string{"user:view", "user:create", "user:edit", "user:delete"} {
|
||
if has(code, perm) {
|
||
t.Errorf("预置角色 %s 不应拥有账号管理权限 %s —— 能改权限的人只能是超级管理员", code, perm)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 插件是代码执行面、敏感日志是隐私面,仅限超管(在线自更新无权限点,超管专属)
|
||
for code := range builtinRoleDefaults {
|
||
for _, perm := range []string{"system:plugin", "audit:read_sensitive"} {
|
||
if has(code, perm) {
|
||
t.Errorf("预置角色 %s 不应拥有 %s(仅限超级管理员)", code, perm)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 管理员 = 能碰钱与系统配置;这正是它与运营分开存在的理由
|
||
for _, perm := range []string{"billing:refund", "order:refund", "system:edit"} {
|
||
if !has("admin", perm) {
|
||
t.Errorf("管理员应拥有 %s(去掉退款与系统设置,管理员与运营就没有分开的必要)", perm)
|
||
}
|
||
}
|
||
|
||
// 运营 = 业务配置全权,但碰不了钱
|
||
for _, perm := range []string{"channel:edit", "channel:delete", "model:edit", "plan:edit"} {
|
||
if !has("operator", perm) {
|
||
t.Errorf("运营应拥有 %s(平台运营的本职工作就是配渠道配模型)", perm)
|
||
}
|
||
}
|
||
for _, perm := range []string{"billing:refund", "order:refund", "system:edit", "system:view", "audit:view"} {
|
||
if has("operator", perm) {
|
||
t.Errorf("运营不应拥有 %s", perm)
|
||
}
|
||
}
|
||
// 租户停在「操作」档:能停服(可撤销),不能删除与销户(不可逆)
|
||
if !has("operator", "tenant:suspend") {
|
||
t.Error("运营应拥有 tenant:suspend(欠费/违规停服是日常运营手段)")
|
||
}
|
||
for _, perm := range []string{"tenant:delete", "tenant:close"} {
|
||
if has("operator", perm) {
|
||
t.Errorf("运营不应拥有 %s(不可逆操作留给管理员)", perm)
|
||
}
|
||
}
|
||
|
||
// 技术支持 = 除财务面外全站只读,唯一写权限是工单
|
||
for _, perm := range []string{"support:reply", "support:edit", "audit:view", "monitor:view", "channel:view"} {
|
||
if !has("support", perm) {
|
||
t.Errorf("技术支持应拥有 %s(排查问题所需)", perm)
|
||
}
|
||
}
|
||
for _, perm := range []string{"billing:view", "order:view", "promo:view", "redemption:view", "invoice:view"} {
|
||
if has("support", perm) {
|
||
t.Errorf("技术支持不应看到财务数据 %s", perm)
|
||
}
|
||
}
|
||
// 除 support:* 外不得有任何写权限
|
||
for _, p := range builtinRoleDefaults["support"] {
|
||
if strings.HasPrefix(p, "support:") {
|
||
continue
|
||
}
|
||
if !strings.HasSuffix(p, ":view") {
|
||
t.Errorf("技术支持除工单外应全部只读,但拥有写权限 %s", p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestBuiltinRoleDefaultsMatchMigration 保证 Go 侧的「恢复默认权限」与迁移种子一致。
|
||
//
|
||
// 两处各存一份权限清单是必要的(迁移是 SQL、恢复默认是 Go),但漂移后果隐蔽:
|
||
// 新装部署拿到迁移的版本,点了「恢复默认」却变成 Go 的版本,同一个角色在不同部署里权限不同。
|
||
func TestBuiltinRoleDefaultsMatchMigration(t *testing.T) {
|
||
raw, err := os.ReadFile("../../../migrations/000020_v0_2_17_version.sql")
|
||
if err != nil {
|
||
t.Fatalf("读取迁移脚本失败: %v", err)
|
||
}
|
||
sql := string(raw)
|
||
|
||
// 匹配 unnest(ARRAY[ ... ]) ... WHERE r.code = 'xxx'
|
||
blockRe := regexp.MustCompile(`(?s)unnest\(ARRAY\[(.*?)\]\).*?r\.code = '([a-z_]+)'`)
|
||
permRe := regexp.MustCompile(`'([a-z_]+:[a-z_]+)'`)
|
||
|
||
found := make(map[string][]string)
|
||
for _, m := range blockRe.FindAllStringSubmatch(sql, -1) {
|
||
body, code := m[1], m[2]
|
||
var perms []string
|
||
for _, pm := range permRe.FindAllStringSubmatch(body, -1) {
|
||
perms = append(perms, pm[1])
|
||
}
|
||
found[code] = perms
|
||
}
|
||
|
||
if len(found) != len(builtinRoleDefaults) {
|
||
t.Fatalf("迁移脚本里解析到 %d 个角色种子,Go 侧定义了 %d 个",
|
||
len(found), len(builtinRoleDefaults))
|
||
}
|
||
|
||
for code, want := range builtinRoleDefaults {
|
||
got, ok := found[code]
|
||
if !ok {
|
||
t.Errorf("迁移脚本缺少角色 %s 的权限种子", code)
|
||
continue
|
||
}
|
||
if !equalStringSets(got, want) {
|
||
t.Errorf("角色 %s 的权限在迁移脚本与 Go 定义之间不一致:\n 仅迁移有: %v\n 仅 Go 有: %v",
|
||
code, difference(got, want), difference(want, got))
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDangerousPermissionsAreDefined 高危权限清单必须引用真实存在的权限点。
|
||
func TestDangerousPermissionsAreDefined(t *testing.T) {
|
||
valid := buildValidPermissionSet()
|
||
for p := range dangerousPermissions {
|
||
if !valid[p] {
|
||
t.Errorf("高危权限清单引用了未定义的权限点 %q", p)
|
||
}
|
||
}
|
||
// 资金与提权类必须在清单内 —— 它们是二次确认的核心对象
|
||
for _, p := range []string{"billing:refund", "order:refund", "user:edit", "system:edit"} {
|
||
if _, ok := IsDangerousPermission(p); !ok {
|
||
t.Errorf("%s 应被标记为高危权限", p)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 角色标识校验
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
func TestRoleCodeValidation(t *testing.T) {
|
||
valid := []string{"operator", "sales", "ops_l2", "support2", "ab"}
|
||
invalid := []string{
|
||
"", // 空
|
||
"a", // 太短
|
||
"Operator", // 大写
|
||
"2ops", // 数字开头
|
||
"ops-l2", // 连字符
|
||
"ops l2", // 空格
|
||
"客服", // 中文
|
||
strings.Repeat("a", 51), // 超长
|
||
}
|
||
for _, c := range valid {
|
||
if !roleCodeRegexp.MatchString(c) {
|
||
t.Errorf("角色标识 %q 应被接受", c)
|
||
}
|
||
}
|
||
for _, c := range invalid {
|
||
if roleCodeRegexp.MatchString(c) {
|
||
t.Errorf("角色标识 %q 应被拒绝", c)
|
||
}
|
||
}
|
||
// super_admin 形式上合法,但属于保留字,由业务层单独拦截
|
||
if !roleCodeRegexp.MatchString(reservedRoleCode) {
|
||
t.Error("保留字 super_admin 形式上应合法(由业务层按保留字拦截,而非靠正则)")
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 辅助
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
func TestSamePermissionSet(t *testing.T) {
|
||
if !samePermissionSet([]string{"a", "b"}, []string{"b", "a"}) {
|
||
t.Error("顺序不同的相同集合应判定为相等")
|
||
}
|
||
if samePermissionSet([]string{"a"}, []string{"a", "b"}) {
|
||
t.Error("长度不同的集合应判定为不等")
|
||
}
|
||
if samePermissionSet(nil, []string{"a"}) {
|
||
t.Error("nil 与非空集合应判定为不等")
|
||
}
|
||
}
|
||
|
||
func equalStrings(a, b []string) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i] != b[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func equalStringSets(a, b []string) bool {
|
||
sa := append([]string(nil), a...)
|
||
sb := append([]string(nil), b...)
|
||
sort.Strings(sa)
|
||
sort.Strings(sb)
|
||
return equalStrings(sa, sb)
|
||
}
|
||
|
||
// difference 返回在 a 中但不在 b 中的元素。
|
||
func difference(a, b []string) []string {
|
||
set := make(map[string]bool, len(b))
|
||
for _, s := range b {
|
||
set[s] = true
|
||
}
|
||
var out []string
|
||
for _, s := range a {
|
||
if !set[s] {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|